Compare commits

..
21 Commits
Author SHA1 Message Date
TowstyandCursor 45e97cc1c3 Add Prompt Builder sequence section order.
Copied scene text and the scene editor follow a persisted sectionOrder, with legacy saves keeping the default style/settings/set/characters/prompt order.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 07:07:43 -05:00
TowstyandCursor b6eef6a511 Replace Prompt Builder beats with complete set framings.
Sets are places with labeled full-text framings; selecting a framing copies only that text, and legacy beat-only sets migrate to a wide framing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 22:28:44 -05:00
Towsty d803d57a78 Fix bench draft structuredClone 500 and Prompt Builder button contrast.
Draft snapshots use plain JSON of picked fields with restore try/catch that clears bad storage; Prompt Builder actions use amber primary/secondary styles with 44px tap targets.
2026-10-03 19:38:55 -05:00
Towsty a8ae61704e Add Prompt Builder sets/framing and harden mobile draft look settings.
Sets and beats filter by shot size; scenes reorder/delete; portrait links Prompt Builder; landscape queue collapses; per-engine look settings and prompt Clear persist across iOS resumes.
2026-10-03 19:31:22 -05:00
Towsty 7799529788 Extend Prompt Builder with sequence characters, outfits, and scene toggles.
Characters live on the sequence with per-scene on/off and outfit picks; Copy scene exports only the active tab; legacy saves still open.
2026-10-02 08:50:31 -05:00
Towsty 790c33fcf0 Fix Prompt Builder Nitro build by inlining sequence helpers in utils.
The relative re-export of shared/prompt-sequences.mjs left an unresolved import in the SSR bundle.
2026-10-02 08:11:09 -05:00
Towsty 1bf97568bf Add Prompt Builder page for multi-scene prompt sequences.
Name, save, and load ordered scenes on the server; assemble copyable text with a Batch-compatible double blank line between scenes.
2026-10-02 07:15:00 -05:00
Towsty 5788d74001 Add desktop Batch toggle, fix xAIGen library hide/selection, per-engine steps/CFG, and mobile prompt editor.
Desktop Batch splits only on double blank lines when enabled; library hide actually conceals thumbs and pinned clicks stick; steps/CFG restore per image model; mobile opens a near-fullscreen prompt editor.
2026-10-01 17:06:25 -05:00
Towsty 4f40641050 Split blank-line paragraphs in the desktop prompt into ordered image batch jobs. 2026-09-30 23:34:47 -05:00
Towsty 14dcd234f3 Let finished caption jobs be dismissed from the Queue panel. 2026-09-30 23:18:44 -05:00
Towsty 15ef891b26 Add image-to-text Describe on the bench via exclusive llama.cpp caption jobs. 2026-09-30 22:50:19 -05:00
TowstyandCursor 9481791ac5 Keep fetching library pages after the first 50 so the whole folder is reachable.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 00:28:22 -05:00
TowstyandCursor 17bb42bea4 Serve the stage from a 1600px preview instead of the grid thumb.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 20:57:01 -05:00
TowstyandCursor ccf852cb0c Clear Queuing when the optimistic job snapshot avoids structuredClone.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 20:45:23 -05:00
TowstyandCursor 4ec8ffa8d6 Serve the bench before the catalog and never await ffmpeg on /thumb.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 20:35:28 -05:00
TowstyandCursor a465dd68ec Stop polling the library catalog and keep the stage on thumbs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 20:15:03 -05:00
TowstyandCursor cfabfd37ee Queue Generate with an immediate local card and stop blocking reload on library or object_info.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 19:46:55 -05:00
TowstyandCursor ffdaa7ce42 Remount stage media on selectedId and show thumb before the archive still.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 19:45:51 -05:00
TowstyandCursor aa0aa871e4 Add paginated library JSON and dedicated still thumbs that never stream archive masters.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 19:45:22 -05:00
TowstyandCursor 845ee5bbf6 Set the selected still on Use image instead of an empty Photos card.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 15:40:35 -05:00
TowstyandCursor ba83520923 Move stage actions into a top chrome bar with Copy and Load menus.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 09:09:00 -05:00
45 changed files with 4883 additions and 424 deletions
+131 -40
View File
@@ -347,7 +347,8 @@
/* Grow until width or height hits the stage; letterbox/pillarbox via contain */
.viewer-frame > img,
.viewer-frame > video {
.viewer-frame > video,
.viewer-still {
width: 100%;
height: 100%;
max-width: 100%;
@@ -356,40 +357,43 @@
object-position: center;
}
.stage-chrome {
position: absolute;
top: 10px;
right: 10px;
left: 10px;
z-index: 5;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
.viewer-still--veiled {
opacity: 0;
pointer-events: none;
}
.stage-chrome-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
.viewer-frame--veiled .viewer-veil-label {
position: absolute;
z-index: 2;
margin: 0;
pointer-events: none;
}
.stage-chrome-top > *,
.stage-actions {
pointer-events: auto;
/* Top chrome is a sibling of the media pane — never absolute over pixels */
.stage-chrome {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding: 8px 10px;
border-bottom: 1px solid var(--line);
background: #0b0b0d;
}
.stage-chrome > .eye-viewer {
margin-left: auto;
}
.stage-library {
display: inline-flex;
min-height: 44px;
min-height: 36px;
align-items: center;
gap: 6px;
border: 1px solid var(--line);
border-radius: 8px;
background: #000a;
padding: 0 14px;
background: #141416;
padding: 0 12px;
color: var(--text);
font: inherit;
font-weight: 600;
@@ -408,15 +412,16 @@
.stage-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
max-width: calc(100% - 108px);
}
.stage-actions button {
.stage-actions > button,
.stage-split-trigger {
min-height: 36px;
border: 1px solid var(--line);
border-radius: 8px;
background: #000a;
background: #141416;
color: var(--text);
padding: 0 10px;
font: inherit;
@@ -424,7 +429,8 @@
cursor: pointer;
}
.stage-actions button:hover {
.stage-actions > button:hover,
.stage-split-trigger:hover {
border-color: var(--amber);
}
@@ -432,15 +438,64 @@
color: #fca5a5;
}
.stage-split {
position: relative;
}
.stage-split-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
}
.stage-split-trigger::after {
content: '▾';
color: var(--muted);
font-size: 11px;
}
.stage-split-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 8;
display: flex;
min-width: 160px;
flex-direction: column;
gap: 2px;
border: 1px solid var(--line);
border-radius: 8px;
background: #141416;
padding: 4px;
box-shadow: 0 8px 24px #0008;
}
.stage-split-menu button {
min-height: 36px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text);
padding: 0 10px;
text-align: left;
font: inherit;
font-size: var(--type-sm);
cursor: pointer;
}
.stage-split-menu button:hover {
background: #ffffff10;
}
.eye-viewer {
display: grid;
width: 44px;
height: 44px;
width: 36px;
height: 36px;
flex-shrink: 0;
place-items: center;
border: 1px solid var(--line);
border-radius: 8px;
background: #000a;
background: #141416;
cursor: pointer;
}
@@ -448,35 +503,41 @@
border-color: var(--amber);
}
.stage-media-row {
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
align-items: stretch;
gap: 0;
}
.stage-nav {
position: absolute;
top: 50%;
z-index: 4;
display: grid;
width: 44px;
height: 44px;
flex-shrink: 0;
place-items: center;
border: 1px solid var(--line);
border-radius: 8px;
background: #000a;
align-self: stretch;
border: 0;
border-radius: 0;
background: #0b0b0d;
color: var(--text);
font-size: 28px;
line-height: 1;
cursor: pointer;
transform: translateY(-50%);
}
.stage-nav:hover {
border-color: var(--amber);
color: var(--amber);
background: #121214;
}
.stage-nav-prev {
left: 10px;
border-right: 1px solid var(--line);
}
.stage-nav-next {
right: 10px;
border-left: 1px solid var(--line);
}
.photos-head {
@@ -625,6 +686,10 @@
flex-direction: column;
}
.queue-col[data-collapsed='1'] {
min-width: 0;
}
.queue-head {
display: flex;
flex-shrink: 0;
@@ -634,6 +699,24 @@
font-weight: 650;
}
.queue-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
font-weight: 650;
cursor: pointer;
padding: 0;
}
.queue-toggle-mark {
color: var(--muted);
font-size: 12px;
}
.queue-badge {
display: inline-grid;
min-width: 1.25rem;
@@ -778,6 +861,14 @@
cursor: not-allowed;
}
.queue-foot-error {
margin: 8px 0 0;
color: #fca5a5;
font-size: var(--type-sm);
line-height: 1.35;
word-break: break-word;
}
.queue-foot .tiny {
margin-top: 8px;
text-align: center;
+8 -1
View File
@@ -55,6 +55,13 @@
role="menuitem"
@click="menuOpen = false"
>Director</NuxtLink>
<NuxtLink
to="/prompt-builder"
class="block rounded-xl px-3 py-2 text-sm hover:bg-white/5 hover:text-white"
:class="current === 'prompt-builder' ? 'text-amber-100' : 'text-zinc-200'"
role="menuitem"
@click="menuOpen = false"
>Prompt Builder</NuxtLink>
<NuxtLink
to="/music"
class="block rounded-xl px-3 py-2 text-sm hover:bg-white/5 hover:text-white"
@@ -131,7 +138,7 @@
<script setup lang="ts">
withDefaults(defineProps<{
subtitle: string
current?: 'studio' | 'library' | 'director' | 'queue' | 'settings' | 'music'
current?: 'studio' | 'library' | 'director' | 'prompt-builder' | 'queue' | 'settings' | 'music'
wide?: boolean
queueCount?: number
}>(), {
+1 -1
View File
@@ -95,7 +95,7 @@
>
<div class="relative h-28 overflow-hidden bg-zinc-900">
<img
:src="`/api/library/stills/${still.id}?thumb=1`"
:src="`/api/library/stills/${still.id}/thumb`"
alt=""
loading="lazy"
decoding="async"
+1 -1
View File
@@ -55,6 +55,6 @@ defineEmits(['pick', 'remove', 'mention', 'update:role'])
const thumbSrc = computed(() => {
if (!props.asset) return ''
if (props.asset.kind === 'clip') return `/api/library/clips/${props.asset.id}/thumb`
return `/api/library/stills/${props.asset.id}`
return `/api/library/stills/${props.asset.id}/thumb`
})
</script>
File diff suppressed because it is too large Load Diff
+113 -10
View File
@@ -60,6 +60,7 @@
v-for="entry in entries"
:key="entry.key"
class="lib-modal-tile"
:data-thumb-key="entry.key"
:data-active="entry.key === focusedKey ? 'true' : undefined"
@click="pickEntry(entry)"
>
@@ -77,13 +78,14 @@
/>
</div>
<img
v-else-if="thumbSrc(entry)"
v-else-if="!previewHidden && liveThumbs.has(entry.key) && thumbSrc(entry)"
:src="thumbSrc(entry)"
alt=""
loading="lazy"
decoding="async"
draggable="false"
>
<span v-else class="lib-modal-fallback" />
<span v-else class="lib-modal-fallback">{{ previewHidden ? 'Hidden' : '' }}</span>
<span class="lib-modal-kind">
{{ entry.kind === 'clip' ? 'Video' : entry.kind === 'track' ? 'Music' : 'Image' }}
</span>
@@ -170,6 +172,10 @@ const clips = ref<LibraryClip[]>([])
const stills = ref<LibraryStill[]>([])
const tracks = ref<LibraryTrack[]>([])
const fetchedAt = ref(0)
const nextCursor = ref<string | null>(null)
const liveThumbs = ref(new Set<string>())
let thumbObserver: IntersectionObserver | null = null
let scrollLoadBound = false
const activeFolderId = computed(() => props.folderId || folders.value[0]?.id || 'all')
@@ -209,7 +215,7 @@ const detailRows = computed(() => {
function thumbSrc(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb`
if (entry.kind === 'track') return ''
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}?thumb=1`
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.head.id}/thumb`
return ''
}
@@ -259,30 +265,107 @@ function onDeleteClick() {
window.setTimeout(() => { void fetchLibrary(true) }, 500)
}
function applyLibrary(data: any) {
folders.value = data?.folders || []
function mergeIds(prev: { id: string }[], next: { id: string }[]) {
const map = new Map(prev.map(item => [item.id, item]))
for (const item of next) map.set(item.id, item)
return [...map.values()]
}
function applyLibraryPage(data: any, replace: boolean) {
folders.value = data?.folders || folders.value
if (replace) {
clips.value = data?.clips || []
stills.value = data?.stills || []
tracks.value = data?.tracks || []
} else {
clips.value = mergeIds(clips.value, data?.clips || []) as LibraryClip[]
stills.value = mergeIds(stills.value, data?.stills || []) as LibraryStill[]
tracks.value = mergeIds(tracks.value, data?.tracks || []) as LibraryTrack[]
}
nextCursor.value = data?.nextCursor || null
fetchedAt.value = Date.now()
}
async function fetchLibraryPage(cursor: string | null) {
const folder = activeFolderId.value && activeFolderId.value !== 'all' ? activeFolderId.value : ''
const type = filter.value === 'all' ? 'all' : filter.value === 'images' ? 'images' : filter.value === 'videos' ? 'videos' : 'music'
const qs = new URLSearchParams({ limit: '50' })
if (folder) qs.set('folderId', folder)
if (type !== 'all') qs.set('type', type)
if (cursor) qs.set('cursor', cursor)
return await $fetch<any>(`/api/library?${qs.toString()}`)
}
let fetchGen = 0
async function fetchLibrary(force = false) {
if (loading.value) return
const stale = !fetchedAt.value || Date.now() - fetchedAt.value > 15_000
const empty = !stills.value.length && !clips.value.length && !tracks.value.length
if (!force && !empty && !stale) return
const gen = ++fetchGen
loading.value = true
loadError.value = ''
liveThumbs.value = new Set()
try {
applyLibrary(await $fetch('/api/library?light=1'))
const first = await fetchLibraryPage(null)
if (gen !== fetchGen) return
applyLibraryPage(first, true)
loading.value = false
nextTick(() => observeThumbs())
// Keep fetching until the folder is exhausted — 50 is a page size, not a cap
let cursor = first?.nextCursor || null
while (cursor && gen === fetchGen) {
const more = await fetchLibraryPage(cursor)
if (gen !== fetchGen) return
applyLibraryPage(more, false)
cursor = more?.nextCursor || null
nextTick(() => observeThumbs())
}
} catch (e: any) {
if (gen !== fetchGen) return
loadError.value = e?.data?.statusMessage || e?.message || 'Could not load library'
} finally {
loading.value = false
}
}
function observeThumbs() {
if (!import.meta.client) return
const root = gridScrollEl.value
if (!root) return
if (thumbObserver) thumbObserver.disconnect()
thumbObserver = new IntersectionObserver((entries) => {
let changed = false
const next = new Set(liveThumbs.value)
for (const entry of entries) {
if (!entry.isIntersecting) continue
const key = (entry.target as HTMLElement).dataset.thumbKey
if (!key || next.has(key)) continue
next.add(key)
changed = true
}
if (changed) liveThumbs.value = next
}, { root, rootMargin: '200px 0px', threshold: 0.01 })
root.querySelectorAll<HTMLElement>('[data-thumb-key]').forEach((el) => thumbObserver!.observe(el))
}
function onGridScroll() {
const el = gridScrollEl.value
if (!el || !nextCursor.value || loading.value) return
if (el.scrollTop + el.clientHeight < el.scrollHeight - 240) return
void fetchMore()
}
async function fetchMore() {
if (!nextCursor.value || loading.value) return
loading.value = true
try {
const more = await fetchLibraryPage(nextCursor.value)
applyLibraryPage(more, false)
nextTick(() => observeThumbs())
} catch { /* keep what we have */ }
finally { loading.value = false }
}
function onTab(e: KeyboardEvent) {
const root = dialogEl.value
if (!root) return
@@ -308,15 +391,35 @@ watch(() => props.open, (on) => {
void fetchLibrary(true)
nextTick(() => {
dialogEl.value?.focus()
if (gridScrollEl.value) gridScrollEl.value.scrollTop = 0
if (gridScrollEl.value) {
gridScrollEl.value.scrollTop = 0
if (!scrollLoadBound) {
gridScrollEl.value.addEventListener('scroll', onGridScroll, { passive: true })
scrollLoadBound = true
}
observeThumbs()
}
})
} else {
document.body.style.overflow = ''
thumbObserver?.disconnect()
thumbObserver = null
}
})
watch(entries, () => nextTick(() => observeThumbs()))
watch([filter, activeFolderId], () => {
if (!props.open) return
void fetchLibrary(true)
})
onBeforeUnmount(() => {
if (import.meta.client) document.body.style.overflow = ''
if (import.meta.client) {
document.body.style.overflow = ''
gridScrollEl.value?.removeEventListener('scroll', onGridScroll)
thumbObserver?.disconnect()
}
})
</script>
+204 -17
View File
@@ -1,5 +1,5 @@
<template>
<div class="m-shell" data-mobile="1">
<div class="m-shell" data-mobile="1" :data-queue-collapsed="queueRailCollapsed ? '1' : undefined">
<header class="m-header">
<span class="m-brand">{{ ctx.instanceName }}</span>
<label class="m-crumb">
@@ -9,11 +9,15 @@
</label>
<span class="m-grow" />
<span class="m-gpu" :data-tone="ctx.gpu.tone" :title="ctx.gpu.detail">● {{ ctx.gpuChipLabel }}</span>
<button type="button" class="m-chip" @click="toggleSheet('queue')">Queue {{ ctx.queueCount }}</button>
<button type="button" class="m-chip m-queue-chip-portrait" @click="toggleSheet('queue')">Queue {{ ctx.queueCount }}</button>
<button type="button" class="m-chip m-queue-chip-landscape" :aria-expanded="queueRailCollapsed ? 'false' : 'true'" @click="queueRailCollapsed=!queueRailCollapsed">
Queue {{ ctx.queueCount }} {{ queueRailCollapsed ? '▸' : '▾' }}
</button>
<details class="m-more">
<summary aria-label="More">⋯</summary>
<div class="m-more-menu">
<NuxtLink to="/library" @click="closeMenus">Library</NuxtLink>
<NuxtLink to="/prompt-builder" @click="closeMenus">Prompt Builder</NuxtLink>
<NuxtLink to="/queue" @click="closeMenus">Queue</NuxtLink>
<NuxtLink to="/settings" @click="closeMenus">Settings</NuxtLink>
</div>
@@ -49,6 +53,7 @@
<div
class="m-stage"
:class="{ 'm-stage--veiled': ctx.selected && ctx.isHidden(ctx.selected) }"
@touchstart.passive="onStageTouchStart"
@touchend.passive="onStageTouchEnd"
>
@@ -102,22 +107,33 @@
aria-label="Next"
@click.stop="ctx.stepFamily(1)"
>›</button>
<template v-if="ctx.selected && ctx.isHidden(ctx.selected)">
<p class="m-hidden-note">{{ ctx.selected.kind==='track' ? 'Track hidden' : 'Output hidden' }}</p>
</template>
<template v-else-if="ctx.selected?.kind==='track'">
<div class="m-audio">
<p
v-show="ctx.selected && ctx.isHidden(ctx.selected)"
class="m-hidden-note"
>{{ ctx.selected?.kind==='track' ? 'Track hidden' : 'Output hidden' }}</p>
<template v-if="ctx.selected?.kind==='track'">
<div v-show="!ctx.isHidden(ctx.selected)" class="m-audio">
<AudioPlayer :src="`/api/library/tracks/${ctx.selected.id}/audio`" :download-name="`${ctx.selected.name || 'track'}.${ctx.selected.ext || 'wav'}`" :label="ctx.selected.name || 'Track'" />
</div>
</template>
<video
v-else-if="ctx.selected?.kind==='clip'"
v-show="!ctx.isHidden(ctx.selected)"
:key="ctx.selected.id"
:src="`/api/library/clips/${ctx.selected.id}/video`"
controls
playsinline
/>
<img v-else-if="ctx.selected" :src="ctx.stillUrl(ctx.selected.id)" alt="Selected still">
<img
v-else-if="ctx.selected"
v-show="!ctx.isHidden(ctx.selected)"
:key="ctx.selected.id+'-'+(ctx.stagePreviewNonce||0)"
class="m-stage-still"
:src="ctx.stagePreviewSrc || ctx.stillPreviewUrl(ctx.selected.id)"
alt="Selected still"
decoding="async"
@error="ctx.onStagePreviewError?.()"
>
<p v-else class="m-muted">Open Library to pick a still or clip</p>
</div>
@@ -127,6 +143,7 @@
:selected="ctx.selected"
:folder-id="ctx.form?.folderId"
:preview-hidden="!ctx.eyeOpen"
:is-hidden="ctx.isHidden"
@close="ctx.closeLibrary()"
@select="ctx.selectOutput"
@use-image="ctx.onLibraryUseImage"
@@ -211,7 +228,18 @@
<label class="m-label">Lyrics<textarea v-model="ctx.music.lyrics" rows="8" class="m-area" /></label>
</template>
<template v-else>
<label class="m-label">Prompt<textarea v-model="ctx.form.promptSections.action" rows="5" class="m-area" placeholder="Describe the image or the shot." /></label>
<label class="m-label">
<span class="m-label-row">Prompt<button type="button" class="m-link" @click.stop="ctx.clearPrompt()">Clear</button></span>
<textarea
:value="ctx.form.promptSections.action"
rows="5"
class="m-area"
placeholder="Describe the image or the shot."
readonly
@focus="openPromptEditor"
@click="openPromptEditor"
/>
</label>
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.enhancePrompt" type="checkbox"> Enhance prompt</label>
<label v-if="ctx.form.engine==='qwen21' && (ctx.form.mode==='generate' || ctx.form.mode==='edit')" class="m-check"><input v-model="ctx.form.turbo" type="checkbox"> Turbo</label>
<p v-if="ctx.form.engine==='qwen21' && ctx.form.turbo" class="m-muted">6-step Viggle distill. CFG stays 1. Enhance prompt recommended.</p>
@@ -306,7 +334,7 @@
</div>
</aside>
<aside v-if="sheet==='queue'" class="m-sheet" role="dialog" aria-label="Queue">
<aside v-if="sheet==='queue'" class="m-sheet m-queue-sheet-portrait" role="dialog" aria-label="Queue">
<button type="button" class="m-handle" aria-label="Close" @click="sheet=''" />
<div class="m-sheet-head"><strong>Queue</strong><button type="button" @click="sheet=''">Done</button></div>
<div class="m-sheet-body">
@@ -323,6 +351,12 @@
<div class="m-job-status">
<span>● {{ card.statusLabel }}</span>
<button v-if="card.cancelable" type="button" @click.stop="ctx.cancel(card.job)">Cancel</button>
<button v-else-if="card.dismissable" type="button" @click.stop="ctx.dismissFinishedCard(card)">Dismiss</button>
</div>
<p v-if="card.resultText" class="m-muted" style="white-space:pre-wrap">{{ card.resultText }}</p>
<div v-if="card.resultText" class="m-job-status">
<button type="button" @click.stop="ctx.copyCaption(card.resultText)">Copy</button>
<button type="button" @click.stop="ctx.useCaptionAsPrompt(card.resultText)">Use as prompt</button>
</div>
<div v-if="card.progress" class="m-job-progress" :data-indeterminate="card.progress.indeterminate ? 'true' : undefined">
<progress
@@ -337,6 +371,52 @@
<button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button>
</div>
</aside>
<aside v-show="!queueRailCollapsed" class="m-queue-rail" aria-label="Queue">
<div class="m-sheet-head">
<strong>Queue</strong>
<button type="button" @click="queueRailCollapsed=true">Hide</button>
</div>
<div class="m-sheet-body">
<div
v-for="card in ctx.queueCards"
:key="'rail-'+card.id"
class="m-job"
:class="{ on: ctx.focusedJobId===card.id }"
@click="ctx.focusQueueCard(card)"
>
<div class="m-job-id">{{ card.code }}</div>
<div class="m-job-title">{{ card.title }}</div>
<div class="m-muted">{{ card.meta }}</div>
<div class="m-job-status">
<span>● {{ card.statusLabel }}</span>
<button v-if="card.cancelable" type="button" @click.stop="ctx.cancel(card.job)">Cancel</button>
<button v-else-if="card.dismissable" type="button" @click.stop="ctx.dismissFinishedCard(card)">Dismiss</button>
</div>
</div>
<p v-if="!ctx.queueCards.length" class="m-muted">No active jobs</p>
<button v-if="ctx.canClearFinished" type="button" class="m-link" @click="ctx.clearFinished">Clear finished</button>
</div>
</aside>
<div
v-if="promptEditorOpen"
class="m-prompt-editor"
role="dialog"
aria-modal="true"
aria-label="Edit prompt"
>
<header class="m-prompt-editor-head">
<strong>Prompt</strong>
<button type="button" @click="closePromptEditor">Done</button>
</header>
<textarea
ref="promptEditorEl"
v-model="promptEditorDraft"
class="m-prompt-editor-area"
placeholder="Describe the image or the shot."
/>
</div>
</div>
</template>
@@ -345,13 +425,35 @@ export type Studio2MobileCtx = Record<string, any>
const props = defineProps<{ ctx: Studio2MobileCtx }>()
const sheet = ref('')
const queueRailCollapsed = ref(false)
const touchStart = ref<{ x: number; y: number } | null>(null)
const promptEditorOpen = ref(false)
const promptEditorDraft = ref('')
const promptEditorEl = ref<HTMLTextAreaElement | null>(null)
function openPromptEditor() {
promptEditorDraft.value = String(props.ctx.form?.promptSections?.action || '')
promptEditorOpen.value = true
nextTick(() => {
promptEditorEl.value?.focus()
const el = promptEditorEl.value
if (el) {
const n = el.value.length
el.setSelectionRange(n, n)
}
})
}
function closePromptEditor() {
if (props.ctx.form?.promptSections) props.ctx.form.promptSections.action = promptEditorDraft.value
promptEditorOpen.value = false
}
function toggleSheet(id: string) {
sheet.value = sheet.value === id ? '' : id
}
function closeMenus() {
sheet.value = ''
if (promptEditorOpen.value) closePromptEditor()
}
function runAction(kind: 'use-image' | 'use-prompt' | 'copy' | 'copy-raw' | 'load-input' | 'load-input-raw' | 'delete') {
sheet.value = ''
@@ -477,13 +579,26 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
background: #080808;
}
.m-stage > img,
.m-stage > video {
.m-stage > video,
.m-stage-still {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.m-stage-still--veiled {
opacity: 0;
pointer-events: none;
}
.m-hidden-note {
position: absolute;
z-index: 3;
margin: 0;
color: var(--muted);
font-size: var(--type-sm);
pointer-events: none;
}
.m-audio { width: 100%; max-width: 100%; padding: 12px; }
.m-stage-chrome {
position: absolute;
@@ -535,12 +650,6 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
padding: 0 4px;
text-align: center;
}
.m-hidden-note {
margin: 0;
color: var(--muted);
font-size: 13px;
text-align: center;
}
.m-nav {
position: absolute;
top: 50%;
@@ -649,6 +758,7 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
gap: 10px;
}
.m-label { display: block; font-size: 11px; color: var(--muted); }
.m-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
.m-area, .m-input {
width: 100%;
margin-top: 4px;
@@ -701,4 +811,81 @@ watch(() => props.ctx.form.mode, () => { sheet.value = '' })
.m-job-progress { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.m-job-progress-bar { flex: 1; height: 4px; accent-color: var(--amber); }
.m-link { border: 0; background: transparent; color: var(--amber); text-align: left; }
.m-prompt-editor {
position: fixed;
inset: 0;
z-index: 90;
display: flex;
flex-direction: column;
background: #0e0e10;
padding: env(safe-area-inset-top, 0) env(safe-area-inset-right, 0) env(safe-area-inset-bottom, 0) env(safe-area-inset-left, 0);
}
.m-prompt-editor-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--line);
}
.m-prompt-editor-head button {
border: 0;
background: transparent;
color: var(--amber);
font-size: 16px;
font-weight: 600;
}
.m-queue-chip-landscape { display: none; }
.m-queue-rail { display: none; }
@media (orientation: landscape) {
.m-queue-chip-portrait,
.m-queue-sheet-portrait { display: none !important; }
.m-queue-chip-landscape { display: inline-flex; align-items: center; }
.m-shell { position: relative; }
.m-queue-rail {
position: absolute;
top: 0;
right: 0;
bottom: 0;
z-index: 25;
display: flex;
width: min(42vw, 16rem);
flex-direction: column;
border-left: 1px solid var(--line);
background: var(--panel);
}
.m-shell[data-queue-collapsed='1'] .m-queue-rail { display: none; }
.m-queue-rail .m-sheet-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--line);
}
.m-queue-rail .m-sheet-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 10px 12px;
}
.m-stage { padding-right: 0; }
.m-shell:not([data-queue-collapsed='1']) .m-stage,
.m-shell:not([data-queue-collapsed='1']) .m-dock,
.m-shell:not([data-queue-collapsed='1']) .m-chips {
margin-right: min(42vw, 16rem);
}
}
.m-prompt-editor-area {
flex: 1;
width: 100%;
margin: 0;
padding: 16px;
border: 0;
resize: none;
background: #0e0e10;
color: #f3f1ea;
font: inherit;
font-size: 17px;
line-height: 1.45;
outline: none;
}
</style>
+57 -34
View File
@@ -634,24 +634,6 @@ const allCount = computed(() => {
sort: 'date-desc'
}).length
})
const folderChipCounts = computed(() => {
const map: Record<string, { videos: number; images: number; music: number }> = {}
for (const clip of clips.value) {
if (clip.hasVideo === false) continue
const row = map[clip.folderId] || (map[clip.folderId] = { videos: 0, images: 0, music: 0 })
row.videos += 1
}
for (const still of stills.value) {
if (still.role !== 'output') continue
const row = map[still.folderId] || (map[still.folderId] = { videos: 0, images: 0, music: 0 })
row.images += 1
}
for (const track of tracks.value) {
const row = map[track.folderId] || (map[track.folderId] = { videos: 0, images: 0, music: 0 })
row.music += 1
}
return map
})
const moveFolders = computed(() => folders.value.filter(folder => folder.id !== folderId.value))
const focused = computed(() => entries.value.find(entry => entry.key === focusedKey.value) || null)
const focusedStill = computed(() => {
@@ -771,7 +753,8 @@ function toast(message: string) {
}, 4200)
}
function applyLibrary(data: unknown) {
function applyLibrary(data: unknown, opts?: { replace?: boolean }) {
const replace = opts?.replace !== false
const payload = (data || {}) as {
id?: string
hideInput?: boolean
@@ -780,6 +763,8 @@ function applyLibrary(data: unknown) {
clips?: LibraryClip[]
stills?: LibraryStill[]
tracks?: LibraryTrack[]
nextCursor?: string | null
hasMore?: boolean
}
if (payload.id && !payload.folders && !payload.clips && !payload.stills && !payload.tracks) {
if (typeof payload.hideInput === 'boolean') {
@@ -792,27 +777,62 @@ function applyLibrary(data: unknown) {
}
return
}
folders.value = payload.folders || []
if (payload.folders?.length) folders.value = payload.folders
if (replace) {
clips.value = payload.clips || []
stills.value = payload.stills || []
tracks.value = payload.tracks || []
} else {
const merge = <T extends { id: string }>(prev: T[], next: T[]) => {
const map = new Map<string, T>()
for (const item of prev) map.set(item.id, item)
for (const item of next) map.set(item.id, item)
return [...map.values()]
}
clips.value = merge(clips.value, payload.clips || [])
stills.value = merge(stills.value, payload.stills || [])
tracks.value = merge(tracks.value, payload.tracks || [])
}
libraryNextCursor.value = payload.nextCursor || null
if (folderId.value !== 'all' && !folders.value.some(folder => folder.id === folderId.value)) {
folderId.value = folders.value[0]?.id || 'all'
}
}
const libraryNextCursor = ref<string | null>(null)
let libraryFetchGen = 0
async function fetchLibraryPage(cursor: string | null) {
const qs = new URLSearchParams({ limit: '50' })
if (folderId.value && folderId.value !== 'all') qs.set('folderId', folderId.value)
if (filter.value === 'images') qs.set('type', 'images')
else if (filter.value === 'videos') qs.set('type', 'videos')
else if (filter.value === 'music') qs.set('type', 'music')
if (cursor) qs.set('cursor', cursor)
return await $fetch<any>(`/api/library?${qs.toString()}`)
}
async function loadLibrary() {
applyLibrary(await $fetch('/api/library?light=1'))
// First page paints immediately; remaining pages append in the background
const gen = ++libraryFetchGen
const first = await fetchLibraryPage(null)
if (gen !== libraryFetchGen) return
applyLibrary(first, { replace: true })
let cursor = first?.nextCursor || null
while (cursor && gen === libraryFetchGen) {
const page = await fetchLibraryPage(cursor)
if (gen !== libraryFetchGen) return
applyLibrary(page, { replace: false })
cursor = page?.nextCursor || null
}
}
function folderChipCount(folder: LibraryFolder) {
if (!folder.unlocked) return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
const row = folderChipCounts.value[folder.id]
if (!row) return 0
if (filter.value === 'videos') return row.videos
if (filter.value === 'images') return row.images
if (filter.value === 'music') return row.music
return row.videos + row.images + row.music
// Prefer server totals so chips match the full folder before pages finish appending
if (filter.value === 'videos') return folder.clipCount || 0
if (filter.value === 'images') return folder.imageCount || 0
if (filter.value === 'music') return folder.trackCount || 0
return (folder.clipCount || 0) + (folder.imageCount || 0) + (folder.trackCount || 0)
}
function folderChipClass(id: string) {
@@ -881,7 +901,7 @@ async function toggleFocusedHidden() {
function thumbSrc(entry: LibraryEntry) {
if (entry.kind === 'clip' && entry.group) return `/api/library/clips/${entry.group.head.id}/thumb`
if (entry.kind === 'track') return ''
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.parts[cardImageIndex(entry)].id}?thumb=1`
if (entry.stillGroup) return `/api/library/stills/${entry.stillGroup.parts[cardImageIndex(entry)].id}/thumb`
return ''
}
@@ -907,7 +927,7 @@ function partThumb(part: LibraryClip | LibraryStill) {
// Prefer this clip’s first frame so siblings don’t all look like the Start thumb.
return `/api/library/clips/${clip.id}/first-frame?thumb=1&v=${encodeURIComponent(clip.id)}`
}
return `/api/library/stills/${part.id}?thumb=1`
return `/api/library/stills/${part.id}/thumb`
}
function focusPart(id: string) {
@@ -1278,6 +1298,10 @@ watch([folderId, filter], () => {
watch(search, () => {
if (scroller.value) scroller.value.scrollTop = 0
})
watch([folderId, filter], () => {
if (scroller.value) scroller.value.scrollTop = 0
void loadLibrary()
})
watch([cols, rowH], ([nextCols, nextRowH], [prevCols, prevRowH]) => {
const el = scroller.value
if (!el || !prevRowH) return
@@ -1309,10 +1333,9 @@ let resizeObserver: ResizeObserver | null = null
let observedScroller: HTMLElement | null = null
onMounted(async () => {
window.addEventListener('keydown', onKey)
const [me] = await Promise.all([
$fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null })),
loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [] }))
])
// Do not block chrome/grid on the full catalog — first page paints via loadLibrary's first apply
void loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [] }))
const me = await $fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null }))
instanceName.value = me.instanceName || instanceName.value
await nextTick()
measure()
+961
View File
@@ -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>
+299
View File
@@ -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 }
+48 -9
View File
@@ -4,6 +4,7 @@ import { stableMemoryArgs } from './comfy-memory-policy.mjs'
import { createGpuReservation } from './gpu-reservation.mjs'
import { createGpuProxy } from './gpu-proxy.mjs'
import { createYue2Host } from './yue2-host.mjs'
import { createCaptionHost } from './caption-host.mjs'
import http from 'node:http'
import net from 'node:net'
import { execFile, spawn } from 'node:child_process'
@@ -147,7 +148,7 @@ let proxyTarget = 0
function ensureProxyListening() {
if (proxyServer) return
proxyServer = createGpuProxy({ target: () => proxyTarget, reservation: gpuReservation, authorized, markWork, externalBusy: () => yue2.busy() || upscale.busy() })
proxyServer = createGpuProxy({ target: () => proxyTarget, reservation: gpuReservation, authorized, markWork, externalBusy: () => yue2.busy() || upscale.busy() || caption.busy() })
proxyServer.on('error', (error) => {
console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'proxy-error', error: String(error.message || error) }))
})
@@ -819,7 +820,7 @@ function purgeDesktopFiles(body) {
}
const gpuReservation = createGpuReservation({ idle: async () => {
if (yue2.busy() || upscale.busy()) return false
if (yue2.busy() || upscale.busy() || caption.busy()) return false
if ((await trainingLock()).busy) return false
const healthy = await syncProxy()
if (healthy) {
@@ -830,29 +831,65 @@ const gpuReservation = createGpuReservation({ idle: async () => {
return !(await processUp()) && !(await pythonMainUp().catch(() => true))
} })
const yue2 = createYue2Host({
leaseValid: lease => gpuReservation.isOwner(lease),
prepare: async () => {
async function prepareExclusiveGpu(label) {
if ((await trainingLock()).busy) throw new Error('GPU is busy with training.')
if (label !== 'YuE2' && yue2.busy()) throw new Error('YuE2 is using the GPU.')
if (label !== 'caption' && caption.busy()) throw new Error('Caption is using the GPU.')
if (upscale.busy()) throw new Error('Local upscale is using the GPU.')
const healthy = await syncProxy()
if (healthy) {
const queue = await fetchLocalQueue(healthy)
if (!queue.ok || queue.running || queue.pending) throw new Error('Comfy is busy; YuE2 cannot start.')
if (!queue.ok || queue.running || queue.pending) throw new Error(`Comfy is busy; ${label} cannot start.`)
}
// Stop Comfy and refuse to launch while its Python still owns VRAM.
if (healthy || await processUp() || await pythonMainUp().catch(() => false)) {
await stopComfyProcesses()
markAsleep()
}
if (await pythonMainUp()) throw new Error('Comfy has not stopped; retry after the GPU is free.')
}
})
const upscale = createUpscaleHost({ leaseValid: token => gpuReservation.isOwner(token) })
const yue2 = createYue2Host({
leaseValid: lease => gpuReservation.isOwner(lease),
prepare: async () => prepareExclusiveGpu('YuE2')
})
const caption = createCaptionHost({
leaseValid: lease => gpuReservation.isOwner(lease),
prepare: async () => prepareExclusiveGpu('caption')
})
async function handleControl(req, res) {
if (!authorized(req)) return json(res, 401, { ok: false, error: 'unauthorized' })
const url = new URL(req.url || '/', 'http://localhost')
if (url.pathname.startsWith('/caption')) {
const match = url.pathname.match(/^\/caption\/jobs\/([a-zA-Z0-9-]{12,80})(\/input|\/cancel)?$/)
if (req.method === 'GET' && url.pathname === '/caption/status') {
return json(res, 200, { configured: caption.configured(), busy: caption.busy(), backend: 'llama.cpp', modelsDir: caption.modelsDir() })
}
if (req.method === 'POST' && url.pathname === '/caption') {
try {
const body = await readJson(req)
return json(res, 200, await caption.captionOnce(body, String(req.headers['x-aigen-gpu-lease'] || '')))
} catch (error) {
return json(res, error.statusCode || 400, { error: error.message || 'Caption failed' })
}
}
if (req.method === 'POST' && url.pathname === '/caption/jobs') {
return json(res, 200, await caption.start(await readJson(req), String(req.headers['x-aigen-gpu-lease'] || '')))
}
if (match && req.method === 'PUT' && match[2] === '/input') {
await caption.upload(match[1], req)
return json(res, 200, { uploaded: true })
}
if (match && req.method === 'POST' && match[2] === '/cancel') return json(res, 200, await caption.cancel(match[1]))
if (match && req.method === 'GET' && !match[2]) {
const job = caption.read(match[1])
return json(res, job ? 200 : 404, job || { error: 'Caption job not found' })
}
return json(res, 404, { error: 'Unknown caption endpoint' })
}
if (url.pathname.startsWith('/upscale/')) {
const match = url.pathname.match(/^\/upscale\/jobs\/([a-zA-Z0-9-]{12,80})(\/input|\/video|\/cancel)?$/)
if (req.method === 'POST' && url.pathname === '/upscale/jobs') return json(res, 200, upscale.start(await readJson(req), String(req.headers['x-aigen-gpu-lease'] || '')))
@@ -909,12 +946,14 @@ async function handleControl(req, res) {
gpu: gpuReservation.availability(),
training: { busy: lastTraining.busy },
yue2: { busy: yue2.busy(), configured: yue2.configured() },
caption: { busy: caption.busy(), configured: caption.configured() },
upscale: { busy: upscale.busy(), engine: 'realesrgan-rife', local: true }
})
}
if (req.method === 'POST' && url.pathname === '/start') {
if (upscale.busy()) return json(res, 409, { message: 'Local upscale is using the GPU.' })
if (yue2.busy()) return json(res, 409, { message: 'YuE2 is using the GPU.' })
if (caption.busy()) return json(res, 409, { message: 'Caption is using the GPU.' })
const training = await trainingLock()
if (training.busy) {
return json(res, 409, {
@@ -1016,7 +1055,7 @@ const server = http.createServer(async (req, res) => {
} else await handleControl(req, res)
} catch (error) {
req.resume()
if ((String(req.url || '').startsWith('/yue2/') || String(req.url || '').startsWith('/upscale/')) && !res.headersSent) return json(res, error.statusCode || 400, { error: error.message || 'Music host request failed' })
if ((String(req.url || '').startsWith('/yue2/') || String(req.url || '').startsWith('/upscale/') || String(req.url || '').startsWith('/caption')) && !res.headersSent) return json(res, error.statusCode || 400, { error: error.message || 'Host request failed' })
if (!res.headersSent) json(res, error.statusCode || 400, { ok: false, message: error.statusCode === 409 ? 'GPU is in use. Waiting for availability.' : 'GPU coordination request failed.' })
}
})
+54
View File
@@ -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"
+7
View File
@@ -18,6 +18,12 @@ Get-NetTCPConnection -LocalPort 8199 -State Listen -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 400
# Caption VLM — point at existing host GGUFs (no download).
$captionModels = Join-Path $env:LOCALAPPDATA 'Comfy-Desktop\ComfyUI-Shared\models\caption\qwen25vl-7b-nsfw-v4'
$llamaServer = Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages\ggml.llamacpp_Microsoft.Winget.Source_8wekyb3d8bbwe\llama-server.exe'
if (Test-Path $captionModels) { $env:CAPTION_MODELS = $captionModels }
if (Test-Path $llamaServer) { $env:CAPTION_LLAMA_SERVER = $llamaServer }
if ($Wait) {
Set-Location $root
& $node $agent
@@ -31,4 +37,5 @@ $psi.WorkingDirectory = $root
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
# CAPTION_* already set on this process; child inherits when UseShellExecute is false
[void][System.Diagnostics.Process]::Start($psi)
+50
View File
@@ -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 }
})
+6 -1
View File
@@ -48,7 +48,7 @@ export default defineEventHandler(async () => {
host: comfy.host || '',
queueRunning: queue.running,
queuePending: queue.pending,
asleep: Boolean(control?.asleep) && !comfyReachable && !control?.yue2?.busy,
asleep: Boolean(control?.asleep) && !comfyReachable && !control?.yue2?.busy && !control?.caption?.busy,
processUp: Boolean(control?.process ?? control?.processUp),
lastActivityAt: control?.lastActivityAt || null,
stats: comfy.ok ? comfy.stats : undefined
@@ -58,6 +58,11 @@ export default defineEventHandler(async () => {
busy: Boolean(control?.yue2?.busy),
backend: control?.yue2?.backend || 'yue2'
},
caption: {
configured: Boolean(control?.caption?.configured),
busy: Boolean(control?.caption?.busy),
backend: control?.caption?.backend || 'llama.cpp'
},
imageComfy: {
configured: imageConfigured,
ok: imageOk,
+1 -1
View File
@@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => {
}
const etag = fileEtag(path)
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', 'private, max-age=86400, stale-while-revalidate=604800')
setHeader(event, 'Cache-Control', 'public, max-age=86400')
if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304)
return null
+21 -8
View File
@@ -1,10 +1,23 @@
import { publicLibrary, publicLibraryPage } from '~/server/utils/library'
export default defineEventHandler((event) => {
const light = String(getQuery(event).light || '') === '1'
const library = publicLibrary(event, { light })
try {
const { owner } = assertLibraryOwner(event)
const folderId = library.folders.find(folder => folder.unlocked)?.id
void maintainLibrary(owner, folderId).catch(() => null)
} catch { /* locked or anonymous — still return what we can */ }
return library
const query = getQuery(event)
const light = String(query.light || '') === '1'
const full = String(query.full || '') === '1'
const limitRaw = query.limit
const hasLimit = limitRaw != null && String(limitRaw) !== ''
// Default GET /api/library → page helper (limit 50). Opt into full catalog with full=1 or legacy light=1.
const wantsFull = full || (light && !hasLimit)
if (!wantsFull) {
return publicLibraryPage(event, {
limit: hasLimit ? Number(limitRaw) || 50 : 50,
cursor: String(query.cursor || ''),
folderId: String(query.folderId || query.folder || ''),
type: String(query.type || query.filter || 'all')
})
}
// Full catalog path — no thumb prewarm on the request (thumbs build on /thumb hit)
return publicLibrary(event, { light: true })
})
+29 -8
View File
@@ -1,23 +1,44 @@
import { existsSync, createReadStream } from 'node:fs'
import { fileEtag, stillPath, stillThumbPath, sniffImageType } from '~/server/utils/library'
import { fileEtag, stillPath, existingStillThumbPath, stillThumbPath, sniffImageType } from '~/server/utils/library'
export default defineEventHandler(async (event) => {
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const still = getStill(owner, id)
assertFolderAccess(event, still.folderId)
const wantThumb = String(getQuery(event).thumb || '') === '1'
const path = wantThumb ? await stillThumbPath(owner, id) : stillPath(owner, still.id)
if (!path || !existsSync(path)) {
throw createError({ statusCode: 404, statusMessage: 'Image file is missing' })
}
const original = stillPath(owner, still.id)
if (wantThumb) {
const path = existingStillThumbPath(owner, id)
if (path && path !== original && existsSync(path)) {
const etag = fileEtag(path)
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', wantThumb ? 'private, max-age=86400, stale-while-revalidate=604800' : 'private, max-age=3600')
setHeader(event, 'Cache-Control', 'public, max-age=86400')
if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304)
return null
}
setHeader(event, 'Content-Type', wantThumb ? 'image/jpeg' : sniffImageType(path))
setHeader(event, 'Content-Type', 'image/jpeg')
return sendStream(event, createReadStream(path))
}
void stillThumbPath(owner, id).catch(() => null)
setHeader(event, 'Cache-Control', 'no-store')
setResponseStatus(event, 204)
return null
}
if (!existsSync(original)) {
throw createError({ statusCode: 404, statusMessage: 'Image file is missing' })
}
const etag = fileEtag(original)
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', 'private, max-age=3600')
if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304)
return null
}
setHeader(event, 'Content-Type', sniffImageType(original))
return sendStream(event, createReadStream(original))
})
@@ -0,0 +1,30 @@
import { existsSync, createReadStream } from 'node:fs'
import { fileEtag, stillPath, stillPreviewPath, existingStillPreviewPath } from '~/server/utils/library'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const still = getStill(owner, id)
assertFolderAccess(event, still.folderId)
const path = existingStillPreviewPath(owner, id)
const original = stillPath(owner, still.id)
// Ready preview → stream. Never await ffmpeg; never stream the archive master.
if (path && existsSync(path) && path !== original) {
const etag = fileEtag(path)
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', 'public, max-age=86400')
if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304)
return null
}
setHeader(event, 'Content-Type', 'image/jpeg')
return sendStream(event, createReadStream(path))
}
// Missing → answer now; build .preview.jpg in the background
void stillPreviewPath(owner, id).catch(() => null)
setHeader(event, 'Cache-Control', 'no-store')
setResponseStatus(event, 204)
return null
})
@@ -0,0 +1,30 @@
import { existsSync, createReadStream } from 'node:fs'
import { fileEtag, stillPath, stillThumbPath, existingStillThumbPath } from '~/server/utils/library'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const id = String(getRouterParam(event, 'id') || '')
const still = getStill(owner, id)
assertFolderAccess(event, still.folderId)
const path = existingStillThumbPath(owner, id)
const original = stillPath(owner, still.id)
// Ready thumb → stream. Never await ffmpeg on the request path.
if (path && existsSync(path) && path !== original) {
const etag = fileEtag(path)
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', 'public, max-age=86400')
if (getHeader(event, 'if-none-match') === etag) {
setResponseStatus(event, 304)
return null
}
setHeader(event, 'Content-Type', 'image/jpeg')
return sendStream(event, createReadStream(path))
}
// Missing → answer now; build .thumb.jpg in the background (job save / upload also kick this)
void stillThumbPath(owner, id).catch(() => null)
setHeader(event, 'Cache-Control', 'no-store')
setResponseStatus(event, 204)
return null
})
+8
View File
@@ -0,0 +1,8 @@
import { listPromptSequences } from '~/server/utils/promptSequences'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
return {
sequences: listPromptSequences(owner)
}
})
+25
View File
@@ -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)
}
})
+9
View File
@@ -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)
}
})
+24
View File
@@ -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)
}
})
+36 -11
View File
@@ -1,15 +1,40 @@
import { records, saveRecord } from '../../utils/studio2/store';
import { listStudioJobs } from '../../utils/studioQueue';
export default defineEventHandler(event => {
const { owner } = assertLibraryOwner(event), queue=listStudioJobs(owner);
let cleared=0;
import { records, saveRecord } from '../../utils/studio2/store'
import { dismissFinishedStudioJobs, dismissStudioJob, listStudioJobs } from '../../utils/studioQueue'
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const body = await readBody<{ id?: string }>(event).catch(() => ({} as { id?: string }))
const queue = listStudioJobs(owner)
let cleared = 0
if (body?.id) {
const row = queue.find(job => job.id === body.id)
if (!row) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
try { assertFolderAccess(event, row.payload.folderId) } catch {
throw createError({ statusCode: 403, statusMessage: 'Folder locked' })
}
await dismissStudioJob(owner, body.id)
return { cleared: 1 }
}
for (const r of records(owner)) {
try { assertFolderAccess(event,r.request.folderId); } catch { continue; }
const status=queue.find(q=>q.id===r.queueId)?.status;
try { assertFolderAccess(event, r.request.folderId) } catch { continue }
const status = queue.find(q => q.id === r.queueId)?.status
if (['complete', 'failed', 'cancelled'].includes(r.state) || ['cancelled', 'error'].includes(status || '')) {
r.dismissed=true; saveRecord(r); cleared++;
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 }
})
+8 -1
View File
@@ -25,11 +25,18 @@ export default defineEventHandler(async (event) => {
assertFolderAccess(event, getStill(owner, id).folderId);
if (request.startClipId)
assertFolderAccess(event, getClip(owner, request.startClipId).folderId);
// Header-only size probe — never decode the archive PNG on Generate click
await resolveRequestSize(owner,request);
const id = crypto.randomUUID(), record = { id, owner, request, state: 'waiting', queuedAt: Date.now(), familyId: crypto.randomUUID(), outputs: [], purgeResult: 'Not yet saved' };
saveRecord(record);
const ack = { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height };
// Enqueue + kick after the client has the id — do not block the response on the worker wake path
void (async () => {
try {
const row = await addStudioJob({ ownerKey: owner, kind: ['video', 'extend'].includes(request.mode) ? 'video' : 'edit', familyId: record.familyId, payload: { ...request.settings, studio2Id: id, studio2Request: structuredClone(request), prompt: request.compiledPrompt, folderId: request.folderId, extensions: [], referenceStillIds: [], useIdentityRefs: false, queueAutoRun: true } as StudioJobPayload });
saveRecord({ ...readRecord(owner, id), queueId: row.id });
} catch { /* poll / resume will pick up waiting studio2 records */ }
kickStudioQueue();
return { id, seed:request.settings.seed, width:request.settings.width, height:request.settings.height };
})();
return ack;
});
+15 -9
View File
@@ -1,9 +1,15 @@
import { existsSync } from 'node:fs';
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog';
import { publicLibrary, stillPath, clipVideoPath } from '../../utils/library';
export default defineEventHandler(event => {
const { owner } = assertLibraryOwner(event);
const library = publicLibrary(event);
const catalog=studio2LoraCatalog(owner);
return {...library,loraAliases:catalog.aliases,loraTriggers:catalog.triggers, stills:library.stills.filter(s=>existsSync(stillPath(owner,s.id))), clips:library.clips.filter(c=>existsSync(clipVideoPath(owner,c.id))), tracks:library.tracks || []};
});
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog'
import { publicLibrary } from '../../utils/library'
export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
// light:true — metadata only; never existsSync every still/clip on the volume
const library = publicLibrary(event, { light: true })
const catalog = studio2LoraCatalog(owner)
return {
...library,
loraAliases: catalog.aliases,
loraTriggers: catalog.triggers,
tracks: library.tracks || []
}
})
+3 -3
View File
@@ -5,15 +5,15 @@ import { listStudioLoras } from '../../utils/loras'
import { studio2Root } from '../../utils/studio2/store'
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog'
export default defineEventHandler(async event => {
export default defineEventHandler(async (event) => {
const { owner } = assertLibraryOwner(event)
const path = join(studio2Root(), `loras-${owner}.cache`)
// Same live Comfy scan as Settings (/api/loras?fresh=1). Always fresh so CACHE_MS cannot freeze adds.
const fresh = String(getQuery(event).fresh || '') === '1'
const listed = await cachedLoras({
read: () => { try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return null } },
write: value => { writeFileSync(path + '.tmp', JSON.stringify(value)); renameSync(path + '.tmp', path) },
live: () => listStudioLoras({ fresh: true }),
fresh: true
fresh
})
return { ...listed, ...studio2LoraCatalog(owner) }
})
+4 -1
View File
@@ -5,18 +5,21 @@ export default defineEventHandler((event) => {
const { owner } = assertLibraryOwner(event)
const full = String(getQuery(event).full || '') === '1'
const active = listStudioJobs(owner)
.filter(job => !job.dismissed)
.filter(job => (
job.status === 'waiting'
|| job.status === 'running'
|| job.status === 'held'
|| (job.status === 'error' && Date.now() - job.updatedAt < 60 * 60 * 1000)
|| (job.kind === 'caption' && job.status === 'complete' && Date.now() - job.updatedAt < 60 * 60 * 1000)
|| (job.kind === 'caption' && job.status === 'cancelled' && Date.now() - job.updatedAt < 60 * 60 * 1000)
))
const claimed = new Set(active.map(job => job.shotQueueId).filter(Boolean) as string[])
const queues = listShotQueues(owner)
const byId = new Map(queues.map(queue => [queue.id, queue]))
const jobs = active.map((job) => {
const shots = job.shotQueueId ? byId.get(job.shotQueueId) || null : null
const kind = job.kind === 'edit' ? 'edit' : job.kind === 'music' ? 'music' : 'video'
const kind = job.kind === 'edit' ? 'edit' : job.kind === 'music' ? 'music' : job.kind === 'caption' ? 'caption' : 'video'
const shotSummary = shots ? summarizeQueue(shots) : null
const plannedShots = shotSummary
? undefined
+3
View File
@@ -0,0 +1,3 @@
import { resumeCaptionJobs } from '~/server/utils/caption'
export default defineNitroPlugin(() => { resumeCaptionJobs() })
+232
View File
@@ -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 */ }
}
}
+4 -1
View File
@@ -36,9 +36,11 @@ export interface Job {
upscale?: boolean
yueGp?: boolean
yue2?: boolean
caption?: boolean
resultText?: string
musicActivity?: { checkedAt: number; running: boolean }
id: string
kind?: 'video' | 'edit' | 'music'
kind?: 'video' | 'edit' | 'music' | 'caption'
promptId?: string
clientId: string
status: JobStatus
@@ -222,6 +224,7 @@ export function jobSnapshot(job: Job) {
clipId: job.clipId,
stillId: job.stillId,
trackId: job.trackId,
resultText: job.resultText,
hideThumbnail: job.hideThumbnail,
error: job.error,
folderLocked: job.library?.folderLocked,
+253 -8
View File
@@ -187,6 +187,7 @@ interface Catalog {
}
const writeChains = new Map<string, Promise<unknown>>()
const catalogCache = new Map<string, { catalog: Catalog; mtimeMs: number }>()
export function libraryRoot() {
const config = useRuntimeConfig()
@@ -355,6 +356,13 @@ function ensureOwner(owner: string) {
function readCatalog(owner: string): Catalog {
ensureOwner(owner)
const path = catalogPath(owner)
let mtimeMs = 0
try {
mtimeMs = existsSync(path) ? statSync(path).mtimeMs : 0
} catch { mtimeMs = 0 }
const hit = catalogCache.get(owner)
if (hit && hit.mtimeMs === mtimeMs && mtimeMs > 0) return hit.catalog
let catalog: Catalog
try {
catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
@@ -364,8 +372,11 @@ function readCatalog(owner: string): Catalog {
}
catalog = emptyCatalog()
writeFileSync(path, JSON.stringify(catalog, null, 2))
try { mtimeMs = statSync(path).mtimeMs } catch { mtimeMs = Date.now() }
catalogCache.set(owner, { catalog, mtimeMs })
return catalog
}
catalogCache.set(owner, { catalog, mtimeMs })
return catalog
}
@@ -374,6 +385,14 @@ function writeCatalog(owner: string, catalog: Catalog) {
const tmp = catalogPath(owner) + '.tmp'
writeFileSync(tmp, JSON.stringify(catalog, null, 2))
renameSync(tmp, catalogPath(owner))
let mtimeMs = Date.now()
try { mtimeMs = statSync(catalogPath(owner)).mtimeMs } catch { /* ignore */ }
catalogCache.set(owner, { catalog, mtimeMs })
}
function invalidateCatalogCache(owner?: string) {
if (owner) catalogCache.delete(owner)
else catalogCache.clear()
}
function mutate<T>(owner: string, fn: (catalog: Catalog) => T): Promise<T> {
@@ -615,6 +634,164 @@ export function publicLibrary(event: H3Event, opts?: { light?: boolean }) {
}
}
/** Paginated metadata page — reads catalog rows only; no existsSync / full publicLibrary map. */
export function publicLibraryPage(
event: H3Event,
opts: { limit?: number; cursor?: string; folderId?: string; type?: string } = {}
) {
const limit = Math.min(Math.max(Number(opts.limit) || 50, 1), 60)
const cursor = String(opts.cursor || '')
const folderId = String(opts.folderId || '')
const typeFilter = String(opts.type || 'all')
const owner = libraryOwnerKey(event)
const catalog = readCatalog(owner)
const clipIds = new Set(catalog.clips.map(clip => clip.id.toLowerCase()))
const clipCounts = new Map<string, number>()
const imageCounts = new Map<string, number>()
const trackCounts = new Map<string, number>()
for (const clip of catalog.clips) {
clipCounts.set(clip.folderId, (clipCounts.get(clip.folderId) || 0) + 1)
}
for (const still of catalog.stills) {
if (isPipelineStill(catalog, still, clipIds) || stillRole(still) !== 'output') continue
imageCounts.set(still.folderId, (imageCounts.get(still.folderId) || 0) + 1)
}
for (const track of catalog.tracks || []) {
trackCounts.set(track.folderId, (trackCounts.get(track.folderId) || 0) + 1)
}
const folders = catalog.folders.map(folder => publicFolder(
event,
folder,
clipCounts.get(folder.id) || 0,
imageCounts.get(folder.id) || 0,
trackCounts.get(folder.id) || 0
))
const unlockedIds = new Set(folders.filter(folder => folder.unlocked).map(folder => folder.id))
type Row = {
id: string
name: string
type: 'still' | 'clip' | 'track'
createdAt: number
thumbUrl: string
folderId: string
prompt?: string
promptPre?: string
promptPost?: string
width?: number
height?: number
role?: string
hideInput?: boolean
filename?: string
duration?: number
ext?: string
}
const rows: Row[] = []
if (typeFilter !== 'videos' && typeFilter !== 'music') {
for (const still of catalog.stills) {
if (!unlockedIds.has(still.folderId)) continue
if (folderId && folderId !== 'all' && still.folderId !== folderId) continue
if (isPipelineStill(catalog, still, clipIds)) continue
rows.push({
id: still.id,
name: still.name || still.filename || still.id,
type: 'still',
createdAt: still.createdAt || 0,
thumbUrl: `/api/library/stills/${still.id}/thumb`,
folderId: still.folderId,
prompt: still.prompt,
promptPre: still.promptPre,
promptPost: still.promptPost,
width: still.width,
height: still.height,
role: stillRole(still),
hideInput: still.hideInput,
filename: still.filename
})
}
}
if (typeFilter !== 'images' && typeFilter !== 'music') {
for (const clip of catalog.clips) {
if (!unlockedIds.has(clip.folderId)) continue
if (folderId && folderId !== 'all' && clip.folderId !== folderId) continue
rows.push({
id: clip.id,
name: clip.name || clip.id,
type: 'clip',
createdAt: clip.createdAt || 0,
thumbUrl: `/api/library/clips/${clip.id}/thumb`,
folderId: clip.folderId,
prompt: clip.prompt,
promptPre: clip.promptPre,
promptPost: clip.promptPost,
width: clip.width,
height: clip.height,
duration: clip.duration
})
}
}
if (typeFilter !== 'images' && typeFilter !== 'videos') {
for (const track of catalog.tracks || []) {
if (!unlockedIds.has(track.folderId)) continue
if (folderId && folderId !== 'all' && track.folderId !== folderId) continue
rows.push({
id: track.id,
name: track.name || track.id,
type: 'track',
createdAt: track.createdAt || 0,
thumbUrl: '',
folderId: track.folderId,
duration: track.duration,
ext: track.ext
})
}
}
rows.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0) || String(b.id).localeCompare(String(a.id)))
let start = 0
if (cursor) {
if (cursor.startsWith('offset:')) {
start = Math.max(0, Math.min(rows.length, Number(cursor.slice(7)) || 0))
} else {
const idx = rows.findIndex(row => `${row.type}:${row.id}` === cursor)
// Unknown cursor → end (never restart at 0 or the client loops the first page forever)
start = idx >= 0 ? idx + 1 : rows.length
}
}
const page = rows.slice(start, start + limit)
const nextStart = start + page.length
const hasMore = nextStart < rows.length
const nextCursor = hasMore ? `offset:${nextStart}` : null
const stillIds = new Set(page.filter(r => r.type === 'still').map(r => r.id))
const clipIdPage = new Set(page.filter(r => r.type === 'clip').map(r => r.id))
const trackIds = new Set(page.filter(r => r.type === 'track').map(r => r.id))
return {
folders,
items: page,
nextCursor,
hasMore,
limit,
stills: catalog.stills
.filter(s => stillIds.has(s.id))
.map(still => ({ ...still, role: stillRole(still) })),
clips: catalog.clips
.filter(c => clipIdPage.has(c.id))
.map(clip => ({ ...clip, name: clip.name || clip.id, hasThumbnail: !clip.hideThumbnail, hasVideo: true })),
tracks: (catalog.tracks || [])
.filter(t => trackIds.has(t.id))
.map(track => ({ ...track, hasAudio: true })),
drafts: catalog.drafts,
loraAliases: catalog.loraAliases || {},
loraTriggers: catalog.loraTriggers || {},
keepPrompts: catalog.keepPrompts || emptyKeepPrompts(),
total: rows.length
}
}
export function assertLibraryOwner(event: H3Event) {
const owner = libraryOwnerKey(event)
return { owner, catalog: readCatalog(owner) }
@@ -830,7 +1007,7 @@ export async function ensureScaledThumb(source: string, dest: string) {
if (pending) return pending
const build = withThumbSlot(async () => {
const ok = await buildStillThumb(source, dest)
return ok ? dest : source
return ok ? dest : ''
}).finally(() => {
thumbBuilds.delete(key)
})
@@ -1101,14 +1278,42 @@ export function stillThumbFile(owner: string, id: string) {
return join(ownerRoot(owner), 'stills', `${id}.thumb.jpg`)
}
export function stillPreviewFile(owner: string, id: string) {
return join(ownerRoot(owner), 'stills', `${id}.preview.jpg`)
}
/** Sync check — never kicks ffmpeg. Empty string when thumb is missing or stale. */
export function existingStillThumbPath(owner: string, id: string) {
const source = stillPath(owner, id)
const dest = stillThumbFile(owner, id)
if (!existsSync(source) || !existsSync(dest)) return ''
try {
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
} catch { return '' }
return ''
}
/** Sync check for stage preview — never kicks ffmpeg. */
export function existingStillPreviewPath(owner: string, id: string) {
const source = stillPath(owner, id)
const dest = stillPreviewFile(owner, id)
if (!existsSync(source) || !existsSync(dest)) return ''
try {
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
} catch { return '' }
return ''
}
function removeStillFiles(owner: string, id: string) {
rmSync(stillPath(owner, id), { force: true })
rmSync(stillThumbFile(owner, id), { force: true })
rmSync(stillPreviewFile(owner, id), { force: true })
}
let thumbSlots = 0
const thumbWaiters: Array<() => void> = []
const thumbBuilds = new Map<string, Promise<string>>()
const previewBuilds = new Map<string, Promise<string>>()
function withThumbSlot<T>(fn: () => Promise<T>) {
return new Promise<T>((resolve, reject) => {
@@ -1125,14 +1330,14 @@ function withThumbSlot<T>(fn: () => Promise<T>) {
})
}
function buildStillThumb(source: string, dest: string) {
function buildStillJpeg(source: string, dest: string, scale: string, quality: string) {
return new Promise<boolean>((resolve) => {
const proc = spawn('ffmpeg', [
'-y',
'-i', source,
'-vf', 'scale=320:-2',
'-vf', scale,
'-frames:v', '1',
'-q:v', '5',
'-q:v', quality,
dest
], { stdio: 'ignore' })
const timer = setTimeout(() => {
@@ -1150,6 +1355,14 @@ function buildStillThumb(source: string, dest: string) {
})
}
function buildStillThumb(source: string, dest: string) {
return buildStillJpeg(source, dest, 'scale=384:-2', '5')
}
function buildStillPreview(source: string, dest: string) {
return buildStillJpeg(source, dest, 'scale=1600:-2', '3')
}
export async function stillThumbPath(owner: string, id: string) {
const source = stillPath(owner, id)
const dest = stillThumbFile(owner, id)
@@ -1159,12 +1372,13 @@ export async function stillThumbPath(owner: string, id: string) {
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
} catch { /* rebuild */ }
}
const key = `${owner}:${id}`
const key = `${owner}:${id}:thumb`
const pending = thumbBuilds.get(key)
if (pending) return pending
const build = withThumbSlot(async () => {
const ok = await buildStillThumb(source, dest)
return ok ? dest : source
// Never return the archive master — callers must not stream originals as thumbs
return ok ? dest : ''
}).finally(() => {
thumbBuilds.delete(key)
})
@@ -1172,11 +1386,40 @@ export async function stillThumbPath(owner: string, id: string) {
return build
}
export async function ensureStillThumbnails(owner: string) {
export async function stillPreviewPath(owner: string, id: string) {
const source = stillPath(owner, id)
const dest = stillPreviewFile(owner, id)
if (!existsSync(source)) return ''
if (existsSync(dest)) {
try {
if (statSync(dest).mtimeMs >= statSync(source).mtimeMs) return dest
} catch { /* rebuild */ }
}
const key = `${owner}:${id}:preview`
const pending = previewBuilds.get(key)
if (pending) return pending
const build = withThumbSlot(async () => {
const ok = await buildStillPreview(source, dest)
// Never return the archive master as a stage preview
return ok ? dest : ''
}).finally(() => {
previewBuilds.delete(key)
})
previewBuilds.set(key, build)
return build
}
export async function ensureStillThumbnails(owner: string, opts?: { limit?: number }) {
const catalog = readCatalog(owner)
const cap = Math.max(0, Math.min(Number(opts?.limit) || 2, 8))
let built = 0
for (const still of catalog.stills) {
if (built >= cap) break
if (isPipelineStill(catalog, still)) continue
const dest = stillThumbFile(owner, still.id)
if (existsSync(dest)) continue
await stillThumbPath(owner, still.id)
built++
}
}
@@ -1186,7 +1429,8 @@ export async function maintainLibrary(owner: string, folderId?: string) {
const prev = lastMaintain.get(owner) || 0
if (Date.now() - prev < 20000) return
lastMaintain.set(owner, Date.now())
void ensureStillThumbnails(owner).catch(() => null)
// At most 2 missing thumbs per tick — never walk the whole catalog on list
void ensureStillThumbnails(owner, { limit: 2 }).catch(() => null)
const live = await fetchLiveQueue().catch(() => null)
const busy = Boolean(live && live.running > 0)
if (!busy) {
@@ -1414,6 +1658,7 @@ export async function saveStill(params: {
next.stills.unshift(still)
})
void stillThumbPath(params.ownerKey, still.id).catch(() => null)
void stillPreviewPath(params.ownerKey, still.id).catch(() => null)
return still
}
+192
View File
@@ -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
})
}
+25 -4
View File
@@ -1,22 +1,43 @@
import { readFileSync } from 'node:fs'
import { openSync, readSync, closeSync } from 'node:fs'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { getClip, getStill, clipVideoPath, stillPath } from '../library'
import { imageDimensions } from '../resolution'
import { resolveSize } from '~/shared/studio2/input.mjs'
const exec = promisify(execFile)
function readImageHeader(path: string, maxBytes = 65536) {
const fd = openSync(path, 'r')
try {
const buf = Buffer.alloc(maxBytes)
const n = readSync(fd, buf, 0, maxBytes, 0)
return buf.subarray(0, n)
} finally {
closeSync(fd)
}
}
export async function resolveRequestSize(owner: string, request: any) {
const video = ['video', 'extend'].includes(request.mode)
let dimensions: any
if (video && request.startClipId) {
getClip(owner,request.startClipId)
const clip = getClip(owner, request.startClipId)
if (clip.width && clip.height) {
dimensions = { width: clip.width, height: clip.height }
} else {
const result = await exec('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'json', clipVideoPath(owner, request.startClipId)], { windowsHide: true, timeout: 15000 })
dimensions = JSON.parse(result.stdout).streams?.[0]
}
} else {
const id = request.imageAId || request.identityStillId
if (id) {
getStill(owner,id)
dimensions=imageDimensions(readFileSync(stillPath(owner,id)))
const still = getStill(owner, id)
if (still.width && still.height) {
dimensions = { width: still.width, height: still.height }
} else {
// Header only — never read the full archive PNG into memory on Generate click
dimensions = imageDimensions(readImageHeader(stillPath(owner, id)))
}
if (!dimensions) throw new Error('Cannot measure the source still.')
}
}
+97 -9
View File
@@ -13,7 +13,7 @@ import { imageV2StackSpecials } from '~/utils/imageV2'
import { allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
export type StudioJobStatus = 'waiting' | 'running' | 'held' | 'complete' | 'error' | 'cancelled'
export type StudioJobKind = 'video' | 'edit' | 'music'
export type StudioJobKind = 'video' | 'edit' | 'music' | 'caption'
export interface StudioJobPayload {
studio2Id?: string
@@ -42,6 +42,7 @@ export interface StudioJobPayload {
useIdentityRefs: boolean
stillId?: string
stillFilename?: string
captionStyle?: string
hideThumbnail: boolean
hideInput?: boolean
folderLocked?: boolean
@@ -105,6 +106,9 @@ export interface StudioJob {
resumeAutoRun?: boolean
lastError?: string
waitReason?: string
resultText?: string
/** Hidden from Queue UI; keeps resultText on the stored row. */
dismissed?: boolean
}
type StudioQueueStore = {
@@ -226,6 +230,7 @@ export function listStudioJobs(owner: string) {
export function studioJobKind(job: Pick<StudioJob, 'kind'> | { kind?: string }) {
if (job.kind === 'edit') return 'edit'
if (job.kind === 'music') return 'music'
if (job.kind === 'caption') return 'caption'
return 'video'
}
@@ -245,6 +250,8 @@ export function summarizeStudioJob(job: StudioJob) {
folderId: job.payload.folderId,
musicEngine: job.payload.musicEngine,
stillId: job.payload.stillId,
captionStyle: job.payload.captionStyle,
resultText: job.resultText,
workflow: job.payload.workflow,
imagePipeline: job.payload.imagePipeline || 'v1',
duration: job.payload.duration,
@@ -290,7 +297,7 @@ function failZombieLiveJob(job: Job, error: string) {
function sweepStaleLiveJobs() {
const now = Date.now()
for (const job of listJobs()) {
if (job.studio2 || job.yueGp || job.yue2 || job.upscale) continue
if (job.studio2 || job.yueGp || job.yue2 || job.upscale || job.caption) continue
if (job.saving) continue
if (job.status === 'queued' && !job.promptId && now - job.startedAt >= QUEUED_GRACE_MS) {
failZombieLiveJob(job, 'Job never started')
@@ -310,12 +317,12 @@ async function reapZombieLiveJobs() {
const { fetchHistory } = await import('~/server/utils/comfy')
for (const job of listJobs()) {
// Never interrupt download/stitch/library save — Comfy is idle then by design.
if (job.studio2 || job.yueGp || job.yue2 || job.upscale) continue
if (job.studio2 || job.yueGp || job.yue2 || job.upscale || job.caption) continue
if (job.saving) continue
if (job.library?.chainContinuing) continue
if (job.status !== 'running' && job.status !== 'uploading' && job.status !== 'queued') continue
if (jobIsLocallySubmitting(job)) continue
const zombieMs = (job.kind === 'edit' || job.kind === 'music') ? 45_000 : ZOMBIE_EMPTY_COMFY_MS
const zombieMs = (job.kind === 'edit' || job.kind === 'music' || job.kind === 'caption') ? 45_000 : ZOMBIE_EMPTY_COMFY_MS
if (jobAgeMs(job) < zombieMs) continue
if (!job.promptId) {
if (jobAgeMs(job) >= QUEUED_GRACE_MS) failZombieLiveJob(job, 'Job never started on ComfyUI')
@@ -355,7 +362,7 @@ async function reapZombieLiveJobs() {
}
function liveJobOwnsGpu(job: Job) {
if ((job.studio2 || job.yueGp || job.yue2 || job.upscale) && ['running', 'queued', 'uploading'].includes(job.status)) return true
if ((job.studio2 || job.yueGp || job.yue2 || job.upscale || job.caption) && ['running', 'queued', 'uploading'].includes(job.status)) return true
if (job.library?.stopAfterCurrent) return false
if (job.library?.chainContinuing) return true
if (job.saving) return true
@@ -375,7 +382,7 @@ function liveJobOwnsGpu(job: Job) {
*/
function clearDeadGpuClaimsForForceStart() {
for (const live of listJobs()) {
if (live.studio2 || live.yueGp || live.yue2 || live.upscale) continue
if (live.studio2 || live.yueGp || live.yue2 || live.upscale || live.caption) continue
if (live.saving) continue
if (jobIsLocallySubmitting(live)) continue
if (live.status !== 'running' && live.status !== 'queued' && live.status !== 'uploading') {
@@ -469,8 +476,11 @@ export async function addStudioJob(params: {
kind?: StudioJobKind
}) {
const now = Date.now()
const kind = params.kind === 'edit' ? 'edit' : params.kind === 'music' ? 'music' : 'video'
const shotCount = kind === 'music'
const kind = params.kind === 'edit' ? 'edit'
: params.kind === 'music' ? 'music'
: params.kind === 'caption' ? 'caption'
: 'video'
const shotCount = kind === 'music' || kind === 'caption'
? 1
: kind === 'edit'
? 1 + (params.payload.passes?.length || 0)
@@ -527,6 +537,40 @@ function canCancelStudioStatus(status: StudioJobStatus) {
return status === 'waiting' || status === 'held' || status === 'running' || status === 'error'
}
function isFinishedStudioStatus(status: StudioJobStatus) {
return status === 'complete' || status === 'error' || status === 'cancelled'
}
/** Hide a finished studio-queue row from the Queue panel (keeps resultText). */
export async function dismissStudioJob(owner: string, id: string) {
return mutate(owner, (jobs) => {
const job = jobs.find(item => item.id === id)
if (!job) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
if (!isFinishedStudioStatus(job.status)) {
throw createError({ statusCode: 409, statusMessage: 'Only finished jobs can be dismissed' })
}
job.dismissed = true
job.updatedAt = Date.now()
return structuredClone(job)
})
}
/** Dismiss finished studio-queue rows (caption complete cards, errors, etc.). */
export async function dismissFinishedStudioJobs(owner: string, ids?: string[]) {
return mutate(owner, (jobs) => {
let cleared = 0
for (const job of jobs) {
if (ids?.length && !ids.includes(job.id)) continue
if (job.dismissed) continue
if (!isFinishedStudioStatus(job.status)) continue
job.dismissed = true
job.updatedAt = Date.now()
cleared += 1
}
return cleared
})
}
export async function cancelStudioJob(owner: string, id: string) {
const before = readJobs(owner).find(item => item.id === id)
if (!before) throw createError({ statusCode: 404, statusMessage: 'Queued job not found' })
@@ -606,6 +650,10 @@ export async function clearStuckStudioWork(owner: string) {
const { cancelYue2Job } = await import('./yue2')
await cancelYue2Job(job)
}
if (job.caption) {
const { cancelCaptionJob } = await import('./caption')
await cancelCaptionJob(job)
}
job.status = 'cancelled'
job.error = 'Cleared by force reset'
if (job.library) {
@@ -678,6 +726,11 @@ async function stopLiveGeneration(liveJobId?: string, shotQueueId?: string) {
await cancelYue2Job(live)
return
}
if (live?.caption) {
const { cancelCaptionJob } = await import('./caption')
await cancelCaptionJob(live)
return
}
if (live) {
live.status = 'cancelled'
if (live.library) {
@@ -889,6 +942,10 @@ function pendingAlive(job: StudioJob) {
const root = join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'yue2-pending', `${job.liveJobId}.json`)
if (existsSync(root)) return true
}
if (job.kind === 'caption') {
const root = join(String(useRuntimeConfig().libraryDir || process.env.LIBRARY_DIR || '/data/library'), 'caption-pending', `${job.liveJobId}.json`)
if (existsSync(root)) return true
}
}
if (!job.shotQueueId) return false
return listPendingJobs().some(pending => (
@@ -1397,6 +1454,32 @@ async function startStudioExtendJob(item: StudioJob) {
}
}
async function startStudioCaptionJob(item: StudioJob) {
let live: import('~/server/utils/jobs').Job | undefined
try {
const { startCaptionJob } = await import('./caption')
const style = String(item.payload.captionStyle || 'descriptive')
const stillId = String(item.payload.stillId || '')
if (!stillId) throw new Error('Caption requires a still.')
live = await startCaptionJob({
ownerKey: item.ownerKey,
folderId: item.payload.folderId,
stillId,
captionStyle: style,
name: item.payload.name
})
await markStudioLive(item.ownerKey, item.id, live.id)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (live && (live.status === 'queued' || live.status === 'uploading' || live.status === 'running')) {
live.status = 'error'
live.error = message
}
await parkStudioOnStartFailure(item.ownerKey, item.id, message)
kickStudioQueue()
}
}
async function startStudioMusicJob(item: StudioJob) {
let live: import('~/server/utils/jobs').Job | undefined
try {
@@ -1439,6 +1522,10 @@ export async function startStudioJob(item: StudioJob) {
}
async function startStudioJobReserved(item: StudioJob) {
if (studioJobKind(item) === 'caption') {
await startStudioCaptionJob(item)
return
}
if (item.payload.studio2Id) {
try {
const { startStudio2Job } = await import('./studio2/runner')
@@ -1703,7 +1790,7 @@ export async function onLiveVideoSettled(job: Job) {
return
}
const remaining = remainingStudioShots(job)
const wakeFail = !job.studio2 && !job.yueGp && !job.yue2 && !job.upscale && job.status === 'error' && remaining > 0 && isTransientComfyError(job.error)
const wakeFail = !job.studio2 && !job.yueGp && !job.yue2 && !job.upscale && !job.caption && job.status === 'error' && remaining > 0 && isTransientComfyError(job.error)
const failed = (job.status === 'error' || job.status === 'cancelled') && !wakeFail
await mutateStore(owner, (store) => {
@@ -1777,6 +1864,7 @@ export async function onLiveVideoSettled(job: Job) {
: failed
? 'error'
: 'complete'
if (job.caption && job.resultText) row.resultText = job.resultText
clearStudioRowSlot(row, status, failed ? job.error : undefined)
syncPausedFlag(store)
})
+30
View File
@@ -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 }
}
+4 -1
View File
@@ -1,4 +1,4 @@
export function gpuStatus(jobs, health, upscales=[], musicJobs=[]) {
export function gpuStatus(jobs, health, upscales=[], musicJobs=[], captionJobs=[]) {
if (!health) return {label:'Status unavailable',tone:'unknown',detail:'Checking GPU connection…'}
const upscale=upscales.find(j=>j.status==='running')
if(upscale)return {label:'Upscaling',tone:'busy',detail:upscale.name || 'Video upscale'}
@@ -7,6 +7,9 @@ export function gpuStatus(jobs, health, upscales=[], musicJobs=[]) {
const stage=music.live?.message || music.live?.stage || music.waitReason || 'YuE2'
return {label:music.status==='waiting'?'Queued':'Generating',tone:'busy',detail:String(stage)}
}
const caption=captionJobs.find(j=>['waiting','running','held'].includes(j.status) || ['queued','running','uploading'].includes(j.live?.status))
if(caption)return {label:caption.status==='waiting'?'Queued':'Describing',tone:'busy',detail:caption.payload?.captionStyle || caption.live?.message || 'Caption'}
if(health.caption?.busy)return {label:'Describing',tone:'busy',detail:'Caption model is using the GPU'}
if(health.yue2?.busy)return {label:'Generating',tone:'busy',detail:'YuE2 is using the GPU'}
const job=jobs.find(j=>['waking','enhancing','submitting','rendering','saving'].includes(j.state))
if(job)return {label:{waking:'Waking up',enhancing:'Enhancing prompt…',submitting:'Submitting',rendering:'Generating…',saving:'Saving'}[job.state],tone:'busy',detail:job.request?.promptSections?.action || 'Generation in progress'}
+8
View File
@@ -9,5 +9,13 @@ export function normalizeStyles(value={}) {
}
export function toggleStyle(value,chip,negative=false) { const next=normalizeStyles(value),key=negative?'negative':'positive',other=negative?'positive':'negative';next[other]=next[other].filter(s=>s!==chip);next[key]=next[key].includes(chip)?next[key].filter(s=>s!==chip):[...next[key],chip];return next }
export function stylePrompt(value,negative=false){return normalizeStyles(value)[negative?'negative':'positive'].join(', ')}
/** Single blank line (mobile / video / edit batchText). */
export function parseBatch(value){return String(value||'').split(/\r?\n\s*\r?\n/).map(s=>s.trim()).filter(Boolean)}
/**
* Desktop image Batch toggle: split only on a double blank line (two empty lines → \n\n\n+).
* A single newline or a single blank line stays inside the same prompt.
*/
export function parseBatchDoubleBlank(value){
return String(value||'').split(/\r?\n(?:[ \t]*\r?\n){2,}/).map(s=>s.trim()).filter(Boolean)
}
+4 -1
View File
@@ -6,7 +6,10 @@ export function folderAssets(library, jobs, folderId) {
export function newestOutput(jobs, seen, folderId) {
return jobs.filter(j=>j.request.folderId===folderId).flatMap(j=>j.outputs || []).filter(a=>!seen.has(a.id)).sort((a,b)=>(a.studio2?.savedAt || 0)-(b.studio2?.savedAt || 0)).at(-1) || null
}
export function reconcileSelection(selected, assets) { return assets.find(a=>a.id===selected?.id) || assets.at(-1) || null }
export function reconcileSelection(selected, assets) {
if (!selected?.id) return assets.at(-1) || null
return assets.find(a => a.id === selected.id) || selected
}
export function activeJobs(jobs) { return jobs.filter(j=>['waking','enhancing','submitting','rendering','saving'].includes(j.state)) }
export function previewHidden(asset, revealed, xaigen) {
if (!xaigen || !asset?.id) return false
+52
View File
@@ -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')
+288
View File
@@ -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'))
})
+7 -1
View File
@@ -4,7 +4,7 @@ import {readFileSync} from 'node:fs'
import ts from 'typescript'
import {folderAssets,reconcileSelection,activeJobs,previewHidden} from '../shared/studio2/viewer.mjs'
import {useAsHero,useAsSource,restoreAll,hydrateStudio2Job} from '../shared/studio2/input.mjs'
import {toggleStyle,parseBatch,stylePrompt} from '../shared/studio2/styles.mjs'
import {toggleStyle,parseBatch,parseBatchDoubleBlank,stylePrompt} from '../shared/studio2/styles.mjs'
import {compilePrompt,validateRequest} from '../shared/studio2/contracts.mjs'
import {cachedLoras} from '../shared/studio2/lora-cache.mjs'
import {resolveQwen21Size} from '../shared/studio2/qwen21-size.mjs'
@@ -79,6 +79,12 @@ test('image chips compile positive and negative independently; video chips do no
assert.match(q.compiledPrompt,/Photorealistic/);assert.doesNotMatch(q.compiledPrompt,/Comic|pores|tripod|hold|VIDEO ONLY/i);assert.deepEqual(q.imageStyles,styles)
assert.doesNotMatch(compilePrompt(q.promptSections,false,q),/Picture 1/)
assert.deepEqual(parseBatch('A wrapped\nsentence\n\nSecond shot\r\n\r\nThird'),['A wrapped\nsentence','Second shot','Third'])
assert.deepEqual(
parseBatchDoubleBlank('Prompt 1 starts here\nBal blaglas\nsldkjf lkaj\n\nsldkfjs lkdfj\nsdlfkjsdkljf\n\n\nPrompt 2 starts here flaskjflaskdj'),
['Prompt 1 starts here\nBal blaglas\nsldkjf lkaj\n\nsldkfjs lkdfj\nsdlfkjsdkljf','Prompt 2 starts here flaskjflaskdj']
)
assert.deepEqual(parseBatchDoubleBlank('one\n\ntwo'),['one\n\ntwo'])
assert.deepEqual(parseBatchDoubleBlank('one\n\n\ntwo\n\n\n\nthree\n'),['one','two','three'])
})
test('qwen21 supports Generate and Edit with 25/1 defaults and no locks',()=>{
const q=validateRequest({mode:'generate',engine:'qwen21',folderId:'f',promptSections:{action:'a red bicycle'},settings:{aspect:'1:1'}})
+575
View File
@@ -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)
})
}
}