Compare commits
20
Commits
322e5a276e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6eef6a511 | ||
|
|
d803d57a78 | ||
|
|
a8ae61704e | ||
|
|
7799529788 | ||
|
|
790c33fcf0 | ||
|
|
1bf97568bf | ||
|
|
5788d74001 | ||
|
|
4f40641050 | ||
|
|
14dcd234f3 | ||
|
|
15ef891b26 | ||
|
|
9481791ac5 | ||
|
|
17bb42bea4 | ||
|
|
ccf852cb0c | ||
|
|
4ec8ffa8d6 | ||
|
|
a465dd68ec | ||
|
|
cfabfd37ee | ||
|
|
ffdaa7ce42 | ||
|
|
aa0aa871e4 | ||
|
|
845ee5bbf6 | ||
|
|
ba83520923 |
+131
-40
@@ -347,7 +347,8 @@
|
||||
|
||||
/* Grow until width or height hits the stage; letterbox/pillarbox via contain */
|
||||
.viewer-frame > img,
|
||||
.viewer-frame > video {
|
||||
.viewer-frame > video,
|
||||
.viewer-still {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
@@ -356,40 +357,43 @@
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.stage-chrome {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
.viewer-still--veiled {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stage-chrome-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
.viewer-frame--veiled .viewer-veil-label {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stage-chrome-top > *,
|
||||
.stage-actions {
|
||||
pointer-events: auto;
|
||||
/* Top chrome is a sibling of the media pane — never absolute over pixels */
|
||||
.stage-chrome {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #0b0b0d;
|
||||
}
|
||||
|
||||
.stage-chrome > .eye-viewer {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.stage-library {
|
||||
display: inline-flex;
|
||||
min-height: 44px;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #000a;
|
||||
padding: 0 14px;
|
||||
background: #141416;
|
||||
padding: 0 12px;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
@@ -408,15 +412,16 @@
|
||||
.stage-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: calc(100% - 108px);
|
||||
}
|
||||
|
||||
.stage-actions button {
|
||||
.stage-actions > button,
|
||||
.stage-split-trigger {
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #000a;
|
||||
background: #141416;
|
||||
color: var(--text);
|
||||
padding: 0 10px;
|
||||
font: inherit;
|
||||
@@ -424,7 +429,8 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stage-actions button:hover {
|
||||
.stage-actions > button:hover,
|
||||
.stage-split-trigger:hover {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
@@ -432,15 +438,64 @@
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.stage-split {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stage-split-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stage-split-trigger::after {
|
||||
content: '▾';
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.stage-split-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 8;
|
||||
display: flex;
|
||||
min-width: 160px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #141416;
|
||||
padding: 4px;
|
||||
box-shadow: 0 8px 24px #0008;
|
||||
}
|
||||
|
||||
.stage-split-menu button {
|
||||
min-height: 36px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
padding: 0 10px;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: var(--type-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stage-split-menu button:hover {
|
||||
background: #ffffff10;
|
||||
}
|
||||
|
||||
.eye-viewer {
|
||||
display: grid;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #000a;
|
||||
background: #141416;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -448,35 +503,41 @@
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
.stage-media-row {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.stage-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 4;
|
||||
display: grid;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #000a;
|
||||
align-self: stretch;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: #0b0b0d;
|
||||
color: var(--text);
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.stage-nav:hover {
|
||||
border-color: var(--amber);
|
||||
color: var(--amber);
|
||||
background: #121214;
|
||||
}
|
||||
|
||||
.stage-nav-prev {
|
||||
left: 10px;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.stage-nav-next {
|
||||
right: 10px;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.photos-head {
|
||||
@@ -625,6 +686,10 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.queue-col[data-collapsed='1'] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.queue-head {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
@@ -634,6 +699,24 @@
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.queue-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.queue-toggle-mark {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.queue-badge {
|
||||
display: inline-grid;
|
||||
min-width: 1.25rem;
|
||||
@@ -778,6 +861,14 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.queue-foot-error {
|
||||
margin: 8px 0 0;
|
||||
color: #fca5a5;
|
||||
font-size: var(--type-sm);
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.queue-foot .tiny {
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
|
||||
@@ -55,6 +55,13 @@
|
||||
role="menuitem"
|
||||
@click="menuOpen = false"
|
||||
>Director</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/prompt-builder"
|
||||
class="block rounded-xl px-3 py-2 text-sm hover:bg-white/5 hover:text-white"
|
||||
:class="current === 'prompt-builder' ? 'text-amber-100' : 'text-zinc-200'"
|
||||
role="menuitem"
|
||||
@click="menuOpen = false"
|
||||
>Prompt Builder</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/music"
|
||||
class="block rounded-xl px-3 py-2 text-sm hover:bg-white/5 hover:text-white"
|
||||
@@ -131,7 +138,7 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
subtitle: string
|
||||
current?: 'studio' | 'library' | 'director' | 'queue' | 'settings' | 'music'
|
||||
current?: 'studio' | 'library' | 'director' | 'prompt-builder' | 'queue' | 'settings' | 'music'
|
||||
wide?: boolean
|
||||
queueCount?: number
|
||||
}>(), {
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
>
|
||||
<div class="relative h-28 overflow-hidden bg-zinc-900">
|
||||
<img
|
||||
:src="`/api/library/stills/${still.id}?thumb=1`"
|
||||
:src="`/api/library/stills/${still.id}/thumb`"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
|
||||
@@ -55,6 +55,6 @@ defineEmits(['pick', 'remove', 'mention', 'update:role'])
|
||||
const thumbSrc = computed(() => {
|
||||
if (!props.asset) return ''
|
||||
if (props.asset.kind === 'clip') return `/api/library/clips/${props.asset.id}/thumb`
|
||||
return `/api/library/stills/${props.asset.id}`
|
||||
return `/api/library/stills/${props.asset.id}/thumb`
|
||||
})
|
||||
</script>
|
||||
|
||||
+820
-148
File diff suppressed because it is too large
Load Diff
@@ -60,6 +60,7 @@
|
||||
v-for="entry in entries"
|
||||
:key="entry.key"
|
||||
class="lib-modal-tile"
|
||||
:data-thumb-key="entry.key"
|
||||
:data-active="entry.key === focusedKey ? 'true' : undefined"
|
||||
@click="pickEntry(entry)"
|
||||
>
|
||||
@@ -77,13 +78,14 @@
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
v-else-if="thumbSrc(entry)"
|
||||
v-else-if="!previewHidden && liveThumbs.has(entry.key) && thumbSrc(entry)"
|
||||
:src="thumbSrc(entry)"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
>
|
||||
<span v-else class="lib-modal-fallback" />
|
||||
<span v-else class="lib-modal-fallback">{{ previewHidden ? 'Hidden' : '' }}</span>
|
||||
<span class="lib-modal-kind">
|
||||
{{ entry.kind === 'clip' ? 'Video' : entry.kind === 'track' ? 'Music' : 'Image' }}
|
||||
</span>
|
||||
@@ -170,6 +172,10 @@ const clips = ref<LibraryClip[]>([])
|
||||
const stills = ref<LibraryStill[]>([])
|
||||
const tracks = ref<LibraryTrack[]>([])
|
||||
const fetchedAt = ref(0)
|
||||
const nextCursor = ref<string | null>(null)
|
||||
const liveThumbs = ref(new Set<string>())
|
||||
let thumbObserver: IntersectionObserver | null = null
|
||||
let scrollLoadBound = false
|
||||
|
||||
const activeFolderId = computed(() => props.folderId || folders.value[0]?.id || 'all')
|
||||
|
||||
@@ -209,7 +215,7 @@ const detailRows = computed(() => {
|
||||
function thumbSrc(entry: LibraryEntry) {
|
||||
if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb`
|
||||
if (entry.kind === 'track') return ''
|
||||
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}?thumb=1`
|
||||
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}/thumb`
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -259,30 +265,107 @@ function onDeleteClick() {
|
||||
window.setTimeout(() => { void fetchLibrary(true) }, 500)
|
||||
}
|
||||
|
||||
function applyLibrary(data: any) {
|
||||
folders.value = data?.folders || []
|
||||
function mergeIds(prev: { id: string }[], next: { id: string }[]) {
|
||||
const map = new Map(prev.map(item => [item.id, item]))
|
||||
for (const item of next) map.set(item.id, item)
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
function applyLibraryPage(data: any, replace: boolean) {
|
||||
folders.value = data?.folders || folders.value
|
||||
if (replace) {
|
||||
clips.value = data?.clips || []
|
||||
stills.value = data?.stills || []
|
||||
tracks.value = data?.tracks || []
|
||||
} else {
|
||||
clips.value = mergeIds(clips.value, data?.clips || []) as LibraryClip[]
|
||||
stills.value = mergeIds(stills.value, data?.stills || []) as LibraryStill[]
|
||||
tracks.value = mergeIds(tracks.value, data?.tracks || []) as LibraryTrack[]
|
||||
}
|
||||
nextCursor.value = data?.nextCursor || null
|
||||
fetchedAt.value = Date.now()
|
||||
}
|
||||
|
||||
async function fetchLibraryPage(cursor: string | null) {
|
||||
const folder = activeFolderId.value && activeFolderId.value !== 'all' ? activeFolderId.value : ''
|
||||
const type = filter.value === 'all' ? 'all' : filter.value === 'images' ? 'images' : filter.value === 'videos' ? 'videos' : 'music'
|
||||
const qs = new URLSearchParams({ limit: '50' })
|
||||
if (folder) qs.set('folderId', folder)
|
||||
if (type !== 'all') qs.set('type', type)
|
||||
if (cursor) qs.set('cursor', cursor)
|
||||
return await $fetch<any>(`/api/library?${qs.toString()}`)
|
||||
}
|
||||
|
||||
let fetchGen = 0
|
||||
|
||||
async function fetchLibrary(force = false) {
|
||||
if (loading.value) return
|
||||
const stale = !fetchedAt.value || Date.now() - fetchedAt.value > 15_000
|
||||
const empty = !stills.value.length && !clips.value.length && !tracks.value.length
|
||||
if (!force && !empty && !stale) return
|
||||
const gen = ++fetchGen
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
liveThumbs.value = new Set()
|
||||
try {
|
||||
applyLibrary(await $fetch('/api/library?light=1'))
|
||||
const first = await fetchLibraryPage(null)
|
||||
if (gen !== fetchGen) return
|
||||
applyLibraryPage(first, true)
|
||||
loading.value = false
|
||||
nextTick(() => observeThumbs())
|
||||
// Keep fetching until the folder is exhausted — 50 is a page size, not a cap
|
||||
let cursor = first?.nextCursor || null
|
||||
while (cursor && gen === fetchGen) {
|
||||
const more = await fetchLibraryPage(cursor)
|
||||
if (gen !== fetchGen) return
|
||||
applyLibraryPage(more, false)
|
||||
cursor = more?.nextCursor || null
|
||||
nextTick(() => observeThumbs())
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (gen !== fetchGen) return
|
||||
loadError.value = e?.data?.statusMessage || e?.message || 'Could not load library'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function observeThumbs() {
|
||||
if (!import.meta.client) return
|
||||
const root = gridScrollEl.value
|
||||
if (!root) return
|
||||
if (thumbObserver) thumbObserver.disconnect()
|
||||
thumbObserver = new IntersectionObserver((entries) => {
|
||||
let changed = false
|
||||
const next = new Set(liveThumbs.value)
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue
|
||||
const key = (entry.target as HTMLElement).dataset.thumbKey
|
||||
if (!key || next.has(key)) continue
|
||||
next.add(key)
|
||||
changed = true
|
||||
}
|
||||
if (changed) liveThumbs.value = next
|
||||
}, { root, rootMargin: '200px 0px', threshold: 0.01 })
|
||||
root.querySelectorAll<HTMLElement>('[data-thumb-key]').forEach((el) => thumbObserver!.observe(el))
|
||||
}
|
||||
|
||||
function onGridScroll() {
|
||||
const el = gridScrollEl.value
|
||||
if (!el || !nextCursor.value || loading.value) return
|
||||
if (el.scrollTop + el.clientHeight < el.scrollHeight - 240) return
|
||||
void fetchMore()
|
||||
}
|
||||
|
||||
async function fetchMore() {
|
||||
if (!nextCursor.value || loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const more = await fetchLibraryPage(nextCursor.value)
|
||||
applyLibraryPage(more, false)
|
||||
nextTick(() => observeThumbs())
|
||||
} catch { /* keep what we have */ }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onTab(e: KeyboardEvent) {
|
||||
const root = dialogEl.value
|
||||
if (!root) return
|
||||
@@ -308,15 +391,35 @@ watch(() => props.open, (on) => {
|
||||
void fetchLibrary(true)
|
||||
nextTick(() => {
|
||||
dialogEl.value?.focus()
|
||||
if (gridScrollEl.value) gridScrollEl.value.scrollTop = 0
|
||||
if (gridScrollEl.value) {
|
||||
gridScrollEl.value.scrollTop = 0
|
||||
if (!scrollLoadBound) {
|
||||
gridScrollEl.value.addEventListener('scroll', onGridScroll, { passive: true })
|
||||
scrollLoadBound = true
|
||||
}
|
||||
observeThumbs()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
thumbObserver?.disconnect()
|
||||
thumbObserver = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(entries, () => nextTick(() => observeThumbs()))
|
||||
|
||||
watch([filter, activeFolderId], () => {
|
||||
if (!props.open) return
|
||||
void fetchLibrary(true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (import.meta.client) document.body.style.overflow = ''
|
||||
if (import.meta.client) {
|
||||
document.body.style.overflow = ''
|
||||
gridScrollEl.value?.removeEventListener('scroll', onGridScroll)
|
||||
thumbObserver?.disconnect()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
+204
-17
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="m-shell" data-mobile="1">
|
||||
<div class="m-shell" data-mobile="1" :data-queue-collapsed="queueRailCollapsed ? '1' : undefined">
|
||||
<header class="m-header">
|
||||
<span class="m-brand">{{ ctx.instanceName }}</span>
|
||||
<label class="m-crumb">
|
||||
@@ -9,11 +9,15 @@
|
||||
</label>
|
||||
<span class="m-grow" />
|
||||
<span class="m-gpu" :data-tone="ctx.gpu.tone" :title="ctx.gpu.detail">● {{ ctx.gpuChipLabel }}</span>
|
||||
<button type="button" class="m-chip" @click="toggleSheet('queue')">Queue {{ ctx.queueCount }}</button>
|
||||
<button type="button" class="m-chip m-queue-chip-portrait" @click="toggleSheet('queue')">Queue {{ ctx.queueCount }}</button>
|
||||
<button type="button" class="m-chip m-queue-chip-landscape" :aria-expanded="queueRailCollapsed ? 'false' : 'true'" @click="queueRailCollapsed=!queueRailCollapsed">
|
||||
Queue {{ ctx.queueCount }} {{ queueRailCollapsed ? '▸' : '▾' }}
|
||||
</button>
|
||||
<details class="m-more">
|
||||
<summary aria-label="More">⋯</summary>
|
||||
<div class="m-more-menu">
|
||||
<NuxtLink to="/library" @click="closeMenus">Library</NuxtLink>
|
||||
<NuxtLink to="/prompt-builder" @click="closeMenus">Prompt Builder</NuxtLink>
|
||||
<NuxtLink to="/queue" @click="closeMenus">Queue</NuxtLink>
|
||||
<NuxtLink to="/settings" @click="closeMenus">Settings</NuxtLink>
|
||||
</div>
|
||||
@@ -49,6 +53,7 @@
|
||||
|
||||
<div
|
||||
class="m-stage"
|
||||
:class="{ 'm-stage--veiled': ctx.selected && ctx.isHidden(ctx.selected) }"
|
||||
@touchstart.passive="onStageTouchStart"
|
||||
@touchend.passive="onStageTouchEnd"
|
||||
>
|
||||
@@ -102,22 +107,33 @@
|
||||
aria-label="Next"
|
||||
@click.stop="ctx.stepFamily(1)"
|
||||
>›</button>
|
||||
<template v-if="ctx.selected && ctx.isHidden(ctx.selected)">
|
||||
<p class="m-hidden-note">{{ ctx.selected.kind==='track' ? 'Track hidden' : 'Output hidden' }}</p>
|
||||
</template>
|
||||
<template v-else-if="ctx.selected?.kind==='track'">
|
||||
<div class="m-audio">
|
||||
<p
|
||||
v-show="ctx.selected && ctx.isHidden(ctx.selected)"
|
||||
class="m-hidden-note"
|
||||
>{{ ctx.selected?.kind==='track' ? 'Track hidden' : 'Output hidden' }}</p>
|
||||
<template v-if="ctx.selected?.kind==='track'">
|
||||
<div v-show="!ctx.isHidden(ctx.selected)" class="m-audio">
|
||||
<AudioPlayer :src="`/api/library/tracks/${ctx.selected.id}/audio`" :download-name="`${ctx.selected.name || 'track'}.${ctx.selected.ext || 'wav'}`" :label="ctx.selected.name || 'Track'" />
|
||||
</div>
|
||||
</template>
|
||||
<video
|
||||
v-else-if="ctx.selected?.kind==='clip'"
|
||||
v-show="!ctx.isHidden(ctx.selected)"
|
||||
:key="ctx.selected.id"
|
||||
:src="`/api/library/clips/${ctx.selected.id}/video`"
|
||||
controls
|
||||
playsinline
|
||||
/>
|
||||
<img v-else-if="ctx.selected" :src="ctx.stillUrl(ctx.selected.id)" alt="Selected still">
|
||||
<img
|
||||
v-else-if="ctx.selected"
|
||||
v-show="!ctx.isHidden(ctx.selected)"
|
||||
:key="ctx.selected.id+'-'+(ctx.stagePreviewNonce||0)"
|
||||
class="m-stage-still"
|
||||
:src="ctx.stagePreviewSrc || ctx.stillPreviewUrl(ctx.selected.id)"
|
||||
alt="Selected still"
|
||||
decoding="async"
|
||||
@error="ctx.onStagePreviewError?.()"
|
||||
>
|
||||
<p v-else class="m-muted">Open Library to pick a still or clip</p>
|
||||
</div>
|
||||
|
||||
@@ -127,6 +143,7 @@
|
||||
:selected="ctx.selected"
|
||||
:folder-id="ctx.form?.folderId"
|
||||
:preview-hidden="!ctx.eyeOpen"
|
||||
:is-hidden="ctx.isHidden"
|
||||
@close="ctx.closeLibrary()"
|
||||
@select="ctx.selectOutput"
|
||||
@use-image="ctx.onLibraryUseImage"
|
||||
@@ -211,7 +228,18 @@
|
||||
<label class="m-label">Lyrics<textarea v-model="ctx.music.lyrics" rows="8" class="m-area" /></label>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label class="m-label">Prompt<textarea v-model="ctx.form.promptSections.action" rows="5" class="m-area" placeholder="Describe the image or the shot." /></label>
|
||||
<label class="m-label">
|
||||
<span class="m-label-row">Prompt<button type="button" class="m-link" @click.stop="ctx.clearPrompt()">Clear</button></span>
|
||||
<textarea
|
||||
:value="ctx.form.promptSections.action"
|
||||
rows="5"
|
||||
class="m-area"
|
||||
placeholder="Describe the image or the shot."
|
||||
readonly
|
||||
@focus="openPromptEditor"
|
||||
@click="openPromptEditor"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.enhancePrompt" type="checkbox"> Enhance prompt</label>
|
||||
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.turbo" type="checkbox"> Turbo</label>
|
||||
<p v-if="ctx.form.engine==='qwen21' && ctx.form.turbo" class="m-muted">6-step Viggle distill. CFG stays 1. Enhance prompt recommended.</p>
|
||||
@@ -306,7 +334,7 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside v-if="sheet==='queue'" class="m-sheet" role="dialog" aria-label="Queue">
|
||||
<aside v-if="sheet==='queue'" class="m-sheet m-queue-sheet-portrait" role="dialog" aria-label="Queue">
|
||||
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
|
||||
<div class="m-sheet-head"><strong>Queue</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||
<div class="m-sheet-body">
|
||||
@@ -323,6 +351,12 @@
|
||||
<div class="m-job-status">
|
||||
<span>● {{ card.statusLabel }}</span>
|
||||
<button v-if="card.cancelable" type="button" @click.stop="ctx.cancel(card.job)">Cancel</button>
|
||||
<button v-else-if="card.dismissable" type="button" @click.stop="ctx.dismissFinishedCard(card)">Dismiss</button>
|
||||
</div>
|
||||
<p v-if="card.resultText" class="m-muted" style="white-space:pre-wrap">{{ card.resultText }}</p>
|
||||
<div v-if="card.resultText" class="m-job-status">
|
||||
<button type="button" @click.stop="ctx.copyCaption(card.resultText)">Copy</button>
|
||||
<button type="button" @click.stop="ctx.useCaptionAsPrompt(card.resultText)">Use as prompt</button>
|
||||
</div>
|
||||
<div v-if="card.progress" class="m-job-progress" :data-indeterminate="card.progress.indeterminate ? 'true' : undefined">
|
||||
<progress
|
||||
@@ -337,6 +371,52 @@
|
||||
<button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside v-show="!queueRailCollapsed" class="m-queue-rail" aria-label="Queue">
|
||||
<div class="m-sheet-head">
|
||||
<strong>Queue</strong>
|
||||
<button type="button" @click="queueRailCollapsed=true">Hide</button>
|
||||
</div>
|
||||
<div class="m-sheet-body">
|
||||
<div
|
||||
v-for="card in ctx.queueCards"
|
||||
:key="'rail-'+card.id"
|
||||
class="m-job"
|
||||
:class="{ on: ctx.focusedJobId===card.id }"
|
||||
@click="ctx.focusQueueCard(card)"
|
||||
>
|
||||
<div class="m-job-id">{{ card.code }}</div>
|
||||
<div class="m-job-title">{{ card.title }}</div>
|
||||
<div class="m-muted">{{ card.meta }}</div>
|
||||
<div class="m-job-status">
|
||||
<span>● {{ card.statusLabel }}</span>
|
||||
<button v-if="card.cancelable" type="button" @click.stop="ctx.cancel(card.job)">Cancel</button>
|
||||
<button v-else-if="card.dismissable" type="button" @click.stop="ctx.dismissFinishedCard(card)">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!ctx.queueCards.length" class="m-muted">No active jobs</p>
|
||||
<button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
v-if="promptEditorOpen"
|
||||
class="m-prompt-editor"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Edit prompt"
|
||||
>
|
||||
<header class="m-prompt-editor-head">
|
||||
<strong>Prompt</strong>
|
||||
<button type="button" @click="closePromptEditor">Done</button>
|
||||
</header>
|
||||
<textarea
|
||||
ref="promptEditorEl"
|
||||
v-model="promptEditorDraft"
|
||||
class="m-prompt-editor-area"
|
||||
placeholder="Describe the image or the shot."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -345,13 +425,35 @@ export type Studio2MobileCtx = Record<string, any>
|
||||
|
||||
const props = defineProps<{ ctx: Studio2MobileCtx }>()
|
||||
const sheet = ref('')
|
||||
const queueRailCollapsed = ref(false)
|
||||
const touchStart = ref<{ x: number; y: number } | null>(null)
|
||||
const promptEditorOpen = ref(false)
|
||||
const promptEditorDraft = ref('')
|
||||
const promptEditorEl = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
function openPromptEditor() {
|
||||
promptEditorDraft.value = String(props.ctx.form?.promptSections?.action || '')
|
||||
promptEditorOpen.value = true
|
||||
nextTick(() => {
|
||||
promptEditorEl.value?.focus()
|
||||
const el = promptEditorEl.value
|
||||
if (el) {
|
||||
const n = el.value.length
|
||||
el.setSelectionRange(n, n)
|
||||
}
|
||||
})
|
||||
}
|
||||
function closePromptEditor() {
|
||||
if (props.ctx.form?.promptSections) props.ctx.form.promptSections.action = promptEditorDraft.value
|
||||
promptEditorOpen.value = false
|
||||
}
|
||||
|
||||
function toggleSheet(id: string) {
|
||||
sheet.value = sheet.value === id ? '' : id
|
||||
}
|
||||
function closeMenus() {
|
||||
sheet.value = ''
|
||||
if (promptEditorOpen.value) closePromptEditor()
|
||||
}
|
||||
function runAction(kind: 'use-image' | 'use-prompt' | 'copy' | 'copy-raw' | 'load-input' | 'load-input-raw' | 'delete') {
|
||||
sheet.value = ''
|
||||
@@ -477,13 +579,26 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
|
||||
background: #080808;
|
||||
}
|
||||
.m-stage > img,
|
||||
.m-stage > video {
|
||||
.m-stage > video,
|
||||
.m-stage-still {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.m-stage-still--veiled {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.m-hidden-note {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--type-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
.m-audio { width: 100%; max-width: 100%; padding: 12px; }
|
||||
.m-stage-chrome {
|
||||
position: absolute;
|
||||
@@ -535,12 +650,6 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
|
||||
padding: 0 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.m-hidden-note {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
.m-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -649,6 +758,7 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
|
||||
gap: 10px;
|
||||
}
|
||||
.m-label { display: block; font-size: 11px; color: var(--muted); }
|
||||
.m-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
|
||||
.m-area, .m-input {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
@@ -701,4 +811,81 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
|
||||
.m-job-progress { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
|
||||
.m-job-progress-bar { flex: 1; height: 4px; accent-color: var(--amber); }
|
||||
.m-link { border: 0; background: transparent; color: var(--amber); text-align: left; }
|
||||
.m-prompt-editor {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #0e0e10;
|
||||
padding: env(safe-area-inset-top, 0) env(safe-area-inset-right, 0) env(safe-area-inset-bottom, 0) env(safe-area-inset-left, 0);
|
||||
}
|
||||
.m-prompt-editor-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.m-prompt-editor-head button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--amber);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.m-queue-chip-landscape { display: none; }
|
||||
.m-queue-rail { display: none; }
|
||||
@media (orientation: landscape) {
|
||||
.m-queue-chip-portrait,
|
||||
.m-queue-sheet-portrait { display: none !important; }
|
||||
.m-queue-chip-landscape { display: inline-flex; align-items: center; }
|
||||
.m-shell { position: relative; }
|
||||
.m-queue-rail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 25;
|
||||
display: flex;
|
||||
width: min(42vw, 16rem);
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
.m-shell[data-queue-collapsed='1'] .m-queue-rail { display: none; }
|
||||
.m-queue-rail .m-sheet-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.m-queue-rail .m-sheet-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.m-stage { padding-right: 0; }
|
||||
.m-shell:not([data-queue-collapsed='1']) .m-stage,
|
||||
.m-shell:not([data-queue-collapsed='1']) .m-dock,
|
||||
.m-shell:not([data-queue-collapsed='1']) .m-chips {
|
||||
margin-right: min(42vw, 16rem);
|
||||
}
|
||||
}
|
||||
.m-prompt-editor-area {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
border: 0;
|
||||
resize: none;
|
||||
background: #0e0e10;
|
||||
color: #f3f1ea;
|
||||
font: inherit;
|
||||
font-size: 17px;
|
||||
line-height: 1.45;
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
+57
-34
@@ -634,24 +634,6 @@ const allCount = computed(() => {
|
||||
sort: 'date-desc'
|
||||
}).length
|
||||
})
|
||||
const folderChipCounts = computed(() => {
|
||||
const map: Record<string, { videos: number; images: number; music: number }> = {}
|
||||
for (const clip of clips.value) {
|
||||
if (clip.hasVideo === false) continue
|
||||
const row = map[clip.folderId] || (map[clip.folderId] = { videos: 0, images: 0, music: 0 })
|
||||
row.videos += 1
|
||||
}
|
||||
for (const still of stills.value) {
|
||||
if (still.role !== 'output') continue
|
||||
const row = map[still.folderId] || (map[still.folderId] = { videos: 0, images: 0, music: 0 })
|
||||
row.images += 1
|
||||
}
|
||||
for (const track of tracks.value) {
|
||||
const row = map[track.folderId] || (map[track.folderId] = { videos: 0, images: 0, music: 0 })
|
||||
row.music += 1
|
||||
}
|
||||
return map
|
||||
})
|
||||
const moveFolders = computed(() => folders.value.filter(folder => folder.id !== folderId.value))
|
||||
const focused = computed(() => entries.value.find(entry => entry.key === focusedKey.value) || null)
|
||||
const focusedStill = computed(() => {
|
||||
@@ -771,7 +753,8 @@ function toast(message: string) {
|
||||
}, 4200)
|
||||
}
|
||||
|
||||
function applyLibrary(data: unknown) {
|
||||
function applyLibrary(data: unknown, opts?: { replace?: boolean }) {
|
||||
const replace = opts?.replace !== false
|
||||
const payload = (data || {}) as {
|
||||
id?: string
|
||||
hideInput?: boolean
|
||||
@@ -780,6 +763,8 @@ function applyLibrary(data: unknown) {
|
||||
clips?: LibraryClip[]
|
||||
stills?: LibraryStill[]
|
||||
tracks?: LibraryTrack[]
|
||||
nextCursor?: string | null
|
||||
hasMore?: boolean
|
||||
}
|
||||
if (payload.id && !payload.folders && !payload.clips && !payload.stills && !payload.tracks) {
|
||||
if (typeof payload.hideInput === 'boolean') {
|
||||
@@ -792,27 +777,62 @@ function applyLibrary(data: unknown) {
|
||||
}
|
||||
return
|
||||
}
|
||||
folders.value = payload.folders || []
|
||||
if (payload.folders?.length) folders.value = payload.folders
|
||||
if (replace) {
|
||||
clips.value = payload.clips || []
|
||||
stills.value = payload.stills || []
|
||||
tracks.value = payload.tracks || []
|
||||
} else {
|
||||
const merge = <T extends { id: string }>(prev: T[], next: T[]) => {
|
||||
const map = new Map<string, T>()
|
||||
for (const item of prev) map.set(item.id, item)
|
||||
for (const item of next) map.set(item.id, item)
|
||||
return [...map.values()]
|
||||
}
|
||||
clips.value = merge(clips.value, payload.clips || [])
|
||||
stills.value = merge(stills.value, payload.stills || [])
|
||||
tracks.value = merge(tracks.value, payload.tracks || [])
|
||||
}
|
||||
libraryNextCursor.value = payload.nextCursor || null
|
||||
if (folderId.value !== 'all' && !folders.value.some(folder => folder.id === folderId.value)) {
|
||||
folderId.value = folders.value[0]?.id || 'all'
|
||||
}
|
||||
}
|
||||
|
||||
const libraryNextCursor = ref<string | null>(null)
|
||||
let libraryFetchGen = 0
|
||||
|
||||
async function fetchLibraryPage(cursor: string | null) {
|
||||
const qs = new URLSearchParams({ limit: '50' })
|
||||
if (folderId.value && folderId.value !== 'all') qs.set('folderId', folderId.value)
|
||||
if (filter.value === 'images') qs.set('type', 'images')
|
||||
else if (filter.value === 'videos') qs.set('type', 'videos')
|
||||
else if (filter.value === 'music') qs.set('type', 'music')
|
||||
if (cursor) qs.set('cursor', cursor)
|
||||
return await $fetch<any>(`/api/library?${qs.toString()}`)
|
||||
}
|
||||
|
||||
async function loadLibrary() {
|
||||
applyLibrary(await $fetch('/api/library?light=1'))
|
||||
// First page paints immediately; remaining pages append in the background
|
||||
const gen = ++libraryFetchGen
|
||||
const first = await fetchLibraryPage(null)
|
||||
if (gen !== libraryFetchGen) return
|
||||
applyLibrary(first, { replace: true })
|
||||
let cursor = first?.nextCursor || null
|
||||
while (cursor && gen === libraryFetchGen) {
|
||||
const page = await fetchLibraryPage(cursor)
|
||||
if (gen !== libraryFetchGen) return
|
||||
applyLibrary(page, { replace: false })
|
||||
cursor = page?.nextCursor || null
|
||||
}
|
||||
}
|
||||
|
||||
function folderChipCount(folder: LibraryFolder) {
|
||||
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
|
||||
const row = folderChipCounts.value[folder.id]
|
||||
if (!row) return 0
|
||||
if (filter.value === 'videos') return row.videos
|
||||
if (filter.value === 'images') return row.images
|
||||
if (filter.value === 'music') return row.music
|
||||
return row.videos + row.images + row.music
|
||||
// Prefer server totals so chips match the full folder before pages finish appending
|
||||
if (filter.value === 'videos') return folder.clipCount || 0
|
||||
if (filter.value === 'images') return folder.imageCount || 0
|
||||
if (filter.value === 'music') return folder.trackCount || 0
|
||||
return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
|
||||
}
|
||||
|
||||
function folderChipClass(id: string) {
|
||||
@@ -881,7 +901,7 @@ async function toggleFocusedHidden() {
|
||||
function thumbSrc(entry: LibraryEntry) {
|
||||
if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb`
|
||||
if (entry.kind === 'track') return ''
|
||||
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.parts[cardImageIndex(entry)].id}?thumb=1`
|
||||
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.parts[cardImageIndex(entry)].id}/thumb`
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -907,7 +927,7 @@ function partThumb(part: LibraryClip | LibraryStill) {
|
||||
// Prefer this clip’s first frame so siblings don’t all look like the Start thumb.
|
||||
return `/api/library/clips/${clip.id}/first-frame?thumb=1&v=${encodeURIComponent(clip.id)}`
|
||||
}
|
||||
return `/api/library/stills/${part.id}?thumb=1`
|
||||
return `/api/library/stills/${part.id}/thumb`
|
||||
}
|
||||
|
||||
function focusPart(id: string) {
|
||||
@@ -1278,6 +1298,10 @@ watch([folderId, filter], () => {
|
||||
watch(search, () => {
|
||||
if (scroller.value) scroller.value.scrollTop = 0
|
||||
})
|
||||
watch([folderId, filter], () => {
|
||||
if (scroller.value) scroller.value.scrollTop = 0
|
||||
void loadLibrary()
|
||||
})
|
||||
watch([cols, rowH], ([nextCols, nextRowH], [prevCols, prevRowH]) => {
|
||||
const el = scroller.value
|
||||
if (!el || !prevRowH) return
|
||||
@@ -1309,10 +1333,9 @@ let resizeObserver: ResizeObserver | null = null
|
||||
let observedScroller: HTMLElement | null = null
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', onKey)
|
||||
const [me] = await Promise.all([
|
||||
$fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null })),
|
||||
loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [] }))
|
||||
])
|
||||
// Do not block chrome/grid on the full catalog — first page paints via loadLibrary's first apply
|
||||
void loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [] }))
|
||||
const me = await $fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null }))
|
||||
instanceName.value = me.instanceName || instanceName.value
|
||||
await nextTick()
|
||||
measure()
|
||||
|
||||
@@ -0,0 +1,868 @@
|
||||
<template>␍
|
||||
<div class="min-h-dvh min-w-0">␍
|
||||
<AppHeader subtitle="Prompt Builder" current="prompt-builder" />␍
|
||||
␍
|
||||
<main class="mx-auto grid min-w-0 max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,22rem)]">␍
|
||||
<section class="glass min-w-0 max-w-full rounded-3xl p-5 sm:p-6 space-y-5">␍
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">␍
|
||||
<div>␍
|
||||
<h2 class="font-display text-2xl font-bold">Prompt Builder</h2>␍
|
||||
<p class="mt-1 max-w-2xl text-sm text-zinc-400">␍
|
||||
Build multi-image prompt sequences. Copy the preview into Generate with Batch on — scenes split on the double blank line.␍
|
||||
</p>␍
|
||||
</div>␍
|
||||
<div class="flex flex-wrap items-center gap-2">␍
|
||||
<button␍
|
||||
type="button"␍
|
||||
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"␍
|
||||
@click="startNew"␍
|
||||
>␍
|
||||
New␍
|
||||
</button>␍
|
||||
<button␍
|
||||
type="button"␍
|
||||
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"␍
|
||||
:disabled="busy || !name.trim()"␍
|
||||
@click="save"␍
|
||||
>␍
|
||||
{{ busy === 'save' ? 'Saving…' : selectedId ? 'Save' : 'Save sequence' }}␍
|
||||
</button>␍
|
||||
</div>␍
|
||||
</div>␍
|
||||
␍
|
||||
<p v-if="error" class="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm text-red-200">{{ error }}</p>␍
|
||||
<p v-else-if="notice" class="rounded-2xl border border-amber-300/30 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">{{ notice }}</p>␍
|
||||
␍
|
||||
<label class="block text-sm">␍
|
||||
<span class="mb-1 block font-medium text-zinc-300">Sequence name</span>␍
|
||||
<input␍
|
||||
v-model="name"␍
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"␍
|
||||
maxlength="80"␍
|
||||
placeholder="e.g. Badge set A"␍
|
||||
>␍
|
||||
<p class="mt-1 text-[11px] text-zinc-500">Save key only — never copied into the prompt text.</p>␍
|
||||
</label>␍
|
||||
␍
|
||||
<div class="space-y-3">␍
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">␍
|
||||
<div>␍
|
||||
<span class="text-sm font-medium text-zinc-300">Characters</span>␍
|
||||
<p class="text-[11px] text-zinc-500">Sequence-wide. Toggle and outfit per scene below.</p>␍
|
||||
</div>␍
|
||||
<button␍
|
||||
type="button"␍
|
||||
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"␍
|
||||
@click="addCharacter"␍
|
||||
>␍
|
||||
Add character␍
|
||||
</button>␍
|
||||
</div>␍
|
||||
<div␍
|
||||
v-for="(character, cIndex) in characters"␍
|
||||
:key="character.id"␍
|
||||
class="rounded-2xl border border-white/10 bg-zinc-950/40 p-3 space-y-3"␍
|
||||
>␍
|
||||
<div class="flex items-center justify-between gap-2">␍
|
||||
<span class="text-[11px] uppercase tracking-wide text-zinc-500">Character {{ cIndex + 1 }}</span>␍
|
||||
<div class="flex items-center gap-2">␍
|
||||
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="clearCharacter(cIndex)">Clear</button>␍
|
||||
<button␍
|
||||
v-if="characters.length > 1"␍
|
||||
type="button"␍
|
||||
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10"␍
|
||||
@click="removeCharacter(cIndex)"␍
|
||||
>␍
|
||||
Remove␍
|
||||
</button>␍
|
||||
</div>␍
|
||||
</div>␍
|
||||
<input␍
|
||||
v-model="character.name"␍
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"␍
|
||||
maxlength="120"␍
|
||||
placeholder="Name"␍
|
||||
>␍
|
||||
<textarea␍
|
||||
v-model="character.description"␍
|
||||
rows="2"␍
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"␍
|
||||
placeholder="Description"␍
|
||||
/>␍
|
||||
<div class="space-y-2 border-t border-white/5 pt-3">␍
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">␍
|
||||
<span class="text-[11px] font-medium uppercase tracking-wide text-zinc-500">Outfits</span>␍
|
||||
<button␍
|
||||
type="button"␍
|
||||
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"␍
|
||||
@click="addOutfit(cIndex)"␍
|
||||
>␍
|
||||
Add outfit␍
|
||||
</button>␍
|
||||
</div>␍
|
||||
<p v-if="!character.outfits.length" class="text-[11px] text-zinc-600">No outfits yet.</p>␍
|
||||
<div␍
|
||||
v-for="(outfit, oIndex) in character.outfits"␍
|
||||
:key="outfit.id"␍
|
||||
class="rounded-xl border border-white/5 bg-zinc-950/50 p-2 space-y-2"␍
|
||||
>␍
|
||||
<div class="flex items-center justify-between gap-2">␍
|
||||
<span class="text-[11px] text-zinc-500">Outfit {{ oIndex + 1 }}</span>␍
|
||||
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="removeOutfit(cIndex, oIndex)">Remove</button>␍
|
||||
</div>␍
|
||||
<input␍
|
||||
v-model="outfit.name"␍
|
||||
class="w-full rounded-lg border border-white/10 bg-zinc-950 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"␍
|
||||
maxlength="120"␍
|
||||
placeholder="Outfit name"␍
|
||||
>␍
|
||||
<textarea␍
|
||||
v-model="outfit.description"␍
|
||||
rows="2"␍
|
||||
class="w-full rounded-lg border border-white/10 bg-zinc-950/70 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"␍
|
||||
placeholder="What they are wearing"␍
|
||||
/>␍
|
||||
</div>␍
|
||||
</div>␍
|
||||
</div>␍
|
||||
</div>␍
|
||||
␍
|
||||
<div class="space-y-3">␍
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">␍
|
||||
<div>␍
|
||||
<span class="text-sm font-medium text-zinc-300">Sets</span>␍
|
||||
<p class="text-[11px] text-zinc-500">Sequence-wide locations and props. Toggle and framing per scene below.</p>␍
|
||||
</div>␍
|
||||
<button␍
|
||||
type="button"␍
|
||||
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"␍
|
||||
@click="addSet"␍
|
||||
>␍
|
||||
Add set␍
|
||||
</button>␍
|
||||
</div>␍
|
||||
<p v-if="!sets.length" class="text-[11px] text-zinc-600">No sets yet.</p>␍
|
||||
<div␍
|
||||
v-for="(set, sIndex) in sets"␍
|
||||
:key="set.id"␍
|
||||
class="rounded-2xl border border-white/10 bg-zinc-950/40 p-3 space-y-3"␍
|
||||
>␍
|
||||
<div class="flex items-center justify-between gap-2">␍
|
||||
<span class="text-[11px] uppercase tracking-wide text-zinc-500">Set {{ sIndex + 1 }}</span>␍
|
||||
<div class="flex items-center gap-2">␍
|
||||
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="clearSet(sIndex)">Clear</button>␍
|
||||
<button␍
|
||||
type="button"␍
|
||||
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10"␍
|
||||
@click="removeSet(sIndex)"␍
|
||||
>␍
|
||||
Remove␍
|
||||
</button>␍
|
||||
</div>␍
|
||||
</div>␍
|
||||
<label class="block text-sm">␍
|
||||
<input␍
|
||||
v-model="set.name"␍
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"␍
|
||||
maxlength="120"␍
|
||||
placeholder="Name"␍
|
||||
>␍
|
||||
<p class="mt-1 text-[11px] text-zinc-500">Label only — not copied into the prompt.</p>␍
|
||||
</label>␍
|
||||
<div class="space-y-2 border-t border-white/5 pt-3">␍
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">␍
|
||||
<span class="text-[11px] font-medium uppercase tracking-wide text-zinc-500">Framings</span>␍
|
||||
<button␍
|
||||
type="button"␍
|
||||
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"␍
|
||||
@click="addFraming(sIndex)"␍
|
||||
>␍
|
||||
Add framing␍
|
||||
</button>␍
|
||||
</div>␍
|
||||
<div␍
|
||||
v-for="(framing, fIndex) in set.framings"␍
|
||||
:key="framing.id"␍
|
||||
class="rounded-xl border border-white/5 bg-zinc-950/50 p-2 space-y-2"␍
|
||||
>␍
|
||||
<div class="flex items-center justify-between gap-2">␍
|
||||
<span class="text-[11px] text-zinc-500">Framing {{ fIndex + 1 }}</span>␍
|
||||
<div class="flex items-center gap-2">␍
|
||||
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="clearFraming(sIndex, fIndex)">Clear</button>␍
|
||||
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="removeFraming(sIndex, fIndex)">Remove</button>␍
|
||||
</div>␍
|
||||
</div>␍
|
||||
<input␍
|
||||
v-model="framing.label"␍
|
||||
class="w-full rounded-lg border border-white/10 bg-zinc-950 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"␍
|
||||
maxlength="120"␍
|
||||
:placeholder="framingLabelPlaceholder(fIndex)"␍
|
||||
>␍
|
||||
<textarea␍
|
||||
v-model="framing.text"␍
|
||||
rows="2"␍
|
||||
class="w-full rounded-lg border border-white/10 bg-zinc-950/70 px-2.5 py-1.5 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"␍
|
||||
placeholder="Place / location text copied into the prompt"␍
|
||||
/>␍
|
||||
</div>␍
|
||||
</div>␍
|
||||
</div>␍
|
||||
</div>␍
|
||||
␍
|
||||
<div class="flex flex-wrap items-center gap-2 border-b border-white/10 pb-2">␍
|
||||
<button␍
|
||||
v-for="(scene, index) in scenes"␍
|
||||
:key="scene.id"␍
|
||||
type="button"␍
|
||||
class="rounded-full px-3 py-1.5 text-xs transition"␍
|
||||
:class="index === activeIndex␍
|
||||
? 'min-h-11 bg-amber-400/15 px-3 py-2 text-sm font-semibold text-amber-100 ring-1 ring-amber-300/40'␍
|
||||
: 'min-h-11 border border-white/25 px-3 py-2 text-sm font-semibold text-zinc-300 hover:bg-white/5 hover:text-zinc-100'"␍
|
||||
@click="activeIndex = index"␍
|
||||
>␍
|
||||
{{ scene.title || `Scene ${index + 1}` }}␍
|
||||
</button>␍
|
||||
<button␍
|
||||
type="button"␍
|
||||
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"␍
|
||||
@click="addNextScene"␍
|
||||
>␍
|
||||
Next scene␍
|
||||
</button>␍
|
||||
<button␍
|
||||
type="button"␍
|
||||
class="rounded-full border border-amber-300/70 px-4 min-h-11 py-2.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"␍
|
||||
title="Move scene earlier"␍
|
||||
:disabled="activeIndex <= 0"␍
|
||||
@click="moveScene(activeIndex, -1)"␍
|
||||
>␍
|
||||
←␍
|
||||
</button>␍
|
||||
<button␍
|
||||
type="button"␍
|
||||
class="rounded-full border border-amber-300/70 px-4 min-h-11 py-2.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"␍
|
||||
title="Move scene later"␍
|
||||
:disabled="activeIndex >= scenes.length - 1"␍
|
||||
@click="moveScene(activeIndex, 1)"␍
|
||||
>␍
|
||||
→␍
|
||||
</button>␍
|
||||
<button␍
|
||||
type="button"␍
|
||||
class="rounded-full border border-amber-300/70 px-4 min-h-11 py-2.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"␍
|
||||
:disabled="scenes.length <= 1"␍
|
||||
@click="deleteScene(activeIndex)"␍
|
||||
>␍
|
||||
Delete scene␍
|
||||
</button>␍
|
||||
</div>␍
|
||||
␍
|
||||
<div v-if="activeScene" class="space-y-4">␍
|
||||
<label class="block text-sm">␍
|
||||
<span class="mb-1 flex items-center justify-between gap-2 font-medium text-zinc-300">␍
|
||||
Scene tab name␍
|
||||
<span class="text-[11px] font-normal text-zinc-500">optional</span>␍
|
||||
</span>␍
|
||||
<input␍
|
||||
v-model="activeScene.title"␍
|
||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"␍
|
||||
maxlength="60"␍
|
||||
:placeholder="`Scene ${activeIndex + 1}`"␍
|
||||
>␍
|
||||
</label>␍
|
||||
␍
|
||||
<div class="space-y-1">␍
|
||||
<div class="flex items-center justify-between gap-2">␍
|
||||
<span class="text-sm font-medium text-zinc-300">Style</span>␍
|
||||
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="activeScene.style = ''">Clear</button>␍
|
||||
</div>␍
|
||||
<textarea␍
|
||||
v-model="activeScene.style"␍
|
||||
rows="3"␍
|
||||
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"␍
|
||||
placeholder="Visual style for this image"␍
|
||||
/>␍
|
||||
</div>␍
|
||||
␍
|
||||
<div class="space-y-1">␍
|
||||
<div class="flex items-center justify-between gap-2">␍
|
||||
<span class="text-sm font-medium text-zinc-300">Settings</span>␍
|
||||
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="activeScene.settings = ''">Clear</button>␍
|
||||
</div>␍
|
||||
<textarea␍
|
||||
v-model="activeScene.settings"␍
|
||||
rows="3"␍
|
||||
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"␍
|
||||
placeholder="Lighting, framing, technical notes"␍
|
||||
/>␍
|
||||
</div>␍
|
||||
␍
|
||||
<div v-if="sets.length" class="space-y-3">␍
|
||||
<div>␍
|
||||
<span class="text-sm font-medium text-zinc-300">Sets in this scene</span>␍
|
||||
<p class="text-[11px] text-zinc-500">Toggle which set blocks appear. Pick a framing for each on set.</p>␍
|
||||
</div>␍
|
||||
<div␍
|
||||
v-for="set in sets"␍
|
||||
:key="'scene-set-'+set.id"␍
|
||||
class="rounded-2xl border border-white/10 bg-zinc-950/40 px-3 py-2.5 space-y-2"␍
|
||||
>␍
|
||||
<label class="flex items-center justify-between gap-3 text-sm text-zinc-200">␍
|
||||
<span class="min-w-0 truncate">{{ set.name || 'Unnamed set' }}</span>␍
|
||||
<input␍
|
||||
type="checkbox"␍
|
||||
class="accent-amber-400"␍
|
||||
:checked="isSetOn(set.id)"␍
|
||||
@change="setSetOn(set.id, ($event.target as HTMLInputElement).checked)"␍
|
||||
>␍
|
||||
</label>␍
|
||||
<label v-if="isSetOn(set.id)" class="block text-[11px] text-zinc-500">␍
|
||||
Framing␍
|
||||
<select␍
|
||||
class="mt-1 w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm text-zinc-200 outline-none ring-amber-300/40 focus:ring-2"␍
|
||||
:value="activeSetFramingId(set.id) || ''"␍
|
||||
@change="setSetFraming(set.id, ($event.target as HTMLSelectElement).value)"␍
|
||||
>␍
|
||||
<option value="">None</option>␍
|
||||
<option v-for="framing in set.framings" :key="framing.id" :value="framing.id">␍
|
||||
{{ framing.label || 'Untitled framing' }}␍
|
||||
</option>␍
|
||||
</select>␍
|
||||
</label>␍
|
||||
</div>␍
|
||||
</div>␍
|
||||
␍
|
||||
<div class="space-y-3">␍
|
||||
<div>␍
|
||||
<span class="text-sm font-medium text-zinc-300">In this scene</span>␍
|
||||
<p class="text-[11px] text-zinc-500">Toggle who appears. Pick an outfit for each on character.</p>␍
|
||||
</div>␍
|
||||
<div␍
|
||||
v-for="character in characters"␍
|
||||
:key="'scene-'+character.id"␍
|
||||
class="rounded-2xl border border-white/10 bg-zinc-950/40 px-3 py-2.5 space-y-2"␍
|
||||
>␍
|
||||
<label class="flex items-center justify-between gap-3 text-sm text-zinc-200">␍
|
||||
<span class="min-w-0 truncate">{{ character.name || 'Unnamed character' }}</span>␍
|
||||
<input␍
|
||||
type="checkbox"␍
|
||||
class="accent-amber-400"␍
|
||||
:checked="isCharacterOn(character.id)"␍
|
||||
@change="setCharacterOn(character.id, ($event.target as HTMLInputElement).checked)"␍
|
||||
>␍
|
||||
</label>␍
|
||||
<label v-if="isCharacterOn(character.id)" class="block text-[11px] text-zinc-500">␍
|
||||
Outfit␍
|
||||
<select␍
|
||||
class="mt-1 w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm text-zinc-200 outline-none ring-amber-300/40 focus:ring-2"␍
|
||||
:value="activeOutfitId(character.id) || ''"␍
|
||||
@change="setCharacterOutfit(character.id, ($event.target as HTMLSelectElement).value)"␍
|
||||
>␍
|
||||
<option value="">None</option>␍
|
||||
<option v-for="outfit in character.outfits" :key="outfit.id" :value="outfit.id">␍
|
||||
{{ outfit.name || 'Untitled outfit' }}␍
|
||||
</option>␍
|
||||
</select>␍
|
||||
</label>␍
|
||||
</div>␍
|
||||
</div>␍
|
||||
␍
|
||||
<div class="space-y-1">␍
|
||||
<div class="flex items-center justify-between gap-2">␍
|
||||
<span class="text-sm font-medium text-zinc-300">Prompt</span>␍
|
||||
<button type="button" class="rounded-full border border-amber-300/70 px-3 min-h-11 py-1.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10" @click="activeScene.prompt = ''">Clear</button>␍
|
||||
</div>␍
|
||||
<textarea␍
|
||||
v-model="activeScene.prompt"␍
|
||||
rows="5"␍
|
||||
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"␍
|
||||
placeholder="What happens in this image"␍
|
||||
/>␍
|
||||
</div>␍
|
||||
</div>␍
|
||||
␍
|
||||
<section class="rounded-2xl border border-white/10 bg-zinc-950/30">␍
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-white/5 px-4 py-3">␍
|
||||
<div>␍
|
||||
<h3 class="text-sm font-medium text-zinc-200">Preview</h3>␍
|
||||
<p class="text-[11px] text-zinc-500">Assembled text ready to copy. Sequence name is omitted.</p>␍
|
||||
</div>␍
|
||||
<div class="flex items-center gap-2">␍
|
||||
<button␍
|
||||
type="button"␍
|
||||
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"␍
|
||||
:disabled="!activeSceneText"␍
|
||||
@click="copyScene"␍
|
||||
>␍
|
||||
{{ copiedScene ? 'Scene copied' : 'Copy scene' }}␍
|
||||
</button>␍
|
||||
<button␍
|
||||
type="button"␍
|
||||
class="rounded-full bg-amber-400 px-4 min-h-11 py-2.5 text-sm font-semibold text-zinc-950 hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"␍
|
||||
:disabled="!previewText"␍
|
||||
@click="copyPreview"␍
|
||||
>␍
|
||||
{{ copied ? 'Copied' : 'Copy' }}␍
|
||||
</button>␍
|
||||
<button␍
|
||||
type="button"␍
|
||||
class="rounded-full border border-amber-300/70 px-4 min-h-11 py-2.5 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10 disabled:cursor-not-allowed disabled:opacity-40"␍
|
||||
@click="previewOpen = !previewOpen"␍
|
||||
>␍
|
||||
{{ previewOpen ? 'Hide' : 'Show' }}␍
|
||||
</button>␍
|
||||
</div>␍
|
||||
</div>␍
|
||||
<pre␍
|
||||
v-if="previewOpen"␍
|
||||
class="max-h-[min(28rem,50vh)] overflow-auto whitespace-pre-wrap px-4 py-3 text-xs text-zinc-300 [overflow-wrap:anywhere]"␍
|
||||
>{{ previewText || 'Fill a scene to see the assembled prompt.' }}</pre>␍
|
||||
</section>␍
|
||||
</section>␍
|
||||
␍
|
||||
<aside class="min-w-0 space-y-4">␍
|
||||
<section class="glass rounded-3xl p-5 sm:p-6">␍
|
||||
<h3 class="font-display text-lg font-bold">Saved sequences</h3>␍
|
||||
<p class="mt-1 text-xs text-zinc-500">Stored with your other library data. Open one to edit.</p>␍
|
||||
<p v-if="!sequences.length" class="mt-4 rounded-2xl border border-white/5 bg-zinc-950/40 px-4 py-6 text-center text-sm text-zinc-500">␍
|
||||
Nothing saved yet.␍
|
||||
</p>␍
|
||||
<div v-else class="mt-4 space-y-2">␍
|
||||
<button␍
|
||||
v-for="sequence in sequences"␍
|
||||
:key="sequence.id"␍
|
||||
type="button"␍
|
||||
class="w-full rounded-2xl border p-3 text-left transition"␍
|
||||
:class="sequence.id === selectedId␍
|
||||
? 'border-amber-300/30 bg-amber-400/5'␍
|
||||
: 'border-white/5 bg-zinc-950/40 hover:border-white/15'"␍
|
||||
@click="select(sequence)"␍
|
||||
>␍
|
||||
<div class="flex items-start justify-between gap-2">␍
|
||||
<div class="min-w-0">␍
|
||||
<p class="truncate text-sm font-medium text-zinc-100">{{ sequence.name }}</p>␍
|
||||
<p class="mt-0.5 text-[11px] text-zinc-500">␍
|
||||
{{ sequence.scenes?.length || 0 }} scene{{ (sequence.scenes?.length || 0) === 1 ? '' : 's' }}␍
|
||||
· {{ sequence.characters?.length || 0 }} character{{ (sequence.characters?.length || 0) === 1 ? '' : 's' }}␍
|
||||
</p>␍
|
||||
</div>␍
|
||||
<button␍
|
||||
type="button"␍
|
||||
class="rounded-full border border-amber-300/70 px-3 min-h-11 py-2 text-sm font-semibold text-amber-100 hover:border-amber-300 hover:bg-amber-400/10"␍
|
||||
@click.stop="remove(sequence)"␍
|
||||
>␍
|
||||
Delete␍
|
||||
</button>␍
|
||||
</div>␍
|
||||
</button>␍
|
||||
</div>␍
|
||||
</section>␍
|
||||
</aside>␍
|
||||
</main>␍
|
||||
</div>␍
|
||||
</template>␍
|
||||
␍
|
||||
<script setup lang="ts">␍
|
||||
import type {␍
|
||||
PromptSequence,␍
|
||||
PromptSequenceCharacter,␍
|
||||
PromptSequenceScene,␍
|
||||
PromptSequenceSet␍
|
||||
} from '~/utils/promptSequences'␍
|
||||
import {␍
|
||||
PROMPT_SEQUENCE_FRAMING_SUGGESTIONS,␍
|
||||
assembleSceneText,␍
|
||||
assembleSequenceText,␍
|
||||
cloneSceneForNext,␍
|
||||
emptyCharacter,␍
|
||||
emptyFraming,␍
|
||||
emptyOutfit,␍
|
||||
emptyScene,␍
|
||||
emptySet,␍
|
||||
ensureSceneStatesForCharacters,␍
|
||||
ensureSceneStatesForSets,␍
|
||||
normalizePromptSequence␍
|
||||
} from '~/utils/promptSequences'␍
|
||||
␍
|
||||
useHead({ title: 'Prompt Builder' })␍
|
||||
␍
|
||||
const sequences = ref<PromptSequence[]>([])␍
|
||||
const selectedId = ref('')␍
|
||||
const name = ref('')␍
|
||||
const characters = ref<PromptSequenceCharacter[]>([emptyCharacter()])␍
|
||||
const sets = ref<PromptSequenceSet[]>([])␍
|
||||
const scenes = ref<PromptSequenceScene[]>([emptyScene(1, characters.value, sets.value)])␍
|
||||
const activeIndex = ref(0)␍
|
||||
const previewOpen = ref(true)␍
|
||||
const busy = ref('')␍
|
||||
const error = ref('')␍
|
||||
const notice = ref('')␍
|
||||
const copied = ref(false)␍
|
||||
const copiedScene = ref(false)␍
|
||||
let copyTimer: ReturnType<typeof setTimeout> | null = null␍
|
||||
let copySceneTimer: ReturnType<typeof setTimeout> | null = null␍
|
||||
␍
|
||||
const activeScene = computed(() => scenes.value[activeIndex.value] || null)␍
|
||||
const previewText = computed(() => assembleSequenceText({␍
|
||||
characters: characters.value,␍
|
||||
sets: sets.value,␍
|
||||
scenes: scenes.value␍
|
||||
}))␍
|
||||
const activeSceneText = computed(() => {␍
|
||||
if (!activeScene.value) return ''␍
|
||||
return assembleSceneText(activeScene.value, characters.value, sets.value)␍
|
||||
})␍
|
||||
␍
|
||||
function framingLabelPlaceholder(index: number) {␍
|
||||
const suggestion = PROMPT_SEQUENCE_FRAMING_SUGGESTIONS[index % PROMPT_SEQUENCE_FRAMING_SUGGESTIONS.length]␍
|
||||
return suggestion || 'wide / medium / narrow'␍
|
||||
}␍
|
||||
␍
|
||||
function startNew() {␍
|
||||
selectedId.value = ''␍
|
||||
name.value = ''␍
|
||||
characters.value = [emptyCharacter()]␍
|
||||
sets.value = []␍
|
||||
scenes.value = [emptyScene(1, characters.value, sets.value)]␍
|
||||
activeIndex.value = 0␍
|
||||
error.value = ''␍
|
||||
notice.value = ''␍
|
||||
}␍
|
||||
␍
|
||||
function select(sequence: PromptSequence) {␍
|
||||
const normalized = normalizePromptSequence(sequence) || sequence␍
|
||||
selectedId.value = normalized.id␍
|
||||
name.value = normalized.name␍
|
||||
characters.value = (normalized.characters?.length ? normalized.characters : [emptyCharacter()]).map(character => ({␍
|
||||
...emptyCharacter(),␍
|
||||
...character,␍
|
||||
outfits: (character.outfits || []).map(outfit => ({ ...emptyOutfit(), ...outfit }))␍
|
||||
}))␍
|
||||
sets.value = (normalized.sets || []).map(set => ({␍
|
||||
...emptySet(),␍
|
||||
...set,␍
|
||||
framings: (set.framings?.length ? set.framings : [emptyFraming()]).map(framing => ({ ...emptyFraming(), ...framing }))␍
|
||||
}))␍
|
||||
scenes.value = (normalized.scenes?.length ? normalized.scenes : [emptyScene(1, characters.value, sets.value)]).map((scene, index) => ({␍
|
||||
...emptyScene(index + 1, characters.value, sets.value),␍
|
||||
...scene,␍
|
||||
setStates: (scene.setStates || []).map(state => ({␍
|
||||
setId: state.setId,␍
|
||||
on: state.on !== false,␍
|
||||
framingId: state.framingId || null␍
|
||||
})),␍
|
||||
characterStates: (scene.characterStates || []).map(state => ({␍
|
||||
characterId: state.characterId,␍
|
||||
on: state.on !== false,␍
|
||||
outfitId: state.outfitId || null␍
|
||||
}))␍
|
||||
}))␍
|
||||
ensureSceneStatesForSets(scenes.value, sets.value)␍
|
||||
ensureSceneStatesForCharacters(scenes.value, characters.value)␍
|
||||
activeIndex.value = 0␍
|
||||
error.value = ''␍
|
||||
notice.value = ''␍
|
||||
}␍
|
||||
␍
|
||||
function addNextScene() {␍
|
||||
const previous = scenes.value[scenes.value.length - 1] || emptyScene(1, characters.value, sets.value)␍
|
||||
const next = cloneSceneForNext(previous, scenes.value.length + 1)␍
|
||||
scenes.value.push(next)␍
|
||||
activeIndex.value = scenes.value.length - 1␍
|
||||
}␍
|
||||
␍
|
||||
function moveScene(index: number, delta: number) {␍
|
||||
const newIndex = index + delta␍
|
||||
if (newIndex < 0 || newIndex >= scenes.value.length) return␍
|
||||
const [scene] = scenes.value.splice(index, 1)␍
|
||||
if (!scene) return␍
|
||||
scenes.value.splice(newIndex, 0, scene)␍
|
||||
if (activeIndex.value === index) {␍
|
||||
activeIndex.value = newIndex␍
|
||||
} else if (index < activeIndex.value && newIndex >= activeIndex.value) {␍
|
||||
activeIndex.value--␍
|
||||
} else if (index > activeIndex.value && newIndex <= activeIndex.value) {␍
|
||||
activeIndex.value++␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
function deleteScene(index: number) {␍
|
||||
if (scenes.value.length <= 1) return␍
|
||||
scenes.value.splice(index, 1)␍
|
||||
if (activeIndex.value >= scenes.value.length) {␍
|
||||
activeIndex.value = scenes.value.length - 1␍
|
||||
} else if (activeIndex.value > index) {␍
|
||||
activeIndex.value--␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
function addCharacter() {␍
|
||||
const character = emptyCharacter()␍
|
||||
characters.value.push(character)␍
|
||||
ensureSceneStatesForCharacters(scenes.value, characters.value, {␍
|
||||
newCharacterId: character.id,␍
|
||||
turnOnSceneIndex: activeIndex.value␍
|
||||
})␍
|
||||
}␍
|
||||
␍
|
||||
function clearCharacter(index: number) {␍
|
||||
const character = characters.value[index]␍
|
||||
if (!character) return␍
|
||||
character.name = ''␍
|
||||
character.description = ''␍
|
||||
character.outfits = []␍
|
||||
for (const scene of scenes.value) {␍
|
||||
const state = scene.characterStates.find(item => item.characterId === character.id)␍
|
||||
if (state) state.outfitId = null␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
function removeCharacter(index: number) {␍
|
||||
if (characters.value.length <= 1) return␍
|
||||
const [removed] = characters.value.splice(index, 1)␍
|
||||
if (!removed) return␍
|
||||
for (const scene of scenes.value) {␍
|
||||
scene.characterStates = scene.characterStates.filter(state => state.characterId !== removed.id)␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
function addOutfit(characterIndex: number) {␍
|
||||
const character = characters.value[characterIndex]␍
|
||||
if (!character) return␍
|
||||
character.outfits.push(emptyOutfit())␍
|
||||
}␍
|
||||
␍
|
||||
function removeOutfit(characterIndex: number, outfitIndex: number) {␍
|
||||
const character = characters.value[characterIndex]␍
|
||||
if (!character) return␍
|
||||
const [removed] = character.outfits.splice(outfitIndex, 1)␍
|
||||
if (!removed) return␍
|
||||
for (const scene of scenes.value) {␍
|
||||
const state = scene.characterStates.find(item => item.characterId === character.id)␍
|
||||
if (state?.outfitId === removed.id) state.outfitId = null␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
function addSet() {␍
|
||||
const set = emptySet()␍
|
||||
sets.value.push(set)␍
|
||||
ensureSceneStatesForSets(scenes.value, sets.value, {␍
|
||||
newSetId: set.id,␍
|
||||
turnOnSceneIndex: activeIndex.value␍
|
||||
})␍
|
||||
}␍
|
||||
␍
|
||||
function clearSet(index: number) {␍
|
||||
const set = sets.value[index]␍
|
||||
if (!set) return␍
|
||||
set.name = ''␍
|
||||
set.framings = [emptyFraming()]␍
|
||||
for (const scene of scenes.value) {␍
|
||||
const state = scene.setStates.find(item => item.setId === set.id)␍
|
||||
if (state) state.framingId = null␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
function removeSet(index: number) {␍
|
||||
const [removed] = sets.value.splice(index, 1)␍
|
||||
if (!removed) return␍
|
||||
for (const scene of scenes.value) {␍
|
||||
scene.setStates = (scene.setStates || []).filter(state => state.setId !== removed.id)␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
function addFraming(setIndex: number) {␍
|
||||
const set = sets.value[setIndex]␍
|
||||
if (!set) return␍
|
||||
set.framings.push(emptyFraming())␍
|
||||
}␍
|
||||
␍
|
||||
function removeFraming(setIndex: number, framingIndex: number) {␍
|
||||
const set = sets.value[setIndex]␍
|
||||
if (!set) return␍
|
||||
const [removed] = set.framings.splice(framingIndex, 1)␍
|
||||
if (!removed) return␍
|
||||
for (const scene of scenes.value) {␍
|
||||
const state = scene.setStates.find(item => item.setId === set.id)␍
|
||||
if (state?.framingId === removed.id) state.framingId = null␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
function clearFraming(setIndex: number, framingIndex: number) {␍
|
||||
const framing = sets.value[setIndex]?.framings[framingIndex]␍
|
||||
if (!framing) return␍
|
||||
framing.label = ''␍
|
||||
framing.text = ''␍
|
||||
}␍
|
||||
␍
|
||||
function sceneSetState(setId: string) {␍
|
||||
return activeScene.value?.setStates?.find(state => state.setId === setId)␍
|
||||
}␍
|
||||
␍
|
||||
function isSetOn(setId: string) {␍
|
||||
const state = sceneSetState(setId)␍
|
||||
return state ? !!state.on : true␍
|
||||
}␍
|
||||
␍
|
||||
function setSetOn(setId: string, on: boolean) {␍
|
||||
const state = sceneSetState(setId)␍
|
||||
if (!state) {␍
|
||||
activeScene.value?.setStates.push({ setId, on, framingId: null })␍
|
||||
return␍
|
||||
}␍
|
||||
state.on = on␍
|
||||
}␍
|
||||
␍
|
||||
function activeSetFramingId(setId: string) {␍
|
||||
return sceneSetState(setId)?.framingId || ''␍
|
||||
}␍
|
||||
␍
|
||||
function setSetFraming(setId: string, framingId: string) {␍
|
||||
const state = sceneSetState(setId)␍
|
||||
if (!state) {␍
|
||||
activeScene.value?.setStates.push({␍
|
||||
setId,␍
|
||||
on: true,␍
|
||||
framingId: framingId || null␍
|
||||
})␍
|
||||
return␍
|
||||
}␍
|
||||
state.framingId = framingId || null␍
|
||||
}␍
|
||||
␍
|
||||
function sceneState(characterId: string) {␍
|
||||
return activeScene.value?.characterStates.find(state => state.characterId === characterId)␍
|
||||
}␍
|
||||
␍
|
||||
function isCharacterOn(characterId: string) {␍
|
||||
const state = sceneState(characterId)␍
|
||||
return state ? !!state.on : true␍
|
||||
}␍
|
||||
␍
|
||||
function setCharacterOn(characterId: string, on: boolean) {␍
|
||||
const state = sceneState(characterId)␍
|
||||
if (!state) {␍
|
||||
activeScene.value?.characterStates.push({ characterId, on, outfitId: null })␍
|
||||
return␍
|
||||
}␍
|
||||
state.on = on␍
|
||||
}␍
|
||||
␍
|
||||
function activeOutfitId(characterId: string) {␍
|
||||
return sceneState(characterId)?.outfitId || ''␍
|
||||
}␍
|
||||
␍
|
||||
function setCharacterOutfit(characterId: string, outfitId: string) {␍
|
||||
const state = sceneState(characterId)␍
|
||||
if (!state) {␍
|
||||
activeScene.value?.characterStates.push({␍
|
||||
characterId,␍
|
||||
on: true,␍
|
||||
outfitId: outfitId || null␍
|
||||
})␍
|
||||
return␍
|
||||
}␍
|
||||
state.outfitId = outfitId || null␍
|
||||
}␍
|
||||
␍
|
||||
async function loadSequences() {␍
|
||||
const listed = await $fetch<{ sequences?: PromptSequence[] }>('/api/prompt-sequences')␍
|
||||
sequences.value = listed.sequences || []␍
|
||||
}␍
|
||||
␍
|
||||
async function save() {␍
|
||||
const nextName = name.value.trim()␍
|
||||
if (!nextName) return␍
|
||||
const existingByName = sequences.value.find(item => item.name.toLowerCase() === nextName.toLowerCase())␍
|
||||
const updateExisting = Boolean(selectedId.value) && (!existingByName || existingByName.id === selectedId.value)␍
|
||||
if (existingByName && existingByName.id !== selectedId.value && !window.confirm(`Overwrite sequence “${existingByName.name}”?`)) return␍
|
||||
busy.value = 'save'␍
|
||||
error.value = ''␍
|
||||
notice.value = ''␍
|
||||
const payload = {␍
|
||||
name: nextName,␍
|
||||
characters: characters.value,␍
|
||||
sets: sets.value,␍
|
||||
scenes: scenes.value␍
|
||||
}␍
|
||||
try {␍
|
||||
if (updateExisting && selectedId.value) {␍
|
||||
const saved = await $fetch<{ sequence: PromptSequence; sequences: PromptSequence[] }>(`/api/prompt-sequences/${selectedId.value}`, {␍
|
||||
method: 'PATCH',␍
|
||||
body: payload␍
|
||||
})␍
|
||||
sequences.value = saved.sequences␍
|
||||
select(saved.sequence)␍
|
||||
notice.value = `Saved “${saved.sequence.name}”.`␍
|
||||
} else {␍
|
||||
const saved = await $fetch<{ sequence: PromptSequence; sequences: PromptSequence[] }>('/api/prompt-sequences', {␍
|
||||
method: 'POST',␍
|
||||
body: { ...payload, overwrite: Boolean(existingByName) }␍
|
||||
})␍
|
||||
sequences.value = saved.sequences␍
|
||||
select(saved.sequence)␍
|
||||
notice.value = existingByName ? `Overwrote “${saved.sequence.name}”.` : `Saved “${saved.sequence.name}”.`␍
|
||||
}␍
|
||||
} catch (err: any) {␍
|
||||
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not save that sequence'␍
|
||||
} finally {␍
|
||||
busy.value = ''␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
async function remove(sequence: PromptSequence) {␍
|
||||
if (!window.confirm(`Delete sequence “${sequence.name}”?`)) return␍
|
||||
busy.value = 'delete'␍
|
||||
error.value = ''␍
|
||||
notice.value = ''␍
|
||||
try {␍
|
||||
const saved = await $fetch<{ sequences: PromptSequence[] }>(`/api/prompt-sequences/${sequence.id}`, { method: 'DELETE' })␍
|
||||
sequences.value = saved.sequences␍
|
||||
if (selectedId.value === sequence.id) startNew()␍
|
||||
notice.value = `Deleted “${sequence.name}”.`␍
|
||||
} catch (err: any) {␍
|
||||
error.value = err?.data?.statusMessage || err?.statusMessage || err?.message || 'Could not delete that sequence'␍
|
||||
} finally {␍
|
||||
busy.value = ''␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
async function writeClipboard(text: string, kind: 'all' | 'scene') {␍
|
||||
if (!text) return␍
|
||||
try {␍
|
||||
await navigator.clipboard.writeText(text)␍
|
||||
if (kind === 'scene') {␍
|
||||
copiedScene.value = true␍
|
||||
if (copySceneTimer) clearTimeout(copySceneTimer)␍
|
||||
copySceneTimer = setTimeout(() => { copiedScene.value = false }, 1500)␍
|
||||
} else {␍
|
||||
copied.value = true␍
|
||||
if (copyTimer) clearTimeout(copyTimer)␍
|
||||
copyTimer = setTimeout(() => { copied.value = false }, 1500)␍
|
||||
}␍
|
||||
} catch {␍
|
||||
error.value = 'Could not copy to the clipboard'␍
|
||||
}␍
|
||||
}␍
|
||||
␍
|
||||
async function copyPreview() {␍
|
||||
await writeClipboard(previewText.value, 'all')␍
|
||||
}␍
|
||||
␍
|
||||
async function copyScene() {␍
|
||||
await writeClipboard(activeSceneText.value, 'scene')␍
|
||||
}␍
|
||||
␍
|
||||
onMounted(async () => {␍
|
||||
try {␍
|
||||
await loadSequences()␍
|
||||
} catch (err: any) {␍
|
||||
error.value = err?.data?.statusMessage || err?.message || 'Could not load saved sequences'␍
|
||||
}␍
|
||||
})␍
|
||||
␍
|
||||
onBeforeUnmount(() => {␍
|
||||
if (copyTimer) clearTimeout(copyTimer)␍
|
||||
if (copySceneTimer) clearTimeout(copySceneTimer)␍
|
||||
})␍
|
||||
</script>␍
|
||||
@@ -0,0 +1,299 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createServer } from 'node:net'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, appendFileSync, readdirSync, copyFileSync, createWriteStream } from 'node:fs'
|
||||
import { join, resolve, extname } from 'node:path'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { Transform } from 'node:stream'
|
||||
import { captionStylePrompt, CAPTION_STYLES } from '../shared/caption.mjs'
|
||||
|
||||
const MODEL_NAME = 'Qwen2.5-VL-7B-NSFW-Caption-V4.Q5_K_M.gguf'
|
||||
const MMPROJ_NAME = 'Qwen2.5-VL-7B-NSFW-Caption-V4.mmproj-f16.gguf'
|
||||
|
||||
function defaultModelsDir() {
|
||||
const shared = process.env.COMFY_MODELS_ROOT
|
||||
|| join(process.env.LOCALAPPDATA || '', 'Comfy-Desktop', 'ComfyUI-Shared', 'models')
|
||||
return resolve(process.env.CAPTION_MODELS || join(shared, 'caption', 'qwen25vl-7b-nsfw-v4'))
|
||||
}
|
||||
|
||||
/** Prefer winget ggml.llamacpp; never download. Process exit after one caption = keep_alive 0. */
|
||||
function resolveLlamaServer(explicit) {
|
||||
if (explicit) return resolve(explicit)
|
||||
if (process.env.CAPTION_LLAMA_SERVER) return resolve(process.env.CAPTION_LLAMA_SERVER)
|
||||
const local = process.env.LOCALAPPDATA || ''
|
||||
const candidates = [
|
||||
join(local, 'Microsoft', 'WinGet', 'Packages', 'ggml.llamacpp_Microsoft.Winget.Source_8wekyb3d8bbwe', 'llama-server.exe'),
|
||||
join(local, 'llama.cpp', 'llama-server.exe'),
|
||||
join('C:\\', 'llama.cpp', 'llama-server.exe')
|
||||
]
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) return path
|
||||
}
|
||||
return 'llama-server'
|
||||
}
|
||||
|
||||
function freePort() {
|
||||
return new Promise((resolvePort, reject) => {
|
||||
const server = createServer()
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address()
|
||||
const port = typeof address === 'object' && address ? address.port : 0
|
||||
server.close(error => error ? reject(error) : resolvePort(port))
|
||||
})
|
||||
server.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForServer(port, signal, timeoutMs = 120_000, fetchImpl = fetch) {
|
||||
const started = Date.now()
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
if (signal?.aborted) throw new Error('Caption cancelled while loading the model.')
|
||||
try {
|
||||
const response = await fetchImpl(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(2000) })
|
||||
if (response.ok) return
|
||||
} catch { /* booting */ }
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
}
|
||||
throw new Error('llama-server did not become ready in time.')
|
||||
}
|
||||
|
||||
function mimeFor(path) {
|
||||
const ext = extname(path).toLowerCase()
|
||||
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
||||
if (ext === '.webp') return 'image/webp'
|
||||
if (ext === '.gif') return 'image/gif'
|
||||
return 'image/png'
|
||||
}
|
||||
|
||||
function killTree(child) {
|
||||
return new Promise(resolveKill => {
|
||||
if (!child?.pid) return resolveKill()
|
||||
let finished = false
|
||||
const done = () => { if (finished) return; finished = true; resolveKill() }
|
||||
child.once('close', done)
|
||||
try { if (typeof child.kill === 'function') child.kill() } catch { /* ignore */ }
|
||||
if (process.platform === 'win32' && child.pid > 0) {
|
||||
try {
|
||||
const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
|
||||
killer.once('close', done)
|
||||
killer.once('error', done)
|
||||
} catch { done() }
|
||||
}
|
||||
setTimeout(done, 3000)
|
||||
})
|
||||
}
|
||||
|
||||
export function validateCaptionHostRequest(body) {
|
||||
if (!/^[a-zA-Z0-9-]{12,80}$/.test(body?.id || '')) throw new Error('Invalid caption job ID.')
|
||||
const style = String(body.style || body.captionStyle || 'descriptive')
|
||||
if (!CAPTION_STYLES.includes(style)) throw new Error('Unknown caption style.')
|
||||
return { id: body.id, style, imagePath: body.imagePath ? String(body.imagePath) : '' }
|
||||
}
|
||||
|
||||
/** One llama-server process per caption. Process exit unloads VRAM (keep_alive 0). */
|
||||
export function createCaptionHost({
|
||||
prepare,
|
||||
leaseValid,
|
||||
spawnProcess = spawn,
|
||||
modelsDir,
|
||||
llamaServer,
|
||||
dataDir,
|
||||
fetchImpl = fetch,
|
||||
serverWaitMs = 120_000
|
||||
} = {}) {
|
||||
const models = resolve(modelsDir || defaultModelsDir())
|
||||
const executable = resolveLlamaServer(llamaServer)
|
||||
const data = resolve(dataDir || process.env.CAPTION_JOBS_DIR || join(models, 'aigen-jobs'))
|
||||
let active = null
|
||||
let holdUntil = 0
|
||||
if (existsSync(data)) {
|
||||
for (const id of readdirSync(data)) {
|
||||
try {
|
||||
const path = join(data, id, 'status.json')
|
||||
if (!existsSync(path)) continue
|
||||
const state = JSON.parse(readFileSync(path, 'utf8'))
|
||||
if (['running', 'starting', 'cancelling'].includes(state.status)) {
|
||||
holdUntil = Date.now() + 10000
|
||||
state.status = 'error'
|
||||
state.error = 'Caption host restarted. GPU model unloaded.'
|
||||
writeFileSync(path + '.tmp', JSON.stringify(state))
|
||||
renameSync(path + '.tmp', path)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
const dir = id => {
|
||||
if (!/^[a-zA-Z0-9-]{12,80}$/.test(id || '')) throw new Error('Invalid caption job ID.')
|
||||
return join(data, id)
|
||||
}
|
||||
const persist = job => {
|
||||
const target = join(dir(job.id), 'status.json')
|
||||
writeFileSync(target + '.tmp', JSON.stringify(job))
|
||||
renameSync(target + '.tmp', target)
|
||||
}
|
||||
const read = id => {
|
||||
if (active?.job.id === id) return { ...active.job }
|
||||
const path = join(dir(id), 'status.json')
|
||||
return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null
|
||||
}
|
||||
const modelPath = () => join(models, MODEL_NAME)
|
||||
const mmprojPath = () => join(models, MMPROJ_NAME)
|
||||
const configured = () => {
|
||||
try {
|
||||
if (!existsSync(modelPath()) || !existsSync(mmprojPath())) return false
|
||||
if (executable.includes('\\') || executable.includes('/')) return existsSync(executable)
|
||||
return true
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
return {
|
||||
busy: () => Boolean(active) || Date.now() < holdUntil,
|
||||
configured,
|
||||
modelsDir: () => models,
|
||||
read,
|
||||
async upload(id, stream) {
|
||||
if (read(id)?.status === 'complete') throw new Error('This caption job has already finished.')
|
||||
const jobDir = dir(id)
|
||||
mkdirSync(jobDir, { recursive: true })
|
||||
const dest = join(jobDir, 'input.upload')
|
||||
let bytes = 0
|
||||
await pipeline(stream, new Transform({
|
||||
transform(chunk, _, callback) {
|
||||
bytes += chunk.length
|
||||
callback(bytes > 40 * 1024 * 1024 ? new Error('Image exceeds the 40 MB upload limit.') : null, chunk)
|
||||
}
|
||||
}), createWriteStream(dest, { flags: 'w' }))
|
||||
if (bytes < 32) throw new Error('Image file is empty.')
|
||||
renameSync(dest, join(jobDir, 'input.png'))
|
||||
},
|
||||
async start(body, lease) {
|
||||
const request = validateCaptionHostRequest(body)
|
||||
const previous = read(request.id)
|
||||
if (previous) return previous
|
||||
if (active || Date.now() < holdUntil) throw Object.assign(new Error('Caption is already running or releasing VRAM.'), { statusCode: 409 })
|
||||
if (!configured()) throw new Error(`Caption model missing under ${models}. Run scripts/setup-caption.ps1.`)
|
||||
if (!leaseValid(lease)) throw new Error('GPU reservation expired.')
|
||||
|
||||
let imagePath = request.imagePath
|
||||
if (imagePath) {
|
||||
if (!existsSync(imagePath)) throw new Error('imagePath does not exist on the GPU host.')
|
||||
mkdirSync(dir(request.id), { recursive: true })
|
||||
const dest = join(dir(request.id), `input${extname(imagePath) || '.png'}`)
|
||||
copyFileSync(imagePath, dest)
|
||||
imagePath = dest
|
||||
} else {
|
||||
imagePath = join(dir(request.id), 'input.png')
|
||||
if (!existsSync(imagePath)) {
|
||||
const alt = existsSync(dir(request.id)) && readdirSync(dir(request.id)).find(name => /^input\./i.test(name))
|
||||
if (!alt) throw new Error('Upload an image first or pass imagePath.')
|
||||
imagePath = join(dir(request.id), alt)
|
||||
}
|
||||
}
|
||||
|
||||
const job = {
|
||||
id: request.id, status: 'starting', message: 'Preparing GPU for caption', progress: 0,
|
||||
style: request.style, startedAt: Date.now(), checkedAt: Date.now()
|
||||
}
|
||||
active = { job, child: null, cancelled: false, abort: new AbortController() }
|
||||
const run = active
|
||||
mkdirSync(dir(job.id), { recursive: true })
|
||||
writeFileSync(join(dir(job.id), 'request.json'), JSON.stringify({ ...request, imagePath, prompt: captionStylePrompt(request.style) }))
|
||||
persist(job)
|
||||
|
||||
try {
|
||||
if (prepare) await prepare()
|
||||
if (run.cancelled || !leaseValid(lease)) throw new Error('Caption start cancelled or GPU reservation expired.')
|
||||
job.status = 'running'; job.message = 'Loading Qwen2.5-VL caption model'; job.progress = 5; persist(job)
|
||||
|
||||
const port = await freePort()
|
||||
// One caption per process. killTree after response unloads VRAM (keep_alive 0).
|
||||
const child = spawnProcess(executable, [
|
||||
'-m', modelPath(), '--mmproj', mmprojPath(),
|
||||
'--host', '127.0.0.1', '--port', String(port),
|
||||
'-ngl', String(process.env.CAPTION_NGL || '99'),
|
||||
'-c', String(process.env.CAPTION_CTX || '4096'),
|
||||
'--jinja'
|
||||
], { windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } })
|
||||
run.child = child
|
||||
const log = chunk => appendFileSync(join(dir(job.id), 'worker.log'), chunk)
|
||||
child.stdout.on('data', log)
|
||||
child.stderr.on('data', log)
|
||||
const watchdog = setInterval(() => {
|
||||
if (!leaseValid(lease)) { job.error = 'GPU reservation expired; caption stopped.'; run.abort.abort(); void killTree(child) }
|
||||
}, 5000)
|
||||
|
||||
try {
|
||||
await waitForServer(port, run.abort.signal, serverWaitMs, fetchImpl)
|
||||
if (run.cancelled) throw new Error('Cancelled')
|
||||
job.message = 'Captioning'; job.progress = 40; persist(job)
|
||||
const bytes = readFileSync(imagePath)
|
||||
const dataUrl = `data:${mimeFor(imagePath)};base64,${bytes.toString('base64')}`
|
||||
const response = await fetchImpl(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: run.abort.signal.aborted ? run.abort.signal : AbortSignal.timeout(Number(process.env.CAPTION_TIMEOUT_MS || 180_000)),
|
||||
body: JSON.stringify({
|
||||
temperature: 0.2,
|
||||
max_tokens: 512,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: captionStylePrompt(request.style) },
|
||||
{ type: 'image_url', image_url: { url: dataUrl } }
|
||||
]
|
||||
}]
|
||||
})
|
||||
})
|
||||
if (run.abort.signal.aborted) throw new Error('Cancelled')
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(`llama-server caption failed (${response.status}): ${detail.slice(0, 400)}`)
|
||||
}
|
||||
const payload = await response.json()
|
||||
const text = String(payload?.choices?.[0]?.message?.content || '').trim()
|
||||
if (!text) throw new Error('Caption model returned empty text.')
|
||||
job.text = text
|
||||
job.status = run.cancelled ? 'cancelled' : 'complete'
|
||||
job.message = job.status === 'complete' ? 'Caption ready' : 'Cancelled'
|
||||
job.progress = 100
|
||||
job.checkedAt = Date.now()
|
||||
persist(job)
|
||||
return { ...job }
|
||||
} finally {
|
||||
clearInterval(watchdog)
|
||||
await killTree(child)
|
||||
holdUntil = Date.now() + 2000
|
||||
if (active === run) active = null
|
||||
}
|
||||
} catch (error) {
|
||||
job.status = run.cancelled ? 'cancelled' : 'error'
|
||||
job.error = error.message || String(error)
|
||||
job.message = job.error
|
||||
job.checkedAt = Date.now()
|
||||
persist(job)
|
||||
if (run.child) await killTree(run.child)
|
||||
if (active === run) active = null
|
||||
holdUntil = Date.now() + 2000
|
||||
throw error
|
||||
}
|
||||
},
|
||||
async cancel(id) {
|
||||
if (active?.job.id !== id) return read(id)
|
||||
const run = active
|
||||
run.cancelled = true
|
||||
run.job.status = 'cancelling'
|
||||
run.job.message = 'Cancelling'
|
||||
persist(run.job)
|
||||
run.abort.abort()
|
||||
if (run.child) await killTree(run.child)
|
||||
return read(id)
|
||||
},
|
||||
async captionOnce(body, lease) {
|
||||
const id = body.id || `caption-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
const result = await this.start({ ...body, id }, lease)
|
||||
if (result.status === 'complete') return { text: result.text, id: result.id, style: result.style }
|
||||
throw new Error(result.error || 'Caption failed.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const captionModelFiles = { model: MODEL_NAME, mmproj: MMPROJ_NAME }
|
||||
@@ -4,6 +4,7 @@ import { stableMemoryArgs } from './comfy-memory-policy.mjs'
|
||||
import { createGpuReservation } from './gpu-reservation.mjs'
|
||||
import { createGpuProxy } from './gpu-proxy.mjs'
|
||||
import { createYue2Host } from './yue2-host.mjs'
|
||||
import { createCaptionHost } from './caption-host.mjs'
|
||||
import http from 'node:http'
|
||||
import net from 'node:net'
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
@@ -147,7 +148,7 @@ let proxyTarget = 0
|
||||
|
||||
function ensureProxyListening() {
|
||||
if (proxyServer) return
|
||||
proxyServer = createGpuProxy({ target: () => proxyTarget, reservation: gpuReservation, authorized, markWork, externalBusy: () => yue2.busy() || upscale.busy() })
|
||||
proxyServer = createGpuProxy({ target: () => proxyTarget, reservation: gpuReservation, authorized, markWork, externalBusy: () => yue2.busy() || upscale.busy() || caption.busy() })
|
||||
proxyServer.on('error', (error) => {
|
||||
console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'proxy-error', error: String(error.message || error) }))
|
||||
})
|
||||
@@ -819,7 +820,7 @@ function purgeDesktopFiles(body) {
|
||||
}
|
||||
|
||||
const gpuReservation = createGpuReservation({ idle: async () => {
|
||||
if (yue2.busy() || upscale.busy()) return false
|
||||
if (yue2.busy() || upscale.busy() || caption.busy()) return false
|
||||
if ((await trainingLock()).busy) return false
|
||||
const healthy = await syncProxy()
|
||||
if (healthy) {
|
||||
@@ -830,29 +831,65 @@ const gpuReservation = createGpuReservation({ idle: async () => {
|
||||
return !(await processUp()) && !(await pythonMainUp().catch(() => true))
|
||||
} })
|
||||
|
||||
const yue2 = createYue2Host({
|
||||
leaseValid: lease => gpuReservation.isOwner(lease),
|
||||
prepare: async () => {
|
||||
async function prepareExclusiveGpu(label) {
|
||||
if ((await trainingLock()).busy) throw new Error('GPU is busy with training.')
|
||||
if (label !== 'YuE2' && yue2.busy()) throw new Error('YuE2 is using the GPU.')
|
||||
if (label !== 'caption' && caption.busy()) throw new Error('Caption is using the GPU.')
|
||||
if (upscale.busy()) throw new Error('Local upscale is using the GPU.')
|
||||
const healthy = await syncProxy()
|
||||
if (healthy) {
|
||||
const queue = await fetchLocalQueue(healthy)
|
||||
if (!queue.ok || queue.running || queue.pending) throw new Error('Comfy is busy; YuE2 cannot start.')
|
||||
if (!queue.ok || queue.running || queue.pending) throw new Error(`Comfy is busy; ${label} cannot start.`)
|
||||
}
|
||||
// Stop Comfy and refuse to launch while its Python still owns VRAM.
|
||||
if (healthy || await processUp() || await pythonMainUp().catch(() => false)) {
|
||||
await stopComfyProcesses()
|
||||
markAsleep()
|
||||
}
|
||||
if (await pythonMainUp()) throw new Error('Comfy has not stopped; retry after the GPU is free.')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const upscale = createUpscaleHost({ leaseValid: token => gpuReservation.isOwner(token) })
|
||||
|
||||
const yue2 = createYue2Host({
|
||||
leaseValid: lease => gpuReservation.isOwner(lease),
|
||||
prepare: async () => prepareExclusiveGpu('YuE2')
|
||||
})
|
||||
|
||||
const caption = createCaptionHost({
|
||||
leaseValid: lease => gpuReservation.isOwner(lease),
|
||||
prepare: async () => prepareExclusiveGpu('caption')
|
||||
})
|
||||
|
||||
async function handleControl(req, res) {
|
||||
if (!authorized(req)) return json(res, 401, { ok: false, error: 'unauthorized' })
|
||||
const url = new URL(req.url || '/', 'http://localhost')
|
||||
if (url.pathname.startsWith('/caption')) {
|
||||
const match = url.pathname.match(/^\/caption\/jobs\/([a-zA-Z0-9-]{12,80})(\/input|\/cancel)?$/)
|
||||
if (req.method === 'GET' && url.pathname === '/caption/status') {
|
||||
return json(res, 200, { configured: caption.configured(), busy: caption.busy(), backend: 'llama.cpp', modelsDir: caption.modelsDir() })
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/caption') {
|
||||
try {
|
||||
const body = await readJson(req)
|
||||
return json(res, 200, await caption.captionOnce(body, String(req.headers['x-aigen-gpu-lease'] || '')))
|
||||
} catch (error) {
|
||||
return json(res, error.statusCode || 400, { error: error.message || 'Caption failed' })
|
||||
}
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/caption/jobs') {
|
||||
return json(res, 200, await caption.start(await readJson(req), String(req.headers['x-aigen-gpu-lease'] || '')))
|
||||
}
|
||||
if (match && req.method === 'PUT' && match[2] === '/input') {
|
||||
await caption.upload(match[1], req)
|
||||
return json(res, 200, { uploaded: true })
|
||||
}
|
||||
if (match && req.method === 'POST' && match[2] === '/cancel') return json(res, 200, await caption.cancel(match[1]))
|
||||
if (match && req.method === 'GET' && !match[2]) {
|
||||
const job = caption.read(match[1])
|
||||
return json(res, job ? 200 : 404, job || { error: 'Caption job not found' })
|
||||
}
|
||||
return json(res, 404, { error: 'Unknown caption endpoint' })
|
||||
}
|
||||
if (url.pathname.startsWith('/upscale/')) {
|
||||
const match = url.pathname.match(/^\/upscale\/jobs\/([a-zA-Z0-9-]{12,80})(\/input|\/video|\/cancel)?$/)
|
||||
if (req.method === 'POST' && url.pathname === '/upscale/jobs') return json(res, 200, upscale.start(await readJson(req), String(req.headers['x-aigen-gpu-lease'] || '')))
|
||||
@@ -909,12 +946,14 @@ async function handleControl(req, res) {
|
||||
gpu: gpuReservation.availability(),
|
||||
training: { busy: lastTraining.busy },
|
||||
yue2: { busy: yue2.busy(), configured: yue2.configured() },
|
||||
caption: { busy: caption.busy(), configured: caption.configured() },
|
||||
upscale: { busy: upscale.busy(), engine: 'realesrgan-rife', local: true }
|
||||
})
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/start') {
|
||||
if (upscale.busy()) return json(res, 409, { message: 'Local upscale is using the GPU.' })
|
||||
if (yue2.busy()) return json(res, 409, { message: 'YuE2 is using the GPU.' })
|
||||
if (caption.busy()) return json(res, 409, { message: 'Caption is using the GPU.' })
|
||||
const training = await trainingLock()
|
||||
if (training.busy) {
|
||||
return json(res, 409, {
|
||||
@@ -1016,7 +1055,7 @@ const server = http.createServer(async (req, res) => {
|
||||
} else await handleControl(req, res)
|
||||
} catch (error) {
|
||||
req.resume()
|
||||
if ((String(req.url || '').startsWith('/yue2/') || String(req.url || '').startsWith('/upscale/')) && !res.headersSent) return json(res, error.statusCode || 400, { error: error.message || 'Music host request failed' })
|
||||
if ((String(req.url || '').startsWith('/yue2/') || String(req.url || '').startsWith('/upscale/') || String(req.url || '').startsWith('/caption')) && !res.headersSent) return json(res, error.statusCode || 400, { error: error.message || 'Host request failed' })
|
||||
if (!res.headersSent) json(res, error.statusCode || 400, { ok: false, message: error.statusCode === 409 ? 'GPU is in use. Waiting for availability.' : 'GPU coordination request failed.' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Caption (Qwen2.5-VL NSFW Caption V4 GGUF) — verify host files only.
|
||||
# Models are already on the 5080 host. Do not download. Do not search Hugging Face.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Resolve-ModelsRoot {
|
||||
if ($env:COMFY_MODELS_ROOT) { return $env:COMFY_MODELS_ROOT }
|
||||
$shared = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models'
|
||||
if (Test-Path $shared) { return $shared }
|
||||
throw 'Set COMFY_MODELS_ROOT or install Comfy Desktop Shared models.'
|
||||
}
|
||||
|
||||
$root = Join-Path (Resolve-ModelsRoot) 'caption\qwen25vl-7b-nsfw-v4'
|
||||
if (-not (Test-Path $root)) {
|
||||
throw "Caption models folder missing: $root"
|
||||
}
|
||||
|
||||
$files = @(
|
||||
'Qwen2.5-VL-7B-NSFW-Caption-V4.Q5_K_M.gguf',
|
||||
'Qwen2.5-VL-7B-NSFW-Caption-V4.mmproj-f16.gguf'
|
||||
)
|
||||
|
||||
foreach ($name in $files) {
|
||||
$path = Join-Path $root $name
|
||||
if (-not (Test-Path $path)) {
|
||||
throw "Missing $name under $root. Do not download; use the files already on this host."
|
||||
}
|
||||
Write-Host "OK $name"
|
||||
}
|
||||
|
||||
$llama = $null
|
||||
if ($env:CAPTION_LLAMA_SERVER -and (Test-Path $env:CAPTION_LLAMA_SERVER)) {
|
||||
$llama = Get-Item $env:CAPTION_LLAMA_SERVER
|
||||
} else {
|
||||
$winget = Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages\ggml.llamacpp_Microsoft.Winget.Source_8wekyb3d8bbwe\llama-server.exe'
|
||||
if (Test-Path $winget) { $llama = Get-Item $winget }
|
||||
else { $llama = Get-Command llama-server -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
if (-not $llama) {
|
||||
throw 'llama-server not found. Install with: winget install ggml.llamacpp'
|
||||
}
|
||||
$llamaPath = if ($llama.PSObject.Properties['FullName'] -and $llama.FullName) { $llama.FullName } elseif ($llama.PSObject.Properties['Source'] -and $llama.Source) { $llama.Source } else { [string]$llama }
|
||||
Write-Host "llama-server: $llamaPath"
|
||||
|
||||
$ready = @{
|
||||
model = 'Qwen2.5-VL-7B-NSFW-Caption-V4.Q5_K_M.gguf'
|
||||
mmproj = 'Qwen2.5-VL-7B-NSFW-Caption-V4.mmproj-f16.gguf'
|
||||
modelsDir = $root
|
||||
llamaServer = $llamaPath
|
||||
readyAt = (Get-Date).ToUniversalTime().ToString('o')
|
||||
} | ConvertTo-Json
|
||||
Set-Content -Path (Join-Path $root 'aigen-ready.json') -Value $ready -Encoding utf8
|
||||
Write-Host "Caption ready: $root"
|
||||
@@ -18,6 +18,12 @@ Get-NetTCPConnection -LocalPort 8199 -State Listen -ErrorAction SilentlyContinue
|
||||
|
||||
Start-Sleep -Milliseconds 400
|
||||
|
||||
# Caption VLM — point at existing host GGUFs (no download).
|
||||
$captionModels = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models\caption\qwen25vl-7b-nsfw-v4'
|
||||
$llamaServer = Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages\ggml.llamacpp_Microsoft.Winget.Source_8wekyb3d8bbwe\llama-server.exe'
|
||||
if (Test-Path $captionModels) { $env:CAPTION_MODELS = $captionModels }
|
||||
if (Test-Path $llamaServer) { $env:CAPTION_LLAMA_SERVER = $llamaServer }
|
||||
|
||||
if ($Wait) {
|
||||
Set-Location $root
|
||||
& $node $agent
|
||||
@@ -31,4 +37,5 @@ $psi.WorkingDirectory = $root
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
|
||||
# CAPTION_* already set on this process; child inherits when UseShellExecute is false
|
||||
[void][System.Diagnostics.Process]::Start($psi)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { validateCaptionBody } from '~/shared/caption.mjs'
|
||||
import { addStudioJob, kickStudioQueue, type StudioJobPayload } from '~/server/utils/studioQueue'
|
||||
import { getStill } from '~/server/utils/library'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const raw = await readBody(event)
|
||||
let body: ReturnType<typeof validateCaptionBody>
|
||||
try {
|
||||
body = validateCaptionBody(raw)
|
||||
} catch (e: any) {
|
||||
throw createError({ statusCode: e.statusCode || 400, statusMessage: e.message })
|
||||
}
|
||||
assertFolderAccess(event, body.folderId)
|
||||
const still = getStill(owner, body.stillId)
|
||||
assertFolderAccess(event, still.folderId)
|
||||
|
||||
const row = await addStudioJob({
|
||||
ownerKey: owner,
|
||||
kind: 'caption',
|
||||
familyId: crypto.randomUUID(),
|
||||
payload: {
|
||||
prompt: `Describe · ${body.captionStyle}`,
|
||||
name: `Describe · ${body.captionStyle}`,
|
||||
folderId: body.folderId,
|
||||
stillId: still.id,
|
||||
captionStyle: body.captionStyle,
|
||||
aspect: 'auto',
|
||||
width: 0,
|
||||
height: 0,
|
||||
steps: 1,
|
||||
turbo: true,
|
||||
seed: 0,
|
||||
cfg: 0,
|
||||
fps: 24,
|
||||
samplerName: 'euler',
|
||||
scheduler: 'simple',
|
||||
duration: 0,
|
||||
sound: false,
|
||||
workflow: 'minimax',
|
||||
useIdentityRefs: false,
|
||||
hideThumbnail: false,
|
||||
referenceStillIds: [],
|
||||
extensions: [],
|
||||
queueAutoRun: true
|
||||
} as StudioJobPayload
|
||||
})
|
||||
kickStudioQueue()
|
||||
return { id: row.id, liveJobId: row.liveJobId, captionStyle: body.captionStyle, stillId: still.id }
|
||||
})
|
||||
@@ -48,7 +48,7 @@ export default defineEventHandler(async () => {
|
||||
host: comfy.host || '',
|
||||
queueRunning: queue.running,
|
||||
queuePending: queue.pending,
|
||||
asleep: Boolean(control?.asleep) && !comfyReachable && !control?.yue2?.busy,
|
||||
asleep: Boolean(control?.asleep) && !comfyReachable && !control?.yue2?.busy && !control?.caption?.busy,
|
||||
processUp: Boolean(control?.process ?? control?.processUp),
|
||||
lastActivityAt: control?.lastActivityAt || null,
|
||||
stats: comfy.ok ? comfy.stats : undefined
|
||||
@@ -58,6 +58,11 @@ export default defineEventHandler(async () => {
|
||||
busy: Boolean(control?.yue2?.busy),
|
||||
backend: control?.yue2?.backend || 'yue2'
|
||||
},
|
||||
caption: {
|
||||
configured: Boolean(control?.caption?.configured),
|
||||
busy: Boolean(control?.caption?.busy),
|
||||
backend: control?.caption?.backend || 'llama.cpp'
|
||||
},
|
||||
imageComfy: {
|
||||
configured: imageConfigured,
|
||||
ok: imageOk,
|
||||
|
||||
@@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
const etag = fileEtag(path)
|
||||
setHeader(event, 'ETag', etag)
|
||||
setHeader(event, 'Cache-Control', 'private, max-age=86400, stale-while-revalidate=604800')
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=86400')
|
||||
if (getHeader(event, 'if-none-match') === etag) {
|
||||
setResponseStatus(event, 304)
|
||||
return null
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { publicLibrary, publicLibraryPage } from '~/server/utils/library'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const light = String(getQuery(event).light || '') === '1'
|
||||
const library = publicLibrary(event, { light })
|
||||
try {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const folderId = library.folders.find(folder => folder.unlocked)?.id
|
||||
void maintainLibrary(owner, folderId).catch(() => null)
|
||||
} catch { /* locked or anonymous — still return what we can */ }
|
||||
return library
|
||||
const query = getQuery(event)
|
||||
const light = String(query.light || '') === '1'
|
||||
const full = String(query.full || '') === '1'
|
||||
const limitRaw = query.limit
|
||||
const hasLimit = limitRaw != null && String(limitRaw) !== ''
|
||||
// Default GET /api/library → page helper (limit 50). Opt into full catalog with full=1 or legacy light=1.
|
||||
const wantsFull = full || (light && !hasLimit)
|
||||
|
||||
if (!wantsFull) {
|
||||
return publicLibraryPage(event, {
|
||||
limit: hasLimit ? Number(limitRaw) || 50 : 50,
|
||||
cursor: String(query.cursor || ''),
|
||||
folderId: String(query.folderId || query.folder || ''),
|
||||
type: String(query.type || query.filter || 'all')
|
||||
})
|
||||
}
|
||||
|
||||
// Full catalog path — no thumb prewarm on the request (thumbs build on /thumb hit)
|
||||
return publicLibrary(event, { light: true })
|
||||
})
|
||||
|
||||
@@ -1,23 +1,44 @@
|
||||
import { existsSync, createReadStream } from 'node:fs'
|
||||
import { fileEtag, stillPath, stillThumbPath, sniffImageType } from '~/server/utils/library'
|
||||
import { fileEtag, stillPath, existingStillThumbPath, stillThumbPath, sniffImageType } from '~/server/utils/library'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
export default defineEventHandler((event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const still = getStill(owner, id)
|
||||
assertFolderAccess(event, still.folderId)
|
||||
const wantThumb = String(getQuery(event).thumb || '') === '1'
|
||||
const path = wantThumb ? await stillThumbPath(owner, id) : stillPath(owner, still.id)
|
||||
if (!path || !existsSync(path)) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Image file is missing' })
|
||||
}
|
||||
const original = stillPath(owner, still.id)
|
||||
|
||||
if (wantThumb) {
|
||||
const path = existingStillThumbPath(owner, id)
|
||||
if (path && path !== original && existsSync(path)) {
|
||||
const etag = fileEtag(path)
|
||||
setHeader(event, 'ETag', etag)
|
||||
setHeader(event, 'Cache-Control', wantThumb ? 'private, max-age=86400, stale-while-revalidate=604800' : 'private, max-age=3600')
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=86400')
|
||||
if (getHeader(event, 'if-none-match') === etag) {
|
||||
setResponseStatus(event, 304)
|
||||
return null
|
||||
}
|
||||
setHeader(event, 'Content-Type', wantThumb ? 'image/jpeg' : sniffImageType(path))
|
||||
setHeader(event, 'Content-Type', 'image/jpeg')
|
||||
return sendStream(event, createReadStream(path))
|
||||
}
|
||||
void stillThumbPath(owner, id).catch(() => null)
|
||||
setHeader(event, 'Cache-Control', 'no-store')
|
||||
setResponseStatus(event, 204)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!existsSync(original)) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Image file is missing' })
|
||||
}
|
||||
|
||||
const etag = fileEtag(original)
|
||||
setHeader(event, 'ETag', etag)
|
||||
setHeader(event, 'Cache-Control', 'private, max-age=3600')
|
||||
if (getHeader(event, 'if-none-match') === etag) {
|
||||
setResponseStatus(event, 304)
|
||||
return null
|
||||
}
|
||||
setHeader(event, 'Content-Type', sniffImageType(original))
|
||||
return sendStream(event, createReadStream(original))
|
||||
})
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { existsSync, createReadStream } from 'node:fs'
|
||||
import { fileEtag, stillPath, stillPreviewPath, existingStillPreviewPath } from '~/server/utils/library'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const still = getStill(owner, id)
|
||||
assertFolderAccess(event, still.folderId)
|
||||
|
||||
const path = existingStillPreviewPath(owner, id)
|
||||
const original = stillPath(owner, still.id)
|
||||
// Ready preview → stream. Never await ffmpeg; never stream the archive master.
|
||||
if (path && existsSync(path) && path !== original) {
|
||||
const etag = fileEtag(path)
|
||||
setHeader(event, 'ETag', etag)
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=86400')
|
||||
if (getHeader(event, 'if-none-match') === etag) {
|
||||
setResponseStatus(event, 304)
|
||||
return null
|
||||
}
|
||||
setHeader(event, 'Content-Type', 'image/jpeg')
|
||||
return sendStream(event, createReadStream(path))
|
||||
}
|
||||
|
||||
// Missing → answer now; build .preview.jpg in the background
|
||||
void stillPreviewPath(owner, id).catch(() => null)
|
||||
setHeader(event, 'Cache-Control', 'no-store')
|
||||
setResponseStatus(event, 204)
|
||||
return null
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { existsSync, createReadStream } from 'node:fs'
|
||||
import { fileEtag, stillPath, stillThumbPath, existingStillThumbPath } from '~/server/utils/library'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const still = getStill(owner, id)
|
||||
assertFolderAccess(event, still.folderId)
|
||||
|
||||
const path = existingStillThumbPath(owner, id)
|
||||
const original = stillPath(owner, still.id)
|
||||
// Ready thumb → stream. Never await ffmpeg on the request path.
|
||||
if (path && existsSync(path) && path !== original) {
|
||||
const etag = fileEtag(path)
|
||||
setHeader(event, 'ETag', etag)
|
||||
setHeader(event, 'Cache-Control', 'public, max-age=86400')
|
||||
if (getHeader(event, 'if-none-match') === etag) {
|
||||
setResponseStatus(event, 304)
|
||||
return null
|
||||
}
|
||||
setHeader(event, 'Content-Type', 'image/jpeg')
|
||||
return sendStream(event, createReadStream(path))
|
||||
}
|
||||
|
||||
// Missing → answer now; build .thumb.jpg in the background (job save / upload also kick this)
|
||||
void stillThumbPath(owner, id).catch(() => null)
|
||||
setHeader(event, 'Cache-Control', 'no-store')
|
||||
setResponseStatus(event, 204)
|
||||
return null
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { listPromptSequences } from '~/server/utils/promptSequences'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
return {
|
||||
sequences: listPromptSequences(owner)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { listPromptSequences, savePromptSequence } from '~/server/utils/promptSequences'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const body = await readBody<{
|
||||
name?: string
|
||||
characters?: unknown
|
||||
sets?: unknown
|
||||
scenes?: unknown
|
||||
overwrite?: boolean
|
||||
}>(event).catch(() => ({}))
|
||||
const sequence = await savePromptSequence(owner, {
|
||||
name: body?.name || '',
|
||||
characters: body?.characters,
|
||||
sets: body?.sets,
|
||||
scenes: body?.scenes,
|
||||
overwrite: body?.overwrite === true
|
||||
})
|
||||
return {
|
||||
sequence,
|
||||
sequences: listPromptSequences(owner)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { deletePromptSequence, listPromptSequences } from '~/server/utils/promptSequences'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
await deletePromptSequence(owner, id)
|
||||
return {
|
||||
ok: true,
|
||||
sequences: listPromptSequences(owner)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { getPromptSequence } from '~/server/utils/promptSequences'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
return {
|
||||
sequence: getPromptSequence(owner, id)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { listPromptSequences, updatePromptSequence } from '~/server/utils/promptSequences'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const id = String(getRouterParam(event, 'id') || '')
|
||||
const body = await readBody<{ name?: string; characters?: unknown; sets?: unknown; scenes?: unknown }>(event).catch(() => ({}))
|
||||
const sequence = await updatePromptSequence(owner, id, {
|
||||
name: body?.name,
|
||||
characters: body?.characters,
|
||||
sets: body?.sets,
|
||||
scenes: body?.scenes
|
||||
})
|
||||
return {
|
||||
sequence,
|
||||
sequences: listPromptSequences(owner)
|
||||
}
|
||||
})
|
||||
@@ -1,15 +1,40 @@
|
||||
import { records, saveRecord } from '../../utils/studio2/store';
|
||||
import { listStudioJobs } from '../../utils/studioQueue';
|
||||
export default defineEventHandler(event => {
|
||||
const { owner } = assertLibraryOwner(event), queue=listStudioJobs(owner);
|
||||
let cleared=0;
|
||||
import { records, saveRecord } from '../../utils/studio2/store'
|
||||
import { dismissFinishedStudioJobs, dismissStudioJob, listStudioJobs } from '../../utils/studioQueue'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const body = await readBody<{ id?: string }>(event).catch(() => ({} as { id?: string }))
|
||||
const queue = listStudioJobs(owner)
|
||||
let cleared = 0
|
||||
|
||||
if (body?.id) {
|
||||
const row = queue.find(job => job.id === body.id)
|
||||
if (!row) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
|
||||
try { assertFolderAccess(event, row.payload.folderId) } catch {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Folder locked' })
|
||||
}
|
||||
await dismissStudioJob(owner, body.id)
|
||||
return { cleared: 1 }
|
||||
}
|
||||
|
||||
for (const r of records(owner)) {
|
||||
try { assertFolderAccess(event,r.request.folderId); } catch { continue; }
|
||||
const status=queue.find(q=>q.id===r.queueId)?.status;
|
||||
if (['complete','failed','cancelled'].includes(r.state) || ['cancelled','error'].includes(status || '')) {
|
||||
r.dismissed=true; saveRecord(r); cleared++;
|
||||
try { assertFolderAccess(event, r.request.folderId) } catch { continue }
|
||||
const status = queue.find(q => q.id === r.queueId)?.status
|
||||
if (['complete', 'failed', 'cancelled'].includes(r.state) || ['cancelled', 'error'].includes(status || '')) {
|
||||
r.dismissed = true
|
||||
saveRecord(r)
|
||||
cleared++
|
||||
}
|
||||
}
|
||||
// Retain internal manifests for cleanup retries; remove these records from the visible jobs list.
|
||||
return {cleared};
|
||||
});
|
||||
|
||||
// Caption (and other finished studio-queue) cards — hide from Queue, keep resultText on the row
|
||||
const finishedIds = queue
|
||||
.filter(job => !job.dismissed && ['complete', 'error', 'cancelled'].includes(job.status))
|
||||
.filter(job => {
|
||||
try { assertFolderAccess(event, job.payload.folderId); return true } catch { return false }
|
||||
})
|
||||
.map(job => job.id)
|
||||
cleared += await dismissFinishedStudioJobs(owner, finishedIds)
|
||||
|
||||
return { cleared }
|
||||
})
|
||||
|
||||
@@ -25,11 +25,18 @@ export default defineEventHandler(async (event) => {
|
||||
assertFolderAccess(event, getStill(owner, id).folderId);
|
||||
if (request.startClipId)
|
||||
assertFolderAccess(event, getClip(owner, request.startClipId).folderId);
|
||||
// Header-only size probe — never decode the archive PNG on Generate click
|
||||
await resolveRequestSize(owner,request);
|
||||
const id = crypto.randomUUID(), record = { id, owner, request, state: 'waiting', queuedAt: Date.now(), familyId: crypto.randomUUID(), outputs: [], purgeResult: 'Not yet saved' };
|
||||
saveRecord(record);
|
||||
const ack = { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height };
|
||||
// Enqueue + kick after the client has the id — do not block the response on the worker wake path
|
||||
void (async () => {
|
||||
try {
|
||||
const row = await addStudioJob({ ownerKey: owner, kind: ['video', 'extend'].includes(request.mode) ? 'video' : 'edit', familyId: record.familyId, payload: { ...request.settings, studio2Id: id, studio2Request: structuredClone(request), prompt: request.compiledPrompt, folderId: request.folderId, extensions: [], referenceStillIds: [], useIdentityRefs: false, queueAutoRun: true } as StudioJobPayload });
|
||||
saveRecord({ ...readRecord(owner, id), queueId: row.id });
|
||||
} catch { /* poll / resume will pick up waiting studio2 records */ }
|
||||
kickStudioQueue();
|
||||
return { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height };
|
||||
})();
|
||||
return ack;
|
||||
});
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog';
|
||||
import { publicLibrary, stillPath, clipVideoPath } from '../../utils/library';
|
||||
export default defineEventHandler(event => {
|
||||
const { owner } = assertLibraryOwner(event);
|
||||
const library = publicLibrary(event);
|
||||
const catalog=studio2LoraCatalog(owner);
|
||||
return {...library,loraAliases:catalog.aliases,loraTriggers:catalog.triggers, stills:library.stills.filter(s=>existsSync(stillPath(owner,s.id))), clips:library.clips.filter(c=>existsSync(clipVideoPath(owner,c.id))), tracks:library.tracks || []};
|
||||
});
|
||||
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog'
|
||||
import { publicLibrary } from '../../utils/library'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
// light:true — metadata only; never existsSync every still/clip on the volume
|
||||
const library = publicLibrary(event, { light: true })
|
||||
const catalog = studio2LoraCatalog(owner)
|
||||
return {
|
||||
...library,
|
||||
loraAliases: catalog.aliases,
|
||||
loraTriggers: catalog.triggers,
|
||||
tracks: library.tracks || []
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { readFileSync,writeFileSync,renameSync } from 'node:fs'
|
||||
import { readFileSync, writeFileSync, renameSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { cachedLoras } from '~/shared/studio2/lora-cache.mjs'
|
||||
import { listStudioLoras } from '../../utils/loras'
|
||||
import { studio2Root } from '../../utils/studio2/store'
|
||||
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog'
|
||||
|
||||
export default defineEventHandler(async event => {
|
||||
export default defineEventHandler(async (event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const path = join(studio2Root(), `loras-${owner}.cache`)
|
||||
// Same live Comfy scan as Settings (/api/loras?fresh=1). Always fresh so CACHE_MS cannot freeze adds.
|
||||
const fresh = String(getQuery(event).fresh || '') === '1'
|
||||
const listed = await cachedLoras({
|
||||
read: () => { try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return null } },
|
||||
write: value => { writeFileSync(path + '.tmp', JSON.stringify(value)); renameSync(path + '.tmp', path) },
|
||||
live: () => listStudioLoras({ fresh: true }),
|
||||
fresh: true
|
||||
fresh
|
||||
})
|
||||
return { ...listed, ...studio2LoraCatalog(owner) }
|
||||
})
|
||||
|
||||
@@ -5,18 +5,21 @@ export default defineEventHandler((event) => {
|
||||
const { owner } = assertLibraryOwner(event)
|
||||
const full = String(getQuery(event).full || '') === '1'
|
||||
const active = listStudioJobs(owner)
|
||||
.filter(job => !job.dismissed)
|
||||
.filter(job => (
|
||||
job.status === 'waiting'
|
||||
|| job.status === 'running'
|
||||
|| job.status === 'held'
|
||||
|| (job.status === 'error' && Date.now() - job.updatedAt < 60 * 60 * 1000)
|
||||
|| (job.kind === 'caption' && job.status === 'complete' && Date.now() - job.updatedAt < 60 * 60 * 1000)
|
||||
|| (job.kind === 'caption' && job.status === 'cancelled' && Date.now() - job.updatedAt < 60 * 60 * 1000)
|
||||
))
|
||||
const claimed = new Set(active.map(job => job.shotQueueId).filter(Boolean) as string[])
|
||||
const queues = listShotQueues(owner)
|
||||
const byId = new Map(queues.map(queue => [queue.id, queue]))
|
||||
const jobs = active.map((job) => {
|
||||
const shots = job.shotQueueId ? byId.get(job.shotQueueId) || null : null
|
||||
const kind = job.kind === 'edit' ? 'edit' : job.kind === 'music' ? 'music' : 'video'
|
||||
const kind = job.kind === 'edit' ? 'edit' : job.kind === 'music' ? 'music' : job.kind === 'caption' ? 'caption' : 'video'
|
||||
const shotSummary = shots ? summarizeQueue(shots) : null
|
||||
const plannedShots = shotSummary
|
||||
? undefined
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { resumeCaptionJobs } from '~/server/utils/caption'
|
||||
|
||||
export default defineNitroPlugin(() => { resumeCaptionJobs() })
|
||||
@@ -0,0 +1,232 @@
|
||||
import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { createJob, emitJob, getJob, restoreJob, type Job } from './jobs'
|
||||
import { getStill, stillPath } from './library'
|
||||
import { sharedGpuHeaders } from './sharedGpu'
|
||||
import { CAPTION_STYLES } from '~/shared/caption.mjs'
|
||||
|
||||
function settings() {
|
||||
const config = useRuntimeConfig()
|
||||
return {
|
||||
url: String(config.comfyControlUrl || process.env.COMFY_CONTROL_URL || '').replace(/\/$/, ''),
|
||||
token: String(config.comfyControlToken || process.env.COMFY_CONTROL_TOKEN || '')
|
||||
}
|
||||
}
|
||||
|
||||
export function captionConfigured() { return Boolean(settings().url) }
|
||||
|
||||
function pendingRoot() {
|
||||
return join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'caption-pending')
|
||||
}
|
||||
|
||||
export function captionPendingAlive(jobId: string) {
|
||||
return Boolean(jobId) && existsSync(join(pendingRoot(), `${jobId}.json`))
|
||||
}
|
||||
|
||||
async function request(path: string, method = 'GET', body?: unknown) {
|
||||
const { url, token } = settings()
|
||||
if (!url) throw new Error('Caption host is not configured. Set COMFY_CONTROL_URL.')
|
||||
const response = await fetch(`${url}/caption/${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(method === 'GET' ? {} : sharedGpuHeaders()),
|
||||
...(method === 'POST' ? { 'Content-Type': 'application/json' } : {})
|
||||
},
|
||||
body: method === 'POST' ? JSON.stringify(body) : body as BodyInit | undefined,
|
||||
...(method === 'PUT' ? { duplex: 'half' as const } : {}),
|
||||
// Caption loads a 7B VLM; allow the host POST to finish or fall through to watch
|
||||
signal: AbortSignal.timeout(method === 'PUT' ? 120_000 : method === 'POST' ? 300_000 : 60_000)
|
||||
} as RequestInit)
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => ({})) as { message?: string; error?: string }
|
||||
throw Object.assign(new Error(detail.message || detail.error || `Caption host returned ${response.status}`), { statusCode: response.status })
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
function persist(record: Record<string, unknown>) {
|
||||
mkdirSync(pendingRoot(), { recursive: true })
|
||||
const path = join(pendingRoot(), `${record.id}.json`)
|
||||
writeFileSync(path + '.tmp', JSON.stringify(record))
|
||||
renameSync(path + '.tmp', path)
|
||||
}
|
||||
|
||||
function clearPending(id: string) {
|
||||
const path = join(pendingRoot(), `${id}.json`)
|
||||
if (existsSync(path)) unlinkSync(path)
|
||||
}
|
||||
|
||||
function writeSidecar(owner: string, stillId: string, text: string) {
|
||||
try {
|
||||
const image = stillPath(owner, stillId)
|
||||
if (!existsSync(image)) return
|
||||
writeFileSync(`${image}.txt`, text, 'utf8')
|
||||
} catch { /* optional */ }
|
||||
}
|
||||
|
||||
export async function cancelCaptionJob(job: Job) {
|
||||
await request(`jobs/${job.id}/cancel`, 'POST', {})
|
||||
job.status = 'cancelled'
|
||||
emitJob(job, { type: 'error', error: 'Cancelled', message: 'Cancelled' })
|
||||
}
|
||||
|
||||
async function settle(job: Job) {
|
||||
const { onLiveVideoSettled } = await import('./studioQueue')
|
||||
await onLiveVideoSettled(job)
|
||||
clearPending(job.id)
|
||||
}
|
||||
|
||||
async function watch(job: Job, record: {
|
||||
id: string
|
||||
owner: string
|
||||
stillId: string
|
||||
folderId: string
|
||||
captionStyle: string
|
||||
liveId: string
|
||||
}) {
|
||||
let failures = 0
|
||||
while (job.status !== 'cancelled') {
|
||||
try {
|
||||
const state = await (await request(`jobs/${job.id}`)).json() as {
|
||||
status: string; message?: string; error?: string; progress?: number; text?: string
|
||||
}
|
||||
if (job.status === 'cancelled') { await settle(job); return }
|
||||
if (state.status === 'error' || state.status === 'cancelled') {
|
||||
job.status = state.status === 'cancelled' ? 'cancelled' : 'error'
|
||||
job.error = state.error || state.message || 'Caption failed'
|
||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||
await settle(job)
|
||||
return
|
||||
}
|
||||
if (state.status === 'complete') {
|
||||
const text = String(state.text || '').trim()
|
||||
job.message = 'Caption ready'
|
||||
job.progress = 100
|
||||
job.status = 'complete'
|
||||
job.resultText = text
|
||||
writeSidecar(record.owner, record.stillId, text)
|
||||
persist({ ...record, resultText: text, status: 'complete' })
|
||||
emitJob(job, { type: 'complete', message: 'Caption ready', progress: 100 })
|
||||
await settle(job)
|
||||
return
|
||||
}
|
||||
failures = 0
|
||||
job.status = 'running'
|
||||
emitJob(job, {
|
||||
type: 'progress',
|
||||
message: state.message || 'Captioning',
|
||||
progress: Math.min(95, Math.max(1, Number(state.progress || 10)))
|
||||
})
|
||||
} catch (error) {
|
||||
failures++
|
||||
emitJob(job, { type: 'status', message: `Caption host check failed; retrying: ${error instanceof Error ? error.message : String(error)}` })
|
||||
if (failures >= 10) {
|
||||
job.status = 'error'
|
||||
job.error = 'Caption host unreachable.'
|
||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||
await settle(job)
|
||||
return
|
||||
}
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
}
|
||||
await settle(job)
|
||||
}
|
||||
|
||||
export async function startCaptionJob(params: {
|
||||
ownerKey: string
|
||||
folderId: string
|
||||
stillId: string
|
||||
captionStyle: string
|
||||
name?: string
|
||||
}) {
|
||||
if (!CAPTION_STYLES.includes(params.captionStyle)) throw new Error('Unknown caption style.')
|
||||
getStill(params.ownerKey, params.stillId)
|
||||
const job = createJob('caption')
|
||||
job.caption = true
|
||||
job.library = {
|
||||
ownerKey: params.ownerKey,
|
||||
folderId: params.folderId,
|
||||
hideThumbnail: false,
|
||||
name: params.name || `Describe · ${params.captionStyle}`,
|
||||
prompt: params.captionStyle,
|
||||
aspect: 'image',
|
||||
width: 0,
|
||||
height: 0,
|
||||
steps: 1,
|
||||
turbo: true,
|
||||
seed: 0,
|
||||
stillId: params.stillId,
|
||||
engine: 'caption'
|
||||
}
|
||||
const record = {
|
||||
id: job.id,
|
||||
liveId: job.id,
|
||||
owner: params.ownerKey,
|
||||
stillId: params.stillId,
|
||||
folderId: params.folderId,
|
||||
captionStyle: params.captionStyle,
|
||||
createdAt: Date.now(),
|
||||
status: 'running'
|
||||
}
|
||||
persist(record)
|
||||
void (async () => {
|
||||
try {
|
||||
if (job.status === 'cancelled') { await settle(job); return }
|
||||
await request(`jobs/${job.id}/input`, 'PUT', createReadStream(stillPath(params.ownerKey, params.stillId)))
|
||||
if (job.status === 'cancelled') { await settle(job); return }
|
||||
try {
|
||||
await request('jobs', 'POST', { id: job.id, style: params.captionStyle })
|
||||
} catch { /* confirm by ID on timeout */ }
|
||||
await watch(job, record)
|
||||
} catch (error) {
|
||||
const statusCode = (error as { statusCode?: number })?.statusCode
|
||||
if (statusCode && statusCode >= 400 && statusCode < 500) {
|
||||
job.status = 'error'
|
||||
job.error = error instanceof Error ? error.message : String(error)
|
||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||
await settle(job)
|
||||
return
|
||||
}
|
||||
emitJob(job, { type: 'status', message: `Checking caption submission: ${error instanceof Error ? error.message : String(error)}` })
|
||||
await watch(job, record)
|
||||
}
|
||||
})()
|
||||
return job
|
||||
}
|
||||
|
||||
export function resumeCaptionJobs() {
|
||||
if (!existsSync(pendingRoot())) return
|
||||
for (const file of readdirSync(pendingRoot()).filter(file => /^[a-zA-Z0-9-]+\.json$/.test(file))) {
|
||||
try {
|
||||
const record = JSON.parse(readFileSync(join(pendingRoot(), file), 'utf8'))
|
||||
if (getJob(record.id)) continue
|
||||
const job = restoreJob({
|
||||
id: record.liveId || record.id,
|
||||
clientId: record.clientId || crypto.randomUUID(),
|
||||
promptId: '',
|
||||
startedAt: record.createdAt || Date.now(),
|
||||
library: {
|
||||
ownerKey: record.owner,
|
||||
folderId: record.folderId,
|
||||
hideThumbnail: false,
|
||||
prompt: record.captionStyle || 'descriptive',
|
||||
aspect: 'image',
|
||||
width: 0,
|
||||
height: 0,
|
||||
steps: 1,
|
||||
turbo: true,
|
||||
seed: 0,
|
||||
stillId: record.stillId,
|
||||
engine: 'caption'
|
||||
}
|
||||
})
|
||||
job.caption = true
|
||||
job.kind = 'caption'
|
||||
if (record.resultText) job.resultText = record.resultText
|
||||
job.message = 'Reconnecting to caption host'
|
||||
void watch(job, record)
|
||||
} catch { /* keep bad records */ }
|
||||
}
|
||||
}
|
||||
@@ -36,9 +36,11 @@ export interface Job {
|
||||
upscale?: boolean
|
||||
yueGp?: boolean
|
||||
yue2?: boolean
|
||||
caption?: boolean
|
||||
resultText?: string
|
||||
musicActivity?: { checkedAt: number; running: boolean }
|
||||
id: string
|
||||
kind?: 'video' | 'edit' | 'music'
|
||||
kind?: 'video' | 'edit' | 'music' | 'caption'
|
||||
promptId?: string
|
||||
clientId: string
|
||||
status: JobStatus
|
||||
@@ -222,6 +224,7 @@ export function jobSnapshot(job: Job) {
|
||||
clipId: job.clipId,
|
||||
stillId: job.stillId,
|
||||
trackId: job.trackId,
|
||||
resultText: job.resultText,
|
||||
hideThumbnail: job.hideThumbnail,
|
||||
error: job.error,
|
||||
folderLocked: job.library?.folderLocked,
|
||||
|
||||
+253
-8
@@ -187,6 +187,7 @@ interface Catalog {
|
||||
}
|
||||
|
||||
const writeChains = new Map<string, Promise<unknown>>()
|
||||
const catalogCache = new Map<string, { catalog: Catalog; mtimeMs: number }>()
|
||||
|
||||
export function libraryRoot() {
|
||||
const config = useRuntimeConfig()
|
||||
@@ -355,6 +356,13 @@ function ensureOwner(owner: string) {
|
||||
function readCatalog(owner: string): Catalog {
|
||||
ensureOwner(owner)
|
||||
const path = catalogPath(owner)
|
||||
let mtimeMs = 0
|
||||
try {
|
||||
mtimeMs = existsSync(path) ? statSync(path).mtimeMs : 0
|
||||
} catch { mtimeMs = 0 }
|
||||
const hit = catalogCache.get(owner)
|
||||
if (hit && hit.mtimeMs === mtimeMs && mtimeMs > 0) return hit.catalog
|
||||
|
||||
let catalog: Catalog
|
||||
try {
|
||||
catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
|
||||
@@ -364,8 +372,11 @@ function readCatalog(owner: string): Catalog {
|
||||
}
|
||||
catalog = emptyCatalog()
|
||||
writeFileSync(path, JSON.stringify(catalog, null, 2))
|
||||
try { mtimeMs = statSync(path).mtimeMs } catch { mtimeMs = Date.now() }
|
||||
catalogCache.set(owner, { catalog, mtimeMs })
|
||||
return catalog
|
||||
}
|
||||
catalogCache.set(owner, { catalog, mtimeMs })
|
||||
return catalog
|
||||
}
|
||||
|
||||
@@ -374,6 +385,14 @@ function writeCatalog(owner: string, catalog: Catalog) {
|
||||
const tmp = catalogPath(owner) + '.tmp'
|
||||
writeFileSync(tmp, JSON.stringify(catalog, null, 2))
|
||||
renameSync(tmp, catalogPath(owner))
|
||||
let mtimeMs = Date.now()
|
||||
try { mtimeMs = statSync(catalogPath(owner)).mtimeMs } catch { /* ignore */ }
|
||||
catalogCache.set(owner, { catalog, mtimeMs })
|
||||
}
|
||||
|
||||
function invalidateCatalogCache(owner?: string) {
|
||||
if (owner) catalogCache.delete(owner)
|
||||
else catalogCache.clear()
|
||||
}
|
||||
|
||||
function mutate<T>(owner: string, fn: (catalog: Catalog) => T): Promise<T> {
|
||||
@@ -615,6 +634,164 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Paginated metadata page — reads catalog rows only; no existsSync / full publicLibrary map. */
|
||||
export function publicLibraryPage(
|
||||
event: H3Event,
|
||||
opts: { limit?: number; cursor?: string; folderId?: string; type?: string } = {}
|
||||
) {
|
||||
const limit = Math.min(Math.max(Number(opts.limit) || 50, 1), 60)
|
||||
const cursor = String(opts.cursor || '')
|
||||
const folderId = String(opts.folderId || '')
|
||||
const typeFilter = String(opts.type || 'all')
|
||||
const owner = libraryOwnerKey(event)
|
||||
const catalog = readCatalog(owner)
|
||||
const clipIds = new Set(catalog.clips.map(clip => clip.id.toLowerCase()))
|
||||
const clipCounts = new Map<string, number>()
|
||||
const imageCounts = new Map<string, number>()
|
||||
const trackCounts = new Map<string, number>()
|
||||
for (const clip of catalog.clips) {
|
||||
clipCounts.set(clip.folderId, (clipCounts.get(clip.folderId) || 0) + 1)
|
||||
}
|
||||
for (const still of catalog.stills) {
|
||||
if (isPipelineStill(catalog, still, clipIds) || stillRole(still) !== 'output') continue
|
||||
imageCounts.set(still.folderId, (imageCounts.get(still.folderId) || 0) + 1)
|
||||
}
|
||||
for (const track of catalog.tracks || []) {
|
||||
trackCounts.set(track.folderId, (trackCounts.get(track.folderId) || 0) + 1)
|
||||
}
|
||||
const folders = catalog.folders.map(folder => publicFolder(
|
||||
event,
|
||||
folder,
|
||||
clipCounts.get(folder.id) || 0,
|
||||
imageCounts.get(folder.id) || 0,
|
||||
trackCounts.get(folder.id) || 0
|
||||
))
|
||||
const unlockedIds = new Set(folders.filter(folder => folder.unlocked).map(folder => folder.id))
|
||||
|
||||
type Row = {
|
||||
id: string
|
||||
name: string
|
||||
type: 'still' | 'clip' | 'track'
|
||||
createdAt: number
|
||||
thumbUrl: string
|
||||
folderId: string
|
||||
prompt?: string
|
||||
promptPre?: string
|
||||
promptPost?: string
|
||||
width?: number
|
||||
height?: number
|
||||
role?: string
|
||||
hideInput?: boolean
|
||||
filename?: string
|
||||
duration?: number
|
||||
ext?: string
|
||||
}
|
||||
|
||||
const rows: Row[] = []
|
||||
if (typeFilter !== 'videos' && typeFilter !== 'music') {
|
||||
for (const still of catalog.stills) {
|
||||
if (!unlockedIds.has(still.folderId)) continue
|
||||
if (folderId && folderId !== 'all' && still.folderId !== folderId) continue
|
||||
if (isPipelineStill(catalog, still, clipIds)) continue
|
||||
rows.push({
|
||||
id: still.id,
|
||||
name: still.name || still.filename || still.id,
|
||||
type: 'still',
|
||||
createdAt: still.createdAt || 0,
|
||||
thumbUrl: `/api/library/stills/${still.id}/thumb`,
|
||||
folderId: still.folderId,
|
||||
prompt: still.prompt,
|
||||
promptPre: still.promptPre,
|
||||
promptPost: still.promptPost,
|
||||
width: still.width,
|
||||
height: still.height,
|
||||
role: stillRole(still),
|
||||
hideInput: still.hideInput,
|
||||
filename: still.filename
|
||||
})
|
||||
}
|
||||
}
|
||||
if (typeFilter !== 'images' && typeFilter !== 'music') {
|
||||
for (const clip of catalog.clips) {
|
||||
if (!unlockedIds.has(clip.folderId)) continue
|
||||
if (folderId && folderId !== 'all' && clip.folderId !== folderId) continue
|
||||
rows.push({
|
||||
id: clip.id,
|
||||
name: clip.name || clip.id,
|
||||
type: 'clip',
|
||||
createdAt: clip.createdAt || 0,
|
||||
thumbUrl: `/api/library/clips/${clip.id}/thumb`,
|
||||
folderId: clip.folderId,
|
||||
prompt: clip.prompt,
|
||||
promptPre: clip.promptPre,
|
||||
promptPost: clip.promptPost,
|
||||
width: clip.width,
|
||||
height: clip.height,
|
||||
duration: clip.duration
|
||||
})
|
||||
}
|
||||
}
|
||||
if (typeFilter !== 'images' && typeFilter !== 'videos') {
|
||||
for (const track of catalog.tracks || []) {
|
||||
if (!unlockedIds.has(track.folderId)) continue
|
||||
if (folderId && folderId !== 'all' && track.folderId !== folderId) continue
|
||||
rows.push({
|
||||
id: track.id,
|
||||
name: track.name || track.id,
|
||||
type: 'track',
|
||||
createdAt: track.createdAt || 0,
|
||||
thumbUrl: '',
|
||||
folderId: track.folderId,
|
||||
duration: track.duration,
|
||||
ext: track.ext
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
rows.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0) || String(b.id).localeCompare(String(a.id)))
|
||||
|
||||
let start = 0
|
||||
if (cursor) {
|
||||
if (cursor.startsWith('offset:')) {
|
||||
start = Math.max(0, Math.min(rows.length, Number(cursor.slice(7)) || 0))
|
||||
} else {
|
||||
const idx = rows.findIndex(row => `${row.type}:${row.id}` === cursor)
|
||||
// Unknown cursor → end (never restart at 0 or the client loops the first page forever)
|
||||
start = idx >= 0 ? idx + 1 : rows.length
|
||||
}
|
||||
}
|
||||
const page = rows.slice(start, start + limit)
|
||||
const nextStart = start + page.length
|
||||
const hasMore = nextStart < rows.length
|
||||
const nextCursor = hasMore ? `offset:${nextStart}` : null
|
||||
|
||||
const stillIds = new Set(page.filter(r => r.type === 'still').map(r => r.id))
|
||||
const clipIdPage = new Set(page.filter(r => r.type === 'clip').map(r => r.id))
|
||||
const trackIds = new Set(page.filter(r => r.type === 'track').map(r => r.id))
|
||||
|
||||
return {
|
||||
folders,
|
||||
items: page,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
limit,
|
||||
stills: catalog.stills
|
||||
.filter(s => stillIds.has(s.id))
|
||||
.map(still => ({ ...still, role: stillRole(still) })),
|
||||
clips: catalog.clips
|
||||
.filter(c => clipIdPage.has(c.id))
|
||||
.map(clip => ({ ...clip, name: clip.name || clip.id, hasThumbnail: !clip.hideThumbnail, hasVideo: true })),
|
||||
tracks: (catalog.tracks || [])
|
||||
.filter(t => trackIds.has(t.id))
|
||||
.map(track => ({ ...track, hasAudio: true })),
|
||||
drafts: catalog.drafts,
|
||||
loraAliases: catalog.loraAliases || {},
|
||||
loraTriggers: catalog.loraTriggers || {},
|
||||
keepPrompts: catalog.keepPrompts || emptyKeepPrompts(),
|
||||
total: rows.length
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLibraryOwner(event: H3Event) {
|
||||
const owner = libraryOwnerKey(event)
|
||||
return { owner, catalog: readCatalog(owner) }
|
||||
@@ -830,7 +1007,7 @@ export async function ensureScaledThumb(source: string, dest: string) {
|
||||
if (pending) return pending
|
||||
const build = withThumbSlot(async () => {
|
||||
const ok = await buildStillThumb(source, dest)
|
||||
return ok ? dest : source
|
||||
return ok ? dest : ''
|
||||
}).finally(() => {
|
||||
thumbBuilds.delete(key)
|
||||
})
|
||||
@@ -1101,14 +1278,42 @@ export function stillThumbFile(owner: string, id: string) {
|
||||
return join(ownerRoot(owner), 'stills', `${id}.thumb.jpg`)
|
||||
}
|
||||
|
||||
export function stillPreviewFile(owner: string, id: string) {
|
||||
return join(ownerRoot(owner), 'stills', `${id}.preview.jpg`)
|
||||
}
|
||||
|
||||
/** Sync check — never kicks ffmpeg. Empty string when thumb is missing or stale. */
|
||||
export function existingStillThumbPath(owner: string, id: string) {
|
||||
const source = stillPath(owner, id)
|
||||
const dest = stillThumbFile(owner, id)
|
||||
if (!existsSync(source) || !existsSync(dest)) return ''
|
||||
try {
|
||||
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
|
||||
} catch { return '' }
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Sync check for stage preview — never kicks ffmpeg. */
|
||||
export function existingStillPreviewPath(owner: string, id: string) {
|
||||
const source = stillPath(owner, id)
|
||||
const dest = stillPreviewFile(owner, id)
|
||||
if (!existsSync(source) || !existsSync(dest)) return ''
|
||||
try {
|
||||
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
|
||||
} catch { return '' }
|
||||
return ''
|
||||
}
|
||||
|
||||
function removeStillFiles(owner: string, id: string) {
|
||||
rmSync(stillPath(owner, id), { force: true })
|
||||
rmSync(stillThumbFile(owner, id), { force: true })
|
||||
rmSync(stillPreviewFile(owner, id), { force: true })
|
||||
}
|
||||
|
||||
let thumbSlots = 0
|
||||
const thumbWaiters: Array<() => void> = []
|
||||
const thumbBuilds = new Map<string, Promise<string>>()
|
||||
const previewBuilds = new Map<string, Promise<string>>()
|
||||
|
||||
function withThumbSlot<T>(fn: () => Promise<T>) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
@@ -1125,14 +1330,14 @@ function withThumbSlot<T>(fn: () => Promise<T>) {
|
||||
})
|
||||
}
|
||||
|
||||
function buildStillThumb(source: string, dest: string) {
|
||||
function buildStillJpeg(source: string, dest: string, scale: string, quality: string) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const proc = spawn('ffmpeg', [
|
||||
'-y',
|
||||
'-i', source,
|
||||
'-vf', 'scale=320:-2',
|
||||
'-vf', scale,
|
||||
'-frames:v', '1',
|
||||
'-q:v', '5',
|
||||
'-q:v', quality,
|
||||
dest
|
||||
], { stdio: 'ignore' })
|
||||
const timer = setTimeout(() => {
|
||||
@@ -1150,6 +1355,14 @@ function buildStillThumb(source: string, dest: string) {
|
||||
})
|
||||
}
|
||||
|
||||
function buildStillThumb(source: string, dest: string) {
|
||||
return buildStillJpeg(source, dest, 'scale=384:-2', '5')
|
||||
}
|
||||
|
||||
function buildStillPreview(source: string, dest: string) {
|
||||
return buildStillJpeg(source, dest, 'scale=1600:-2', '3')
|
||||
}
|
||||
|
||||
export async function stillThumbPath(owner: string, id: string) {
|
||||
const source = stillPath(owner, id)
|
||||
const dest = stillThumbFile(owner, id)
|
||||
@@ -1159,12 +1372,13 @@ export async function stillThumbPath(owner: string, id: string) {
|
||||
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
|
||||
} catch { /* rebuild */ }
|
||||
}
|
||||
const key = `${owner}:${id}`
|
||||
const key = `${owner}:${id}:thumb`
|
||||
const pending = thumbBuilds.get(key)
|
||||
if (pending) return pending
|
||||
const build = withThumbSlot(async () => {
|
||||
const ok = await buildStillThumb(source, dest)
|
||||
return ok ? dest : source
|
||||
// Never return the archive master — callers must not stream originals as thumbs
|
||||
return ok ? dest : ''
|
||||
}).finally(() => {
|
||||
thumbBuilds.delete(key)
|
||||
})
|
||||
@@ -1172,11 +1386,40 @@ export async function stillThumbPath(owner: string, id: string) {
|
||||
return build
|
||||
}
|
||||
|
||||
export async function ensureStillThumbnails(owner: string) {
|
||||
export async function stillPreviewPath(owner: string, id: string) {
|
||||
const source = stillPath(owner, id)
|
||||
const dest = stillPreviewFile(owner, id)
|
||||
if (!existsSync(source)) return ''
|
||||
if (existsSync(dest)) {
|
||||
try {
|
||||
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
|
||||
} catch { /* rebuild */ }
|
||||
}
|
||||
const key = `${owner}:${id}:preview`
|
||||
const pending = previewBuilds.get(key)
|
||||
if (pending) return pending
|
||||
const build = withThumbSlot(async () => {
|
||||
const ok = await buildStillPreview(source, dest)
|
||||
// Never return the archive master as a stage preview
|
||||
return ok ? dest : ''
|
||||
}).finally(() => {
|
||||
previewBuilds.delete(key)
|
||||
})
|
||||
previewBuilds.set(key, build)
|
||||
return build
|
||||
}
|
||||
|
||||
export async function ensureStillThumbnails(owner: string, opts?: { limit?: number }) {
|
||||
const catalog = readCatalog(owner)
|
||||
const cap = Math.max(0, Math.min(Number(opts?.limit) || 2, 8))
|
||||
let built = 0
|
||||
for (const still of catalog.stills) {
|
||||
if (built >= cap) break
|
||||
if (isPipelineStill(catalog, still)) continue
|
||||
const dest = stillThumbFile(owner, still.id)
|
||||
if (existsSync(dest)) continue
|
||||
await stillThumbPath(owner, still.id)
|
||||
built++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1186,7 +1429,8 @@ export async function maintainLibrary(owner: string, folderId?: string) {
|
||||
const prev = lastMaintain.get(owner) || 0
|
||||
if (Date.now() - prev < 20000) return
|
||||
lastMaintain.set(owner, Date.now())
|
||||
void ensureStillThumbnails(owner).catch(() => null)
|
||||
// At most 2 missing thumbs per tick — never walk the whole catalog on list
|
||||
void ensureStillThumbnails(owner, { limit: 2 }).catch(() => null)
|
||||
const live = await fetchLiveQueue().catch(() => null)
|
||||
const busy = Boolean(live && live.running > 0)
|
||||
if (!busy) {
|
||||
@@ -1414,6 +1658,7 @@ export async function saveStill(params: {
|
||||
next.stills.unshift(still)
|
||||
})
|
||||
void stillThumbPath(params.ownerKey, still.id).catch(() => null)
|
||||
void stillPreviewPath(params.ownerKey, still.id).catch(() => null)
|
||||
return still
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { ownerRoot } from '~/server/utils/library'
|
||||
import {
|
||||
normalizePromptSequence,
|
||||
normalizePromptSequenceName,
|
||||
normalizeCharacter,
|
||||
normalizeSet,
|
||||
normalizeScene,
|
||||
emptyCharacter,
|
||||
emptyScene,
|
||||
type PromptSequence,
|
||||
type PromptSequenceCharacter,
|
||||
type PromptSequenceSet,
|
||||
type PromptSequenceScene
|
||||
} from '~/utils/promptSequences'
|
||||
|
||||
const writeChains = new Map<string, Promise<unknown>>()
|
||||
|
||||
function sequencesPath(owner: string) {
|
||||
return join(ownerRoot(owner), 'prompt-sequences.json')
|
||||
}
|
||||
|
||||
function ensureOwner(owner: string) {
|
||||
mkdirSync(ownerRoot(owner), { recursive: true })
|
||||
}
|
||||
|
||||
function readSequences(owner: string): PromptSequence[] {
|
||||
ensureOwner(owner)
|
||||
const path = sequencesPath(owner)
|
||||
if (!existsSync(path)) return []
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
||||
return Array.isArray(parsed)
|
||||
? parsed.map(normalizePromptSequence).filter((item): item is PromptSequence => Boolean(item))
|
||||
: []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeSequences(owner: string, sequences: PromptSequence[]) {
|
||||
ensureOwner(owner)
|
||||
const path = sequencesPath(owner)
|
||||
const tmp = `${path}.tmp`
|
||||
writeFileSync(tmp, JSON.stringify(sequences, null, 2))
|
||||
renameSync(tmp, path)
|
||||
}
|
||||
|
||||
function mutate<T>(owner: string, fn: (sequences: PromptSequence[]) => T): Promise<T> {
|
||||
const prev = writeChains.get(owner) || Promise.resolve()
|
||||
const run = prev.then(() => {
|
||||
const sequences = readSequences(owner)
|
||||
const result = fn(sequences)
|
||||
writeSequences(owner, sequences)
|
||||
return result
|
||||
})
|
||||
writeChains.set(owner, run.then(() => undefined, () => undefined))
|
||||
return run
|
||||
}
|
||||
|
||||
function normalizeCharactersInput(raw: unknown): PromptSequenceCharacter[] {
|
||||
if (!Array.isArray(raw) || !raw.length) return [emptyCharacter()]
|
||||
return raw.map(normalizeCharacter)
|
||||
}
|
||||
|
||||
function normalizeSetsInput(raw: unknown): PromptSequenceSet[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map(normalizeSet)
|
||||
}
|
||||
|
||||
function normalizeScenesInput(
|
||||
raw: unknown,
|
||||
characters: PromptSequenceCharacter[],
|
||||
sets: PromptSequenceSet[]
|
||||
): PromptSequenceScene[] {
|
||||
if (!Array.isArray(raw) || !raw.length) return [emptyScene(1, characters, sets)]
|
||||
return raw.map((scene, index) => normalizeScene(scene, index, characters, sets))
|
||||
}
|
||||
|
||||
export function listPromptSequences(owner: string) {
|
||||
return readSequences(owner).sort((a, b) => b.updatedAt - a.updatedAt || a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
export function getPromptSequence(owner: string, id: string) {
|
||||
const wanted = String(id || '').trim()
|
||||
const sequence = readSequences(owner).find(item => item.id === wanted)
|
||||
if (!sequence) throw createError({ statusCode: 404, statusMessage: 'Sequence not found' })
|
||||
return sequence
|
||||
}
|
||||
|
||||
export async function savePromptSequence(owner: string, input: {
|
||||
name: string
|
||||
characters?: unknown
|
||||
sets?: unknown
|
||||
scenes?: unknown
|
||||
overwrite?: boolean
|
||||
}) {
|
||||
const name = normalizePromptSequenceName(input.name)
|
||||
if (!name) throw createError({ statusCode: 400, statusMessage: 'A sequence name is required' })
|
||||
const characters = normalizeCharactersInput(input.characters)
|
||||
const sets = normalizeSetsInput(input.sets)
|
||||
const scenes = normalizeScenesInput(input.scenes, characters, sets)
|
||||
const now = Date.now()
|
||||
return mutate(owner, (sequences) => {
|
||||
const existing = sequences.find(item => item.name.toLowerCase() === name.toLowerCase())
|
||||
if (existing && !input.overwrite) {
|
||||
throw createError({ statusCode: 409, statusMessage: `Overwrite sequence “${existing.name}”?` })
|
||||
}
|
||||
if (existing) {
|
||||
existing.characters = characters
|
||||
existing.sets = sets
|
||||
existing.scenes = scenes
|
||||
existing.updatedAt = now
|
||||
return existing
|
||||
}
|
||||
const created: PromptSequence = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
characters,
|
||||
sets,
|
||||
scenes,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
sequences.unshift(created)
|
||||
return created
|
||||
})
|
||||
}
|
||||
|
||||
export async function updatePromptSequence(owner: string, id: string, input: {
|
||||
name?: string
|
||||
characters?: unknown
|
||||
sets?: unknown
|
||||
scenes?: unknown
|
||||
}) {
|
||||
const wanted = String(id || '').trim()
|
||||
if (!wanted) throw createError({ statusCode: 400, statusMessage: 'Missing sequence id' })
|
||||
return mutate(owner, (sequences) => {
|
||||
const existing = sequences.find(item => item.id === wanted)
|
||||
if (!existing) throw createError({ statusCode: 404, statusMessage: 'Sequence not found' })
|
||||
if (input.name !== undefined) {
|
||||
const name = normalizePromptSequenceName(input.name)
|
||||
if (!name) throw createError({ statusCode: 400, statusMessage: 'A sequence name is required' })
|
||||
const clash = sequences.find(item => item.id !== wanted && item.name.toLowerCase() === name.toLowerCase())
|
||||
if (clash) throw createError({ statusCode: 409, statusMessage: `A sequence named “${clash.name}” already exists` })
|
||||
existing.name = name
|
||||
}
|
||||
if (input.characters !== undefined || input.sets !== undefined || input.scenes !== undefined) {
|
||||
const characters = input.characters !== undefined
|
||||
? normalizeCharactersInput(input.characters)
|
||||
: existing.characters
|
||||
const sets = input.sets !== undefined
|
||||
? normalizeSetsInput(input.sets)
|
||||
: (existing.sets || [])
|
||||
const scenes = input.scenes !== undefined
|
||||
? normalizeScenesInput(input.scenes, characters, sets)
|
||||
: existing.scenes.map((scene, index) => normalizeScene(scene, index, characters, sets))
|
||||
existing.characters = characters
|
||||
existing.sets = sets
|
||||
existing.scenes = scenes
|
||||
}
|
||||
existing.updatedAt = Date.now()
|
||||
return existing
|
||||
})
|
||||
}
|
||||
|
||||
export async function deletePromptSequence(owner: string, id: string) {
|
||||
const wanted = String(id || '').trim()
|
||||
if (!wanted) throw createError({ statusCode: 400, statusMessage: 'Missing sequence id' })
|
||||
return mutate(owner, (sequences) => {
|
||||
const index = sequences.findIndex(item => item.id === wanted)
|
||||
if (index < 0) throw createError({ statusCode: 404, statusMessage: 'Sequence not found' })
|
||||
const [removed] = sequences.splice(index, 1)
|
||||
return removed
|
||||
})
|
||||
}
|
||||
@@ -1,25 +1,46 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { openSync, readSync, closeSync } from 'node:fs'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { getClip, getStill, clipVideoPath, stillPath } from '../library'
|
||||
import { imageDimensions } from '../resolution'
|
||||
import { resolveSize } from '~/shared/studio2/input.mjs'
|
||||
const exec = promisify(execFile)
|
||||
|
||||
function readImageHeader(path: string, maxBytes = 65536) {
|
||||
const fd = openSync(path, 'r')
|
||||
try {
|
||||
const buf = Buffer.alloc(maxBytes)
|
||||
const n = readSync(fd, buf, 0, maxBytes, 0)
|
||||
return buf.subarray(0, n)
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveRequestSize(owner: string, request: any) {
|
||||
const video=['video','extend'].includes(request.mode)
|
||||
let dimensions:any
|
||||
const video = ['video', 'extend'].includes(request.mode)
|
||||
let dimensions: any
|
||||
if (video && request.startClipId) {
|
||||
getClip(owner,request.startClipId)
|
||||
const result=await exec('ffprobe',['-v','error','-select_streams','v:0','-show_entries','stream=width,height','-of','json',clipVideoPath(owner,request.startClipId)],{windowsHide:true,timeout:15000})
|
||||
dimensions=JSON.parse(result.stdout).streams?.[0]
|
||||
const clip = getClip(owner, request.startClipId)
|
||||
if (clip.width && clip.height) {
|
||||
dimensions = { width: clip.width, height: clip.height }
|
||||
} else {
|
||||
const id=request.imageAId || request.identityStillId
|
||||
const result = await exec('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'json', clipVideoPath(owner, request.startClipId)], { windowsHide: true, timeout: 15000 })
|
||||
dimensions = JSON.parse(result.stdout).streams?.[0]
|
||||
}
|
||||
} else {
|
||||
const id = request.imageAId || request.identityStillId
|
||||
if (id) {
|
||||
getStill(owner,id)
|
||||
dimensions=imageDimensions(readFileSync(stillPath(owner,id)))
|
||||
const still = getStill(owner, id)
|
||||
if (still.width && still.height) {
|
||||
dimensions = { width: still.width, height: still.height }
|
||||
} else {
|
||||
// Header only — never read the full archive PNG into memory on Generate click
|
||||
dimensions = imageDimensions(readImageHeader(stillPath(owner, id)))
|
||||
}
|
||||
if (!dimensions) throw new Error('Cannot measure the source still.')
|
||||
}
|
||||
}
|
||||
if (video && request.startClipId && !dimensions?.width) throw new Error('Cannot measure the previous clip.')
|
||||
Object.assign(request.settings,resolveSize(request.settings.aspect,dimensions,undefined,request.engine))
|
||||
Object.assign(request.settings, resolveSize(request.settings.aspect, dimensions, undefined, request.engine))
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { imageV2StackSpecials } from '~/utils/imageV2'
|
||||
import { allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
|
||||
|
||||
export type StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'error' | 'cancelled'
|
||||
export type StudioJobKind = 'video' | 'edit' | 'music'
|
||||
export type StudioJobKind = 'video' | 'edit' | 'music' | 'caption'
|
||||
|
||||
export interface StudioJobPayload {
|
||||
studio2Id?: string
|
||||
@@ -42,6 +42,7 @@ export interface StudioJobPayload {
|
||||
useIdentityRefs: boolean
|
||||
stillId?: string
|
||||
stillFilename?: string
|
||||
captionStyle?: string
|
||||
hideThumbnail: boolean
|
||||
hideInput?: boolean
|
||||
folderLocked?: boolean
|
||||
@@ -105,6 +106,9 @@ export interface StudioJob {
|
||||
resumeAutoRun?: boolean
|
||||
lastError?: string
|
||||
waitReason?: string
|
||||
resultText?: string
|
||||
/** Hidden from Queue UI; keeps resultText on the stored row. */
|
||||
dismissed?: boolean
|
||||
}
|
||||
|
||||
type StudioQueueStore = {
|
||||
@@ -226,6 +230,7 @@ export function listStudioJobs(owner: string) {
|
||||
export function studioJobKind(job: Pick<StudioJob, 'kind'> | { kind?: string }) {
|
||||
if (job.kind === 'edit') return 'edit'
|
||||
if (job.kind === 'music') return 'music'
|
||||
if (job.kind === 'caption') return 'caption'
|
||||
return 'video'
|
||||
}
|
||||
|
||||
@@ -245,6 +250,8 @@ export function summarizeStudioJob(job: StudioJob) {
|
||||
folderId: job.payload.folderId,
|
||||
musicEngine: job.payload.musicEngine,
|
||||
stillId: job.payload.stillId,
|
||||
captionStyle: job.payload.captionStyle,
|
||||
resultText: job.resultText,
|
||||
workflow: job.payload.workflow,
|
||||
imagePipeline: job.payload.imagePipeline || 'v1',
|
||||
duration: job.payload.duration,
|
||||
@@ -290,7 +297,7 @@ function failZombieLiveJob(job: Job, error: string) {
|
||||
function sweepStaleLiveJobs() {
|
||||
const now = Date.now()
|
||||
for (const job of listJobs()) {
|
||||
if (job.studio2 || job.yueGp || job.yue2 || job.upscale) continue
|
||||
if (job.studio2 || job.yueGp || job.yue2 || job.upscale || job.caption) continue
|
||||
if (job.saving) continue
|
||||
if (job.status === 'queued' && !job.promptId && now - job.startedAt >= QUEUED_GRACE_MS) {
|
||||
failZombieLiveJob(job, 'Job never started')
|
||||
@@ -310,12 +317,12 @@ async function reapZombieLiveJobs() {
|
||||
const { fetchHistory } = await import('~/server/utils/comfy')
|
||||
for (const job of listJobs()) {
|
||||
// Never interrupt download/stitch/library save — Comfy is idle then by design.
|
||||
if (job.studio2 || job.yueGp || job.yue2 || job.upscale) continue
|
||||
if (job.studio2 || job.yueGp || job.yue2 || job.upscale || job.caption) continue
|
||||
if (job.saving) continue
|
||||
if (job.library?.chainContinuing) continue
|
||||
if (job.status !== 'running' && job.status !== 'uploading' && job.status !== 'queued') continue
|
||||
if (jobIsLocallySubmitting(job)) continue
|
||||
const zombieMs = (job.kind === 'edit' || job.kind === 'music') ? 45_000 : ZOMBIE_EMPTY_COMFY_MS
|
||||
const zombieMs = (job.kind === 'edit' || job.kind === 'music' || job.kind === 'caption') ? 45_000 : ZOMBIE_EMPTY_COMFY_MS
|
||||
if (jobAgeMs(job) < zombieMs) continue
|
||||
if (!job.promptId) {
|
||||
if (jobAgeMs(job) >= QUEUED_GRACE_MS) failZombieLiveJob(job, 'Job never started on ComfyUI')
|
||||
@@ -355,7 +362,7 @@ async function reapZombieLiveJobs() {
|
||||
}
|
||||
|
||||
function liveJobOwnsGpu(job: Job) {
|
||||
if ((job.studio2 || job.yueGp || job.yue2 || job.upscale) && ['running', 'queued', 'uploading'].includes(job.status)) return true
|
||||
if ((job.studio2 || job.yueGp || job.yue2 || job.upscale || job.caption) && ['running', 'queued', 'uploading'].includes(job.status)) return true
|
||||
if (job.library?.stopAfterCurrent) return false
|
||||
if (job.library?.chainContinuing) return true
|
||||
if (job.saving) return true
|
||||
@@ -375,7 +382,7 @@ function liveJobOwnsGpu(job: Job) {
|
||||
*/
|
||||
function clearDeadGpuClaimsForForceStart() {
|
||||
for (const live of listJobs()) {
|
||||
if (live.studio2 || live.yueGp || live.yue2 || live.upscale) continue
|
||||
if (live.studio2 || live.yueGp || live.yue2 || live.upscale || live.caption) continue
|
||||
if (live.saving) continue
|
||||
if (jobIsLocallySubmitting(live)) continue
|
||||
if (live.status !== 'running' && live.status !== 'queued' && live.status !== 'uploading') {
|
||||
@@ -469,8 +476,11 @@ export async function addStudioJob(params: {
|
||||
kind?: StudioJobKind
|
||||
}) {
|
||||
const now = Date.now()
|
||||
const kind = params.kind === 'edit' ? 'edit' : params.kind === 'music' ? 'music' : 'video'
|
||||
const shotCount = kind === 'music'
|
||||
const kind = params.kind === 'edit' ? 'edit'
|
||||
: params.kind === 'music' ? 'music'
|
||||
: params.kind === 'caption' ? 'caption'
|
||||
: 'video'
|
||||
const shotCount = kind === 'music' || kind === 'caption'
|
||||
? 1
|
||||
: kind === 'edit'
|
||||
? 1 + (params.payload.passes?.length || 0)
|
||||
@@ -527,6 +537,40 @@ function canCancelStudioStatus(status: StudioJobStatus) {
|
||||
return status === 'waiting' || status === 'held' || status === 'running' || status === 'error'
|
||||
}
|
||||
|
||||
function isFinishedStudioStatus(status: StudioJobStatus) {
|
||||
return status === 'complete' || status === 'error' || status === 'cancelled'
|
||||
}
|
||||
|
||||
/** Hide a finished studio-queue row from the Queue panel (keeps resultText). */
|
||||
export async function dismissStudioJob(owner: string, id: string) {
|
||||
return mutate(owner, (jobs) => {
|
||||
const job = jobs.find(item => item.id === id)
|
||||
if (!job) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
|
||||
if (!isFinishedStudioStatus(job.status)) {
|
||||
throw createError({ statusCode: 409, statusMessage: 'Only finished jobs can be dismissed' })
|
||||
}
|
||||
job.dismissed = true
|
||||
job.updatedAt = Date.now()
|
||||
return structuredClone(job)
|
||||
})
|
||||
}
|
||||
|
||||
/** Dismiss finished studio-queue rows (caption complete cards, errors, etc.). */
|
||||
export async function dismissFinishedStudioJobs(owner: string, ids?: string[]) {
|
||||
return mutate(owner, (jobs) => {
|
||||
let cleared = 0
|
||||
for (const job of jobs) {
|
||||
if (ids?.length && !ids.includes(job.id)) continue
|
||||
if (job.dismissed) continue
|
||||
if (!isFinishedStudioStatus(job.status)) continue
|
||||
job.dismissed = true
|
||||
job.updatedAt = Date.now()
|
||||
cleared += 1
|
||||
}
|
||||
return cleared
|
||||
})
|
||||
}
|
||||
|
||||
export async function cancelStudioJob(owner: string, id: string) {
|
||||
const before = readJobs(owner).find(item => item.id === id)
|
||||
if (!before) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
|
||||
@@ -606,6 +650,10 @@ export async function clearStuckStudioWork(owner: string) {
|
||||
const { cancelYue2Job } = await import('./yue2')
|
||||
await cancelYue2Job(job)
|
||||
}
|
||||
if (job.caption) {
|
||||
const { cancelCaptionJob } = await import('./caption')
|
||||
await cancelCaptionJob(job)
|
||||
}
|
||||
job.status = 'cancelled'
|
||||
job.error = 'Cleared by force reset'
|
||||
if (job.library) {
|
||||
@@ -678,6 +726,11 @@ async function stopLiveGeneration(liveJobId?: string, shotQueueId?: string) {
|
||||
await cancelYue2Job(live)
|
||||
return
|
||||
}
|
||||
if (live?.caption) {
|
||||
const { cancelCaptionJob } = await import('./caption')
|
||||
await cancelCaptionJob(live)
|
||||
return
|
||||
}
|
||||
if (live) {
|
||||
live.status = 'cancelled'
|
||||
if (live.library) {
|
||||
@@ -889,6 +942,10 @@ function pendingAlive(job: StudioJob) {
|
||||
const root = join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'yue2-pending', `${job.liveJobId}.json`)
|
||||
if (existsSync(root)) return true
|
||||
}
|
||||
if (job.kind === 'caption') {
|
||||
const root = join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'caption-pending', `${job.liveJobId}.json`)
|
||||
if (existsSync(root)) return true
|
||||
}
|
||||
}
|
||||
if (!job.shotQueueId) return false
|
||||
return listPendingJobs().some(pending => (
|
||||
@@ -1397,6 +1454,32 @@ async function startStudioExtendJob(item: StudioJob) {
|
||||
}
|
||||
}
|
||||
|
||||
async function startStudioCaptionJob(item: StudioJob) {
|
||||
let live: import('~/server/utils/jobs').Job | undefined
|
||||
try {
|
||||
const { startCaptionJob } = await import('./caption')
|
||||
const style = String(item.payload.captionStyle || 'descriptive')
|
||||
const stillId = String(item.payload.stillId || '')
|
||||
if (!stillId) throw new Error('Caption requires a still.')
|
||||
live = await startCaptionJob({
|
||||
ownerKey: item.ownerKey,
|
||||
folderId: item.payload.folderId,
|
||||
stillId,
|
||||
captionStyle: style,
|
||||
name: item.payload.name
|
||||
})
|
||||
await markStudioLive(item.ownerKey, item.id, live.id)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (live && (live.status === 'queued' || live.status === 'uploading' || live.status === 'running')) {
|
||||
live.status = 'error'
|
||||
live.error = message
|
||||
}
|
||||
await parkStudioOnStartFailure(item.ownerKey, item.id, message)
|
||||
kickStudioQueue()
|
||||
}
|
||||
}
|
||||
|
||||
async function startStudioMusicJob(item: StudioJob) {
|
||||
let live: import('~/server/utils/jobs').Job | undefined
|
||||
try {
|
||||
@@ -1439,6 +1522,10 @@ export async function startStudioJob(item: StudioJob) {
|
||||
}
|
||||
|
||||
async function startStudioJobReserved(item: StudioJob) {
|
||||
if (studioJobKind(item) === 'caption') {
|
||||
await startStudioCaptionJob(item)
|
||||
return
|
||||
}
|
||||
if (item.payload.studio2Id) {
|
||||
try {
|
||||
const { startStudio2Job } = await import('./studio2/runner')
|
||||
@@ -1703,7 +1790,7 @@ export async function onLiveVideoSettled(job: Job) {
|
||||
return
|
||||
}
|
||||
const remaining = remainingStudioShots(job)
|
||||
const wakeFail = !job.studio2 && !job.yueGp && !job.yue2 && !job.upscale && job.status === 'error' && remaining > 0 && isTransientComfyError(job.error)
|
||||
const wakeFail = !job.studio2 && !job.yueGp && !job.yue2 && !job.upscale && !job.caption && job.status === 'error' && remaining > 0 && isTransientComfyError(job.error)
|
||||
const failed = (job.status === 'error' || job.status === 'cancelled') && !wakeFail
|
||||
|
||||
await mutateStore(owner, (store) => {
|
||||
@@ -1777,6 +1864,7 @@ export async function onLiveVideoSettled(job: Job) {
|
||||
: failed
|
||||
? 'error'
|
||||
: 'complete'
|
||||
if (job.caption && job.resultText) row.resultText = job.resultText
|
||||
clearStudioRowSlot(row, status, failed ? job.error : undefined)
|
||||
syncPausedFlag(store)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export const CAPTION_STYLES = ['descriptive', 'klein_prompt', 'delta', 'tags']
|
||||
|
||||
export const DELTA_LOCKED_LOOK =
|
||||
'short light-brown bob, pink tank, light blue shorts, open green Slytherin robe with red-gold trim, barefoot'
|
||||
|
||||
export function captionStylePrompt(style) {
|
||||
switch (style) {
|
||||
case 'klein_prompt':
|
||||
return 'Write one self-contained image generation prompt for Flux Klein. Include subject, pose, clothing, setting, lighting, and camera. No preamble or quotes.'
|
||||
case 'delta':
|
||||
return `The character's locked baseline look is: ${DELTA_LOCKED_LOOK}. Describe ONLY what changed from that baseline in this image. Do not restate the locked look.`
|
||||
case 'tags':
|
||||
return 'List comma-separated descriptive tags for this image. Tags only, no sentences.'
|
||||
case 'descriptive':
|
||||
default:
|
||||
return 'Describe this image in clear, detailed prose. Cover subject, appearance, clothing, pose, setting, and lighting.'
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCaptionBody(raw) {
|
||||
const fail = message => { throw Object.assign(new Error(message), { statusCode: 400 }) }
|
||||
const body = raw || {}
|
||||
const style = String(body.captionStyle || body.style || 'descriptive')
|
||||
if (!CAPTION_STYLES.includes(style)) fail('Unknown caption style.')
|
||||
const folderId = String(body.folderId || '')
|
||||
if (!folderId) fail('Choose a folder.')
|
||||
const stillId = String(body.stillId || body.sourcePath || '')
|
||||
if (!stillId) fail('Choose a still to describe.')
|
||||
return { folderId, stillId, captionStyle: style }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export function gpuStatus(jobs, health, upscales=[], musicJobs=[]) {
|
||||
export function gpuStatus(jobs, health, upscales=[], musicJobs=[], captionJobs=[]) {
|
||||
if (!health) return {label:'Status unavailable',tone:'unknown',detail:'Checking GPU connection…'}
|
||||
const upscale=upscales.find(j=>j.status==='running')
|
||||
if(upscale)return {label:'Upscaling',tone:'busy',detail:upscale.name || 'Video upscale'}
|
||||
@@ -7,6 +7,9 @@ export function gpuStatus(jobs, health, upscales=[], musicJobs=[]) {
|
||||
const stage=music.live?.message || music.live?.stage || music.waitReason || 'YuE2'
|
||||
return {label:music.status==='waiting'?'Queued':'Generating',tone:'busy',detail:String(stage)}
|
||||
}
|
||||
const caption=captionJobs.find(j=>['waiting','running','held'].includes(j.status) || ['queued','running','uploading'].includes(j.live?.status))
|
||||
if(caption)return {label:caption.status==='waiting'?'Queued':'Describing',tone:'busy',detail:caption.payload?.captionStyle || caption.live?.message || 'Caption'}
|
||||
if(health.caption?.busy)return {label:'Describing',tone:'busy',detail:'Caption model is using the GPU'}
|
||||
if(health.yue2?.busy)return {label:'Generating',tone:'busy',detail:'YuE2 is using the GPU'}
|
||||
const job=jobs.find(j=>['waking','enhancing','submitting','rendering','saving'].includes(j.state))
|
||||
if(job)return {label:{waking:'Waking up',enhancing:'Enhancing prompt…',submitting:'Submitting',rendering:'Generating…',saving:'Saving'}[job.state],tone:'busy',detail:job.request?.promptSections?.action || 'Generation in progress'}
|
||||
|
||||
@@ -9,5 +9,13 @@ export function normalizeStyles(value={}) {
|
||||
}
|
||||
export function toggleStyle(value,chip,negative=false) { const next=normalizeStyles(value),key=negative?'negative':'positive',other=negative?'positive':'negative';next[other]=next[other].filter(s=>s!==chip);next[key]=next[key].includes(chip)?next[key].filter(s=>s!==chip):[...next[key],chip];return next }
|
||||
export function stylePrompt(value,negative=false){return normalizeStyles(value)[negative?'negative':'positive'].join(', ')}
|
||||
/** Single blank line (mobile / video / edit batchText). */
|
||||
export function parseBatch(value){return String(value||'').split(/\r?\n\s*\r?\n/).map(s=>s.trim()).filter(Boolean)}
|
||||
/**
|
||||
* Desktop image Batch toggle: split only on a double blank line (two empty lines → \n\n\n+).
|
||||
* A single newline or a single blank line stays inside the same prompt.
|
||||
*/
|
||||
export function parseBatchDoubleBlank(value){
|
||||
return String(value||'').split(/\r?\n(?:[ \t]*\r?\n){2,}/).map(s=>s.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ export function folderAssets(library, jobs, folderId) {
|
||||
export function newestOutput(jobs, seen, folderId) {
|
||||
return jobs.filter(j=>j.request.folderId===folderId).flatMap(j=>j.outputs || []).filter(a=>!seen.has(a.id)).sort((a,b)=>(a.studio2?.savedAt || 0)-(b.studio2?.savedAt || 0)).at(-1) || null
|
||||
}
|
||||
export function reconcileSelection(selected, assets) { return assets.find(a=>a.id===selected?.id) || assets.at(-1) || null }
|
||||
export function reconcileSelection(selected, assets) {
|
||||
if (!selected?.id) return assets.at(-1) || null
|
||||
return assets.find(a => a.id === selected.id) || selected
|
||||
}
|
||||
export function activeJobs(jobs) { return jobs.filter(j=>['waking','enhancing','submitting','rendering','saving'].includes(j.state)) }
|
||||
export function previewHidden(asset, revealed, xaigen) {
|
||||
if (!xaigen || !asset?.id) return false
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createCaptionHost, validateCaptionHostRequest } from '../scripts/caption-host.mjs'
|
||||
import { CAPTION_STYLES, captionStylePrompt, DELTA_LOCKED_LOOK, validateCaptionBody } from '../shared/caption.mjs'
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
assert.deepEqual(CAPTION_STYLES, ['descriptive', 'klein_prompt', 'delta', 'tags'])
|
||||
assert.match(captionStylePrompt('delta'), new RegExp(DELTA_LOCKED_LOOK.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
||||
assert.equal(validateCaptionBody({ folderId: 'f1', stillId: 's1', captionStyle: 'tags' }).captionStyle, 'tags')
|
||||
assert.throws(() => validateCaptionBody({ folderId: 'f1' }), /still/)
|
||||
assert.throws(() => validateCaptionHostRequest({ id: 'short', style: 'descriptive' }), /Invalid/)
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'caption-test-'))
|
||||
const models = join(root, 'models')
|
||||
mkdirSync(models)
|
||||
writeFileSync(join(models, 'Qwen2.5-VL-7B-NSFW-Caption-V4.Q5_K_M.gguf'), 'x')
|
||||
writeFileSync(join(models, 'Qwen2.5-VL-7B-NSFW-Caption-V4.mmproj-f16.gguf'), 'x')
|
||||
writeFileSync(join(root, 'llama-server.exe'), 'x')
|
||||
|
||||
let prepared = 0
|
||||
const host = createCaptionHost({
|
||||
modelsDir: models,
|
||||
llamaServer: join(root, 'llama-server.exe'),
|
||||
dataDir: join(root, 'jobs'),
|
||||
leaseValid: () => true,
|
||||
prepare: async () => { prepared += 1 },
|
||||
serverWaitMs: 800,
|
||||
spawnProcess: () => {
|
||||
const child = new EventEmitter()
|
||||
child.pid = 4242
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.kill = () => child.emit('close', 0)
|
||||
setTimeout(() => child.emit('close', 0), 50)
|
||||
return child
|
||||
},
|
||||
fetchImpl: async () => ({ ok: false, status: 503, text: async () => 'down' })
|
||||
})
|
||||
|
||||
assert.equal(host.configured(), true)
|
||||
assert.equal(host.busy(), false)
|
||||
|
||||
const id = 'caption-test-abcdefghijkl'
|
||||
mkdirSync(join(root, 'jobs', id), { recursive: true })
|
||||
writeFileSync(join(root, 'jobs', id, 'input.png'), Buffer.alloc(64, 1))
|
||||
|
||||
await assert.rejects(() => host.start({ id, style: 'descriptive' }, 'lease'), /llama-server|Caption/)
|
||||
assert.equal(prepared, 1)
|
||||
assert.ok(existsSync(join(root, 'jobs', id, 'status.json')))
|
||||
console.log('caption tests ok')
|
||||
@@ -0,0 +1,206 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
|
||||
const src = readFileSync(new URL('../utils/promptSequences.ts', import.meta.url), 'utf8')
|
||||
const js = ts.transpileModule(src, {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
module: ts.ModuleKind.ESNext
|
||||
}
|
||||
}).outputText
|
||||
const tmp = join(tmpdir(), `prompt-sequences-${process.pid}.mjs`)
|
||||
writeFileSync(tmp, js)
|
||||
const {
|
||||
assembleSceneText,
|
||||
assembleSequenceText,
|
||||
formatCharacterLine,
|
||||
emptyScene,
|
||||
emptyCharacter,
|
||||
emptyOutfit,
|
||||
emptySet,
|
||||
emptyFraming,
|
||||
cloneSceneForNext,
|
||||
normalizePromptSequence,
|
||||
ensureSceneStatesForCharacters
|
||||
} = await import(pathToFileURL(tmp).href)
|
||||
unlinkSync(tmp)
|
||||
|
||||
test('formatCharacterLine covers name/description and outfit', () => {
|
||||
assert.equal(formatCharacterLine({ name: 'Ada', description: 'tall' }), 'Ada: tall')
|
||||
assert.equal(
|
||||
formatCharacterLine({ name: 'Ada', description: 'tall' }, { name: 'Work', description: 'blue jacket' }),
|
||||
'Ada: tall. Outfit: blue jacket'
|
||||
)
|
||||
})
|
||||
|
||||
test('selecting a framing copies only that framing text; labels never appear', () => {
|
||||
const wide = { ...emptyFraming('wide'), id: 'f-wide', text: 'open courtyard at dawn' }
|
||||
const medium = { ...emptyFraming('medium'), id: 'f-med', text: 'courtyard from the arch' }
|
||||
const narrow = { ...emptyFraming('narrow'), id: 'f-nar', text: 'door latch detail' }
|
||||
const set = {
|
||||
...emptySet(),
|
||||
id: 's1',
|
||||
name: 'Courtyard',
|
||||
framings: [wide, medium, narrow]
|
||||
}
|
||||
const base = {
|
||||
style: 'style',
|
||||
settings: 'settings',
|
||||
characterStates: [],
|
||||
prompt: 'prompt'
|
||||
}
|
||||
const mediumText = assembleSceneText({
|
||||
...base,
|
||||
setStates: [{ setId: 's1', on: true, framingId: 'f-med' }]
|
||||
}, [], [set])
|
||||
assert.equal(mediumText, 'style\n\nsettings\n\ncourtyard from the arch\n\nprompt')
|
||||
assert.ok(!mediumText.includes('open courtyard'))
|
||||
assert.ok(!mediumText.includes('door latch'))
|
||||
assert.ok(!mediumText.includes('Courtyard'))
|
||||
assert.ok(!mediumText.includes('wide'))
|
||||
assert.ok(!mediumText.includes('medium'))
|
||||
assert.ok(!mediumText.includes('narrow'))
|
||||
|
||||
const narrowText = assembleSceneText({
|
||||
...base,
|
||||
setStates: [{ setId: 's1', on: true, framingId: 'f-nar' }]
|
||||
}, [], [set])
|
||||
assert.equal(narrowText, 'style\n\nsettings\n\ndoor latch detail\n\nprompt')
|
||||
assert.ok(!narrowText.includes('open courtyard'))
|
||||
assert.ok(!narrowText.includes('from the arch'))
|
||||
|
||||
const noneText = assembleSceneText({
|
||||
...base,
|
||||
setStates: [{ setId: 's1', on: true, framingId: null }]
|
||||
}, [], [set])
|
||||
assert.equal(noneText, 'style\n\nsettings\n\nprompt')
|
||||
})
|
||||
|
||||
test('off set is omitted; sequence join is double blank', () => {
|
||||
const framing = { ...emptyFraming('wide'), id: 'f1', text: 'set text' }
|
||||
const set = { ...emptySet(), id: 's1', name: 'Place', framings: [framing] }
|
||||
const scene1 = {
|
||||
style: 'a',
|
||||
settings: '',
|
||||
setStates: [{ setId: 's1', on: true, framingId: 'f1' }],
|
||||
characterStates: [],
|
||||
prompt: 'p1'
|
||||
}
|
||||
const scene2 = {
|
||||
style: 'b',
|
||||
settings: '',
|
||||
setStates: [{ setId: 's1', on: false, framingId: 'f1' }],
|
||||
characterStates: [],
|
||||
prompt: 'p2'
|
||||
}
|
||||
assert.equal(assembleSceneText(scene2, [], [set]), 'b\n\np2')
|
||||
const full = assembleSequenceText({ sets: [set], scenes: [scene1, scene2] })
|
||||
assert.equal(full, 'a\n\nset text\n\np1\n\n\nb\n\np2')
|
||||
assert.ok(!full.includes('Place'))
|
||||
assert.ok(!full.includes('wide'))
|
||||
})
|
||||
|
||||
test('legacy beat-only set still opens as one wide framing', () => {
|
||||
const legacy = normalizePromptSequence({
|
||||
id: 'seq1',
|
||||
name: 'Old beats',
|
||||
sets: [{
|
||||
id: 's1',
|
||||
name: 'Hall',
|
||||
description: 'stone walls',
|
||||
beats: [
|
||||
{ id: 'b1', name: 'Beat1', text: 'first beat', framings: ['wide'] },
|
||||
{ id: 'b2', name: 'Beat2', text: 'second beat', framings: ['medium'] }
|
||||
]
|
||||
}],
|
||||
scenes: [{
|
||||
id: 'sc1',
|
||||
title: 'Scene 1',
|
||||
style: 'oil',
|
||||
settings: '',
|
||||
shotSize: 'wide',
|
||||
setStates: [{ setId: 's1', on: true }],
|
||||
prompt: 'walk',
|
||||
characters: []
|
||||
}],
|
||||
characters: [{ id: 'x', name: 'Ada', description: 'tall', outfits: [] }],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
})
|
||||
assert.equal(legacy.sets.length, 1)
|
||||
assert.equal(legacy.sets[0].framings.length, 1)
|
||||
assert.equal(legacy.sets[0].framings[0].label, 'wide')
|
||||
assert.ok(legacy.sets[0].framings[0].text.includes('stone walls'))
|
||||
assert.ok(legacy.sets[0].framings[0].text.includes('first beat'))
|
||||
assert.ok(legacy.sets[0].framings[0].text.includes('second beat'))
|
||||
assert.equal(legacy.scenes[0].setStates[0].framingId, legacy.sets[0].framings[0].id)
|
||||
const text = assembleSequenceText(legacy)
|
||||
assert.ok(text.includes('stone walls'))
|
||||
assert.ok(text.includes('first beat'))
|
||||
assert.ok(!text.includes('Hall'))
|
||||
assert.ok(!text.includes('Beat1'))
|
||||
})
|
||||
|
||||
test('legacy sequence without sets still assembles', () => {
|
||||
const legacy = normalizePromptSequence({
|
||||
id: 'seq1',
|
||||
name: 'Old',
|
||||
scenes: [
|
||||
{
|
||||
id: 'sc1',
|
||||
title: 'Scene 1',
|
||||
style: 'oil',
|
||||
settings: '',
|
||||
prompt: 'walk',
|
||||
characters: [{ id: 'x', name: 'Ada', description: 'tall' }]
|
||||
}
|
||||
],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
})
|
||||
assert.deepEqual(legacy.sets, [])
|
||||
assert.equal(assembleSequenceText(legacy), 'oil\n\nAda: tall\n\nwalk')
|
||||
})
|
||||
|
||||
test('cloneSceneForNext copies setStates including framingId', () => {
|
||||
const sets = [emptySet()]
|
||||
const first = emptyScene(1, [], sets)
|
||||
first.setStates[0].on = false
|
||||
first.setStates[0].framingId = sets[0].framings[0].id
|
||||
const next = cloneSceneForNext(first, 2)
|
||||
assert.equal(next.setStates[0].on, false)
|
||||
assert.equal(next.setStates[0].framingId, sets[0].framings[0].id)
|
||||
assert.equal(next.setStates[0].setId, sets[0].id)
|
||||
})
|
||||
|
||||
test('character outfit and off still work with sets', () => {
|
||||
const ada = {
|
||||
...emptyCharacter(),
|
||||
id: 'c1',
|
||||
name: 'Ada',
|
||||
description: 'tall',
|
||||
outfits: [{ ...emptyOutfit(), id: 'o1', description: 'red dress' }]
|
||||
}
|
||||
const text = assembleSceneText({
|
||||
style: '',
|
||||
settings: '',
|
||||
setStates: [],
|
||||
characterStates: [{ characterId: 'c1', on: true, outfitId: 'o1' }],
|
||||
prompt: 'hi'
|
||||
}, [ada], [])
|
||||
assert.equal(text, 'Ada: tall. Outfit: red dress\n\nhi')
|
||||
})
|
||||
|
||||
test('ensureSceneStatesForCharacters turns new character on only for current scene', () => {
|
||||
const a = emptyCharacter()
|
||||
const scenes = [emptyScene(1, [a]), emptyScene(2, [a])]
|
||||
const b = emptyCharacter()
|
||||
ensureSceneStatesForCharacters(scenes, [a, b], { newCharacterId: b.id, turnOnSceneIndex: 1 })
|
||||
assert.equal(scenes[0].characterStates.find(s => s.characterId === b.id).on, false)
|
||||
assert.equal(scenes[1].characterStates.find(s => s.characterId === b.id).on, true)
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import {readFileSync} from 'node:fs'
|
||||
import ts from 'typescript'
|
||||
import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs'
|
||||
import {useAsHero,useAsSource,restoreAll,hydrateStudio2Job} from '../shared/studio2/input.mjs'
|
||||
import {toggleStyle,parseBatch,stylePrompt} from '../shared/studio2/styles.mjs'
|
||||
import {toggleStyle,parseBatch,parseBatchDoubleBlank,stylePrompt} from '../shared/studio2/styles.mjs'
|
||||
import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
|
||||
import {cachedLoras} from '../shared/studio2/lora-cache.mjs'
|
||||
import {resolveQwen21Size} from '../shared/studio2/qwen21-size.mjs'
|
||||
@@ -79,6 +79,12 @@ test('image chips compile positive and negative independently; video chips do no
|
||||
assert.match(q.compiledPrompt,/Photorealistic/);assert.doesNotMatch(q.compiledPrompt,/Comic|pores|tripod|hold|VIDEO ONLY/i);assert.deepEqual(q.imageStyles,styles)
|
||||
assert.doesNotMatch(compilePrompt(q.promptSections,false,q),/Picture 1/)
|
||||
assert.deepEqual(parseBatch('A wrapped\nsentence\n\nSecond shot\r\n\r\nThird'),['A wrapped\nsentence','Second shot','Third'])
|
||||
assert.deepEqual(
|
||||
parseBatchDoubleBlank('Prompt 1 starts here\nBal blaglas\nsldkjf lkaj\n\nsldkfjs lkdfj\nsdlfkjsdkljf\n\n\nPrompt 2 starts here flaskjflaskdj'),
|
||||
['Prompt 1 starts here\nBal blaglas\nsldkjf lkaj\n\nsldkfjs lkdfj\nsdlfkjsdkljf','Prompt 2 starts here flaskjflaskdj']
|
||||
)
|
||||
assert.deepEqual(parseBatchDoubleBlank('one\n\ntwo'),['one\n\ntwo'])
|
||||
assert.deepEqual(parseBatchDoubleBlank('one\n\n\ntwo\n\n\n\nthree\n'),['one','two','three'])
|
||||
})
|
||||
test('qwen21 supports Generate and Edit with 25/1 defaults and no locks',()=>{
|
||||
const q=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'a red bicycle'},settings:{aspect:'1:1'}})
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
export type PromptSequenceOutfit = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export type PromptSequenceCharacter = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
outfits: PromptSequenceOutfit[]
|
||||
}
|
||||
|
||||
export type PromptSequenceCharacterState = {
|
||||
characterId: string
|
||||
on: boolean
|
||||
/** Active outfit id for this scene, or null/'' for none. */
|
||||
outfitId: string | null
|
||||
}
|
||||
|
||||
/** Suggested labels only — not a hard enum. */
|
||||
export const PROMPT_SEQUENCE_FRAMING_SUGGESTIONS = ['wide', 'medium', 'narrow'] as const
|
||||
|
||||
export type PromptSequenceFraming = {
|
||||
id: string
|
||||
label: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export type PromptSequenceSet = {
|
||||
id: string
|
||||
name: string
|
||||
framings: PromptSequenceFraming[]
|
||||
}
|
||||
|
||||
export type PromptSequenceSetState = {
|
||||
setId: string
|
||||
on: boolean
|
||||
/** Chosen framing for this set in this scene (like outfitId). Empty = copy nothing. */
|
||||
framingId: string | null
|
||||
}
|
||||
|
||||
export type PromptSequenceScene = {
|
||||
id: string
|
||||
title: string
|
||||
style: string
|
||||
settings: string
|
||||
setStates: PromptSequenceSetState[]
|
||||
characterStates: PromptSequenceCharacterState[]
|
||||
prompt: string
|
||||
}
|
||||
|
||||
export type PromptSequence = {
|
||||
id: string
|
||||
name: string
|
||||
characters: PromptSequenceCharacter[]
|
||||
sets: PromptSequenceSet[]
|
||||
scenes: PromptSequenceScene[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export const PROMPT_SEQUENCE_NAME_MAX = 80
|
||||
export const PROMPT_SEQUENCE_FIELD_MAX = 50_000
|
||||
export const PROMPT_SEQUENCE_TITLE_MAX = 60
|
||||
|
||||
export function newId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `ps-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
export function emptyOutfit(): PromptSequenceOutfit {
|
||||
return { id: newId(), name: '', description: '' }
|
||||
}
|
||||
|
||||
export function emptyCharacter(): PromptSequenceCharacter {
|
||||
return { id: newId(), name: '', description: '', outfits: [] }
|
||||
}
|
||||
|
||||
export function emptyFraming(label = ''): PromptSequenceFraming {
|
||||
return { id: newId(), label, text: '' }
|
||||
}
|
||||
|
||||
export function emptySet(): PromptSequenceSet {
|
||||
return { id: newId(), name: '', framings: [emptyFraming()] }
|
||||
}
|
||||
|
||||
export function defaultCharacterState(characterId: string, on = true): PromptSequenceCharacterState {
|
||||
return { characterId, on, outfitId: null }
|
||||
}
|
||||
|
||||
export function defaultSetState(setId: string, on = true, framingId: string | null = null): PromptSequenceSetState {
|
||||
return { setId, on, framingId }
|
||||
}
|
||||
|
||||
export function emptyScene(
|
||||
index = 1,
|
||||
characters: PromptSequenceCharacter[] = [],
|
||||
sets: PromptSequenceSet[] = []
|
||||
): PromptSequenceScene {
|
||||
return {
|
||||
id: newId(),
|
||||
title: `Scene ${Math.max(1, index)}`,
|
||||
style: '',
|
||||
settings: '',
|
||||
setStates: sets.map(set => defaultSetState(set.id, true, null)),
|
||||
characterStates: characters.map(character => defaultCharacterState(character.id, true)),
|
||||
prompt: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function cloneSceneForNext(previous: PromptSequenceScene, nextIndex: number): PromptSequenceScene {
|
||||
return {
|
||||
id: newId(),
|
||||
title: `Scene ${nextIndex}`,
|
||||
style: previous.style,
|
||||
settings: previous.settings,
|
||||
setStates: (previous.setStates || []).map(state => ({
|
||||
setId: state.setId,
|
||||
on: !!state.on,
|
||||
framingId: state.framingId || null
|
||||
})),
|
||||
characterStates: (previous.characterStates || []).map(state => ({
|
||||
characterId: state.characterId,
|
||||
on: !!state.on,
|
||||
outfitId: state.outfitId || null
|
||||
})),
|
||||
prompt: previous.prompt
|
||||
}
|
||||
}
|
||||
|
||||
function outfitPromptText(outfit?: { name?: string; description?: string } | null) {
|
||||
if (!outfit) return ''
|
||||
return String(outfit.description || '').trim() || String(outfit.name || '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Character line when on:
|
||||
* - with outfit: "Name: description. Outfit: outfit description"
|
||||
* - without: "Name: description" (or name-only / description-only)
|
||||
*/
|
||||
export function formatCharacterLine(
|
||||
character: { name?: string; description?: string },
|
||||
outfit?: { name?: string; description?: string } | null
|
||||
) {
|
||||
const name = String(character?.name || '').trim()
|
||||
const description = String(character?.description || '').trim()
|
||||
let base = ''
|
||||
if (name && description) base = `${name}: ${description}`
|
||||
else base = name || description
|
||||
if (!base) return ''
|
||||
const outfitText = outfitPromptText(outfit)
|
||||
if (!outfitText) return base
|
||||
return `${base}. Outfit: ${outfitText}`
|
||||
}
|
||||
|
||||
/** Selected framing text for each on set. No set name, no framing label. */
|
||||
export function assembleSetBlock(
|
||||
scene: { setStates?: PromptSequenceSetState[] },
|
||||
sets: PromptSequenceSet[] = []
|
||||
) {
|
||||
const states = scene.setStates || []
|
||||
const stateById = new Map(states.map(state => [state.setId, state]))
|
||||
const chunks: string[] = []
|
||||
for (const set of sets) {
|
||||
const state = stateById.get(set.id)
|
||||
if (state && !state.on) continue
|
||||
const framingId = state?.framingId || null
|
||||
if (!framingId) continue
|
||||
const framing = (set.framings || []).find(item => item.id === framingId)
|
||||
const text = String(framing?.text || '').trim()
|
||||
if (text) chunks.push(text)
|
||||
}
|
||||
return chunks.join('\n')
|
||||
}
|
||||
|
||||
export function resolveSceneCharacterLines(
|
||||
scene: { characterStates?: PromptSequenceCharacterState[] },
|
||||
characters: PromptSequenceCharacter[] = []
|
||||
) {
|
||||
const states = scene.characterStates || []
|
||||
const stateById = new Map(states.map(state => [state.characterId, state]))
|
||||
return characters
|
||||
.map((character) => {
|
||||
const state = stateById.get(character.id)
|
||||
if (state && !state.on) return ''
|
||||
const outfitId = state?.outfitId || null
|
||||
const outfit = outfitId
|
||||
? (character.outfits || []).find(item => item.id === outfitId) || null
|
||||
: null
|
||||
return formatCharacterLine(character, outfit)
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function assembleSceneText(
|
||||
scene: {
|
||||
style?: string
|
||||
settings?: string
|
||||
prompt?: string
|
||||
setStates?: PromptSequenceSetState[]
|
||||
characterStates?: PromptSequenceCharacterState[]
|
||||
/** Legacy per-scene characters (migration / old tests). */
|
||||
characters?: Array<{ name?: string; description?: string }>
|
||||
},
|
||||
sequenceCharacters?: PromptSequenceCharacter[],
|
||||
sequenceSets?: PromptSequenceSet[]
|
||||
) {
|
||||
const setBlock = sequenceSets?.length
|
||||
? assembleSetBlock(scene, sequenceSets)
|
||||
: ''
|
||||
const characterBlock = sequenceCharacters?.length
|
||||
? resolveSceneCharacterLines(scene, sequenceCharacters).join('\n')
|
||||
: (scene.characters || [])
|
||||
.map(character => formatCharacterLine(character))
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
const parts = [
|
||||
String(scene.style || '').trim(),
|
||||
String(scene.settings || '').trim(),
|
||||
setBlock,
|
||||
characterBlock,
|
||||
String(scene.prompt || '').trim()
|
||||
].filter(Boolean)
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole sequence as one copyable block.
|
||||
* Scenes join with a double blank line (\n\n\n) — same separator desktop Batch uses.
|
||||
* Sequence name is never included.
|
||||
*/
|
||||
export function assembleSequenceText(sequence: {
|
||||
characters?: PromptSequenceCharacter[]
|
||||
sets?: PromptSequenceSet[]
|
||||
scenes?: Array<{
|
||||
style?: string
|
||||
settings?: string
|
||||
prompt?: string
|
||||
setStates?: PromptSequenceSetState[]
|
||||
characterStates?: PromptSequenceCharacterState[]
|
||||
characters?: Array<{ name?: string; description?: string }>
|
||||
}>
|
||||
}) {
|
||||
const characters = sequence.characters || []
|
||||
const sets = sequence.sets || []
|
||||
return (sequence.scenes || [])
|
||||
.map(scene => assembleSceneText(
|
||||
scene,
|
||||
characters.length ? characters : undefined,
|
||||
sets.length ? sets : undefined
|
||||
))
|
||||
.filter(Boolean)
|
||||
.join('\n\n\n')
|
||||
}
|
||||
|
||||
export function normalizePromptSequenceName(raw: unknown) {
|
||||
return String(raw || '').trim().replace(/\s+/g, ' ').slice(0, PROMPT_SEQUENCE_NAME_MAX)
|
||||
}
|
||||
|
||||
export function normalizePromptSequenceField(raw: unknown) {
|
||||
return String(raw || '').replace(/\r\n/g, '\n').slice(0, PROMPT_SEQUENCE_FIELD_MAX)
|
||||
}
|
||||
|
||||
export function normalizePromptSequenceTitle(raw: unknown, fallback: string) {
|
||||
const title = String(raw || '').trim().replace(/\s+/g, ' ').slice(0, PROMPT_SEQUENCE_TITLE_MAX)
|
||||
return title || fallback
|
||||
}
|
||||
|
||||
export function normalizeOutfit(raw: unknown): PromptSequenceOutfit {
|
||||
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
||||
return {
|
||||
id: String(rec.id || '').trim() || newId(),
|
||||
name: String(rec.name || '').trim().slice(0, 120),
|
||||
description: normalizePromptSequenceField(rec.description).trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCharacter(raw: unknown): PromptSequenceCharacter {
|
||||
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
||||
const outfits = Array.isArray(rec.outfits) ? rec.outfits.map(normalizeOutfit) : []
|
||||
return {
|
||||
id: String(rec.id || '').trim() || newId(),
|
||||
name: String(rec.name || '').trim().slice(0, 120),
|
||||
description: normalizePromptSequenceField(rec.description).trim(),
|
||||
outfits
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeFraming(raw: unknown): PromptSequenceFraming {
|
||||
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
||||
return {
|
||||
id: String(rec.id || '').trim() || newId(),
|
||||
label: String(rec.label || '').trim().slice(0, 120),
|
||||
text: normalizePromptSequenceField(rec.text)
|
||||
}
|
||||
}
|
||||
|
||||
/** Fold legacy beat-only sets into one framing labeled "wide". */
|
||||
function framingsFromLegacyBeats(rec: Record<string, unknown>): PromptSequenceFraming[] {
|
||||
const parts: string[] = []
|
||||
const description = normalizePromptSequenceField(rec.description).trim()
|
||||
if (description) parts.push(description)
|
||||
const beats = Array.isArray(rec.beats) ? rec.beats : []
|
||||
for (const beat of beats) {
|
||||
if (!beat || typeof beat !== 'object') continue
|
||||
const text = normalizePromptSequenceField((beat as Record<string, unknown>).text).trim()
|
||||
if (text) parts.push(text)
|
||||
}
|
||||
if (!parts.length) return [emptyFraming()]
|
||||
return [{ id: newId(), label: 'wide', text: parts.join('\n') }]
|
||||
}
|
||||
|
||||
export function normalizeSet(raw: unknown): PromptSequenceSet {
|
||||
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
||||
let framings: PromptSequenceFraming[]
|
||||
if (Array.isArray(rec.framings) && rec.framings.length) {
|
||||
framings = rec.framings.map(normalizeFraming)
|
||||
} else if (Array.isArray(rec.beats) && rec.beats.length) {
|
||||
framings = framingsFromLegacyBeats(rec)
|
||||
} else if (String(rec.description || '').trim()) {
|
||||
framings = framingsFromLegacyBeats(rec)
|
||||
} else {
|
||||
framings = [emptyFraming()]
|
||||
}
|
||||
return {
|
||||
id: String(rec.id || '').trim() || newId(),
|
||||
name: String(rec.name || '').trim().slice(0, 120),
|
||||
framings
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCharacterState(raw: unknown, fallbackCharacterId = ''): PromptSequenceCharacterState {
|
||||
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
||||
const characterId = String(rec.characterId || fallbackCharacterId || '').trim()
|
||||
const outfitRaw = rec.outfitId
|
||||
const outfitId = outfitRaw == null || outfitRaw === '' ? null : String(outfitRaw).trim() || null
|
||||
return {
|
||||
characterId,
|
||||
on: rec.on === undefined ? true : !!rec.on,
|
||||
outfitId
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSetState(
|
||||
raw: unknown,
|
||||
fallbackSetId = '',
|
||||
sets: PromptSequenceSet[] = [],
|
||||
legacyShotSize = ''
|
||||
): PromptSequenceSetState {
|
||||
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
||||
const setId = String(rec.setId || fallbackSetId || '').trim()
|
||||
const framingRaw = rec.framingId
|
||||
let framingId = framingRaw == null || framingRaw === '' ? null : String(framingRaw).trim() || null
|
||||
const set = sets.find(item => item.id === setId)
|
||||
if (framingId && set && !set.framings.some(framing => framing.id === framingId)) {
|
||||
framingId = null
|
||||
}
|
||||
if (!framingId && legacyShotSize && set) {
|
||||
const match = set.framings.find(
|
||||
framing => framing.label.trim().toLowerCase() === legacyShotSize.trim().toLowerCase()
|
||||
)
|
||||
if (match) framingId = match.id
|
||||
}
|
||||
return {
|
||||
setId,
|
||||
on: rec.on === undefined ? true : !!rec.on,
|
||||
framingId
|
||||
}
|
||||
}
|
||||
|
||||
function liftLegacyCharacters(scenesRaw: unknown[]): PromptSequenceCharacter[] {
|
||||
const byId = new Map<string, PromptSequenceCharacter>()
|
||||
for (const scene of scenesRaw) {
|
||||
if (!scene || typeof scene !== 'object') continue
|
||||
const chars = (scene as Record<string, unknown>).characters
|
||||
if (!Array.isArray(chars)) continue
|
||||
for (const character of chars) {
|
||||
const normalized = normalizeCharacter(character)
|
||||
if (!byId.has(normalized.id)) byId.set(normalized.id, normalized)
|
||||
}
|
||||
}
|
||||
return [...byId.values()]
|
||||
}
|
||||
|
||||
function syncCharacterStates(
|
||||
statesRaw: unknown,
|
||||
characters: PromptSequenceCharacter[],
|
||||
defaultOn = true
|
||||
): PromptSequenceCharacterState[] {
|
||||
const incoming = Array.isArray(statesRaw)
|
||||
? statesRaw.map(state => normalizeCharacterState(state)).filter(state => state.characterId)
|
||||
: []
|
||||
const byId = new Map(incoming.map(state => [state.characterId, state]))
|
||||
return characters.map((character) => {
|
||||
const existing = byId.get(character.id)
|
||||
if (existing) {
|
||||
const outfitOk = existing.outfitId
|
||||
&& character.outfits.some(outfit => outfit.id === existing.outfitId)
|
||||
return {
|
||||
characterId: character.id,
|
||||
on: existing.on,
|
||||
outfitId: outfitOk ? existing.outfitId : null
|
||||
}
|
||||
}
|
||||
return defaultCharacterState(character.id, defaultOn)
|
||||
})
|
||||
}
|
||||
|
||||
function syncSetStates(
|
||||
statesRaw: unknown,
|
||||
sets: PromptSequenceSet[],
|
||||
defaultOn = true,
|
||||
legacyShotSize = ''
|
||||
): PromptSequenceSetState[] {
|
||||
const incoming = Array.isArray(statesRaw)
|
||||
? statesRaw.map(state => normalizeSetState(state, '', sets, legacyShotSize)).filter(state => state.setId)
|
||||
: []
|
||||
const byId = new Map(incoming.map(state => [state.setId, state]))
|
||||
return sets.map((set) => {
|
||||
const existing = byId.get(set.id)
|
||||
if (existing) {
|
||||
const framingOk = existing.framingId
|
||||
&& set.framings.some(framing => framing.id === existing.framingId)
|
||||
return {
|
||||
setId: set.id,
|
||||
on: existing.on,
|
||||
framingId: framingOk ? existing.framingId : null
|
||||
}
|
||||
}
|
||||
let framingId: string | null = null
|
||||
if (legacyShotSize) {
|
||||
const match = set.framings.find(
|
||||
framing => framing.label.trim().toLowerCase() === legacyShotSize.trim().toLowerCase()
|
||||
)
|
||||
if (match) framingId = match.id
|
||||
}
|
||||
return defaultSetState(set.id, defaultOn, framingId)
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeScene(
|
||||
raw: unknown,
|
||||
index: number,
|
||||
characters: PromptSequenceCharacter[] = [],
|
||||
sets: PromptSequenceSet[] = []
|
||||
): PromptSequenceScene {
|
||||
const rec = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
|
||||
const legacyShotSize = String(rec.shotSize || '').trim()
|
||||
return {
|
||||
id: String(rec.id || '').trim() || newId(),
|
||||
title: normalizePromptSequenceTitle(rec.title, `Scene ${index + 1}`),
|
||||
style: normalizePromptSequenceField(rec.style),
|
||||
settings: normalizePromptSequenceField(rec.settings),
|
||||
setStates: syncSetStates(rec.setStates, sets, true, legacyShotSize),
|
||||
characterStates: syncCharacterStates(rec.characterStates, characters, true),
|
||||
prompt: normalizePromptSequenceField(rec.prompt)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePromptSequence(raw: unknown): PromptSequence | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const rec = raw as Record<string, unknown>
|
||||
const name = normalizePromptSequenceName(rec.name)
|
||||
if (!name) return null
|
||||
const scenesRaw = Array.isArray(rec.scenes) ? rec.scenes : []
|
||||
let characters = Array.isArray(rec.characters)
|
||||
? rec.characters.map(normalizeCharacter)
|
||||
: liftLegacyCharacters(scenesRaw)
|
||||
if (!characters.length) characters = [emptyCharacter()]
|
||||
const sets = Array.isArray(rec.sets) ? rec.sets.map(normalizeSet) : []
|
||||
const scenes = scenesRaw.length
|
||||
? scenesRaw.map((scene, index) => normalizeScene(scene, index, characters, sets))
|
||||
: [emptyScene(1, characters, sets)]
|
||||
const createdAt = Number(rec.createdAt) || Date.now()
|
||||
return {
|
||||
id: String(rec.id || '').trim() || newId(),
|
||||
name,
|
||||
characters,
|
||||
sets,
|
||||
scenes,
|
||||
createdAt,
|
||||
updatedAt: Number(rec.updatedAt) || createdAt
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure every scene has a state row for every sequence character. */
|
||||
export function ensureSceneStatesForCharacters(
|
||||
scenes: PromptSequenceScene[],
|
||||
characters: PromptSequenceCharacter[],
|
||||
opts?: { newCharacterId?: string; turnOnSceneIndex?: number }
|
||||
) {
|
||||
const newCharacterId = opts?.newCharacterId
|
||||
const turnOnIndex = opts?.turnOnSceneIndex
|
||||
for (let i = 0; i < scenes.length; i++) {
|
||||
const scene = scenes[i]
|
||||
const byId = new Map(scene.characterStates.map(state => [state.characterId, state]))
|
||||
scene.characterStates = characters.map((character) => {
|
||||
const existing = byId.get(character.id)
|
||||
if (existing) return existing
|
||||
const on = newCharacterId && character.id === newCharacterId
|
||||
? i === turnOnIndex
|
||||
: true
|
||||
return defaultCharacterState(character.id, on)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure every scene has a state row for every sequence set. */
|
||||
export function ensureSceneStatesForSets(
|
||||
scenes: PromptSequenceScene[],
|
||||
sets: PromptSequenceSet[],
|
||||
opts?: { newSetId?: string; turnOnSceneIndex?: number }
|
||||
) {
|
||||
const newSetId = opts?.newSetId
|
||||
const turnOnIndex = opts?.turnOnSceneIndex
|
||||
for (let i = 0; i < scenes.length; i++) {
|
||||
const scene = scenes[i]
|
||||
const byId = new Map((scene.setStates || []).map(state => [state.setId, state]))
|
||||
scene.setStates = sets.map((set) => {
|
||||
const existing = byId.get(set.id)
|
||||
if (existing) {
|
||||
const framingOk = existing.framingId
|
||||
&& set.framings.some(framing => framing.id === existing.framingId)
|
||||
return {
|
||||
setId: set.id,
|
||||
on: existing.on,
|
||||
framingId: framingOk ? existing.framingId : null
|
||||
}
|
||||
}
|
||||
const on = newSetId && set.id === newSetId
|
||||
? i === turnOnIndex
|
||||
: true
|
||||
return defaultSetState(set.id, on, null)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user