Move library collections as a group and map v2 identity refs.
Collapsed collection actions now move, rename, delete, and rerun every part so chains stay together. Identity mode follows the v2u3 Ref2VA picture wiring. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+299
-91
@@ -20,7 +20,7 @@
|
||||
<span
|
||||
class="items-center gap-2 rounded-full border px-3 py-1"
|
||||
:class="[studioMode === 'edit' ? 'hidden sm:inline-flex' : 'hidden xl:inline-flex opacity-60', imageComfyBadge.className]"
|
||||
:title="imageComfyHost || 'Image edit sidecar'"
|
||||
:title="imageComfyHost || 'Image edit: Beast, Sidecar overflow'"
|
||||
>
|
||||
<span class="h-2 w-2 rounded-full" :class="imageComfyBadge.dot" />
|
||||
{{ imageComfyBadge.label }}
|
||||
@@ -57,7 +57,7 @@
|
||||
</div>
|
||||
<h2 class="font-display text-2xl font-bold">Input</h2>
|
||||
<p class="text-sm text-zinc-400">{{ studioMode === 'edit'
|
||||
? 'Drop a still and describe the edit. This relays to the sidecar Qwen Comfy box once the workflow is mapped.'
|
||||
? 'Drop a still and describe the edit. Beast handles it when free; Sidecar only if Beast is busy.'
|
||||
: 'Drop a still, describe the motion, then relay the job to the local desktop.' }}</p>
|
||||
</div>
|
||||
|
||||
@@ -88,28 +88,42 @@
|
||||
<div>
|
||||
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<label class="text-sm font-medium text-zinc-300">{{ studioMode === 'edit' ? 'Edit prompt' : 'Motion & scene prompt' }}</label>
|
||||
<label v-if="studioMode === 'video'" class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
|
||||
<span class="relative inline-flex h-5 w-9 shrink-0 items-center">
|
||||
<input v-model="shotScriptMode" type="checkbox" class="peer sr-only" role="switch">
|
||||
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
|
||||
<span class="absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition peer-checked:translate-x-4" />
|
||||
</span>
|
||||
Shot script
|
||||
</label>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
v-if="identityPrompting"
|
||||
type="button"
|
||||
class="text-xs text-amber-200/80 hover:text-amber-100"
|
||||
@click="insertIdentityTemplate"
|
||||
>
|
||||
Insert shot template
|
||||
</button>
|
||||
<label v-if="studioMode === 'video'" class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
|
||||
<span class="relative inline-flex h-5 w-9 shrink-0 items-center">
|
||||
<input v-model="shotScriptMode" type="checkbox" class="peer sr-only" role="switch">
|
||||
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
|
||||
<span class="absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition peer-checked:translate-x-4" />
|
||||
</span>
|
||||
Shot script
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="shotScriptMode && studioMode === 'video'" class="mb-2 text-xs text-zinc-500">
|
||||
Paste a block. Text before the first marker is shot 1. Write <span class="font-medium text-zinc-300">shot 2</span>, <span class="font-medium text-zinc-300">shot 3</span>, and so on to queue extensions. Every shot uses the duration slider.
|
||||
Paste a block. Text before the first marker is shot 1. Put <span class="font-medium text-zinc-300">shot 2</span>, <span class="font-medium text-zinc-300">shot 3</span> on their own lines to queue extensions — a line like <span class="font-medium text-zinc-300">Shot Type</span> will not start a new shot. Every shot uses the duration slider.
|
||||
<span v-if="identityPrompting"> Identity boilerplate is prepended to the first shot only.</span>
|
||||
</p>
|
||||
<p v-else-if="identityPrompting" class="mb-2 text-xs text-zinc-500">
|
||||
Write camera, action, and audio. We prepend the <span class="font-medium text-zinc-300"><Picture 1></span> identity lock automatically.
|
||||
</p>
|
||||
<textarea
|
||||
v-model="prompt"
|
||||
:rows="shotScriptMode ? 12 : 6"
|
||||
:rows="shotScriptMode || identityPrompting ? 12 : 6"
|
||||
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||
:placeholder="studioMode === 'edit'
|
||||
? 'Make the lighting warmer. Replace the background with a rainy street at night. Keep the face unchanged.'
|
||||
: (shotScriptMode
|
||||
? 'A woman walks into the village.\n\nshot 2\nShe crosses the stone bridge at dusk.\n\nshot 3\nShe opens the shop door and steps inside.'
|
||||
: 'Camera slowly pushes in. Soft daylight, fabric ripples, dust motes in the air. Audio: quiet room tone, distant birds.')"
|
||||
:placeholder="promptPlaceholder"
|
||||
/>
|
||||
<details v-if="identityPrompting && prompt.trim()" class="mt-2 rounded-xl border border-white/10 bg-zinc-950/40 px-3 py-2">
|
||||
<summary class="cursor-pointer text-xs text-zinc-400">Prompt sent to MiniMax</summary>
|
||||
<p class="mt-2 whitespace-pre-wrap text-xs text-zinc-300">{{ composedIdentityPrompt }}</p>
|
||||
</details>
|
||||
<div v-if="shotScriptMode && studioMode === 'video'" class="mt-3 space-y-2">
|
||||
<p v-if="!parsedShots.length" class="text-xs text-zinc-500">Add a first shot, then <span class="text-zinc-300">shot 2</span> for an extension.</p>
|
||||
<div
|
||||
@@ -289,7 +303,7 @@
|
||||
class="h-24 w-full object-cover"
|
||||
>
|
||||
<span v-else class="flex h-24 items-center justify-center px-2 text-center text-[11px] text-zinc-500">
|
||||
Ref {{ slot }}
|
||||
Picture {{ slot + 1 }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -302,7 +316,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="useIdentityRefs" class="text-xs text-zinc-500">Extra stills lock people and objects. The first-frame still above stays the opening frame. Identity refs are not reused on queued extensions.</p>
|
||||
<p v-if="useIdentityRefs" class="text-xs text-zinc-500">Picture 1 is the start still. Extra stills map to Picture 2–5 in MiniMax. Empty slots are omitted. Identity refs are not reused on queued extensions.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -409,9 +423,13 @@
|
||||
<summary class="cursor-pointer text-sm font-medium">Image edit settings</summary>
|
||||
<div class="mt-4 space-y-4">
|
||||
<p class="text-xs text-zinc-500">
|
||||
Sidecar: <span class="text-zinc-200">{{ imageComfyHost || 'not configured' }}</span>
|
||||
· {{ imageComfyBadge.label }}
|
||||
· Qwen Image Edit Lightning (4-step LoRA)
|
||||
Beast first, Sidecar overflow.
|
||||
<span class="text-zinc-200">{{ imageComfyBadge.label }}</span>
|
||||
· Qwen Image Edit u2 (Lightning)
|
||||
</p>
|
||||
<p class="text-[11px] text-zinc-500">
|
||||
Beast {{ imageBeastHost || 'unset' }}{{ imageBeastOk ? (imageBeastBusy ? ' · busy' : ' · free') : ' · offline' }}
|
||||
· Sidecar {{ imageSidecarHost || 'unset' }}{{ imageSidecarOk ? (imageSidecarBusy ? ' · busy' : ' · ready') : (imageSidecarConfigured ? ' · offline' : ' · unset') }}
|
||||
</p>
|
||||
<label class="block text-sm">
|
||||
<span class="mb-1 block text-zinc-400">Negative prompt</span>
|
||||
@@ -435,7 +453,7 @@
|
||||
<input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="random">
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-zinc-500">Lower denoise keeps more of the original still. Lightning is tuned for 4 steps, CFG 1, denoise around 0.75.</p>
|
||||
<p class="text-xs text-zinc-500">This graph encodes the still in the Qwen prompt. Lightning is tuned for 4 steps, CFG 1, denoise 1.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -741,23 +759,23 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start gap-2">
|
||||
<input
|
||||
v-if="renamingId === group.head.id"
|
||||
v-if="renamingId === group.head.id && renamingFamily"
|
||||
ref="renameInput"
|
||||
v-model="renameDraft"
|
||||
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||||
maxlength="80"
|
||||
@keydown.enter.prevent="saveRename(group.head)"
|
||||
@keydown.enter.prevent="saveRename(group.head, true)"
|
||||
@keydown.escape.prevent="cancelRename"
|
||||
@blur="saveRename(group.head)"
|
||||
@blur="saveRename(group.head, true)"
|
||||
>
|
||||
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ group.head.name || group.head.prompt }}</p>
|
||||
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ collectionTitle(group) }}</p>
|
||||
<div class="group relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full text-zinc-500 hover:text-amber-200"
|
||||
:aria-label="'Copy prompt for ' + (group.head.name || 'clip')"
|
||||
:aria-label="'Copy prompt for ' + collectionTitle(group)"
|
||||
title="Copy prompt"
|
||||
@click.stop="copyPrompt(group.head.prompt)"
|
||||
@click.stop="copyPrompt(familyScript(group.parts))"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
@@ -765,7 +783,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
<div class="pointer-events-none invisible absolute right-0 top-7 z-20 w-64 whitespace-pre-wrap rounded-xl border border-white/10 bg-zinc-950 p-3 text-xs leading-relaxed text-zinc-300 shadow-2xl group-hover:visible group-focus-within:visible">
|
||||
{{ group.head.prompt }}
|
||||
{{ familyScript(group.parts) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -776,13 +794,13 @@
|
||||
</button>
|
||||
<button type="button" class="text-xs text-amber-300" @click="openClip(group.head)">Open</button>
|
||||
<button type="button" class="text-xs text-amber-300" @click="openExtend(group.head)">Extend</button>
|
||||
<button type="button" class="text-xs text-amber-300" @click="startRename(group.head)">Rename</button>
|
||||
<button type="button" class="text-xs text-amber-300" @click="rerun(group.head)">Rerun</button>
|
||||
<button type="button" class="text-xs text-amber-300" @click="startRename(group.head, true)">Rename</button>
|
||||
<button type="button" class="text-xs text-amber-300" @click="rerun(group.head, true)">Rerun</button>
|
||||
<select
|
||||
v-if="folders.length > 1"
|
||||
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
|
||||
:value="''"
|
||||
@change="onMoveClip(group.head, $event)"
|
||||
@change="onMoveClip(group.head, $event, true)"
|
||||
>
|
||||
<option disabled value="">Move to…</option>
|
||||
<option
|
||||
@@ -793,13 +811,13 @@
|
||||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" class="text-xs text-red-300" @click="removeClip(group.head)">Delete</button>
|
||||
<button type="button" class="text-xs text-red-300" @click="removeClip(group.head, true)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul v-if="expandedFamilies[group.key] && group.parts.length > 1" class="mt-3 space-y-2 border-t border-white/5 pt-3">
|
||||
<li
|
||||
v-for="item in group.parts.slice(1)"
|
||||
v-for="item in chronologicalParts(group.parts)"
|
||||
:key="item.id"
|
||||
class="flex gap-3 rounded-xl border border-white/5 bg-black/20 p-2"
|
||||
>
|
||||
@@ -818,7 +836,7 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start gap-2">
|
||||
<input
|
||||
v-if="renamingId === item.id"
|
||||
v-if="renamingId === item.id && !renamingFamily"
|
||||
v-model="renameDraft"
|
||||
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||||
maxlength="80"
|
||||
@@ -834,6 +852,21 @@
|
||||
<button type="button" class="text-xs text-amber-300" @click="openExtend(item)">Extend</button>
|
||||
<button type="button" class="text-xs text-amber-300" @click="startRename(item)">Rename</button>
|
||||
<button type="button" class="text-xs text-amber-300" @click="rerun(item)">Rerun</button>
|
||||
<select
|
||||
v-if="folders.length > 1"
|
||||
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
|
||||
:value="''"
|
||||
@change="onMoveClip(item, $event)"
|
||||
>
|
||||
<option disabled value="">Move to…</option>
|
||||
<option
|
||||
v-for="folder in folders.filter(folder => folder.id !== item.folderId)"
|
||||
:key="folder.id"
|
||||
:value="folder.id"
|
||||
>
|
||||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" class="text-xs text-red-300" @click="removeClip(item)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -930,6 +963,9 @@ interface LibraryClip {
|
||||
parentClipId?: string
|
||||
chainIndex?: number
|
||||
workflow?: 'v1' | 'v2'
|
||||
stillId?: string
|
||||
sound?: boolean
|
||||
hideInput?: boolean
|
||||
}
|
||||
|
||||
interface RetryDraft {
|
||||
@@ -1017,6 +1053,13 @@ const imageComfyOk = ref(false)
|
||||
const imageComfyBusy = ref(false)
|
||||
const imageComfyConfigured = ref(false)
|
||||
const imageComfyHost = ref('')
|
||||
const imageBeastOk = ref(false)
|
||||
const imageBeastBusy = ref(false)
|
||||
const imageBeastHost = ref('')
|
||||
const imageSidecarOk = ref(false)
|
||||
const imageSidecarBusy = ref(false)
|
||||
const imageSidecarConfigured = ref(false)
|
||||
const imageSidecarHost = ref('')
|
||||
const progress = ref(0)
|
||||
const statusMessage = ref('Waiting for a still.')
|
||||
const statusBusy = ref(false)
|
||||
@@ -1042,7 +1085,7 @@ const editAwaitingReveal = ref(false)
|
||||
const editLockedSave = ref(false)
|
||||
const editSteps = ref(4)
|
||||
const editCfg = ref(1)
|
||||
const editDenoise = ref(0.75)
|
||||
const editDenoise = ref(1)
|
||||
const editNegative = ref('blurry, low quality, deformed, bad anatomy, watermark, text, extra fingers')
|
||||
const videoJobId = ref('')
|
||||
const editJobId = ref('')
|
||||
@@ -1074,6 +1117,7 @@ const drafts = ref<RetryDraft[]>([])
|
||||
const folderId = ref('')
|
||||
const browseFolderId = ref('')
|
||||
const renamingId = ref('')
|
||||
const renamingFamily = ref(false)
|
||||
const renameDraft = ref('')
|
||||
const renameInput = ref<HTMLInputElement | null>(null)
|
||||
const unlockPassword = ref('')
|
||||
@@ -1136,29 +1180,43 @@ const comfyBadge = computed(() => {
|
||||
const imageComfyBadge = computed(() => {
|
||||
if (!imageComfyConfigured.value) {
|
||||
return {
|
||||
label: 'Edit sidecar unset',
|
||||
label: 'Edit unset',
|
||||
className: 'border-white/10 text-zinc-500',
|
||||
dot: 'bg-zinc-500'
|
||||
}
|
||||
}
|
||||
if (!imageComfyOk.value) {
|
||||
if (imageBeastOk.value && !imageBeastBusy.value) {
|
||||
return {
|
||||
label: 'Edit Comfy offline',
|
||||
className: 'border-red-400/30 text-red-300',
|
||||
dot: 'bg-red-400'
|
||||
label: 'Beast · edit ready',
|
||||
className: 'border-emerald-400/30 text-emerald-300',
|
||||
dot: 'bg-emerald-400'
|
||||
}
|
||||
}
|
||||
if (imageBeastBusy.value && imageSidecarOk.value && !imageSidecarBusy.value) {
|
||||
return {
|
||||
label: 'Sidecar · overflow ready',
|
||||
className: 'border-amber-400/40 text-amber-200',
|
||||
dot: 'bg-amber-400'
|
||||
}
|
||||
}
|
||||
if (imageSidecarOk.value && !imageSidecarBusy.value) {
|
||||
return {
|
||||
label: 'Sidecar · edit ready',
|
||||
className: 'border-emerald-400/30 text-emerald-300',
|
||||
dot: 'bg-emerald-400'
|
||||
}
|
||||
}
|
||||
if (imageComfyBusy.value) {
|
||||
return {
|
||||
label: 'Edit Comfy busy',
|
||||
label: 'Edit boxes busy',
|
||||
className: 'border-amber-400/40 text-amber-200',
|
||||
dot: 'bg-amber-400'
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: 'Edit Comfy online',
|
||||
className: 'border-emerald-400/30 text-emerald-300',
|
||||
dot: 'bg-emerald-400'
|
||||
label: 'Edit Comfy offline',
|
||||
className: 'border-red-400/30 text-red-300',
|
||||
dot: 'bg-red-400'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1219,14 +1277,14 @@ const videoGenerateDisabled = computed(() => {
|
||||
const editBlocked = computed(() => {
|
||||
if (editBusy.value) return true
|
||||
if (!file.value || !prompt.value.trim() || !folderId.value) return true
|
||||
if (!imageComfyConfigured.value || !imageComfyOk.value) return true
|
||||
if (imageComfyBusy.value) return true
|
||||
if (!imageComfyConfigured.value) return true
|
||||
if (!imageComfyOk.value && !imageSidecarConfigured.value) return true
|
||||
return false
|
||||
})
|
||||
const editBlockReason = computed(() => {
|
||||
if (!imageComfyConfigured.value) return 'Image edit sidecar is not configured.'
|
||||
if (!imageComfyOk.value) return 'Edit Comfy is offline.'
|
||||
if (imageComfyBusy.value && !editBusy.value) return 'Sidecar is busy with another prompt.'
|
||||
if (!imageComfyConfigured.value) return 'Image edit is not configured. Set COMFY_HOST and IMAGE_COMFY_HOST.'
|
||||
if (!imageComfyOk.value && !imageSidecarConfigured.value) return 'Beast is offline and Sidecar is not configured.'
|
||||
if (!imageComfyOk.value && imageSidecarConfigured.value && !imageSidecarOk.value) return 'Beast and Sidecar are both offline.'
|
||||
return ''
|
||||
})
|
||||
const generateLabel = computed(() => {
|
||||
@@ -1236,6 +1294,28 @@ const generateLabel = computed(() => {
|
||||
return n === 1 ? 'Generate Initial + 1 Extension' : `Generate Initial + ${n} Extensions`
|
||||
})
|
||||
const parsedShots = computed(() => parseShotScript(prompt.value))
|
||||
const identityPrompting = computed(() => studioMode.value === 'video' && videoWorkflow.value === 'v2' && useIdentityRefs.value)
|
||||
const extraIdentityPictures = computed(() => identityRefs.value.flatMap((item, index) => item ? [index + 2] : []))
|
||||
const composedIdentityPrompt = computed(() => {
|
||||
const action = shotScriptMode.value
|
||||
? (parsedShots.value[0]?.prompt || prompt.value)
|
||||
: prompt.value
|
||||
return buildIdentityPrompt(action, extraIdentityPictures.value)
|
||||
})
|
||||
const promptPlaceholder = computed(() => {
|
||||
if (studioMode.value === 'edit') {
|
||||
return 'Make the lighting warmer. Replace the background with a rainy street at night. Keep the face unchanged.'
|
||||
}
|
||||
if (identityPrompting.value) {
|
||||
return shotScriptMode.value
|
||||
? `${IDENTITY_ACTION_TEMPLATE}\n\nshot 2\nThe subject from <Picture 1> continues along the path as the camera eases past her.`
|
||||
: IDENTITY_ACTION_TEMPLATE
|
||||
}
|
||||
if (shotScriptMode.value) {
|
||||
return 'A woman walks into the village.\n\nshot 2\nShe crosses the stone bridge at dusk.\n\nshot 3\nShe opens the shop door and steps inside.'
|
||||
}
|
||||
return 'Camera slowly pushes in. Soft daylight, fabric ripples, dust motes in the air. Audio: quiet room tone, distant birds.'
|
||||
})
|
||||
const plannedChain = computed(() => {
|
||||
if (shotScriptMode.value) {
|
||||
return parsedShots.value.map((shot, index) => ({
|
||||
@@ -1277,10 +1357,14 @@ function clipsIn(id: string) {
|
||||
return clips.value.filter(clip => clip.folderId === id)
|
||||
}
|
||||
|
||||
function familyBaseName(name: string) {
|
||||
return String(name || '').trim().replace(/\s+p\d+$/i, '')
|
||||
}
|
||||
|
||||
function clipFamilyKey(clip: LibraryClip) {
|
||||
const base = String(clip.name || '').trim().replace(/\s+p\d+$/i, '')
|
||||
if (base) return `name:${clip.folderId}:${base.toLowerCase()}`
|
||||
if (clip.familyId) return clip.familyId
|
||||
if (clip.familyId) return `family:${clip.folderId}:${clip.familyId}`
|
||||
const base = familyBaseName(clip.name || '').toLowerCase()
|
||||
if (base) return `name:${clip.folderId}:${base}`
|
||||
return `solo:${clip.id}`
|
||||
}
|
||||
|
||||
@@ -1292,6 +1376,27 @@ function sortClipParts(a: LibraryClip, b: LibraryClip) {
|
||||
return a.createdAt - b.createdAt
|
||||
}
|
||||
|
||||
function chronologicalParts(parts: LibraryClip[]) {
|
||||
return [...parts].sort((a, b) => {
|
||||
const chain = (a.chainIndex || 0) - (b.chainIndex || 0)
|
||||
if (chain) return chain
|
||||
return a.createdAt - b.createdAt
|
||||
})
|
||||
}
|
||||
|
||||
function collectionTitle(group: { head: LibraryClip; parts: LibraryClip[] }) {
|
||||
const named = group.parts.map(part => familyBaseName(part.name || '')).find(name => name)
|
||||
return named || group.head.name || group.head.prompt
|
||||
}
|
||||
|
||||
function familyScript(parts: LibraryClip[]) {
|
||||
const ordered = chronologicalParts(parts)
|
||||
if (ordered.length <= 1) return ordered[0]?.prompt || ''
|
||||
return ordered.map((part, index) => (
|
||||
index === 0 ? part.prompt.trim() : `shot ${index + 1}\n${part.prompt.trim()}`
|
||||
)).join('\n\n')
|
||||
}
|
||||
|
||||
function formatClipDuration(value?: number) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return ''
|
||||
const rounded = Math.round(value * 10) / 10
|
||||
@@ -1365,7 +1470,14 @@ async function loadLibrary() {
|
||||
|
||||
function applyComfyHealth(health: {
|
||||
comfy?: { ok?: boolean; busy?: boolean }
|
||||
imageComfy?: { ok?: boolean; busy?: boolean; configured?: boolean; host?: string }
|
||||
imageComfy?: {
|
||||
ok?: boolean
|
||||
busy?: boolean
|
||||
configured?: boolean
|
||||
host?: string
|
||||
beast?: { ok?: boolean; busy?: boolean; host?: string; configured?: boolean }
|
||||
sidecar?: { ok?: boolean; busy?: boolean; host?: string; configured?: boolean }
|
||||
}
|
||||
} | null | undefined) {
|
||||
comfyOk.value = Boolean(health?.comfy?.ok)
|
||||
comfyBusy.value = Boolean(health?.comfy?.ok && health?.comfy?.busy)
|
||||
@@ -1373,6 +1485,13 @@ function applyComfyHealth(health: {
|
||||
imageComfyOk.value = Boolean(health?.imageComfy?.ok)
|
||||
imageComfyBusy.value = Boolean(health?.imageComfy?.ok && health?.imageComfy?.busy)
|
||||
imageComfyHost.value = String(health?.imageComfy?.host || '')
|
||||
imageBeastOk.value = Boolean(health?.imageComfy?.beast?.ok)
|
||||
imageBeastBusy.value = Boolean(health?.imageComfy?.beast?.busy)
|
||||
imageBeastHost.value = String(health?.imageComfy?.beast?.host || '')
|
||||
imageSidecarOk.value = Boolean(health?.imageComfy?.sidecar?.ok)
|
||||
imageSidecarBusy.value = Boolean(health?.imageComfy?.sidecar?.busy)
|
||||
imageSidecarConfigured.value = Boolean(health?.imageComfy?.sidecar?.configured)
|
||||
imageSidecarHost.value = String(health?.imageComfy?.sidecar?.host || '')
|
||||
}
|
||||
|
||||
async function pollComfyHealth() {
|
||||
@@ -1504,6 +1623,12 @@ function restoreAdvanced(item: { turbo: boolean; aspect?: string; width?: number
|
||||
scheduler.value = restoreScheduler(item.scheduler)
|
||||
}
|
||||
|
||||
function insertIdentityTemplate() {
|
||||
prompt.value = shotScriptMode.value
|
||||
? `${IDENTITY_ACTION_TEMPLATE}\n\nshot 2\nThe subject from <Picture 1> continues along the path, turning to take in the wider view.\n\n[AUDIO CUES]\nAmbient forest breeze, soft footsteps, warm cinematic strings.`
|
||||
: IDENTITY_ACTION_TEMPLATE
|
||||
}
|
||||
|
||||
function toast(message: string) {
|
||||
const id = crypto.randomUUID()
|
||||
toasts.value.push({ id, message })
|
||||
@@ -1533,10 +1658,10 @@ function resetImage() {
|
||||
imageHeight.value = 0
|
||||
}
|
||||
|
||||
function readFile(next: File) {
|
||||
function readFile(next: File, opts?: { keepThumbnailPref?: boolean }) {
|
||||
file.value = next
|
||||
preview.value = URL.createObjectURL(next)
|
||||
applyThumbnailDefault()
|
||||
if (!opts?.keepThumbnailPref) applyThumbnailDefault()
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
imageWidth.value = img.naturalWidth
|
||||
@@ -1584,6 +1709,7 @@ function onPickedImage(next: File, hidden = false) {
|
||||
}
|
||||
|
||||
async function persistDroppedStill(next: File) {
|
||||
if (/last[\s._-]*frame/i.test(next.name || '')) return
|
||||
const body = new FormData()
|
||||
body.append('image', next)
|
||||
body.append('folderId', folderId.value)
|
||||
@@ -1657,7 +1783,7 @@ async function resumeTrack(kind: 'video' | 'edit', id: string, hidden: boolean,
|
||||
if (running && !settled) {
|
||||
if (kind === 'edit') {
|
||||
editBusy.value = true
|
||||
editStatusMessage.value = snap.message || 'Reconnecting to sidecar ComfyUI...'
|
||||
editStatusMessage.value = snap.message || 'Reconnecting to edit ComfyUI...'
|
||||
} else {
|
||||
videoBusy.value = true
|
||||
statusMessage.value = snap.message || 'Reconnecting to ComfyUI...'
|
||||
@@ -1747,14 +1873,90 @@ function nextClipPartName(name: string) {
|
||||
return `${base.slice(0, Math.max(1, 80 - suffix.length))}${suffix}`
|
||||
}
|
||||
|
||||
function rerun(item: LibraryClip) {
|
||||
prompt.value = item.prompt
|
||||
clipName.value = item.name || ''
|
||||
seedInput.value = String(item.seed)
|
||||
restoreAdvanced(item)
|
||||
hideThumbnail.value = showPrivacyToggles.value && item.hideThumbnail
|
||||
folderId.value = item.folderId
|
||||
toast('Settings restored. Choose the same still if it is not already loaded.')
|
||||
function clipGenerateDuration(item: LibraryClip) {
|
||||
if (item.parentClipId) {
|
||||
const parent = clips.value.find(clip => clip.id === item.parentClipId)
|
||||
if (parent?.duration && item.duration && item.duration > parent.duration + 0.4) {
|
||||
return clampDuration(item.duration - parent.duration)
|
||||
}
|
||||
}
|
||||
return clampDuration(Number(item.duration) || 5)
|
||||
}
|
||||
|
||||
function familyPartsFor(item: LibraryClip) {
|
||||
const key = clipFamilyKey(item)
|
||||
const group = libraryGroups.value.find(entry => entry.key === key)
|
||||
return chronologicalParts(group?.parts || [item])
|
||||
}
|
||||
|
||||
async function loadRerunImage(item: LibraryClip, parts: LibraryClip[]) {
|
||||
const index = Math.max(0, parts.findIndex(part => part.id === item.id))
|
||||
const isExtension = index > 0 || Boolean(item.parentClipId) || (item.chainIndex || 0) > 0
|
||||
if (isExtension) {
|
||||
const previous = index > 0
|
||||
? parts[index - 1]
|
||||
: clips.value.find(clip => clip.id === item.parentClipId)
|
||||
if (previous) {
|
||||
const blob = await $fetch<Blob>(`/api/library/clips/${previous.id}/last-frame`, { responseType: 'blob' })
|
||||
readFile(new File([blob], 'last_frame.png', { type: blob.type || 'image/png' }), { keepThumbnailPref: true })
|
||||
return true
|
||||
}
|
||||
}
|
||||
const still = (item.stillId && stills.value.find(entry => entry.id === item.stillId))
|
||||
|| stills.value.find(entry => entry.folderId === item.folderId && entry.filename === `${item.id}.png`)
|
||||
const stillId = still?.id || item.stillId
|
||||
if (stillId) {
|
||||
const blob = await $fetch<Blob>(`/api/library/stills/${stillId}`, { responseType: 'blob' })
|
||||
if (still?.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
|
||||
readFile(new File([blob], still?.filename || 'still.png', { type: blob.type || 'image/png' }), { keepThumbnailPref: true })
|
||||
return true
|
||||
}
|
||||
const blob = await $fetch<Blob>(`/api/library/clips/${item.id}/thumb`, { responseType: 'blob' })
|
||||
readFile(new File([blob], 'start.jpg', { type: blob.type || 'image/jpeg' }), { keepThumbnailPref: true })
|
||||
return true
|
||||
}
|
||||
|
||||
async function rerun(item: LibraryClip, collection = false) {
|
||||
studioMode.value = 'video'
|
||||
closeExtend()
|
||||
const parts = familyPartsFor(item)
|
||||
const target = collection ? parts[0] || item : item
|
||||
const restoreAll = collection && parts.length > 1
|
||||
prompt.value = restoreAll ? familyScript(parts) : target.prompt
|
||||
clipName.value = restoreAll
|
||||
? (familyBaseName(collectionTitle({ head: target, parts })) || target.name || '')
|
||||
: (target.name || '')
|
||||
seedInput.value = String(target.seed)
|
||||
restoreAdvanced(target)
|
||||
duration.value = clipGenerateDuration(target)
|
||||
hideThumbnail.value = showPrivacyToggles.value && target.hideThumbnail
|
||||
if (showPrivacyToggles.value) hideInputPreview.value = target.hideInput === true
|
||||
folderId.value = target.folderId
|
||||
browseFolderId.value = target.folderId
|
||||
videoWorkflow.value = target.workflow === 'v2' ? 'v2' : 'v1'
|
||||
if (typeof target.sound === 'boolean') withSound.value = target.sound
|
||||
shotScriptMode.value = restoreAll
|
||||
useIdentityRefs.value = false
|
||||
identityRefs.value.forEach((_, index) => clearIdentityRef(index))
|
||||
extensionQueue.value = restoreAll
|
||||
? parts.slice(1).map(part => ({
|
||||
id: crypto.randomUUID(),
|
||||
prompt: part.prompt,
|
||||
duration: clipGenerateDuration(part)
|
||||
}))
|
||||
: []
|
||||
try {
|
||||
await loadRerunImage(target, parts)
|
||||
statusMessage.value = restoreAll
|
||||
? 'Collection loaded into the form. Generate when you are ready.'
|
||||
: 'Clip loaded into the form. Generate when you are ready.'
|
||||
toast(restoreAll
|
||||
? 'Shot script and settings restored. Make any changes, then generate.'
|
||||
: 'Inputs restored. Make any changes, then generate.')
|
||||
} catch {
|
||||
statusMessage.value = 'Clip settings loaded. Re-drop the start still if it is missing.'
|
||||
toast('Settings restored, but the start still could not be loaded. Pick it again.')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDraft(draft: RetryDraft) {
|
||||
@@ -1777,7 +1979,7 @@ async function loadDraft(draft: RetryDraft) {
|
||||
try {
|
||||
const blob = await $fetch<Blob>(`/api/library/drafts/${draft.id}/still`, { responseType: 'blob' })
|
||||
const filename = draft.stillFilename || 'held-still.png'
|
||||
readFile(new File([blob], filename, { type: blob.type || 'image/png' }))
|
||||
readFile(new File([blob], filename, { type: blob.type || 'image/png' }), { keepThumbnailPref: true })
|
||||
statusMessage.value = 'Held job restored. Generate when ComfyUI is free.'
|
||||
} catch {
|
||||
statusMessage.value = 'Held job settings restored. Re-drop the still if the image is missing.'
|
||||
@@ -1946,10 +2148,15 @@ function openClip(item: LibraryClip) {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeClip(item: LibraryClip) {
|
||||
async function removeClip(item: LibraryClip, family = false) {
|
||||
const watched = family ? familyPartsFor(item) : [item]
|
||||
try {
|
||||
await $fetch(`/api/library/clips/${item.id}`, { method: 'DELETE' })
|
||||
if (videoUrl.value.includes(item.id)) {
|
||||
await $fetch(`/api/library/clips/${item.id}`, {
|
||||
method: 'DELETE',
|
||||
query: family ? { family: '1' } : undefined
|
||||
})
|
||||
const ids = new Set(watched.map(part => part.id))
|
||||
if ([...ids].some(id => videoUrl.value.includes(id) || currentClipId.value === id)) {
|
||||
videoUrl.value = ''
|
||||
awaitingReveal.value = false
|
||||
concealOutput.value = false
|
||||
@@ -1963,7 +2170,7 @@ async function removeClip(item: LibraryClip) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onMoveClip(item: LibraryClip, event: Event) {
|
||||
async function onMoveClip(item: LibraryClip, event: Event, family = false) {
|
||||
const select = event.target as HTMLSelectElement
|
||||
const destId = select.value
|
||||
select.value = ''
|
||||
@@ -1971,21 +2178,25 @@ async function onMoveClip(item: LibraryClip, event: Event) {
|
||||
try {
|
||||
applyLibrary(await $fetch(`/api/library/clips/${item.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { folderId: destId }
|
||||
body: { folderId: destId, family: family || undefined }
|
||||
}))
|
||||
const dest = folders.value.find(folder => folder.id === destId)
|
||||
if (dest?.unlocked) browseFolderId.value = destId
|
||||
statusMessage.value = dest?.unlocked
|
||||
? `Moved to ${dest.name}`
|
||||
: `Moved to ${dest?.name || 'folder'} (locked)`
|
||||
? `Moved ${family ? 'collection' : 'clip'} to ${dest.name}`
|
||||
: `Moved ${family ? 'collection' : 'clip'} to ${dest?.name || 'folder'} (locked)`
|
||||
} catch (error: any) {
|
||||
toast(error?.data?.statusMessage || 'Could not move clip')
|
||||
}
|
||||
}
|
||||
|
||||
function startRename(item: LibraryClip) {
|
||||
function startRename(item: LibraryClip, family = false) {
|
||||
renamingId.value = item.id
|
||||
renameDraft.value = (item.name || '').trim() || clipFileName(item.prompt.split(/[.!?\n]/)[0] || 'aigen')
|
||||
renamingFamily.value = family
|
||||
const source = family
|
||||
? (familyBaseName(item.name || '') || item.name || '')
|
||||
: (item.name || '')
|
||||
renameDraft.value = source.trim() || clipFileName(item.prompt.split(/[.!?\n]/)[0] || 'aigen')
|
||||
nextTick(() => {
|
||||
renameInput.value?.focus()
|
||||
renameInput.value?.select()
|
||||
@@ -1994,31 +2205,33 @@ function startRename(item: LibraryClip) {
|
||||
|
||||
function cancelRename() {
|
||||
renamingId.value = ''
|
||||
renamingFamily.value = false
|
||||
renameDraft.value = ''
|
||||
}
|
||||
|
||||
async function saveRename(item: LibraryClip) {
|
||||
if (renamingId.value !== item.id) return
|
||||
async function saveRename(item: LibraryClip, family = false) {
|
||||
if (renamingId.value !== item.id || renamingFamily.value !== family) return
|
||||
const name = renameDraft.value.trim().slice(0, 80)
|
||||
if (!name) {
|
||||
toast('A name is required')
|
||||
nextTick(() => renameInput.value?.focus())
|
||||
return
|
||||
}
|
||||
if (name === item.name) {
|
||||
const current = family ? familyBaseName(item.name || '') : item.name
|
||||
if (name === current) {
|
||||
cancelRename()
|
||||
return
|
||||
}
|
||||
try {
|
||||
applyLibrary(await $fetch(`/api/library/clips/${item.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { name }
|
||||
body: { name, family: family || undefined }
|
||||
}))
|
||||
cancelRename()
|
||||
if (videoUrl.value.includes(item.id)) {
|
||||
videoDownloadName.value = clipFileName(name)
|
||||
}
|
||||
statusMessage.value = `Renamed to ${name}`
|
||||
statusMessage.value = family ? `Renamed collection to ${familyBaseName(name) || name}` : `Renamed to ${name}`
|
||||
} catch (error: any) {
|
||||
toast(error?.data?.statusMessage || 'Could not rename clip')
|
||||
}
|
||||
@@ -2061,7 +2274,7 @@ async function editImage() {
|
||||
editResultUrl.value = ''
|
||||
editAwaitingReveal.value = false
|
||||
editLockedSave.value = false
|
||||
editStatusMessage.value = 'Checking sidecar ComfyUI...'
|
||||
editStatusMessage.value = 'Choosing Beast or Sidecar...'
|
||||
editMaxStep.value = editSteps.value
|
||||
startTimer('edit')
|
||||
try {
|
||||
@@ -2119,11 +2332,6 @@ async function generate() {
|
||||
toast('Choose a library folder before generating.')
|
||||
return
|
||||
}
|
||||
const identityFiles = identityRefs.value.filter((item): item is File => Boolean(item))
|
||||
if (videoWorkflow.value === 'v2' && useIdentityRefs.value && !identityFiles.length) {
|
||||
toast('Add at least one identity reference still, or turn references off.')
|
||||
return
|
||||
}
|
||||
const shots = shotScriptMode.value ? parsedShots.value : null
|
||||
const initialPrompt = (shots?.[0]?.prompt || prompt.value).trim()
|
||||
const queued = shots
|
||||
@@ -2173,9 +2381,9 @@ async function generate() {
|
||||
body.append('workflow', videoWorkflow.value)
|
||||
body.append('useIdentityRefs', String(videoWorkflow.value === 'v2' && useIdentityRefs.value))
|
||||
if (videoWorkflow.value === 'v2' && useIdentityRefs.value) {
|
||||
for (const ref of identityFiles) {
|
||||
body.append('refImage', ref)
|
||||
}
|
||||
identityRefs.value.forEach((ref, index) => {
|
||||
if (ref) body.append(`refImage${index}`, ref)
|
||||
})
|
||||
}
|
||||
if (activeDraftId) body.append('draftId', activeDraftId)
|
||||
if (queued.length) {
|
||||
@@ -2443,7 +2651,7 @@ async function cancelJob() {
|
||||
else statusMessage.value = 'Job cancelled. ComfyUI is idle — generate again.'
|
||||
} catch (error: any) {
|
||||
toast(error?.statusMessage || error?.data?.statusMessage || 'Failed to interrupt ComfyUI')
|
||||
if (kind === 'edit') editStatusMessage.value = 'Cancelled locally. Edit again when the sidecar is free.'
|
||||
if (kind === 'edit') editStatusMessage.value = 'Cancelled locally. Edit again when a box is free.'
|
||||
else statusMessage.value = 'Cancelled locally. Generate again when ComfyUI is free.'
|
||||
}
|
||||
if (kind === 'edit') {
|
||||
|
||||
Reference in New Issue
Block a user