Compare commits
16
Commits
cfabfd37ee
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45e97cc1c3 | ||
|
|
b6eef6a511 | ||
|
|
d803d57a78 | ||
|
|
a8ae61704e | ||
|
|
7799529788 | ||
|
|
790c33fcf0 | ||
|
|
1bf97568bf | ||
|
|
5788d74001 | ||
|
|
4f40641050 | ||
|
|
14dcd234f3 | ||
|
|
15ef891b26 | ||
|
|
9481791ac5 | ||
|
|
17bb42bea4 | ||
|
|
ccf852cb0c | ||
|
|
4ec8ffa8d6 | ||
|
|
a465dd68ec |
+44
-1
@@ -347,7 +347,8 @@
|
|||||||
|
|
||||||
/* Grow until width or height hits the stage; letterbox/pillarbox via contain */
|
/* Grow until width or height hits the stage; letterbox/pillarbox via contain */
|
||||||
.viewer-frame > img,
|
.viewer-frame > img,
|
||||||
.viewer-frame > video {
|
.viewer-frame > video,
|
||||||
|
.viewer-still {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -356,6 +357,18 @@
|
|||||||
object-position: center;
|
object-position: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.viewer-still--veiled {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-frame--veiled .viewer-veil-label {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Top chrome is a sibling of the media pane — never absolute over pixels */
|
/* Top chrome is a sibling of the media pane — never absolute over pixels */
|
||||||
.stage-chrome {
|
.stage-chrome {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -673,6 +686,10 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.queue-col[data-collapsed='1'] {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.queue-head {
|
.queue-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -682,6 +699,24 @@
|
|||||||
font-weight: 650;
|
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 {
|
.queue-badge {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
min-width: 1.25rem;
|
min-width: 1.25rem;
|
||||||
@@ -826,6 +861,14 @@
|
|||||||
cursor: not-allowed;
|
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 {
|
.queue-foot .tiny {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -55,6 +55,13 @@
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
@click="menuOpen = false"
|
@click="menuOpen = false"
|
||||||
>Director</NuxtLink>
|
>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
|
<NuxtLink
|
||||||
to="/music"
|
to="/music"
|
||||||
class="block rounded-xl px-3 py-2 text-sm hover:bg-white/5 hover:text-white"
|
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">
|
<script setup lang="ts">
|
||||||
withDefaults(defineProps<{
|
withDefaults(defineProps<{
|
||||||
subtitle: string
|
subtitle: string
|
||||||
current?: 'studio' | 'library' | 'director' | 'queue' | 'settings' | 'music'
|
current?: 'studio' | 'library' | 'director' | 'prompt-builder' | 'queue' | 'settings' | 'music'
|
||||||
wide?: boolean
|
wide?: boolean
|
||||||
queueCount?: number
|
queueCount?: number
|
||||||
}>(), {
|
}>(), {
|
||||||
|
|||||||
+649
-153
File diff suppressed because it is too large
Load Diff
@@ -78,14 +78,14 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<img
|
<img
|
||||||
v-else-if="liveThumbs.has(entry.key) && thumbSrc(entry)"
|
v-else-if="!previewHidden && liveThumbs.has(entry.key) && thumbSrc(entry)"
|
||||||
:src="thumbSrc(entry)"
|
:src="thumbSrc(entry)"
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
>
|
>
|
||||||
<span v-else class="lib-modal-fallback" />
|
<span v-else class="lib-modal-fallback">{{ previewHidden ? 'Hidden' : '' }}</span>
|
||||||
<span class="lib-modal-kind">
|
<span class="lib-modal-kind">
|
||||||
{{ entry.kind === 'clip' ? 'Video' : entry.kind === 'track' ? 'Music' : 'Image' }}
|
{{ entry.kind === 'clip' ? 'Video' : entry.kind === 'track' ? 'Music' : 'Image' }}
|
||||||
</span>
|
</span>
|
||||||
@@ -286,7 +286,7 @@ function applyLibraryPage(data: any, replace: boolean) {
|
|||||||
fetchedAt.value = Date.now()
|
fetchedAt.value = Date.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLibraryPage(cursor: string | null, replace: boolean) {
|
async function fetchLibraryPage(cursor: string | null) {
|
||||||
const folder = activeFolderId.value && activeFolderId.value !== 'all' ? activeFolderId.value : ''
|
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 type = filter.value === 'all' ? 'all' : filter.value === 'images' ? 'images' : filter.value === 'videos' ? 'videos' : 'music'
|
||||||
const qs = new URLSearchParams({ limit: '50' })
|
const qs = new URLSearchParams({ limit: '50' })
|
||||||
@@ -296,28 +296,33 @@ async function fetchLibraryPage(cursor: string | null, replace: boolean) {
|
|||||||
return await $fetch<any>(`/api/library?${qs.toString()}`)
|
return await $fetch<any>(`/api/library?${qs.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let fetchGen = 0
|
||||||
|
|
||||||
async function fetchLibrary(force = false) {
|
async function fetchLibrary(force = false) {
|
||||||
if (loading.value) return
|
|
||||||
const stale = !fetchedAt.value || Date.now() - fetchedAt.value > 15_000
|
const stale = !fetchedAt.value || Date.now() - fetchedAt.value > 15_000
|
||||||
const empty = !stills.value.length && !clips.value.length && !tracks.value.length
|
const empty = !stills.value.length && !clips.value.length && !tracks.value.length
|
||||||
if (!force && !empty && !stale) return
|
if (!force && !empty && !stale) return
|
||||||
|
const gen = ++fetchGen
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
liveThumbs.value = new Set()
|
liveThumbs.value = new Set()
|
||||||
try {
|
try {
|
||||||
const first = await fetchLibraryPage(null, true)
|
const first = await fetchLibraryPage(null)
|
||||||
|
if (gen !== fetchGen) return
|
||||||
applyLibraryPage(first, true)
|
applyLibraryPage(first, true)
|
||||||
loading.value = false
|
loading.value = false
|
||||||
nextTick(() => observeThumbs())
|
nextTick(() => observeThumbs())
|
||||||
// Pull remaining pages in the background — first paint already has ≤50 items
|
// Keep fetching until the folder is exhausted — 50 is a page size, not a cap
|
||||||
let cursor = first?.nextCursor || null
|
let cursor = first?.nextCursor || null
|
||||||
while (cursor) {
|
while (cursor && gen === fetchGen) {
|
||||||
const more = await fetchLibraryPage(cursor, false)
|
const more = await fetchLibraryPage(cursor)
|
||||||
|
if (gen !== fetchGen) return
|
||||||
applyLibraryPage(more, false)
|
applyLibraryPage(more, false)
|
||||||
cursor = more?.nextCursor || null
|
cursor = more?.nextCursor || null
|
||||||
nextTick(() => observeThumbs())
|
nextTick(() => observeThumbs())
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
if (gen !== fetchGen) return
|
||||||
loadError.value = e?.data?.statusMessage || e?.message || 'Could not load library'
|
loadError.value = e?.data?.statusMessage || e?.message || 'Could not load library'
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -354,7 +359,7 @@ async function fetchMore() {
|
|||||||
if (!nextCursor.value || loading.value) return
|
if (!nextCursor.value || loading.value) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const more = await fetchLibraryPage(nextCursor.value, false)
|
const more = await fetchLibraryPage(nextCursor.value)
|
||||||
applyLibraryPage(more, false)
|
applyLibraryPage(more, false)
|
||||||
nextTick(() => observeThumbs())
|
nextTick(() => observeThumbs())
|
||||||
} catch { /* keep what we have */ }
|
} catch { /* keep what we have */ }
|
||||||
@@ -404,6 +409,11 @@ watch(() => props.open, (on) => {
|
|||||||
|
|
||||||
watch(entries, () => nextTick(() => observeThumbs()))
|
watch(entries, () => nextTick(() => observeThumbs()))
|
||||||
|
|
||||||
|
watch([filter, activeFolderId], () => {
|
||||||
|
if (!props.open) return
|
||||||
|
void fetchLibrary(true)
|
||||||
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
|
|||||||
+204
-17
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
<header class="m-header">
|
||||||
<span class="m-brand">{{ ctx.instanceName }}</span>
|
<span class="m-brand">{{ ctx.instanceName }}</span>
|
||||||
<label class="m-crumb">
|
<label class="m-crumb">
|
||||||
@@ -9,11 +9,15 @@
|
|||||||
</label>
|
</label>
|
||||||
<span class="m-grow" />
|
<span class="m-grow" />
|
||||||
<span class="m-gpu" :data-tone="ctx.gpu.tone" :title="ctx.gpu.detail">● {{ ctx.gpuChipLabel }}</span>
|
<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">
|
<details class="m-more">
|
||||||
<summary aria-label="More">⋯</summary>
|
<summary aria-label="More">⋯</summary>
|
||||||
<div class="m-more-menu">
|
<div class="m-more-menu">
|
||||||
<NuxtLink to="/library" @click="closeMenus">Library</NuxtLink>
|
<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="/queue" @click="closeMenus">Queue</NuxtLink>
|
||||||
<NuxtLink to="/settings" @click="closeMenus">Settings</NuxtLink>
|
<NuxtLink to="/settings" @click="closeMenus">Settings</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,6 +53,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="m-stage"
|
class="m-stage"
|
||||||
|
:class="{ 'm-stage--veiled': ctx.selected && ctx.isHidden(ctx.selected) }"
|
||||||
@touchstart.passive="onStageTouchStart"
|
@touchstart.passive="onStageTouchStart"
|
||||||
@touchend.passive="onStageTouchEnd"
|
@touchend.passive="onStageTouchEnd"
|
||||||
>
|
>
|
||||||
@@ -102,22 +107,33 @@
|
|||||||
aria-label="Next"
|
aria-label="Next"
|
||||||
@click.stop="ctx.stepFamily(1)"
|
@click.stop="ctx.stepFamily(1)"
|
||||||
>›</button>
|
>›</button>
|
||||||
<template v-if="ctx.selected && ctx.isHidden(ctx.selected)">
|
<p
|
||||||
<p class="m-hidden-note">{{ ctx.selected.kind==='track' ? 'Track hidden' : 'Output hidden' }}</p>
|
v-show="ctx.selected && ctx.isHidden(ctx.selected)"
|
||||||
</template>
|
class="m-hidden-note"
|
||||||
<template v-else-if="ctx.selected?.kind==='track'">
|
>{{ ctx.selected?.kind==='track' ? 'Track hidden' : 'Output hidden' }}</p>
|
||||||
<div class="m-audio">
|
<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'" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<video
|
<video
|
||||||
v-else-if="ctx.selected?.kind==='clip'"
|
v-else-if="ctx.selected?.kind==='clip'"
|
||||||
|
v-show="!ctx.isHidden(ctx.selected)"
|
||||||
:key="ctx.selected.id"
|
:key="ctx.selected.id"
|
||||||
:src="`/api/library/clips/${ctx.selected.id}/video`"
|
:src="`/api/library/clips/${ctx.selected.id}/video`"
|
||||||
controls
|
controls
|
||||||
playsinline
|
playsinline
|
||||||
/>
|
/>
|
||||||
<img v-else-if="ctx.selected" :key="ctx.selected.id" :src="ctx.stageStillSrc || ctx.stillUrl(ctx.selected.id)" alt="Selected still" decoding="async" @load="ctx.onStageStillLoad">
|
<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>
|
<p v-else class="m-muted">Open Library to pick a still or clip</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -127,6 +143,7 @@
|
|||||||
:selected="ctx.selected"
|
:selected="ctx.selected"
|
||||||
:folder-id="ctx.form?.folderId"
|
:folder-id="ctx.form?.folderId"
|
||||||
:preview-hidden="!ctx.eyeOpen"
|
:preview-hidden="!ctx.eyeOpen"
|
||||||
|
:is-hidden="ctx.isHidden"
|
||||||
@close="ctx.closeLibrary()"
|
@close="ctx.closeLibrary()"
|
||||||
@select="ctx.selectOutput"
|
@select="ctx.selectOutput"
|
||||||
@use-image="ctx.onLibraryUseImage"
|
@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>
|
<label class="m-label">Lyrics<textarea v-model="ctx.music.lyrics" rows="8" class="m-area" /></label>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<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.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>
|
<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>
|
<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>
|
</div>
|
||||||
</aside>
|
</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=''" />
|
<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-head"><strong>Queue</strong><button type="button" @click="sheet=''">Done</button></div>
|
||||||
<div class="m-sheet-body">
|
<div class="m-sheet-body">
|
||||||
@@ -323,6 +351,12 @@
|
|||||||
<div class="m-job-status">
|
<div class="m-job-status">
|
||||||
<span>● {{ card.statusLabel }}</span>
|
<span>● {{ card.statusLabel }}</span>
|
||||||
<button v-if="card.cancelable" type="button" @click.stop="ctx.cancel(card.job)">Cancel</button>
|
<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>
|
||||||
<div v-if="card.progress" class="m-job-progress" :data-indeterminate="card.progress.indeterminate ? 'true' : undefined">
|
<div v-if="card.progress" class="m-job-progress" :data-indeterminate="card.progress.indeterminate ? 'true' : undefined">
|
||||||
<progress
|
<progress
|
||||||
@@ -337,6 +371,52 @@
|
|||||||
<button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button>
|
<button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -345,13 +425,35 @@ export type Studio2MobileCtx = Record<string, any>
|
|||||||
|
|
||||||
const props = defineProps<{ ctx: Studio2MobileCtx }>()
|
const props = defineProps<{ ctx: Studio2MobileCtx }>()
|
||||||
const sheet = ref('')
|
const sheet = ref('')
|
||||||
|
const queueRailCollapsed = ref(false)
|
||||||
const touchStart = ref<{ x: number; y: number } | null>(null)
|
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) {
|
function toggleSheet(id: string) {
|
||||||
sheet.value = sheet.value === id ? '' : id
|
sheet.value = sheet.value === id ? '' : id
|
||||||
}
|
}
|
||||||
function closeMenus() {
|
function closeMenus() {
|
||||||
sheet.value = ''
|
sheet.value = ''
|
||||||
|
if (promptEditorOpen.value) closePromptEditor()
|
||||||
}
|
}
|
||||||
function runAction(kind: 'use-image' | 'use-prompt' | 'copy' | 'copy-raw' | 'load-input' | 'load-input-raw' | 'delete') {
|
function runAction(kind: 'use-image' | 'use-prompt' | 'copy' | 'copy-raw' | 'load-input' | 'load-input-raw' | 'delete') {
|
||||||
sheet.value = ''
|
sheet.value = ''
|
||||||
@@ -477,13 +579,26 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
|
|||||||
background: #080808;
|
background: #080808;
|
||||||
}
|
}
|
||||||
.m-stage > img,
|
.m-stage > img,
|
||||||
.m-stage > video {
|
.m-stage > video,
|
||||||
|
.m-stage-still {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
object-fit: contain;
|
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-audio { width: 100%; max-width: 100%; padding: 12px; }
|
||||||
.m-stage-chrome {
|
.m-stage-chrome {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -535,12 +650,6 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
|
|||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.m-hidden-note {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.m-nav {
|
.m-nav {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -649,6 +758,7 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
.m-label { display: block; font-size: 11px; color: var(--muted); }
|
.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 {
|
.m-area, .m-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 4px;
|
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 { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
|
||||||
.m-job-progress-bar { flex: 1; height: 4px; accent-color: var(--amber); }
|
.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-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>
|
</style>
|
||||||
|
|||||||
+52
-32
@@ -634,24 +634,6 @@ const allCount = computed(() => {
|
|||||||
sort: 'date-desc'
|
sort: 'date-desc'
|
||||||
}).length
|
}).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 moveFolders = computed(() => folders.value.filter(folder => folder.id !== folderId.value))
|
||||||
const focused = computed(() => entries.value.find(entry => entry.key === focusedKey.value) || null)
|
const focused = computed(() => entries.value.find(entry => entry.key === focusedKey.value) || null)
|
||||||
const focusedStill = computed(() => {
|
const focusedStill = computed(() => {
|
||||||
@@ -771,7 +753,8 @@ function toast(message: string) {
|
|||||||
}, 4200)
|
}, 4200)
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyLibrary(data: unknown) {
|
function applyLibrary(data: unknown, opts?: { replace?: boolean }) {
|
||||||
|
const replace = opts?.replace !== false
|
||||||
const payload = (data || {}) as {
|
const payload = (data || {}) as {
|
||||||
id?: string
|
id?: string
|
||||||
hideInput?: boolean
|
hideInput?: boolean
|
||||||
@@ -780,6 +763,8 @@ function applyLibrary(data: unknown) {
|
|||||||
clips?: LibraryClip[]
|
clips?: LibraryClip[]
|
||||||
stills?: LibraryStill[]
|
stills?: LibraryStill[]
|
||||||
tracks?: LibraryTrack[]
|
tracks?: LibraryTrack[]
|
||||||
|
nextCursor?: string | null
|
||||||
|
hasMore?: boolean
|
||||||
}
|
}
|
||||||
if (payload.id && !payload.folders && !payload.clips && !payload.stills && !payload.tracks) {
|
if (payload.id && !payload.folders && !payload.clips && !payload.stills && !payload.tracks) {
|
||||||
if (typeof payload.hideInput === 'boolean') {
|
if (typeof payload.hideInput === 'boolean') {
|
||||||
@@ -792,31 +777,62 @@ function applyLibrary(data: unknown) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
folders.value = payload.folders || []
|
if (payload.folders?.length) folders.value = payload.folders
|
||||||
|
if (replace) {
|
||||||
clips.value = payload.clips || []
|
clips.value = payload.clips || []
|
||||||
stills.value = payload.stills || []
|
stills.value = payload.stills || []
|
||||||
tracks.value = payload.tracks || []
|
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)) {
|
if (folderId.value !== 'all' && !folders.value.some(folder => folder.id === folderId.value)) {
|
||||||
folderId.value = folders.value[0]?.id || 'all'
|
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() {
|
async function loadLibrary() {
|
||||||
// First page of metadata only — grid paints immediately; full catalog follows for grouping/search
|
// First page paints immediately; remaining pages append in the background
|
||||||
const first = await $fetch<any>('/api/library?limit=50')
|
const gen = ++libraryFetchGen
|
||||||
applyLibrary(first)
|
const first = await fetchLibraryPage(null)
|
||||||
const data = await $fetch<any>('/api/library?light=1')
|
if (gen !== libraryFetchGen) return
|
||||||
applyLibrary(data)
|
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) {
|
function folderChipCount(folder: LibraryFolder) {
|
||||||
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
|
// Prefer server totals so chips match the full folder before pages finish appending
|
||||||
const row = folderChipCounts.value[folder.id]
|
if (filter.value === 'videos') return folder.clipCount || 0
|
||||||
if (!row) return 0
|
if (filter.value === 'images') return folder.imageCount || 0
|
||||||
if (filter.value === 'videos') return row.videos
|
if (filter.value === 'music') return folder.trackCount || 0
|
||||||
if (filter.value === 'images') return row.images
|
return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
|
||||||
if (filter.value === 'music') return row.music
|
|
||||||
return row.videos + row.images + row.music
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function folderChipClass(id: string) {
|
function folderChipClass(id: string) {
|
||||||
@@ -1282,6 +1298,10 @@ watch([folderId, filter], () => {
|
|||||||
watch(search, () => {
|
watch(search, () => {
|
||||||
if (scroller.value) scroller.value.scrollTop = 0
|
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]) => {
|
watch([cols, rowH], ([nextCols, nextRowH], [prevCols, prevRowH]) => {
|
||||||
const el = scroller.value
|
const el = scroller.value
|
||||||
if (!el || !prevRowH) return
|
if (!el || !prevRowH) return
|
||||||
|
|||||||
@@ -0,0 +1,961 @@
|
|||||||
|
<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>
|
||||||
|
<span class="text-sm font-medium text-zinc-300">Prompt order</span>
|
||||||
|
<p class="text-[11px] text-zinc-500">Drag to reorder, or use the arrows. Copied text and the scene editor follow this order.</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(key, index) in sectionOrder"
|
||||||
|
:key="key"
|
||||||
|
class="flex items-center gap-2 rounded-2xl border border-white/10 bg-zinc-950/40 px-3 py-2"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onSectionDragStart(index, $event)"
|
||||||
|
@dragover.prevent="onSectionDragOver($event)"
|
||||||
|
@drop.prevent="onSectionDrop(index)"
|
||||||
|
>
|
||||||
|
<span class="cursor-grab select-none text-zinc-500 active:cursor-grabbing" title="Drag to reorder">⠿</span>
|
||||||
|
<span class="min-w-0 flex-1 text-sm text-zinc-200">{{ sectionLabel(key) }}</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 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
title="Move up"
|
||||||
|
:disabled="index === 0"
|
||||||
|
@click="moveSection(index, -1)"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<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 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
title="Move down"
|
||||||
|
:disabled="index >= sectionOrder.length - 1"
|
||||||
|
@click="moveSection(index, 1)"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
</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">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>
|
||||||
|
|
||||||
|
<template v-for="sectionKey in sectionOrder" :key="'scene-section-' + sectionKey">
|
||||||
|
<div v-if="sectionKey === 'style'" 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 v-else-if="sectionKey === 'settings'" 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-else-if="sectionKey === 'set' && 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 v-else-if="sectionKey === 'characters'" 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 v-else-if="sectionKey === 'prompt'" 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>
|
||||||
|
</template>
|
||||||
|
</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,
|
||||||
|
PromptSequenceSectionKey,
|
||||||
|
PromptSequenceSet
|
||||||
|
} from '~/utils/promptSequences'
|
||||||
|
import {
|
||||||
|
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
|
||||||
|
PROMPT_SEQUENCE_FRAMING_SUGGESTIONS,
|
||||||
|
assembleSceneText,
|
||||||
|
assembleSequenceText,
|
||||||
|
cloneSceneForNext,
|
||||||
|
emptyCharacter,
|
||||||
|
emptyFraming,
|
||||||
|
emptyOutfit,
|
||||||
|
emptyScene,
|
||||||
|
emptySet,
|
||||||
|
ensureSceneStatesForCharacters,
|
||||||
|
ensureSceneStatesForSets,
|
||||||
|
normalizePromptSequence,
|
||||||
|
normalizeSectionOrder
|
||||||
|
} from '~/utils/promptSequences'
|
||||||
|
|
||||||
|
useHead({ title: 'Prompt Builder' })
|
||||||
|
|
||||||
|
const SECTION_LABELS: Record<PromptSequenceSectionKey, string> = {
|
||||||
|
style: 'Style',
|
||||||
|
settings: 'Settings',
|
||||||
|
set: 'Set',
|
||||||
|
characters: 'Characters',
|
||||||
|
prompt: 'Prompt'
|
||||||
|
}
|
||||||
|
|
||||||
|
const sequences = ref<PromptSequence[]>([])
|
||||||
|
const selectedId = ref('')
|
||||||
|
const name = ref('')
|
||||||
|
const sectionOrder = ref<PromptSequenceSectionKey[]>([...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER])
|
||||||
|
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
|
||||||
|
let dragSectionIndex: number | null = null
|
||||||
|
|
||||||
|
const activeScene = computed(() => scenes.value[activeIndex.value] || null)
|
||||||
|
const previewText = computed(() => assembleSequenceText({
|
||||||
|
sectionOrder: sectionOrder.value,
|
||||||
|
characters: characters.value,
|
||||||
|
sets: sets.value,
|
||||||
|
scenes: scenes.value
|
||||||
|
}))
|
||||||
|
const activeSceneText = computed(() => {
|
||||||
|
if (!activeScene.value) return ''
|
||||||
|
return assembleSceneText(activeScene.value, characters.value, sets.value, sectionOrder.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function sectionLabel(key: PromptSequenceSectionKey) {
|
||||||
|
return SECTION_LABELS[key] || key
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSection(index: number, delta: number) {
|
||||||
|
const next = index + delta
|
||||||
|
if (next < 0 || next >= sectionOrder.value.length) return
|
||||||
|
const copy = [...sectionOrder.value]
|
||||||
|
const [item] = copy.splice(index, 1)
|
||||||
|
copy.splice(next, 0, item)
|
||||||
|
sectionOrder.value = copy
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSectionDragStart(index: number, event: DragEvent) {
|
||||||
|
dragSectionIndex = index
|
||||||
|
event.dataTransfer?.setData('text/plain', String(index))
|
||||||
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSectionDragOver(event: DragEvent) {
|
||||||
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSectionDrop(index: number) {
|
||||||
|
if (dragSectionIndex == null || dragSectionIndex === index) {
|
||||||
|
dragSectionIndex = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const copy = [...sectionOrder.value]
|
||||||
|
const [item] = copy.splice(dragSectionIndex, 1)
|
||||||
|
copy.splice(index, 0, item)
|
||||||
|
sectionOrder.value = copy
|
||||||
|
dragSectionIndex = null
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = ''
|
||||||
|
sectionOrder.value = [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER]
|
||||||
|
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
|
||||||
|
sectionOrder.value = normalizeSectionOrder(normalized.sectionOrder)
|
||||||
|
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,
|
||||||
|
sectionOrder: sectionOrder.value,
|
||||||
|
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 { createGpuReservation } from './gpu-reservation.mjs'
|
||||||
import { createGpuProxy } from './gpu-proxy.mjs'
|
import { createGpuProxy } from './gpu-proxy.mjs'
|
||||||
import { createYue2Host } from './yue2-host.mjs'
|
import { createYue2Host } from './yue2-host.mjs'
|
||||||
|
import { createCaptionHost } from './caption-host.mjs'
|
||||||
import http from 'node:http'
|
import http from 'node:http'
|
||||||
import net from 'node:net'
|
import net from 'node:net'
|
||||||
import { execFile, spawn } from 'node:child_process'
|
import { execFile, spawn } from 'node:child_process'
|
||||||
@@ -147,7 +148,7 @@ let proxyTarget = 0
|
|||||||
|
|
||||||
function ensureProxyListening() {
|
function ensureProxyListening() {
|
||||||
if (proxyServer) return
|
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) => {
|
proxyServer.on('error', (error) => {
|
||||||
console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'proxy-error', error: String(error.message || 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 () => {
|
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
|
if ((await trainingLock()).busy) return false
|
||||||
const healthy = await syncProxy()
|
const healthy = await syncProxy()
|
||||||
if (healthy) {
|
if (healthy) {
|
||||||
@@ -830,29 +831,65 @@ const gpuReservation = createGpuReservation({ idle: async () => {
|
|||||||
return !(await processUp()) && !(await pythonMainUp().catch(() => true))
|
return !(await processUp()) && !(await pythonMainUp().catch(() => true))
|
||||||
} })
|
} })
|
||||||
|
|
||||||
const yue2 = createYue2Host({
|
async function prepareExclusiveGpu(label) {
|
||||||
leaseValid: lease => gpuReservation.isOwner(lease),
|
|
||||||
prepare: async () => {
|
|
||||||
if ((await trainingLock()).busy) throw new Error('GPU is busy with training.')
|
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()
|
const healthy = await syncProxy()
|
||||||
if (healthy) {
|
if (healthy) {
|
||||||
const queue = await fetchLocalQueue(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)) {
|
if (healthy || await processUp() || await pythonMainUp().catch(() => false)) {
|
||||||
await stopComfyProcesses()
|
await stopComfyProcesses()
|
||||||
markAsleep()
|
markAsleep()
|
||||||
}
|
}
|
||||||
if (await pythonMainUp()) throw new Error('Comfy has not stopped; retry after the GPU is free.')
|
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 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) {
|
async function handleControl(req, res) {
|
||||||
if (!authorized(req)) return json(res, 401, { ok: false, error: 'unauthorized' })
|
if (!authorized(req)) return json(res, 401, { ok: false, error: 'unauthorized' })
|
||||||
const url = new URL(req.url || '/', 'http://localhost')
|
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/')) {
|
if (url.pathname.startsWith('/upscale/')) {
|
||||||
const match = url.pathname.match(/^\/upscale\/jobs\/([a-zA-Z0-9-]{12,80})(\/input|\/video|\/cancel)?$/)
|
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'] || '')))
|
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(),
|
gpu: gpuReservation.availability(),
|
||||||
training: { busy: lastTraining.busy },
|
training: { busy: lastTraining.busy },
|
||||||
yue2: { busy: yue2.busy(), configured: yue2.configured() },
|
yue2: { busy: yue2.busy(), configured: yue2.configured() },
|
||||||
|
caption: { busy: caption.busy(), configured: caption.configured() },
|
||||||
upscale: { busy: upscale.busy(), engine: 'realesrgan-rife', local: true }
|
upscale: { busy: upscale.busy(), engine: 'realesrgan-rife', local: true }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (req.method === 'POST' && url.pathname === '/start') {
|
if (req.method === 'POST' && url.pathname === '/start') {
|
||||||
if (upscale.busy()) return json(res, 409, { message: 'Local upscale is using the GPU.' })
|
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 (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()
|
const training = await trainingLock()
|
||||||
if (training.busy) {
|
if (training.busy) {
|
||||||
return json(res, 409, {
|
return json(res, 409, {
|
||||||
@@ -1016,7 +1055,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
} else await handleControl(req, res)
|
} else await handleControl(req, res)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
req.resume()
|
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.' })
|
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
|
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) {
|
if ($Wait) {
|
||||||
Set-Location $root
|
Set-Location $root
|
||||||
& $node $agent
|
& $node $agent
|
||||||
@@ -31,4 +37,5 @@ $psi.WorkingDirectory = $root
|
|||||||
$psi.UseShellExecute = $false
|
$psi.UseShellExecute = $false
|
||||||
$psi.CreateNoWindow = $true
|
$psi.CreateNoWindow = $true
|
||||||
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
|
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
|
||||||
|
# CAPTION_* already set on this process; child inherits when UseShellExecute is false
|
||||||
[void][System.Diagnostics.Process]::Start($psi)
|
[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 || '',
|
host: comfy.host || '',
|
||||||
queueRunning: queue.running,
|
queueRunning: queue.running,
|
||||||
queuePending: queue.pending,
|
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),
|
processUp: Boolean(control?.process ?? control?.processUp),
|
||||||
lastActivityAt: control?.lastActivityAt || null,
|
lastActivityAt: control?.lastActivityAt || null,
|
||||||
stats: comfy.ok ? comfy.stats : undefined
|
stats: comfy.ok ? comfy.stats : undefined
|
||||||
@@ -58,6 +58,11 @@ export default defineEventHandler(async () => {
|
|||||||
busy: Boolean(control?.yue2?.busy),
|
busy: Boolean(control?.yue2?.busy),
|
||||||
backend: control?.yue2?.backend || 'yue2'
|
backend: control?.yue2?.backend || 'yue2'
|
||||||
},
|
},
|
||||||
|
caption: {
|
||||||
|
configured: Boolean(control?.caption?.configured),
|
||||||
|
busy: Boolean(control?.caption?.busy),
|
||||||
|
backend: control?.caption?.backend || 'llama.cpp'
|
||||||
|
},
|
||||||
imageComfy: {
|
imageComfy: {
|
||||||
configured: imageConfigured,
|
configured: imageConfigured,
|
||||||
ok: imageOk,
|
ok: imageOk,
|
||||||
|
|||||||
@@ -3,29 +3,21 @@ import { publicLibrary, publicLibraryPage } from '~/server/utils/library'
|
|||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler((event) => {
|
||||||
const query = getQuery(event)
|
const query = getQuery(event)
|
||||||
const light = String(query.light || '') === '1'
|
const light = String(query.light || '') === '1'
|
||||||
|
const full = String(query.full || '') === '1'
|
||||||
const limitRaw = query.limit
|
const limitRaw = query.limit
|
||||||
const wantsPage = limitRaw != null && String(limitRaw) !== ''
|
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 (wantsPage) {
|
if (!wantsFull) {
|
||||||
const page = publicLibraryPage(event, {
|
return publicLibraryPage(event, {
|
||||||
limit: Number(limitRaw) || 50,
|
limit: hasLimit ? Number(limitRaw) || 50 : 50,
|
||||||
cursor: String(query.cursor || ''),
|
cursor: String(query.cursor || ''),
|
||||||
folderId: String(query.folderId || query.folder || ''),
|
folderId: String(query.folderId || query.folder || ''),
|
||||||
type: String(query.type || query.filter || 'all')
|
type: String(query.type || query.filter || 'all')
|
||||||
})
|
})
|
||||||
try {
|
|
||||||
const { owner } = assertLibraryOwner(event)
|
|
||||||
const folderId = page.folders.find(folder => folder.unlocked)?.id
|
|
||||||
void maintainLibrary(owner, folderId).catch(() => null)
|
|
||||||
} catch { /* locked or anonymous */ }
|
|
||||||
return page
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const library = publicLibrary(event, { light })
|
// Full catalog path — no thumb prewarm on the request (thumbs build on /thumb hit)
|
||||||
try {
|
return publicLibrary(event, { light: true })
|
||||||
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
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync, createReadStream } from 'node:fs'
|
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 { owner } = assertLibraryOwner(event)
|
||||||
const id = String(getRouterParam(event, 'id') || '')
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
const still = getStill(owner, id)
|
const still = getStill(owner, id)
|
||||||
@@ -9,28 +9,36 @@ export default defineEventHandler(async (event) => {
|
|||||||
const wantThumb = String(getQuery(event).thumb || '') === '1'
|
const wantThumb = String(getQuery(event).thumb || '') === '1'
|
||||||
const original = stillPath(owner, still.id)
|
const original = stillPath(owner, still.id)
|
||||||
|
|
||||||
let path = original
|
|
||||||
if (wantThumb) {
|
if (wantThumb) {
|
||||||
path = await stillThumbPath(owner, id)
|
const path = existingStillThumbPath(owner, id)
|
||||||
// Never fall back to the archive PNG/JPEG for ?thumb=1
|
if (path && path !== original && existsSync(path)) {
|
||||||
if (!path || path === original || !existsSync(path)) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Thumbnail not found' })
|
|
||||||
}
|
|
||||||
} else if (!existsSync(path)) {
|
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Image file is missing' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const etag = fileEtag(path)
|
const etag = fileEtag(path)
|
||||||
setHeader(event, 'ETag', etag)
|
setHeader(event, 'ETag', etag)
|
||||||
setHeader(
|
setHeader(event, 'Cache-Control', 'public, max-age=86400')
|
||||||
event,
|
|
||||||
'Cache-Control',
|
|
||||||
wantThumb ? 'public, max-age=86400' : 'private, max-age=3600'
|
|
||||||
)
|
|
||||||
if (getHeader(event, 'if-none-match') === etag) {
|
if (getHeader(event, 'if-none-match') === etag) {
|
||||||
setResponseStatus(event, 304)
|
setResponseStatus(event, 304)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
setHeader(event, 'Content-Type', wantThumb ? 'image/jpeg' : sniffImageType(path))
|
setHeader(event, 'Content-Type', 'image/jpeg')
|
||||||
return sendStream(event, createReadStream(path))
|
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
|
||||||
|
})
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
import { existsSync, createReadStream } from 'node:fs'
|
import { existsSync, createReadStream } from 'node:fs'
|
||||||
import { fileEtag, stillPath, stillThumbPath } from '~/server/utils/library'
|
import { fileEtag, stillPath, stillThumbPath, existingStillThumbPath } from '~/server/utils/library'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler((event) => {
|
||||||
const { owner } = assertLibraryOwner(event)
|
const { owner } = assertLibraryOwner(event)
|
||||||
const id = String(getRouterParam(event, 'id') || '')
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
const still = getStill(owner, id)
|
const still = getStill(owner, id)
|
||||||
assertFolderAccess(event, still.folderId)
|
assertFolderAccess(event, still.folderId)
|
||||||
|
|
||||||
const path = await stillThumbPath(owner, id)
|
const path = existingStillThumbPath(owner, id)
|
||||||
const original = stillPath(owner, still.id)
|
const original = stillPath(owner, still.id)
|
||||||
// Never stream the archive master as a "thumb"
|
// Ready thumb → stream. Never await ffmpeg on the request path.
|
||||||
if (!path || !existsSync(path) || path === original) {
|
if (path && existsSync(path) && path !== original) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Thumbnail not found' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const etag = fileEtag(path)
|
const etag = fileEtag(path)
|
||||||
setHeader(event, 'ETag', etag)
|
setHeader(event, 'ETag', etag)
|
||||||
setHeader(event, 'Cache-Control', 'public, max-age=86400')
|
setHeader(event, 'Cache-Control', 'public, max-age=86400')
|
||||||
@@ -23,4 +20,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
setHeader(event, 'Content-Type', 'image/jpeg')
|
setHeader(event, 'Content-Type', 'image/jpeg')
|
||||||
return sendStream(event, createReadStream(path))
|
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,25 @@
|
|||||||
|
import { listPromptSequences, savePromptSequence } from '~/server/utils/promptSequences'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { owner } = assertLibraryOwner(event)
|
||||||
|
const body = await readBody<{
|
||||||
|
name?: string
|
||||||
|
sectionOrder?: unknown
|
||||||
|
characters?: unknown
|
||||||
|
sets?: unknown
|
||||||
|
scenes?: unknown
|
||||||
|
overwrite?: boolean
|
||||||
|
}>(event).catch(() => ({}))
|
||||||
|
const sequence = await savePromptSequence(owner, {
|
||||||
|
name: body?.name || '',
|
||||||
|
sectionOrder: body?.sectionOrder,
|
||||||
|
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,24 @@
|
|||||||
|
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
|
||||||
|
sectionOrder?: unknown
|
||||||
|
characters?: unknown
|
||||||
|
sets?: unknown
|
||||||
|
scenes?: unknown
|
||||||
|
}>(event).catch(() => ({}))
|
||||||
|
const sequence = await updatePromptSequence(owner, id, {
|
||||||
|
name: body?.name,
|
||||||
|
sectionOrder: body?.sectionOrder,
|
||||||
|
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 { records, saveRecord } from '../../utils/studio2/store'
|
||||||
import { listStudioJobs } from '../../utils/studioQueue';
|
import { dismissFinishedStudioJobs, dismissStudioJob, listStudioJobs } from '../../utils/studioQueue'
|
||||||
export default defineEventHandler(event => {
|
|
||||||
const { owner } = assertLibraryOwner(event), queue=listStudioJobs(owner);
|
export default defineEventHandler(async (event) => {
|
||||||
let cleared=0;
|
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)) {
|
for (const r of records(owner)) {
|
||||||
try { assertFolderAccess(event,r.request.folderId); } catch { continue; }
|
try { assertFolderAccess(event, r.request.folderId) } catch { continue }
|
||||||
const status=queue.find(q=>q.id===r.queueId)?.status;
|
const status = queue.find(q => q.id === r.queueId)?.status
|
||||||
if (['complete','failed','cancelled'].includes(r.state) || ['cancelled','error'].includes(status || '')) {
|
if (['complete', 'failed', 'cancelled'].includes(r.state) || ['cancelled', 'error'].includes(status || '')) {
|
||||||
r.dismissed=true; saveRecord(r); cleared++;
|
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);
|
assertFolderAccess(event, getStill(owner, id).folderId);
|
||||||
if (request.startClipId)
|
if (request.startClipId)
|
||||||
assertFolderAccess(event, getClip(owner, request.startClipId).folderId);
|
assertFolderAccess(event, getClip(owner, request.startClipId).folderId);
|
||||||
|
// Header-only size probe — never decode the archive PNG on Generate click
|
||||||
await resolveRequestSize(owner,request);
|
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' };
|
const id = crypto.randomUUID(), record = { id, owner, request, state: 'waiting', queuedAt: Date.now(), familyId: crypto.randomUUID(), outputs: [], purgeResult: 'Not yet saved' };
|
||||||
saveRecord(record);
|
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 });
|
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 });
|
saveRecord({ ...readRecord(owner, id), queueId: row.id });
|
||||||
|
} catch { /* poll / resume will pick up waiting studio2 records */ }
|
||||||
kickStudioQueue();
|
kickStudioQueue();
|
||||||
return { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height };
|
})();
|
||||||
|
return ack;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,18 +5,21 @@ export default defineEventHandler((event) => {
|
|||||||
const { owner } = assertLibraryOwner(event)
|
const { owner } = assertLibraryOwner(event)
|
||||||
const full = String(getQuery(event).full || '') === '1'
|
const full = String(getQuery(event).full || '') === '1'
|
||||||
const active = listStudioJobs(owner)
|
const active = listStudioJobs(owner)
|
||||||
|
.filter(job => !job.dismissed)
|
||||||
.filter(job => (
|
.filter(job => (
|
||||||
job.status === 'waiting'
|
job.status === 'waiting'
|
||||||
|| job.status === 'running'
|
|| job.status === 'running'
|
||||||
|| job.status === 'held'
|
|| job.status === 'held'
|
||||||
|| (job.status === 'error' && Date.now() - job.updatedAt < 60 * 60 * 1000)
|
|| (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 claimed = new Set(active.map(job => job.shotQueueId).filter(Boolean) as string[])
|
||||||
const queues = listShotQueues(owner)
|
const queues = listShotQueues(owner)
|
||||||
const byId = new Map(queues.map(queue => [queue.id, queue]))
|
const byId = new Map(queues.map(queue => [queue.id, queue]))
|
||||||
const jobs = active.map((job) => {
|
const jobs = active.map((job) => {
|
||||||
const shots = job.shotQueueId ? byId.get(job.shotQueueId) || null : null
|
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 shotSummary = shots ? summarizeQueue(shots) : null
|
||||||
const plannedShots = shotSummary
|
const plannedShots = shotSummary
|
||||||
? undefined
|
? 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
|
upscale?: boolean
|
||||||
yueGp?: boolean
|
yueGp?: boolean
|
||||||
yue2?: boolean
|
yue2?: boolean
|
||||||
|
caption?: boolean
|
||||||
|
resultText?: string
|
||||||
musicActivity?: { checkedAt: number; running: boolean }
|
musicActivity?: { checkedAt: number; running: boolean }
|
||||||
id: string
|
id: string
|
||||||
kind?: 'video' | 'edit' | 'music'
|
kind?: 'video' | 'edit' | 'music' | 'caption'
|
||||||
promptId?: string
|
promptId?: string
|
||||||
clientId: string
|
clientId: string
|
||||||
status: JobStatus
|
status: JobStatus
|
||||||
@@ -222,6 +224,7 @@ export function jobSnapshot(job: Job) {
|
|||||||
clipId: job.clipId,
|
clipId: job.clipId,
|
||||||
stillId: job.stillId,
|
stillId: job.stillId,
|
||||||
trackId: job.trackId,
|
trackId: job.trackId,
|
||||||
|
resultText: job.resultText,
|
||||||
hideThumbnail: job.hideThumbnail,
|
hideThumbnail: job.hideThumbnail,
|
||||||
error: job.error,
|
error: job.error,
|
||||||
folderLocked: job.library?.folderLocked,
|
folderLocked: job.library?.folderLocked,
|
||||||
|
|||||||
+156
-27
@@ -187,6 +187,7 @@ interface Catalog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const writeChains = new Map<string, Promise<unknown>>()
|
const writeChains = new Map<string, Promise<unknown>>()
|
||||||
|
const catalogCache = new Map<string, { catalog: Catalog; mtimeMs: number }>()
|
||||||
|
|
||||||
export function libraryRoot() {
|
export function libraryRoot() {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
@@ -355,6 +356,13 @@ function ensureOwner(owner: string) {
|
|||||||
function readCatalog(owner: string): Catalog {
|
function readCatalog(owner: string): Catalog {
|
||||||
ensureOwner(owner)
|
ensureOwner(owner)
|
||||||
const path = catalogPath(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
|
let catalog: Catalog
|
||||||
try {
|
try {
|
||||||
catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
|
catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
|
||||||
@@ -364,8 +372,11 @@ function readCatalog(owner: string): Catalog {
|
|||||||
}
|
}
|
||||||
catalog = emptyCatalog()
|
catalog = emptyCatalog()
|
||||||
writeFileSync(path, JSON.stringify(catalog, null, 2))
|
writeFileSync(path, JSON.stringify(catalog, null, 2))
|
||||||
|
try { mtimeMs = statSync(path).mtimeMs } catch { mtimeMs = Date.now() }
|
||||||
|
catalogCache.set(owner, { catalog, mtimeMs })
|
||||||
return catalog
|
return catalog
|
||||||
}
|
}
|
||||||
|
catalogCache.set(owner, { catalog, mtimeMs })
|
||||||
return catalog
|
return catalog
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,6 +385,14 @@ function writeCatalog(owner: string, catalog: Catalog) {
|
|||||||
const tmp = catalogPath(owner) + '.tmp'
|
const tmp = catalogPath(owner) + '.tmp'
|
||||||
writeFileSync(tmp, JSON.stringify(catalog, null, 2))
|
writeFileSync(tmp, JSON.stringify(catalog, null, 2))
|
||||||
renameSync(tmp, catalogPath(owner))
|
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> {
|
function mutate<T>(owner: string, fn: (catalog: Catalog) => T): Promise<T> {
|
||||||
@@ -615,7 +634,7 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Paginated metadata page — no file bytes, no existsSync join of the volume. */
|
/** Paginated metadata page — reads catalog rows only; no existsSync / full publicLibrary map. */
|
||||||
export function publicLibraryPage(
|
export function publicLibraryPage(
|
||||||
event: H3Event,
|
event: H3Event,
|
||||||
opts: { limit?: number; cursor?: string; folderId?: string; type?: string } = {}
|
opts: { limit?: number; cursor?: string; folderId?: string; type?: string } = {}
|
||||||
@@ -624,7 +643,30 @@ export function publicLibraryPage(
|
|||||||
const cursor = String(opts.cursor || '')
|
const cursor = String(opts.cursor || '')
|
||||||
const folderId = String(opts.folderId || '')
|
const folderId = String(opts.folderId || '')
|
||||||
const typeFilter = String(opts.type || 'all')
|
const typeFilter = String(opts.type || 'all')
|
||||||
const full = publicLibrary(event, { light: true })
|
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 = {
|
type Row = {
|
||||||
id: string
|
id: string
|
||||||
@@ -646,9 +688,11 @@ export function publicLibraryPage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rows: Row[] = []
|
const rows: Row[] = []
|
||||||
for (const still of full.stills) {
|
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 (folderId && folderId !== 'all' && still.folderId !== folderId) continue
|
||||||
if (typeFilter === 'videos' || typeFilter === 'music') continue
|
if (isPipelineStill(catalog, still, clipIds)) continue
|
||||||
rows.push({
|
rows.push({
|
||||||
id: still.id,
|
id: still.id,
|
||||||
name: still.name || still.filename || still.id,
|
name: still.name || still.filename || still.id,
|
||||||
@@ -661,14 +705,16 @@ export function publicLibraryPage(
|
|||||||
promptPost: still.promptPost,
|
promptPost: still.promptPost,
|
||||||
width: still.width,
|
width: still.width,
|
||||||
height: still.height,
|
height: still.height,
|
||||||
role: still.role,
|
role: stillRole(still),
|
||||||
hideInput: still.hideInput,
|
hideInput: still.hideInput,
|
||||||
filename: still.filename
|
filename: still.filename
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for (const clip of full.clips) {
|
}
|
||||||
|
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
|
if (folderId && folderId !== 'all' && clip.folderId !== folderId) continue
|
||||||
if (typeFilter === 'images' || typeFilter === 'music') continue
|
|
||||||
rows.push({
|
rows.push({
|
||||||
id: clip.id,
|
id: clip.id,
|
||||||
name: clip.name || clip.id,
|
name: clip.name || clip.id,
|
||||||
@@ -684,9 +730,11 @@ export function publicLibraryPage(
|
|||||||
duration: clip.duration
|
duration: clip.duration
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for (const track of full.tracks || []) {
|
}
|
||||||
|
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
|
if (folderId && folderId !== 'all' && track.folderId !== folderId) continue
|
||||||
if (typeFilter === 'images' || typeFilter === 'videos') continue
|
|
||||||
rows.push({
|
rows.push({
|
||||||
id: track.id,
|
id: track.id,
|
||||||
name: track.name || track.id,
|
name: track.name || track.id,
|
||||||
@@ -698,34 +746,48 @@ export function publicLibraryPage(
|
|||||||
ext: track.ext
|
ext: track.ext
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rows.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0) || String(b.id).localeCompare(String(a.id)))
|
rows.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0) || String(b.id).localeCompare(String(a.id)))
|
||||||
|
|
||||||
let start = 0
|
let start = 0
|
||||||
if (cursor) {
|
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)
|
const idx = rows.findIndex(row => `${row.type}:${row.id}` === cursor)
|
||||||
start = idx >= 0 ? idx + 1 : 0
|
// 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 page = rows.slice(start, start + limit)
|
||||||
const last = page[page.length - 1]
|
const nextStart = start + page.length
|
||||||
const nextCursor = page.length === limit && last ? `${last.type}:${last.id}` : null
|
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 stillIds = new Set(page.filter(r => r.type === 'still').map(r => r.id))
|
||||||
const clipIds = new Set(page.filter(r => r.type === 'clip').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))
|
const trackIds = new Set(page.filter(r => r.type === 'track').map(r => r.id))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
folders: full.folders,
|
folders,
|
||||||
items: page,
|
items: page,
|
||||||
nextCursor,
|
nextCursor,
|
||||||
|
hasMore,
|
||||||
limit,
|
limit,
|
||||||
stills: full.stills.filter(s => stillIds.has(s.id)),
|
stills: catalog.stills
|
||||||
clips: full.clips.filter(c => clipIds.has(c.id)),
|
.filter(s => stillIds.has(s.id))
|
||||||
tracks: (full.tracks || []).filter(t => trackIds.has(t.id)),
|
.map(still => ({ ...still, role: stillRole(still) })),
|
||||||
drafts: full.drafts,
|
clips: catalog.clips
|
||||||
loraAliases: full.loraAliases,
|
.filter(c => clipIdPage.has(c.id))
|
||||||
loraTriggers: full.loraTriggers,
|
.map(clip => ({ ...clip, name: clip.name || clip.id, hasThumbnail: !clip.hideThumbnail, hasVideo: true })),
|
||||||
keepPrompts: full.keepPrompts,
|
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
|
total: rows.length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1216,14 +1278,42 @@ export function stillThumbFile(owner: string, id: string) {
|
|||||||
return join(ownerRoot(owner), 'stills', `${id}.thumb.jpg`)
|
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) {
|
function removeStillFiles(owner: string, id: string) {
|
||||||
rmSync(stillPath(owner, id), { force: true })
|
rmSync(stillPath(owner, id), { force: true })
|
||||||
rmSync(stillThumbFile(owner, id), { force: true })
|
rmSync(stillThumbFile(owner, id), { force: true })
|
||||||
|
rmSync(stillPreviewFile(owner, id), { force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
let thumbSlots = 0
|
let thumbSlots = 0
|
||||||
const thumbWaiters: Array<() => void> = []
|
const thumbWaiters: Array<() => void> = []
|
||||||
const thumbBuilds = new Map<string, Promise<string>>()
|
const thumbBuilds = new Map<string, Promise<string>>()
|
||||||
|
const previewBuilds = new Map<string, Promise<string>>()
|
||||||
|
|
||||||
function withThumbSlot<T>(fn: () => Promise<T>) {
|
function withThumbSlot<T>(fn: () => Promise<T>) {
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
@@ -1240,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) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
const proc = spawn('ffmpeg', [
|
const proc = spawn('ffmpeg', [
|
||||||
'-y',
|
'-y',
|
||||||
'-i', source,
|
'-i', source,
|
||||||
'-vf', 'scale=384:-2',
|
'-vf', scale,
|
||||||
'-frames:v', '1',
|
'-frames:v', '1',
|
||||||
'-q:v', '5',
|
'-q:v', quality,
|
||||||
dest
|
dest
|
||||||
], { stdio: 'ignore' })
|
], { stdio: 'ignore' })
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -1265,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) {
|
export async function stillThumbPath(owner: string, id: string) {
|
||||||
const source = stillPath(owner, id)
|
const source = stillPath(owner, id)
|
||||||
const dest = stillThumbFile(owner, id)
|
const dest = stillThumbFile(owner, id)
|
||||||
@@ -1274,7 +1372,7 @@ export async function stillThumbPath(owner: string, id: string) {
|
|||||||
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
|
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
|
||||||
} catch { /* rebuild */ }
|
} catch { /* rebuild */ }
|
||||||
}
|
}
|
||||||
const key = `${owner}:${id}`
|
const key = `${owner}:${id}:thumb`
|
||||||
const pending = thumbBuilds.get(key)
|
const pending = thumbBuilds.get(key)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
const build = withThumbSlot(async () => {
|
const build = withThumbSlot(async () => {
|
||||||
@@ -1288,11 +1386,40 @@ export async function stillThumbPath(owner: string, id: string) {
|
|||||||
return build
|
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 catalog = readCatalog(owner)
|
||||||
|
const cap = Math.max(0, Math.min(Number(opts?.limit) || 2, 8))
|
||||||
|
let built = 0
|
||||||
for (const still of catalog.stills) {
|
for (const still of catalog.stills) {
|
||||||
|
if (built >= cap) break
|
||||||
if (isPipelineStill(catalog, still)) continue
|
if (isPipelineStill(catalog, still)) continue
|
||||||
|
const dest = stillThumbFile(owner, still.id)
|
||||||
|
if (existsSync(dest)) continue
|
||||||
await stillThumbPath(owner, still.id)
|
await stillThumbPath(owner, still.id)
|
||||||
|
built++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1302,7 +1429,8 @@ export async function maintainLibrary(owner: string, folderId?: string) {
|
|||||||
const prev = lastMaintain.get(owner) || 0
|
const prev = lastMaintain.get(owner) || 0
|
||||||
if (Date.now() - prev < 20000) return
|
if (Date.now() - prev < 20000) return
|
||||||
lastMaintain.set(owner, Date.now())
|
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 live = await fetchLiveQueue().catch(() => null)
|
||||||
const busy = Boolean(live && live.running > 0)
|
const busy = Boolean(live && live.running > 0)
|
||||||
if (!busy) {
|
if (!busy) {
|
||||||
@@ -1530,6 +1658,7 @@ export async function saveStill(params: {
|
|||||||
next.stills.unshift(still)
|
next.stills.unshift(still)
|
||||||
})
|
})
|
||||||
void stillThumbPath(params.ownerKey, still.id).catch(() => null)
|
void stillThumbPath(params.ownerKey, still.id).catch(() => null)
|
||||||
|
void stillPreviewPath(params.ownerKey, still.id).catch(() => null)
|
||||||
return still
|
return still
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
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,
|
||||||
|
normalizeSectionOrder,
|
||||||
|
emptyCharacter,
|
||||||
|
emptyScene,
|
||||||
|
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
|
||||||
|
type PromptSequence,
|
||||||
|
type PromptSequenceCharacter,
|
||||||
|
type PromptSequenceSectionKey,
|
||||||
|
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
|
||||||
|
sectionOrder?: unknown
|
||||||
|
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 sectionOrder = input.sectionOrder !== undefined
|
||||||
|
? normalizeSectionOrder(input.sectionOrder)
|
||||||
|
: [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER]
|
||||||
|
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.sectionOrder = sectionOrder
|
||||||
|
existing.characters = characters
|
||||||
|
existing.sets = sets
|
||||||
|
existing.scenes = scenes
|
||||||
|
existing.updatedAt = now
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
const created: PromptSequence = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name,
|
||||||
|
sectionOrder,
|
||||||
|
characters,
|
||||||
|
sets,
|
||||||
|
scenes,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
}
|
||||||
|
sequences.unshift(created)
|
||||||
|
return created
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePromptSequence(owner: string, id: string, input: {
|
||||||
|
name?: string
|
||||||
|
sectionOrder?: unknown
|
||||||
|
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.sectionOrder !== undefined) {
|
||||||
|
existing.sectionOrder = normalizeSectionOrder(input.sectionOrder)
|
||||||
|
} else if (!existing.sectionOrder?.length) {
|
||||||
|
existing.sectionOrder = [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER] as PromptSequenceSectionKey[]
|
||||||
|
}
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import { imageV2StackSpecials } from '~/utils/imageV2'
|
|||||||
import { allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
|
import { allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
|
||||||
|
|
||||||
export type StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'error' | 'cancelled'
|
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 {
|
export interface StudioJobPayload {
|
||||||
studio2Id?: string
|
studio2Id?: string
|
||||||
@@ -42,6 +42,7 @@ export interface StudioJobPayload {
|
|||||||
useIdentityRefs: boolean
|
useIdentityRefs: boolean
|
||||||
stillId?: string
|
stillId?: string
|
||||||
stillFilename?: string
|
stillFilename?: string
|
||||||
|
captionStyle?: string
|
||||||
hideThumbnail: boolean
|
hideThumbnail: boolean
|
||||||
hideInput?: boolean
|
hideInput?: boolean
|
||||||
folderLocked?: boolean
|
folderLocked?: boolean
|
||||||
@@ -105,6 +106,9 @@ export interface StudioJob {
|
|||||||
resumeAutoRun?: boolean
|
resumeAutoRun?: boolean
|
||||||
lastError?: string
|
lastError?: string
|
||||||
waitReason?: string
|
waitReason?: string
|
||||||
|
resultText?: string
|
||||||
|
/** Hidden from Queue UI; keeps resultText on the stored row. */
|
||||||
|
dismissed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type StudioQueueStore = {
|
type StudioQueueStore = {
|
||||||
@@ -226,6 +230,7 @@ export function listStudioJobs(owner: string) {
|
|||||||
export function studioJobKind(job: Pick<StudioJob, 'kind'> | { kind?: string }) {
|
export function studioJobKind(job: Pick<StudioJob, 'kind'> | { kind?: string }) {
|
||||||
if (job.kind === 'edit') return 'edit'
|
if (job.kind === 'edit') return 'edit'
|
||||||
if (job.kind === 'music') return 'music'
|
if (job.kind === 'music') return 'music'
|
||||||
|
if (job.kind === 'caption') return 'caption'
|
||||||
return 'video'
|
return 'video'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +250,8 @@ export function summarizeStudioJob(job: StudioJob) {
|
|||||||
folderId: job.payload.folderId,
|
folderId: job.payload.folderId,
|
||||||
musicEngine: job.payload.musicEngine,
|
musicEngine: job.payload.musicEngine,
|
||||||
stillId: job.payload.stillId,
|
stillId: job.payload.stillId,
|
||||||
|
captionStyle: job.payload.captionStyle,
|
||||||
|
resultText: job.resultText,
|
||||||
workflow: job.payload.workflow,
|
workflow: job.payload.workflow,
|
||||||
imagePipeline: job.payload.imagePipeline || 'v1',
|
imagePipeline: job.payload.imagePipeline || 'v1',
|
||||||
duration: job.payload.duration,
|
duration: job.payload.duration,
|
||||||
@@ -290,7 +297,7 @@ function failZombieLiveJob(job: Job, error: string) {
|
|||||||
function sweepStaleLiveJobs() {
|
function sweepStaleLiveJobs() {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
for (const job of listJobs()) {
|
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.saving) continue
|
||||||
if (job.status === 'queued' && !job.promptId && now - job.startedAt >= QUEUED_GRACE_MS) {
|
if (job.status === 'queued' && !job.promptId && now - job.startedAt >= QUEUED_GRACE_MS) {
|
||||||
failZombieLiveJob(job, 'Job never started')
|
failZombieLiveJob(job, 'Job never started')
|
||||||
@@ -310,12 +317,12 @@ async function reapZombieLiveJobs() {
|
|||||||
const { fetchHistory } = await import('~/server/utils/comfy')
|
const { fetchHistory } = await import('~/server/utils/comfy')
|
||||||
for (const job of listJobs()) {
|
for (const job of listJobs()) {
|
||||||
// Never interrupt download/stitch/library save — Comfy is idle then by design.
|
// 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.saving) continue
|
||||||
if (job.library?.chainContinuing) continue
|
if (job.library?.chainContinuing) continue
|
||||||
if (job.status !== 'running' && job.status !== 'uploading' && job.status !== 'queued') continue
|
if (job.status !== 'running' && job.status !== 'uploading' && job.status !== 'queued') continue
|
||||||
if (jobIsLocallySubmitting(job)) 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 (jobAgeMs(job) < zombieMs) continue
|
||||||
if (!job.promptId) {
|
if (!job.promptId) {
|
||||||
if (jobAgeMs(job) >= QUEUED_GRACE_MS) failZombieLiveJob(job, 'Job never started on ComfyUI')
|
if (jobAgeMs(job) >= QUEUED_GRACE_MS) failZombieLiveJob(job, 'Job never started on ComfyUI')
|
||||||
@@ -355,7 +362,7 @@ async function reapZombieLiveJobs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function liveJobOwnsGpu(job: Job) {
|
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?.stopAfterCurrent) return false
|
||||||
if (job.library?.chainContinuing) return true
|
if (job.library?.chainContinuing) return true
|
||||||
if (job.saving) return true
|
if (job.saving) return true
|
||||||
@@ -375,7 +382,7 @@ function liveJobOwnsGpu(job: Job) {
|
|||||||
*/
|
*/
|
||||||
function clearDeadGpuClaimsForForceStart() {
|
function clearDeadGpuClaimsForForceStart() {
|
||||||
for (const live of listJobs()) {
|
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 (live.saving) continue
|
||||||
if (jobIsLocallySubmitting(live)) continue
|
if (jobIsLocallySubmitting(live)) continue
|
||||||
if (live.status !== 'running' && live.status !== 'queued' && live.status !== 'uploading') {
|
if (live.status !== 'running' && live.status !== 'queued' && live.status !== 'uploading') {
|
||||||
@@ -469,8 +476,11 @@ export async function addStudioJob(params: {
|
|||||||
kind?: StudioJobKind
|
kind?: StudioJobKind
|
||||||
}) {
|
}) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const kind = params.kind === 'edit' ? 'edit' : params.kind === 'music' ? 'music' : 'video'
|
const kind = params.kind === 'edit' ? 'edit'
|
||||||
const shotCount = kind === 'music'
|
: params.kind === 'music' ? 'music'
|
||||||
|
: params.kind === 'caption' ? 'caption'
|
||||||
|
: 'video'
|
||||||
|
const shotCount = kind === 'music' || kind === 'caption'
|
||||||
? 1
|
? 1
|
||||||
: kind === 'edit'
|
: kind === 'edit'
|
||||||
? 1 + (params.payload.passes?.length || 0)
|
? 1 + (params.payload.passes?.length || 0)
|
||||||
@@ -527,6 +537,40 @@ function canCancelStudioStatus(status: StudioJobStatus) {
|
|||||||
return status === 'waiting' || status === 'held' || status === 'running' || status === 'error'
|
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) {
|
export async function cancelStudioJob(owner: string, id: string) {
|
||||||
const before = readJobs(owner).find(item => item.id === id)
|
const before = readJobs(owner).find(item => item.id === id)
|
||||||
if (!before) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
|
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')
|
const { cancelYue2Job } = await import('./yue2')
|
||||||
await cancelYue2Job(job)
|
await cancelYue2Job(job)
|
||||||
}
|
}
|
||||||
|
if (job.caption) {
|
||||||
|
const { cancelCaptionJob } = await import('./caption')
|
||||||
|
await cancelCaptionJob(job)
|
||||||
|
}
|
||||||
job.status = 'cancelled'
|
job.status = 'cancelled'
|
||||||
job.error = 'Cleared by force reset'
|
job.error = 'Cleared by force reset'
|
||||||
if (job.library) {
|
if (job.library) {
|
||||||
@@ -678,6 +726,11 @@ async function stopLiveGeneration(liveJobId?: string, shotQueueId?: string) {
|
|||||||
await cancelYue2Job(live)
|
await cancelYue2Job(live)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (live?.caption) {
|
||||||
|
const { cancelCaptionJob } = await import('./caption')
|
||||||
|
await cancelCaptionJob(live)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (live) {
|
if (live) {
|
||||||
live.status = 'cancelled'
|
live.status = 'cancelled'
|
||||||
if (live.library) {
|
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`)
|
const root = join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'yue2-pending', `${job.liveJobId}.json`)
|
||||||
if (existsSync(root)) return true
|
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
|
if (!job.shotQueueId) return false
|
||||||
return listPendingJobs().some(pending => (
|
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) {
|
async function startStudioMusicJob(item: StudioJob) {
|
||||||
let live: import('~/server/utils/jobs').Job | undefined
|
let live: import('~/server/utils/jobs').Job | undefined
|
||||||
try {
|
try {
|
||||||
@@ -1439,6 +1522,10 @@ export async function startStudioJob(item: StudioJob) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startStudioJobReserved(item: StudioJob) {
|
async function startStudioJobReserved(item: StudioJob) {
|
||||||
|
if (studioJobKind(item) === 'caption') {
|
||||||
|
await startStudioCaptionJob(item)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (item.payload.studio2Id) {
|
if (item.payload.studio2Id) {
|
||||||
try {
|
try {
|
||||||
const { startStudio2Job } = await import('./studio2/runner')
|
const { startStudio2Job } = await import('./studio2/runner')
|
||||||
@@ -1703,7 +1790,7 @@ export async function onLiveVideoSettled(job: Job) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const remaining = remainingStudioShots(job)
|
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
|
const failed = (job.status === 'error' || job.status === 'cancelled') && !wakeFail
|
||||||
|
|
||||||
await mutateStore(owner, (store) => {
|
await mutateStore(owner, (store) => {
|
||||||
@@ -1777,6 +1864,7 @@ export async function onLiveVideoSettled(job: Job) {
|
|||||||
: failed
|
: failed
|
||||||
? 'error'
|
? 'error'
|
||||||
: 'complete'
|
: 'complete'
|
||||||
|
if (job.caption && job.resultText) row.resultText = job.resultText
|
||||||
clearStudioRowSlot(row, status, failed ? job.error : undefined)
|
clearStudioRowSlot(row, status, failed ? job.error : undefined)
|
||||||
syncPausedFlag(store)
|
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…'}
|
if (!health) return {label:'Status unavailable',tone:'unknown',detail:'Checking GPU connection…'}
|
||||||
const upscale=upscales.find(j=>j.status==='running')
|
const upscale=upscales.find(j=>j.status==='running')
|
||||||
if(upscale)return {label:'Upscaling',tone:'busy',detail:upscale.name || 'Video upscale'}
|
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'
|
const stage=music.live?.message || music.live?.stage || music.waitReason || 'YuE2'
|
||||||
return {label:music.status==='waiting'?'Queued':'Generating',tone:'busy',detail:String(stage)}
|
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'}
|
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))
|
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'}
|
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 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(', ')}
|
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)}
|
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) {
|
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
|
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 activeJobs(jobs) { return jobs.filter(j=>['waking','enhancing','submitting','rendering','saving'].includes(j.state)) }
|
||||||
export function previewHidden(asset, revealed, xaigen) {
|
export function previewHidden(asset, revealed, xaigen) {
|
||||||
if (!xaigen || !asset?.id) return false
|
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,288 @@
|
|||||||
|
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,
|
||||||
|
normalizeSectionOrder,
|
||||||
|
DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER,
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sectionOrder settings then prompt then characters; empty style and set omitted', () => {
|
||||||
|
const ada = {
|
||||||
|
...emptyCharacter(),
|
||||||
|
id: 'c1',
|
||||||
|
name: 'Ada',
|
||||||
|
description: 'tall',
|
||||||
|
outfits: []
|
||||||
|
}
|
||||||
|
const set = {
|
||||||
|
...emptySet(),
|
||||||
|
id: 's1',
|
||||||
|
name: 'Courtyard',
|
||||||
|
framings: [{ ...emptyFraming('wide'), id: 'f1', text: 'open yard' }]
|
||||||
|
}
|
||||||
|
const scene = {
|
||||||
|
style: '',
|
||||||
|
settings: 'soft light',
|
||||||
|
setStates: [{ setId: 's1', on: true, framingId: null }],
|
||||||
|
characterStates: [{ characterId: 'c1', on: true, outfitId: null }],
|
||||||
|
prompt: 'looks up'
|
||||||
|
}
|
||||||
|
const order = ['settings', 'prompt', 'characters']
|
||||||
|
const text = assembleSceneText(scene, [ada], [set], order)
|
||||||
|
assert.equal(text, 'soft light\n\nlooks up\n\nAda: tall')
|
||||||
|
assert.ok(!text.includes('open yard'))
|
||||||
|
assert.ok(!text.includes('Courtyard'))
|
||||||
|
assert.ok(!text.includes('wide'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy sequence without sectionOrder keeps default order and \\n\\n\\n join', () => {
|
||||||
|
const legacy = normalizePromptSequence({
|
||||||
|
id: 'seq1',
|
||||||
|
name: 'Old order',
|
||||||
|
characters: [{ id: 'c1', name: 'Ada', description: 'tall', outfits: [] }],
|
||||||
|
sets: [{
|
||||||
|
id: 's1',
|
||||||
|
name: 'Hall',
|
||||||
|
framings: [{ id: 'f1', label: 'wide', text: 'stone hall' }]
|
||||||
|
}],
|
||||||
|
scenes: [
|
||||||
|
{
|
||||||
|
id: 'sc1',
|
||||||
|
title: 'One',
|
||||||
|
style: 'oil',
|
||||||
|
settings: 'day',
|
||||||
|
setStates: [{ setId: 's1', on: true, framingId: 'f1' }],
|
||||||
|
characterStates: [{ characterId: 'c1', on: true, outfitId: null }],
|
||||||
|
prompt: 'walk'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sc2',
|
||||||
|
title: 'Two',
|
||||||
|
style: 'ink',
|
||||||
|
settings: '',
|
||||||
|
setStates: [{ setId: 's1', on: false, framingId: 'f1' }],
|
||||||
|
characterStates: [{ characterId: 'c1', on: true, outfitId: null }],
|
||||||
|
prompt: 'stop'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1
|
||||||
|
})
|
||||||
|
assert.deepEqual(legacy.sectionOrder, [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER])
|
||||||
|
assert.deepEqual(normalizeSectionOrder(undefined), [...DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER])
|
||||||
|
assert.deepEqual(normalizeSectionOrder(['prompt', 'prompt', 'bogus', 'settings']), [
|
||||||
|
'prompt',
|
||||||
|
'settings',
|
||||||
|
'style',
|
||||||
|
'set',
|
||||||
|
'characters'
|
||||||
|
])
|
||||||
|
const full = assembleSequenceText(legacy)
|
||||||
|
assert.equal(
|
||||||
|
full,
|
||||||
|
'oil\n\nday\n\nstone hall\n\nAda: tall\n\nwalk\n\n\nink\n\nAda: tall\n\nstop'
|
||||||
|
)
|
||||||
|
assert.ok(!full.includes('Hall'))
|
||||||
|
assert.ok(!full.includes('wide'))
|
||||||
|
})
|
||||||
@@ -4,7 +4,7 @@ import {readFileSync} from 'node:fs'
|
|||||||
import ts from 'typescript'
|
import ts from 'typescript'
|
||||||
import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs'
|
import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs'
|
||||||
import {useAsHero,useAsSource,restoreAll,hydrateStudio2Job} from '../shared/studio2/input.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 {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
|
||||||
import {cachedLoras} from '../shared/studio2/lora-cache.mjs'
|
import {cachedLoras} from '../shared/studio2/lora-cache.mjs'
|
||||||
import {resolveQwen21Size} from '../shared/studio2/qwen21-size.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.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.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(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',()=>{
|
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'}})
|
const q=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'a red bicycle'},settings:{aspect:'1:1'}})
|
||||||
|
|||||||
@@ -0,0 +1,575 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scene prompt sections. Default copy order. */
|
||||||
|
export const PROMPT_SEQUENCE_SECTION_KEYS = ['style', 'settings', 'set', 'characters', 'prompt'] as const
|
||||||
|
export type PromptSequenceSectionKey = (typeof PROMPT_SEQUENCE_SECTION_KEYS)[number]
|
||||||
|
export const DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER: PromptSequenceSectionKey[] = [
|
||||||
|
...PROMPT_SEQUENCE_SECTION_KEYS
|
||||||
|
]
|
||||||
|
|
||||||
|
export type PromptSequence = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
/** Order of scene sections when assembling / in the editor. */
|
||||||
|
sectionOrder: PromptSequenceSectionKey[]
|
||||||
|
characters: PromptSequenceCharacter[]
|
||||||
|
sets: PromptSequenceSet[]
|
||||||
|
scenes: PromptSequenceScene[]
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valid unique keys in given order; missing keys append in default order. */
|
||||||
|
export function normalizeSectionOrder(raw: unknown): PromptSequenceSectionKey[] {
|
||||||
|
const allowed = new Set<string>(PROMPT_SEQUENCE_SECTION_KEYS)
|
||||||
|
const seen = new Set<PromptSequenceSectionKey>()
|
||||||
|
const ordered: PromptSequenceSectionKey[] = []
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
for (const item of raw) {
|
||||||
|
const key = String(item || '').trim()
|
||||||
|
if (!allowed.has(key) || seen.has(key as PromptSequenceSectionKey)) continue
|
||||||
|
const typed = key as PromptSequenceSectionKey
|
||||||
|
seen.add(typed)
|
||||||
|
ordered.push(typed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of DEFAULT_PROMPT_SEQUENCE_SECTION_ORDER) {
|
||||||
|
if (!seen.has(key)) ordered.push(key)
|
||||||
|
}
|
||||||
|
return ordered
|
||||||
|
}
|
||||||
|
|
||||||
|
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[],
|
||||||
|
sectionOrder?: PromptSequenceSectionKey[] | unknown
|
||||||
|
) {
|
||||||
|
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 blocks: Record<PromptSequenceSectionKey, string> = {
|
||||||
|
style: String(scene.style || '').trim(),
|
||||||
|
settings: String(scene.settings || '').trim(),
|
||||||
|
set: setBlock,
|
||||||
|
characters: characterBlock,
|
||||||
|
prompt: String(scene.prompt || '').trim()
|
||||||
|
}
|
||||||
|
const order = normalizeSectionOrder(sectionOrder)
|
||||||
|
const parts = order.map(key => blocks[key]).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: {
|
||||||
|
sectionOrder?: PromptSequenceSectionKey[] | unknown
|
||||||
|
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 || []
|
||||||
|
const sectionOrder = normalizeSectionOrder(sequence.sectionOrder)
|
||||||
|
return (sequence.scenes || [])
|
||||||
|
.map(scene => assembleSceneText(
|
||||||
|
scene,
|
||||||
|
characters.length ? characters : undefined,
|
||||||
|
sets.length ? sets : undefined,
|
||||||
|
sectionOrder
|
||||||
|
))
|
||||||
|
.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,
|
||||||
|
sectionOrder: normalizeSectionOrder(rec.sectionOrder),
|
||||||
|
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