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:
+9
-2
@@ -25,12 +25,19 @@ LIBRARY_DIR=/data/library
|
|||||||
# AUTH_USERNAME=
|
# AUTH_USERNAME=
|
||||||
# AUTH_PASSWORD=
|
# AUTH_PASSWORD=
|
||||||
# COMFY_FILENAME_PREFIX=video/xAIGen
|
# COMFY_FILENAME_PREFIX=video/xAIGen
|
||||||
# Desktop Comfy files are deleted after a successful library save (set false to keep them)
|
# Desktop Comfy files are deleted after a successful library save (set false to keep them).
|
||||||
|
# Also removes Qwen Edit input/output files from the image sidecar after saveStill.
|
||||||
# PURGE_COMFY_OUTPUTS=true
|
# PURGE_COMFY_OUTPUTS=true
|
||||||
|
|
||||||
# Second Comfy box (sidecar / Qwen image edit)
|
# Second Comfy box (Sidecar / Qwen overflow). Beast uses COMFY_HOST first.
|
||||||
|
# Image edits run on Beast when that Comfy is idle, and roll over to Sidecar only when Beast is busy.
|
||||||
IMAGE_COMFY_HOST=http://192.168.77.101:8188
|
IMAGE_COMFY_HOST=http://192.168.77.101:8188
|
||||||
IMAGE_COMFY_PORT=8188
|
IMAGE_COMFY_PORT=8188
|
||||||
|
# Optional override if image Comfy on Beast is not the video instance
|
||||||
|
# IMAGE_COMFY_PRIMARY_HOST=http://192.168.77.7:8198
|
||||||
|
# IMAGE_COMFY_FALLBACK_HOST=http://192.168.77.101:8188
|
||||||
|
# Spawned on the sidecar PC (host agent /start and start_comfyui.bat). Listen is 0.0.0.0 so Coolify can reach :8188.
|
||||||
|
IMAGE_COMFY_LAUNCH_CMD=C:\ComfyUI-QwenEdit\python\python.exe C:\ComfyUI-QwenEdit\ComfyUI\main.py --listen 0.0.0.0 --port 8188 --disable-auto-launch --lowvram --fp8_e4m3fn-unet --disable-cuda-malloc
|
||||||
# Optional host agent on the sidecar (scripts/comfy-host-agent.mjs)
|
# Optional host agent on the sidecar (scripts/comfy-host-agent.mjs)
|
||||||
# IMAGE_COMFY_CONTROL_URL=http://192.168.77.101:8199
|
# IMAGE_COMFY_CONTROL_URL=http://192.168.77.101:8199
|
||||||
# IMAGE_COMFY_CONTROL_TOKEN=
|
# IMAGE_COMFY_CONTROL_TOKEN=
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ async function unlock() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function ingest(file: File) {
|
async function ingest(file: File) {
|
||||||
|
if (/last[\s._-]*frame/i.test(file.name || '')) return
|
||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
body.append('image', file)
|
body.append('image', file)
|
||||||
body.append('folderId', current.value?.id || activeFolderId.value)
|
body.append('folderId', current.value?.id || activeFolderId.value)
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export default defineNuxtConfig({
|
|||||||
comfyControlToken: process.env.COMFY_CONTROL_TOKEN || '',
|
comfyControlToken: process.env.COMFY_CONTROL_TOKEN || '',
|
||||||
imageComfyHost: process.env.IMAGE_COMFY_HOST || '',
|
imageComfyHost: process.env.IMAGE_COMFY_HOST || '',
|
||||||
imageComfyPort: process.env.IMAGE_COMFY_PORT || '',
|
imageComfyPort: process.env.IMAGE_COMFY_PORT || '',
|
||||||
|
imageComfyPrimaryHost: process.env.IMAGE_COMFY_PRIMARY_HOST || '',
|
||||||
|
imageComfyFallbackHost: process.env.IMAGE_COMFY_FALLBACK_HOST || '',
|
||||||
imageComfyLaunchCmd: process.env.IMAGE_COMFY_LAUNCH_CMD || '',
|
imageComfyLaunchCmd: process.env.IMAGE_COMFY_LAUNCH_CMD || '',
|
||||||
imageComfyControlUrl: process.env.IMAGE_COMFY_CONTROL_URL || '',
|
imageComfyControlUrl: process.env.IMAGE_COMFY_CONTROL_URL || '',
|
||||||
imageComfyControlToken: process.env.IMAGE_COMFY_CONTROL_TOKEN || '',
|
imageComfyControlToken: process.env.IMAGE_COMFY_CONTROL_TOKEN || '',
|
||||||
|
|||||||
+291
-83
@@ -20,7 +20,7 @@
|
|||||||
<span
|
<span
|
||||||
class="items-center gap-2 rounded-full border px-3 py-1"
|
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]"
|
: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" />
|
<span class="h-2 w-2 rounded-full" :class="imageComfyBadge.dot" />
|
||||||
{{ imageComfyBadge.label }}
|
{{ imageComfyBadge.label }}
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<h2 class="font-display text-2xl font-bold">Input</h2>
|
<h2 class="font-display text-2xl font-bold">Input</h2>
|
||||||
<p class="text-sm text-zinc-400">{{ studioMode === 'edit'
|
<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>
|
: 'Drop a still, describe the motion, then relay the job to the local desktop.' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -88,6 +88,15 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
<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 class="text-sm font-medium text-zinc-300">{{ studioMode === 'edit' ? 'Edit prompt' : 'Motion & scene prompt' }}</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">
|
<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">
|
<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">
|
<input v-model="shotScriptMode" type="checkbox" class="peer sr-only" role="switch">
|
||||||
@@ -97,19 +106,24 @@
|
|||||||
Shot script
|
Shot script
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<p v-if="shotScriptMode && studioMode === 'video'" class="mb-2 text-xs text-zinc-500">
|
<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>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="prompt"
|
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"
|
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'
|
:placeholder="promptPlaceholder"
|
||||||
? '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.')"
|
|
||||||
/>
|
/>
|
||||||
|
<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">
|
<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>
|
<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
|
<div
|
||||||
@@ -289,7 +303,7 @@
|
|||||||
class="h-24 w-full object-cover"
|
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">
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -302,7 +316,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -409,9 +423,13 @@
|
|||||||
<summary class="cursor-pointer text-sm font-medium">Image edit settings</summary>
|
<summary class="cursor-pointer text-sm font-medium">Image edit settings</summary>
|
||||||
<div class="mt-4 space-y-4">
|
<div class="mt-4 space-y-4">
|
||||||
<p class="text-xs text-zinc-500">
|
<p class="text-xs text-zinc-500">
|
||||||
Sidecar: <span class="text-zinc-200">{{ imageComfyHost || 'not configured' }}</span>
|
Beast first, Sidecar overflow.
|
||||||
· {{ imageComfyBadge.label }}
|
<span class="text-zinc-200">{{ imageComfyBadge.label }}</span>
|
||||||
· Qwen Image Edit Lightning (4-step LoRA)
|
· 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>
|
</p>
|
||||||
<label class="block text-sm">
|
<label class="block text-sm">
|
||||||
<span class="mb-1 block text-zinc-400">Negative prompt</span>
|
<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">
|
<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>
|
</label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -741,23 +759,23 @@
|
|||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<input
|
<input
|
||||||
v-if="renamingId === group.head.id"
|
v-if="renamingId === group.head.id && renamingFamily"
|
||||||
ref="renameInput"
|
ref="renameInput"
|
||||||
v-model="renameDraft"
|
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"
|
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"
|
maxlength="80"
|
||||||
@keydown.enter.prevent="saveRename(group.head)"
|
@keydown.enter.prevent="saveRename(group.head, true)"
|
||||||
@keydown.escape.prevent="cancelRename"
|
@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">
|
<div class="group relative shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex h-6 w-6 items-center justify-center rounded-full text-zinc-500 hover:text-amber-200"
|
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"
|
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">
|
<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" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
@@ -765,7 +783,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -776,13 +794,13 @@
|
|||||||
</button>
|
</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="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="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="startRename(group.head, true)">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="rerun(group.head, true)">Rerun</button>
|
||||||
<select
|
<select
|
||||||
v-if="folders.length > 1"
|
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"
|
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="''"
|
:value="''"
|
||||||
@change="onMoveClip(group.head, $event)"
|
@change="onMoveClip(group.head, $event, true)"
|
||||||
>
|
>
|
||||||
<option disabled value="">Move to…</option>
|
<option disabled value="">Move to…</option>
|
||||||
<option
|
<option
|
||||||
@@ -793,13 +811,13 @@
|
|||||||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</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>
|
</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">
|
<ul v-if="expandedFamilies[group.key] && group.parts.length > 1" class="mt-3 space-y-2 border-t border-white/5 pt-3">
|
||||||
<li
|
<li
|
||||||
v-for="item in group.parts.slice(1)"
|
v-for="item in chronologicalParts(group.parts)"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="flex gap-3 rounded-xl border border-white/5 bg-black/20 p-2"
|
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="min-w-0 flex-1">
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<input
|
<input
|
||||||
v-if="renamingId === item.id"
|
v-if="renamingId === item.id && !renamingFamily"
|
||||||
v-model="renameDraft"
|
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"
|
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"
|
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="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="startRename(item)">Rename</button>
|
||||||
<button type="button" class="text-xs text-amber-300" @click="rerun(item)">Rerun</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>
|
<button type="button" class="text-xs text-red-300" @click="removeClip(item)">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -930,6 +963,9 @@ interface LibraryClip {
|
|||||||
parentClipId?: string
|
parentClipId?: string
|
||||||
chainIndex?: number
|
chainIndex?: number
|
||||||
workflow?: 'v1' | 'v2'
|
workflow?: 'v1' | 'v2'
|
||||||
|
stillId?: string
|
||||||
|
sound?: boolean
|
||||||
|
hideInput?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RetryDraft {
|
interface RetryDraft {
|
||||||
@@ -1017,6 +1053,13 @@ const imageComfyOk = ref(false)
|
|||||||
const imageComfyBusy = ref(false)
|
const imageComfyBusy = ref(false)
|
||||||
const imageComfyConfigured = ref(false)
|
const imageComfyConfigured = ref(false)
|
||||||
const imageComfyHost = ref('')
|
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 progress = ref(0)
|
||||||
const statusMessage = ref('Waiting for a still.')
|
const statusMessage = ref('Waiting for a still.')
|
||||||
const statusBusy = ref(false)
|
const statusBusy = ref(false)
|
||||||
@@ -1042,7 +1085,7 @@ const editAwaitingReveal = ref(false)
|
|||||||
const editLockedSave = ref(false)
|
const editLockedSave = ref(false)
|
||||||
const editSteps = ref(4)
|
const editSteps = ref(4)
|
||||||
const editCfg = ref(1)
|
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 editNegative = ref('blurry, low quality, deformed, bad anatomy, watermark, text, extra fingers')
|
||||||
const videoJobId = ref('')
|
const videoJobId = ref('')
|
||||||
const editJobId = ref('')
|
const editJobId = ref('')
|
||||||
@@ -1074,6 +1117,7 @@ const drafts = ref<RetryDraft[]>([])
|
|||||||
const folderId = ref('')
|
const folderId = ref('')
|
||||||
const browseFolderId = ref('')
|
const browseFolderId = ref('')
|
||||||
const renamingId = ref('')
|
const renamingId = ref('')
|
||||||
|
const renamingFamily = ref(false)
|
||||||
const renameDraft = ref('')
|
const renameDraft = ref('')
|
||||||
const renameInput = ref<HTMLInputElement | null>(null)
|
const renameInput = ref<HTMLInputElement | null>(null)
|
||||||
const unlockPassword = ref('')
|
const unlockPassword = ref('')
|
||||||
@@ -1136,29 +1180,43 @@ const comfyBadge = computed(() => {
|
|||||||
const imageComfyBadge = computed(() => {
|
const imageComfyBadge = computed(() => {
|
||||||
if (!imageComfyConfigured.value) {
|
if (!imageComfyConfigured.value) {
|
||||||
return {
|
return {
|
||||||
label: 'Edit sidecar unset',
|
label: 'Edit unset',
|
||||||
className: 'border-white/10 text-zinc-500',
|
className: 'border-white/10 text-zinc-500',
|
||||||
dot: 'bg-zinc-500'
|
dot: 'bg-zinc-500'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!imageComfyOk.value) {
|
if (imageBeastOk.value && !imageBeastBusy.value) {
|
||||||
return {
|
return {
|
||||||
label: 'Edit Comfy offline',
|
label: 'Beast · edit ready',
|
||||||
className: 'border-red-400/30 text-red-300',
|
className: 'border-emerald-400/30 text-emerald-300',
|
||||||
dot: 'bg-red-400'
|
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) {
|
if (imageComfyBusy.value) {
|
||||||
return {
|
return {
|
||||||
label: 'Edit Comfy busy',
|
label: 'Edit boxes busy',
|
||||||
className: 'border-amber-400/40 text-amber-200',
|
className: 'border-amber-400/40 text-amber-200',
|
||||||
dot: 'bg-amber-400'
|
dot: 'bg-amber-400'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
label: 'Edit Comfy online',
|
label: 'Edit Comfy offline',
|
||||||
className: 'border-emerald-400/30 text-emerald-300',
|
className: 'border-red-400/30 text-red-300',
|
||||||
dot: 'bg-emerald-400'
|
dot: 'bg-red-400'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1219,14 +1277,14 @@ const videoGenerateDisabled = computed(() => {
|
|||||||
const editBlocked = computed(() => {
|
const editBlocked = computed(() => {
|
||||||
if (editBusy.value) return true
|
if (editBusy.value) return true
|
||||||
if (!file.value || !prompt.value.trim() || !folderId.value) return true
|
if (!file.value || !prompt.value.trim() || !folderId.value) return true
|
||||||
if (!imageComfyConfigured.value || !imageComfyOk.value) return true
|
if (!imageComfyConfigured.value) return true
|
||||||
if (imageComfyBusy.value) return true
|
if (!imageComfyOk.value && !imageSidecarConfigured.value) return true
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
const editBlockReason = computed(() => {
|
const editBlockReason = computed(() => {
|
||||||
if (!imageComfyConfigured.value) return 'Image edit sidecar is not configured.'
|
if (!imageComfyConfigured.value) return 'Image edit is not configured. Set COMFY_HOST and IMAGE_COMFY_HOST.'
|
||||||
if (!imageComfyOk.value) return 'Edit Comfy is offline.'
|
if (!imageComfyOk.value && !imageSidecarConfigured.value) return 'Beast is offline and Sidecar is not configured.'
|
||||||
if (imageComfyBusy.value && !editBusy.value) return 'Sidecar is busy with another prompt.'
|
if (!imageComfyOk.value && imageSidecarConfigured.value && !imageSidecarOk.value) return 'Beast and Sidecar are both offline.'
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
const generateLabel = computed(() => {
|
const generateLabel = computed(() => {
|
||||||
@@ -1236,6 +1294,28 @@ const generateLabel = computed(() => {
|
|||||||
return n === 1 ? 'Generate Initial + 1 Extension' : `Generate Initial + ${n} Extensions`
|
return n === 1 ? 'Generate Initial + 1 Extension' : `Generate Initial + ${n} Extensions`
|
||||||
})
|
})
|
||||||
const parsedShots = computed(() => parseShotScript(prompt.value))
|
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(() => {
|
const plannedChain = computed(() => {
|
||||||
if (shotScriptMode.value) {
|
if (shotScriptMode.value) {
|
||||||
return parsedShots.value.map((shot, index) => ({
|
return parsedShots.value.map((shot, index) => ({
|
||||||
@@ -1277,10 +1357,14 @@ function clipsIn(id: string) {
|
|||||||
return clips.value.filter(clip => clip.folderId === id)
|
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) {
|
function clipFamilyKey(clip: LibraryClip) {
|
||||||
const base = String(clip.name || '').trim().replace(/\s+p\d+$/i, '')
|
if (clip.familyId) return `family:${clip.folderId}:${clip.familyId}`
|
||||||
if (base) return `name:${clip.folderId}:${base.toLowerCase()}`
|
const base = familyBaseName(clip.name || '').toLowerCase()
|
||||||
if (clip.familyId) return clip.familyId
|
if (base) return `name:${clip.folderId}:${base}`
|
||||||
return `solo:${clip.id}`
|
return `solo:${clip.id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1292,6 +1376,27 @@ function sortClipParts(a: LibraryClip, b: LibraryClip) {
|
|||||||
return a.createdAt - b.createdAt
|
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) {
|
function formatClipDuration(value?: number) {
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return ''
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return ''
|
||||||
const rounded = Math.round(value * 10) / 10
|
const rounded = Math.round(value * 10) / 10
|
||||||
@@ -1365,7 +1470,14 @@ async function loadLibrary() {
|
|||||||
|
|
||||||
function applyComfyHealth(health: {
|
function applyComfyHealth(health: {
|
||||||
comfy?: { ok?: boolean; busy?: boolean }
|
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) {
|
} | null | undefined) {
|
||||||
comfyOk.value = Boolean(health?.comfy?.ok)
|
comfyOk.value = Boolean(health?.comfy?.ok)
|
||||||
comfyBusy.value = Boolean(health?.comfy?.ok && health?.comfy?.busy)
|
comfyBusy.value = Boolean(health?.comfy?.ok && health?.comfy?.busy)
|
||||||
@@ -1373,6 +1485,13 @@ function applyComfyHealth(health: {
|
|||||||
imageComfyOk.value = Boolean(health?.imageComfy?.ok)
|
imageComfyOk.value = Boolean(health?.imageComfy?.ok)
|
||||||
imageComfyBusy.value = Boolean(health?.imageComfy?.ok && health?.imageComfy?.busy)
|
imageComfyBusy.value = Boolean(health?.imageComfy?.ok && health?.imageComfy?.busy)
|
||||||
imageComfyHost.value = String(health?.imageComfy?.host || '')
|
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() {
|
async function pollComfyHealth() {
|
||||||
@@ -1504,6 +1623,12 @@ function restoreAdvanced(item: { turbo: boolean; aspect?: string; width?: number
|
|||||||
scheduler.value = restoreScheduler(item.scheduler)
|
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) {
|
function toast(message: string) {
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
toasts.value.push({ id, message })
|
toasts.value.push({ id, message })
|
||||||
@@ -1533,10 +1658,10 @@ function resetImage() {
|
|||||||
imageHeight.value = 0
|
imageHeight.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function readFile(next: File) {
|
function readFile(next: File, opts?: { keepThumbnailPref?: boolean }) {
|
||||||
file.value = next
|
file.value = next
|
||||||
preview.value = URL.createObjectURL(next)
|
preview.value = URL.createObjectURL(next)
|
||||||
applyThumbnailDefault()
|
if (!opts?.keepThumbnailPref) applyThumbnailDefault()
|
||||||
const img = new Image()
|
const img = new Image()
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
imageWidth.value = img.naturalWidth
|
imageWidth.value = img.naturalWidth
|
||||||
@@ -1584,6 +1709,7 @@ function onPickedImage(next: File, hidden = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function persistDroppedStill(next: File) {
|
async function persistDroppedStill(next: File) {
|
||||||
|
if (/last[\s._-]*frame/i.test(next.name || '')) return
|
||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
body.append('image', next)
|
body.append('image', next)
|
||||||
body.append('folderId', folderId.value)
|
body.append('folderId', folderId.value)
|
||||||
@@ -1657,7 +1783,7 @@ async function resumeTrack(kind: 'video' | 'edit', id: string, hidden: boolean,
|
|||||||
if (running && !settled) {
|
if (running && !settled) {
|
||||||
if (kind === 'edit') {
|
if (kind === 'edit') {
|
||||||
editBusy.value = true
|
editBusy.value = true
|
||||||
editStatusMessage.value = snap.message || 'Reconnecting to sidecar ComfyUI...'
|
editStatusMessage.value = snap.message || 'Reconnecting to edit ComfyUI...'
|
||||||
} else {
|
} else {
|
||||||
videoBusy.value = true
|
videoBusy.value = true
|
||||||
statusMessage.value = snap.message || 'Reconnecting to ComfyUI...'
|
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}`
|
return `${base.slice(0, Math.max(1, 80 - suffix.length))}${suffix}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function rerun(item: LibraryClip) {
|
function clipGenerateDuration(item: LibraryClip) {
|
||||||
prompt.value = item.prompt
|
if (item.parentClipId) {
|
||||||
clipName.value = item.name || ''
|
const parent = clips.value.find(clip => clip.id === item.parentClipId)
|
||||||
seedInput.value = String(item.seed)
|
if (parent?.duration && item.duration && item.duration > parent.duration + 0.4) {
|
||||||
restoreAdvanced(item)
|
return clampDuration(item.duration - parent.duration)
|
||||||
hideThumbnail.value = showPrivacyToggles.value && item.hideThumbnail
|
}
|
||||||
folderId.value = item.folderId
|
}
|
||||||
toast('Settings restored. Choose the same still if it is not already loaded.')
|
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) {
|
async function loadDraft(draft: RetryDraft) {
|
||||||
@@ -1777,7 +1979,7 @@ async function loadDraft(draft: RetryDraft) {
|
|||||||
try {
|
try {
|
||||||
const blob = await $fetch<Blob>(`/api/library/drafts/${draft.id}/still`, { responseType: 'blob' })
|
const blob = await $fetch<Blob>(`/api/library/drafts/${draft.id}/still`, { responseType: 'blob' })
|
||||||
const filename = draft.stillFilename || 'held-still.png'
|
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.'
|
statusMessage.value = 'Held job restored. Generate when ComfyUI is free.'
|
||||||
} catch {
|
} catch {
|
||||||
statusMessage.value = 'Held job settings restored. Re-drop the still if the image is missing.'
|
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 {
|
try {
|
||||||
await $fetch(`/api/library/clips/${item.id}`, { method: 'DELETE' })
|
await $fetch(`/api/library/clips/${item.id}`, {
|
||||||
if (videoUrl.value.includes(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 = ''
|
videoUrl.value = ''
|
||||||
awaitingReveal.value = false
|
awaitingReveal.value = false
|
||||||
concealOutput.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 select = event.target as HTMLSelectElement
|
||||||
const destId = select.value
|
const destId = select.value
|
||||||
select.value = ''
|
select.value = ''
|
||||||
@@ -1971,21 +2178,25 @@ async function onMoveClip(item: LibraryClip, event: Event) {
|
|||||||
try {
|
try {
|
||||||
applyLibrary(await $fetch(`/api/library/clips/${item.id}`, {
|
applyLibrary(await $fetch(`/api/library/clips/${item.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { folderId: destId }
|
body: { folderId: destId, family: family || undefined }
|
||||||
}))
|
}))
|
||||||
const dest = folders.value.find(folder => folder.id === destId)
|
const dest = folders.value.find(folder => folder.id === destId)
|
||||||
if (dest?.unlocked) browseFolderId.value = destId
|
if (dest?.unlocked) browseFolderId.value = destId
|
||||||
statusMessage.value = dest?.unlocked
|
statusMessage.value = dest?.unlocked
|
||||||
? `Moved to ${dest.name}`
|
? `Moved ${family ? 'collection' : 'clip'} to ${dest.name}`
|
||||||
: `Moved to ${dest?.name || 'folder'} (locked)`
|
: `Moved ${family ? 'collection' : 'clip'} to ${dest?.name || 'folder'} (locked)`
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast(error?.data?.statusMessage || 'Could not move clip')
|
toast(error?.data?.statusMessage || 'Could not move clip')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startRename(item: LibraryClip) {
|
function startRename(item: LibraryClip, family = false) {
|
||||||
renamingId.value = item.id
|
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(() => {
|
nextTick(() => {
|
||||||
renameInput.value?.focus()
|
renameInput.value?.focus()
|
||||||
renameInput.value?.select()
|
renameInput.value?.select()
|
||||||
@@ -1994,31 +2205,33 @@ function startRename(item: LibraryClip) {
|
|||||||
|
|
||||||
function cancelRename() {
|
function cancelRename() {
|
||||||
renamingId.value = ''
|
renamingId.value = ''
|
||||||
|
renamingFamily.value = false
|
||||||
renameDraft.value = ''
|
renameDraft.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveRename(item: LibraryClip) {
|
async function saveRename(item: LibraryClip, family = false) {
|
||||||
if (renamingId.value !== item.id) return
|
if (renamingId.value !== item.id || renamingFamily.value !== family) return
|
||||||
const name = renameDraft.value.trim().slice(0, 80)
|
const name = renameDraft.value.trim().slice(0, 80)
|
||||||
if (!name) {
|
if (!name) {
|
||||||
toast('A name is required')
|
toast('A name is required')
|
||||||
nextTick(() => renameInput.value?.focus())
|
nextTick(() => renameInput.value?.focus())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (name === item.name) {
|
const current = family ? familyBaseName(item.name || '') : item.name
|
||||||
|
if (name === current) {
|
||||||
cancelRename()
|
cancelRename()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
applyLibrary(await $fetch(`/api/library/clips/${item.id}`, {
|
applyLibrary(await $fetch(`/api/library/clips/${item.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { name }
|
body: { name, family: family || undefined }
|
||||||
}))
|
}))
|
||||||
cancelRename()
|
cancelRename()
|
||||||
if (videoUrl.value.includes(item.id)) {
|
if (videoUrl.value.includes(item.id)) {
|
||||||
videoDownloadName.value = clipFileName(name)
|
videoDownloadName.value = clipFileName(name)
|
||||||
}
|
}
|
||||||
statusMessage.value = `Renamed to ${name}`
|
statusMessage.value = family ? `Renamed collection to ${familyBaseName(name) || name}` : `Renamed to ${name}`
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast(error?.data?.statusMessage || 'Could not rename clip')
|
toast(error?.data?.statusMessage || 'Could not rename clip')
|
||||||
}
|
}
|
||||||
@@ -2061,7 +2274,7 @@ async function editImage() {
|
|||||||
editResultUrl.value = ''
|
editResultUrl.value = ''
|
||||||
editAwaitingReveal.value = false
|
editAwaitingReveal.value = false
|
||||||
editLockedSave.value = false
|
editLockedSave.value = false
|
||||||
editStatusMessage.value = 'Checking sidecar ComfyUI...'
|
editStatusMessage.value = 'Choosing Beast or Sidecar...'
|
||||||
editMaxStep.value = editSteps.value
|
editMaxStep.value = editSteps.value
|
||||||
startTimer('edit')
|
startTimer('edit')
|
||||||
try {
|
try {
|
||||||
@@ -2119,11 +2332,6 @@ async function generate() {
|
|||||||
toast('Choose a library folder before generating.')
|
toast('Choose a library folder before generating.')
|
||||||
return
|
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 shots = shotScriptMode.value ? parsedShots.value : null
|
||||||
const initialPrompt = (shots?.[0]?.prompt || prompt.value).trim()
|
const initialPrompt = (shots?.[0]?.prompt || prompt.value).trim()
|
||||||
const queued = shots
|
const queued = shots
|
||||||
@@ -2173,9 +2381,9 @@ async function generate() {
|
|||||||
body.append('workflow', videoWorkflow.value)
|
body.append('workflow', videoWorkflow.value)
|
||||||
body.append('useIdentityRefs', String(videoWorkflow.value === 'v2' && useIdentityRefs.value))
|
body.append('useIdentityRefs', String(videoWorkflow.value === 'v2' && useIdentityRefs.value))
|
||||||
if (videoWorkflow.value === 'v2' && useIdentityRefs.value) {
|
if (videoWorkflow.value === 'v2' && useIdentityRefs.value) {
|
||||||
for (const ref of identityFiles) {
|
identityRefs.value.forEach((ref, index) => {
|
||||||
body.append('refImage', ref)
|
if (ref) body.append(`refImage${index}`, ref)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
if (activeDraftId) body.append('draftId', activeDraftId)
|
if (activeDraftId) body.append('draftId', activeDraftId)
|
||||||
if (queued.length) {
|
if (queued.length) {
|
||||||
@@ -2443,7 +2651,7 @@ async function cancelJob() {
|
|||||||
else statusMessage.value = 'Job cancelled. ComfyUI is idle — generate again.'
|
else statusMessage.value = 'Job cancelled. ComfyUI is idle — generate again.'
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast(error?.statusMessage || error?.data?.statusMessage || 'Failed to interrupt ComfyUI')
|
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.'
|
else statusMessage.value = 'Cancelled locally. Generate again when ComfyUI is free.'
|
||||||
}
|
}
|
||||||
if (kind === 'edit') {
|
if (kind === 'edit') {
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ function inputRoots() {
|
|||||||
const roots = [
|
const roots = [
|
||||||
process.env.COMFY_INPUT_DIR || join(local, 'Comfy-Desktop', 'ComfyUI-Shared', 'input')
|
process.env.COMFY_INPUT_DIR || join(local, 'Comfy-Desktop', 'ComfyUI-Shared', 'input')
|
||||||
]
|
]
|
||||||
|
const cwd = process.env.COMFY_LAUNCH_CWD
|
||||||
|
if (cwd) roots.push(join(cwd, 'input'))
|
||||||
const installs = join(local, 'Comfy-Desktop', 'ComfyUI-Installs')
|
const installs = join(local, 'Comfy-Desktop', 'ComfyUI-Installs')
|
||||||
try {
|
try {
|
||||||
for (const dir of readdirSync(installs, { withFileTypes: true })) {
|
for (const dir of readdirSync(installs, { withFileTypes: true })) {
|
||||||
@@ -136,7 +138,14 @@ function inputRoots() {
|
|||||||
roots.push(join(installs, dir.name, 'ComfyUI', 'input'))
|
roots.push(join(installs, dir.name, 'ComfyUI', 'input'))
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return roots
|
return [...new Set(roots)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputRoots() {
|
||||||
|
const roots = [sharedRoots().output]
|
||||||
|
const cwd = process.env.COMFY_LAUNCH_CWD
|
||||||
|
if (cwd) roots.push(join(cwd, 'output'))
|
||||||
|
return [...new Set(roots)]
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeFile(root, subfolder, filename) {
|
function safeFile(root, subfolder, filename) {
|
||||||
@@ -171,7 +180,6 @@ function readJson(req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function purgeDesktopFiles(body) {
|
function purgeDesktopFiles(body) {
|
||||||
const roots = sharedRoots()
|
|
||||||
const deleted = []
|
const deleted = []
|
||||||
const imageName = String(body?.imageName || body?.filename || '')
|
const imageName = String(body?.imageName || body?.filename || '')
|
||||||
const imageSub = String(body?.imageSubfolder || '')
|
const imageSub = String(body?.imageSubfolder || '')
|
||||||
@@ -189,8 +197,28 @@ function purgeDesktopFiles(body) {
|
|||||||
const video = body?.video || null
|
const video = body?.video || null
|
||||||
const videoName = String(video?.filename || '')
|
const videoName = String(video?.filename || '')
|
||||||
if (videoName) {
|
if (videoName) {
|
||||||
const outputPath = safeFile(roots.output, video.subfolder || 'video', videoName)
|
for (const root of outputRoots()) {
|
||||||
if (removeFile(outputPath)) deleted.push(outputPath)
|
const nested = safeFile(root, video.subfolder || 'video', videoName)
|
||||||
|
if (removeFile(nested)) deleted.push(nested)
|
||||||
|
const flat = safeFile(root, '', videoName)
|
||||||
|
if (removeFile(flat)) deleted.push(flat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const output = body?.output || null
|
||||||
|
const outputName = String(output?.filename || '')
|
||||||
|
if (outputName) {
|
||||||
|
const type = String(output?.type || 'output')
|
||||||
|
const sub = String(output?.subfolder || '')
|
||||||
|
const rootsForType = type === 'input' ? inputRoots() : outputRoots()
|
||||||
|
for (const root of rootsForType) {
|
||||||
|
if (!existsSync(root)) continue
|
||||||
|
const nested = safeFile(root, sub, outputName)
|
||||||
|
if (removeFile(nested)) deleted.push(nested)
|
||||||
|
if (sub) {
|
||||||
|
const flat = safeFile(root, '', outputName)
|
||||||
|
if (removeFile(flat)) deleted.push(flat)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'purge', deleted: deleted.length, files: deleted.map((path) => basename(path)), imageName }))
|
console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'purge', deleted: deleted.length, files: deleted.map((path) => basename(path)), imageName }))
|
||||||
return { ok: true, deleted }
|
return { ok: true, deleted }
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ def main():
|
|||||||
"set COMFY_HOST=http://127.0.0.1:8188",
|
"set COMFY_HOST=http://127.0.0.1:8188",
|
||||||
f"set COMFY_CONTROL_TOKEN={token}",
|
f"set COMFY_CONTROL_TOKEN={token}",
|
||||||
r"set COMFY_LAUNCH_CWD=C:\ComfyUI-QwenEdit\ComfyUI",
|
r"set COMFY_LAUNCH_CWD=C:\ComfyUI-QwenEdit\ComfyUI",
|
||||||
r"set COMFY_LAUNCH_CMD=C:\ComfyUI-QwenEdit\python\python.exe C:\ComfyUI-QwenEdit\ComfyUI\main.py --listen 0.0.0.0 --port 8188",
|
r"set COMFY_INPUT_DIR=C:\ComfyUI-QwenEdit\ComfyUI\input",
|
||||||
|
r"set COMFY_OUTPUT_DIR=C:\ComfyUI-QwenEdit\ComfyUI\output",
|
||||||
|
r"set COMFY_LAUNCH_CMD=C:\ComfyUI-QwenEdit\python\python.exe C:\ComfyUI-QwenEdit\ComfyUI\main.py --listen 0.0.0.0 --port 8188 --disable-auto-launch --lowvram --fp8_e4m3fn-unet --disable-cuda-malloc",
|
||||||
r'"C:\Users\comfyapi\aigen-agent\node.exe" comfy-host-agent.mjs',
|
r'"C:\Users\comfyapi\aigen-agent\node.exe" comfy-host-agent.mjs',
|
||||||
"",
|
"",
|
||||||
])
|
])
|
||||||
|
|||||||
+28
-11
@@ -1,5 +1,5 @@
|
|||||||
import { imageComfyConfigured, uploadImageEdit, queueImagePrompt, waitForImageEdit, downloadEditedImage } from '~/server/utils/imageComfy'
|
import { imageComfyConfigured, uploadImageEdit, queueImagePrompt, waitForImageEdit, downloadEditedImage, purgeImageComfyArtifacts, withImageComfyHost } from '~/server/utils/imageComfy'
|
||||||
import { ensureImageComfyReady } from '~/server/utils/imageComfyLifecycle'
|
import { pickImageEditHost } from '~/server/utils/imageComfyLifecycle'
|
||||||
import { buildEditWorkflow } from '~/server/utils/imageWorkflow'
|
import { buildEditWorkflow } from '~/server/utils/imageWorkflow'
|
||||||
import { imageDimensions } from '~/server/utils/resolution'
|
import { imageDimensions } from '~/server/utils/resolution'
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!imageComfyConfigured()) {
|
if (!imageComfyConfigured()) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 503,
|
statusCode: 503,
|
||||||
statusMessage: 'Image edit ComfyUI is not configured yet. Set IMAGE_COMFY_HOST on this instance.'
|
statusMessage: 'Image edit ComfyUI is not configured yet. Set COMFY_HOST for Beast and IMAGE_COMFY_HOST for Sidecar overflow.'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
seed,
|
seed,
|
||||||
cfg
|
cfg
|
||||||
}
|
}
|
||||||
emitJob(job, { type: 'status', message: 'Checking sidecar ComfyUI...', progress: 2 })
|
emitJob(job, { type: 'status', message: 'Choosing Beast or Sidecar...', progress: 2 })
|
||||||
|
|
||||||
void runEdit(job, {
|
void runEdit(job, {
|
||||||
image,
|
image,
|
||||||
@@ -127,7 +127,7 @@ async function runEdit(
|
|||||||
denoise: number
|
denoise: number
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
await ensureImageComfyReady((status) => {
|
const target = await pickImageEditHost((status) => {
|
||||||
emitJob(job, {
|
emitJob(job, {
|
||||||
type: status.state === 'busy' ? 'busy' : 'status',
|
type: status.state === 'busy' ? 'busy' : 'status',
|
||||||
message: status.message,
|
message: status.message,
|
||||||
@@ -135,13 +135,23 @@ async function runEdit(
|
|||||||
busy: status.state === 'busy'
|
busy: status.state === 'busy'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
job.imageComfyHost = target.host
|
||||||
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
||||||
|
|
||||||
emitJob(job, { type: 'status', message: 'Uploading still to Qwen Edit...', progress: 8 })
|
await withImageComfyHost(target.host, async () => {
|
||||||
|
emitJob(job, {
|
||||||
|
type: 'status',
|
||||||
|
message: `Uploading still to ${target.label}...`,
|
||||||
|
progress: 8
|
||||||
|
})
|
||||||
const uploaded = await uploadImageEdit(params.image, job.id)
|
const uploaded = await uploadImageEdit(params.image, job.id)
|
||||||
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
if (job.status === 'cancelled') throw new Error('Job interrupted.')
|
||||||
|
|
||||||
emitJob(job, { type: 'status', message: 'Queueing Qwen Image Edit Lightning...', progress: 12 })
|
emitJob(job, {
|
||||||
|
type: 'status',
|
||||||
|
message: `Queueing Qwen Image Edit on ${target.label}...`,
|
||||||
|
progress: 12
|
||||||
|
})
|
||||||
const graph = buildEditWorkflow({
|
const graph = buildEditWorkflow({
|
||||||
prompt: params.prompt,
|
prompt: params.prompt,
|
||||||
negative: params.negative,
|
negative: params.negative,
|
||||||
@@ -155,7 +165,7 @@ async function runEdit(
|
|||||||
const queued = await queueImagePrompt(graph, job.clientId)
|
const queued = await queueImagePrompt(graph, job.clientId)
|
||||||
job.promptId = queued.prompt_id
|
job.promptId = queued.prompt_id
|
||||||
job.status = 'running'
|
job.status = 'running'
|
||||||
emitJob(job, { type: 'status', message: 'Editing still...', progress: 18, maxStep: params.steps })
|
emitJob(job, { type: 'status', message: `Editing still on ${target.label}...`, progress: 18, maxStep: params.steps })
|
||||||
|
|
||||||
const output = await waitForImageEdit({
|
const output = await waitForImageEdit({
|
||||||
promptId: queued.prompt_id,
|
promptId: queued.prompt_id,
|
||||||
@@ -185,17 +195,24 @@ async function runEdit(
|
|||||||
height: size?.height || 0,
|
height: size?.height || 0,
|
||||||
hideInput: job.library?.hideInput
|
hideInput: job.library?.hideInput
|
||||||
})
|
})
|
||||||
job.stillId = still.id
|
job.stillId = still?.id
|
||||||
|
await purgeImageComfyArtifacts({
|
||||||
|
output,
|
||||||
|
inputName: uploaded.name,
|
||||||
|
inputSubfolder: uploaded.subfolder,
|
||||||
|
promptId: job.promptId
|
||||||
|
})
|
||||||
job.status = 'complete'
|
job.status = 'complete'
|
||||||
emitJob(job, {
|
emitJob(job, {
|
||||||
type: 'complete',
|
type: 'complete',
|
||||||
message: 'Edit finished',
|
message: `Edit finished on ${target.label}`,
|
||||||
progress: 100,
|
progress: 100,
|
||||||
stillId: still.id,
|
stillId: still?.id,
|
||||||
filename: output.filename,
|
filename: output.filename,
|
||||||
subfolder: output.subfolder,
|
subfolder: output.subfolder,
|
||||||
mediaType: 'image',
|
mediaType: 'image',
|
||||||
hideThumbnail: job.hideThumbnail,
|
hideThumbnail: job.hideThumbnail,
|
||||||
folderLocked: job.library?.folderLocked
|
folderLocked: job.library?.folderLocked
|
||||||
})
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ async function runExtend(
|
|||||||
copyFileSync(params.sourcePath, params.part1Path)
|
copyFileSync(params.sourcePath, params.part1Path)
|
||||||
await extractLastFrame(params.part1Path, params.framePath)
|
await extractLastFrame(params.part1Path, params.framePath)
|
||||||
const frame = readFileSync(params.framePath)
|
const frame = readFileSync(params.framePath)
|
||||||
|
if (job.library) job.library.thumb = undefined
|
||||||
const sound = await probeHasAudio(params.part1Path)
|
const sound = await probeHasAudio(params.part1Path)
|
||||||
|
|
||||||
await ensureComfyReady((status) => {
|
await ensureComfyReady((status) => {
|
||||||
|
|||||||
+25
-12
@@ -83,13 +83,20 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const fields: Record<string, string> = {}
|
const fields: Record<string, string> = {}
|
||||||
let image: { filename: string; data: Buffer; type?: string } | null = null
|
let image: { filename: string; data: Buffer; type?: string } | null = null
|
||||||
const referenceImages: { filename: string; data: Buffer; type?: string }[] = []
|
const referenceImages: Array<{ filename: string; data: Buffer; type?: string } | null> = [null, null, null, null]
|
||||||
|
let compactRefSlot = 0
|
||||||
for (const part of form) {
|
for (const part of form) {
|
||||||
if (part.name === 'image' && part.filename && part.data?.length) {
|
if (part.name === 'image' && part.filename && part.data?.length) {
|
||||||
image = { filename: part.filename, data: part.data, type: part.type }
|
image = { filename: part.filename, data: part.data, type: part.type }
|
||||||
|
} else if (part.name && /^refImage(\d)$/.test(part.name) && part.filename && part.data?.length) {
|
||||||
|
const slot = Number(part.name.replace('refImage', ''))
|
||||||
|
if (slot >= 0 && slot < 4) {
|
||||||
|
referenceImages[slot] = { filename: part.filename, data: part.data, type: part.type }
|
||||||
|
}
|
||||||
} else if (part.name === 'refImage' && part.filename && part.data?.length) {
|
} else if (part.name === 'refImage' && part.filename && part.data?.length) {
|
||||||
if (referenceImages.length < 4) {
|
if (compactRefSlot < 4) {
|
||||||
referenceImages.push({ filename: part.filename, data: part.data, type: part.type })
|
referenceImages[compactRefSlot] = { filename: part.filename, data: part.data, type: part.type }
|
||||||
|
compactRefSlot += 1
|
||||||
}
|
}
|
||||||
} else if (part.name && part.data) {
|
} else if (part.name && part.data) {
|
||||||
fields[part.name] = part.data.toString('utf8')
|
fields[part.name] = part.data.toString('utf8')
|
||||||
@@ -105,7 +112,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
const extensions = parseExtensions(fields.extensions)
|
const extensions = parseExtensions(fields.extensions)
|
||||||
const workflow = parseVideoWorkflow(fields.workflow)
|
const workflow = parseVideoWorkflow(fields.workflow)
|
||||||
const useIdentityRefs = workflow === 'v2' && fields.useIdentityRefs === 'true' && referenceImages.length > 0
|
const useIdentityRefs = workflow === 'v2' && fields.useIdentityRefs === 'true'
|
||||||
|
|
||||||
const { width, height } = resolveOutputSize(fields.aspect, image.data, Number(fields.width), Number(fields.height))
|
const { width, height } = resolveOutputSize(fields.aspect, image.data, Number(fields.width), Number(fields.height))
|
||||||
const turbo = fields.turbo !== 'false'
|
const turbo = fields.turbo !== 'false'
|
||||||
@@ -134,7 +141,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
const destFolder = library.folders.find(folder => folder.id === folderId)
|
const destFolder = library.folders.find(folder => folder.id === folderId)
|
||||||
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
||||||
assertFolderExists(event, folderId)
|
assertFolderExists(event, folderId)
|
||||||
const still = await saveStill({
|
const still = isPipelineFrameFilename(image.filename)
|
||||||
|
? null
|
||||||
|
: await saveStill({
|
||||||
ownerKey,
|
ownerKey,
|
||||||
folderId,
|
folderId,
|
||||||
filename: image.filename,
|
filename: image.filename,
|
||||||
@@ -167,9 +176,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
fps,
|
fps,
|
||||||
samplerName,
|
samplerName,
|
||||||
scheduler,
|
scheduler,
|
||||||
thumb: image.data,
|
thumb: isPipelineFrameFilename(image.filename) ? undefined : image.data,
|
||||||
stillId: still.id,
|
stillId: still?.id,
|
||||||
stillFilename: still.filename,
|
stillFilename: still?.filename,
|
||||||
duration: durationSeconds,
|
duration: durationSeconds,
|
||||||
sound,
|
sound,
|
||||||
draftId: (fields.draftId || '').trim() || undefined,
|
draftId: (fields.draftId || '').trim() || undefined,
|
||||||
@@ -285,7 +294,7 @@ type GenerateParams = {
|
|||||||
workflow: 'v1' | 'v2'
|
workflow: 'v1' | 'v2'
|
||||||
duration: number
|
duration: number
|
||||||
useIdentityRefs: boolean
|
useIdentityRefs: boolean
|
||||||
referenceImages: { filename: string; data: Buffer; type?: string }[]
|
referenceImages: Array<{ filename: string; data: Buffer; type?: string } | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
async function queueMiniMax(
|
async function queueMiniMax(
|
||||||
@@ -308,15 +317,16 @@ async function queueMiniMax(
|
|||||||
: 'Queueing MiniMax H3 job...'
|
: 'Queueing MiniMax H3 job...'
|
||||||
emitChainJob(job, { type: 'status', message: uploading, progress: 4 })
|
emitChainJob(job, { type: 'status', message: uploading, progress: 4 })
|
||||||
const uploaded = await uploadImage(params.image, job.id)
|
const uploaded = await uploadImage(params.image, job.id)
|
||||||
const referenceNames: string[] = []
|
const referenceNames: string[] = ['', '', '', '']
|
||||||
const chainIndex = job.library?.chainIndex || 0
|
const chainIndex = job.library?.chainIndex || 0
|
||||||
if (chainIndex === 0) {
|
if (chainIndex === 0) {
|
||||||
for (const [index, ref] of (params.referenceImages || []).entries()) {
|
for (const [index, ref] of (params.referenceImages || []).entries()) {
|
||||||
|
if (!ref?.data?.length) continue
|
||||||
const next = await uploadImage({
|
const next = await uploadImage({
|
||||||
...ref,
|
...ref,
|
||||||
filename: `ref${index + 1}_${ref.filename || 'identity.png'}`
|
filename: `ref${index + 1}_${ref.filename || 'identity.png'}`
|
||||||
}, job.id)
|
}, job.id)
|
||||||
referenceNames.push(next.name)
|
referenceNames[index] = next.name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (job.library) {
|
if (job.library) {
|
||||||
@@ -378,7 +388,9 @@ async function queueMiniMax(
|
|||||||
extendPart1Path: job.library.extendPart1Path,
|
extendPart1Path: job.library.extendPart1Path,
|
||||||
familyId: job.library.familyId,
|
familyId: job.library.familyId,
|
||||||
parentClipId: job.library.parentClipId,
|
parentClipId: job.library.parentClipId,
|
||||||
chainIndex: job.library.chainIndex
|
chainIndex: job.library.chainIndex,
|
||||||
|
stillId: job.library.stillId,
|
||||||
|
sound: job.library.sound
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
emitChainJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
|
emitChainJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
|
||||||
@@ -430,6 +442,7 @@ async function runGeneration(
|
|||||||
job.library.chainStep = i + 2
|
job.library.chainStep = i + 2
|
||||||
job.library.chainLabel = `Extension ${i + 1}`
|
job.library.chainLabel = `Extension ${i + 1}`
|
||||||
job.library.extendPart1Path = undefined
|
job.library.extendPart1Path = undefined
|
||||||
|
job.library.thumb = undefined
|
||||||
|
|
||||||
emitChainJob(job, { type: 'status', message: 'Waiting 3s buffer...', progress: 1 })
|
emitChainJob(job, { type: 'status', message: 'Waiting 3s buffer...', progress: 1 })
|
||||||
await sleep(3000)
|
await sleep(3000)
|
||||||
|
|||||||
+40
-12
@@ -1,23 +1,36 @@
|
|||||||
import { checkComfyHttp, fetchQueue } from '~/server/utils/comfyLifecycle'
|
import { checkComfyHttp, fetchQueue } from '~/server/utils/comfyLifecycle'
|
||||||
import { comfyConfigured } from '~/server/utils/comfy'
|
import { comfyConfigured } from '~/server/utils/comfy'
|
||||||
import { imageComfyConfigured } from '~/server/utils/imageComfy'
|
import { imageComfyConfigured, getBeastImageHost, getSidecarImageHost, sameImageHost } from '~/server/utils/imageComfy'
|
||||||
import { checkImageComfyHttp, fetchImageQueue } from '~/server/utils/imageComfyLifecycle'
|
import { checkImageComfyHttp, fetchQueueAt } from '~/server/utils/imageComfyLifecycle'
|
||||||
|
|
||||||
export default defineEventHandler(async () => {
|
export default defineEventHandler(async () => {
|
||||||
const videoConfigured = comfyConfigured()
|
const videoConfigured = comfyConfigured()
|
||||||
const imageConfigured = imageComfyConfigured()
|
const imageConfigured = imageComfyConfigured()
|
||||||
const [comfy, queue, imageComfy, imageQueue] = await Promise.all([
|
const beastHost = getBeastImageHost()
|
||||||
|
const sidecarHost = getSidecarImageHost()
|
||||||
|
const [comfy, queue, beast, beastQueue, sidecar, sidecarQueue] = await Promise.all([
|
||||||
videoConfigured
|
videoConfigured
|
||||||
? checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
|
? checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
|
||||||
: Promise.resolve({ ok: false, host: '', stats: null }),
|
: Promise.resolve({ ok: false, host: '', stats: null }),
|
||||||
videoConfigured ? fetchQueue() : Promise.resolve({ running: 0, pending: 0 }),
|
videoConfigured ? fetchQueue() : Promise.resolve({ running: 0, pending: 0 }),
|
||||||
imageConfigured
|
beastHost
|
||||||
? checkImageComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
|
? checkImageComfyHttp(2500, beastHost).catch(() => ({ ok: false, host: beastHost, stats: null }))
|
||||||
: Promise.resolve({ ok: false, host: '', stats: null }),
|
: Promise.resolve({ ok: false, host: beastHost, stats: null }),
|
||||||
imageConfigured ? fetchImageQueue() : Promise.resolve({ running: 0, pending: 0 })
|
beastHost ? fetchQueueAt(beastHost) : Promise.resolve({ running: 0, pending: 0 }),
|
||||||
|
sidecarHost && (!beastHost || !sameImageHost(beastHost, sidecarHost))
|
||||||
|
? checkImageComfyHttp(2500, sidecarHost).catch(() => ({ ok: false, host: sidecarHost, stats: null }))
|
||||||
|
: Promise.resolve({ ok: false, host: sidecarHost, stats: null }),
|
||||||
|
sidecarHost && (!beastHost || !sameImageHost(beastHost, sidecarHost))
|
||||||
|
? fetchQueueAt(sidecarHost)
|
||||||
|
: Promise.resolve({ running: 0, pending: 0 })
|
||||||
])
|
])
|
||||||
const busy = Boolean(comfy.ok && queue.running > 0)
|
const busy = Boolean(comfy.ok && queue.running > 0)
|
||||||
const imageBusy = Boolean(imageComfy.ok && imageQueue.running > 0)
|
const beastBusy = Boolean(beast.ok && beastQueue.running > 0)
|
||||||
|
const sidecarBusy = Boolean(sidecar.ok && sidecarQueue.running > 0)
|
||||||
|
const sidecarConfigured = Boolean(sidecarHost && (!beastHost || !sameImageHost(beastHost, sidecarHost)))
|
||||||
|
const imageOk = Boolean(beast.ok || sidecar.ok)
|
||||||
|
const imageBusy = Boolean(imageConfigured && ((beastBusy && (!sidecarConfigured || sidecarBusy || !sidecar.ok)) || (!beast.ok && sidecarBusy)))
|
||||||
|
const preferredHost = beast.ok && !beastBusy ? beastHost : (sidecar.ok ? sidecarHost : beastHost)
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
service: useRuntimeConfig().public.instanceName || 'aigen',
|
service: useRuntimeConfig().public.instanceName || 'aigen',
|
||||||
@@ -31,11 +44,26 @@ export default defineEventHandler(async () => {
|
|||||||
},
|
},
|
||||||
imageComfy: {
|
imageComfy: {
|
||||||
configured: imageConfigured,
|
configured: imageConfigured,
|
||||||
ok: imageComfy.ok,
|
ok: imageOk,
|
||||||
busy: imageBusy,
|
busy: imageBusy,
|
||||||
host: imageComfy.host || '',
|
host: preferredHost || '',
|
||||||
queueRunning: imageQueue.running,
|
box: beast.ok && !beastBusy ? 'beast' : (sidecar.ok ? 'sidecar' : (beast.ok ? 'beast' : '')),
|
||||||
queuePending: imageQueue.pending
|
queueRunning: (beast.ok && !beastBusy ? beastQueue.running : sidecarQueue.running) || 0,
|
||||||
|
queuePending: (beast.ok && !beastBusy ? beastQueue.pending : sidecarQueue.pending) || 0,
|
||||||
|
beast: {
|
||||||
|
label: 'Beast',
|
||||||
|
configured: Boolean(beastHost),
|
||||||
|
ok: Boolean(beast.ok),
|
||||||
|
busy: beastBusy,
|
||||||
|
host: beastHost || ''
|
||||||
|
},
|
||||||
|
sidecar: {
|
||||||
|
label: 'Sidecar',
|
||||||
|
configured: sidecarConfigured,
|
||||||
|
ok: Boolean(sidecar.ok),
|
||||||
|
busy: sidecarBusy,
|
||||||
|
host: sidecarHost || ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (jobId) deletePendingJob(jobId)
|
if (jobId) deletePendingJob(jobId)
|
||||||
const kind = job?.kind || (body?.kind === 'edit' ? 'edit' : 'video')
|
const kind = job?.kind || (body?.kind === 'edit' ? 'edit' : 'video')
|
||||||
const ok = kind === 'edit'
|
const ok = kind === 'edit'
|
||||||
? await interruptImageComfy()
|
? await interruptImageComfy(job?.imageComfyHost)
|
||||||
: await interruptComfy()
|
: await interruptComfy()
|
||||||
if (job) {
|
if (job) {
|
||||||
emitJob(job, { type: 'error', error: 'Job interrupted.', message: 'Job interrupted.' })
|
emitJob(job, { type: 'error', error: 'Job interrupted.', message: 'Job interrupted.' })
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
const id = String(getRouterParam(event, 'id') || '')
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
const clip = getClip(owner, id)
|
const clip = getClip(owner, id)
|
||||||
assertFolderAccess(event, clip.folderId)
|
assertFolderAccess(event, clip.folderId)
|
||||||
await deleteClip(owner, id)
|
const family = ['1', 'true'].includes(String(getQuery(event).family || '').toLowerCase())
|
||||||
|
if (family) await deleteClipFamily(owner, id)
|
||||||
|
else await deleteClip(owner, id)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const { owner } = assertLibraryOwner(event)
|
const { owner } = assertLibraryOwner(event)
|
||||||
const id = String(getRouterParam(event, 'id') || '')
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
const body = await readBody<{ folderId?: string; name?: string }>(event).catch(() => ({} as { folderId?: string; name?: string }))
|
const body = await readBody<{ folderId?: string; name?: string; family?: boolean }>(event).catch(() => ({} as { folderId?: string; name?: string; family?: boolean }))
|
||||||
const folderId = String(body?.folderId || '')
|
const folderId = String(body?.folderId || '')
|
||||||
const hasName = typeof body?.name === 'string'
|
const hasName = typeof body?.name === 'string'
|
||||||
|
const family = body?.family === true
|
||||||
if (!folderId && !hasName) {
|
if (!folderId && !hasName) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder or a new name' })
|
throw createError({ statusCode: 400, statusMessage: 'Choose a library folder or a new name' })
|
||||||
}
|
}
|
||||||
@@ -11,8 +12,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
assertFolderAccess(event, clip.folderId)
|
assertFolderAccess(event, clip.folderId)
|
||||||
if (folderId) {
|
if (folderId) {
|
||||||
assertFolderExists(event, folderId)
|
assertFolderExists(event, folderId)
|
||||||
await moveClip(owner, id, folderId)
|
if (family) await moveClipFamily(owner, id, folderId)
|
||||||
|
else await moveClip(owner, id, folderId)
|
||||||
|
}
|
||||||
|
if (hasName) {
|
||||||
|
if (family) await renameClipFamily(owner, id, body.name || '')
|
||||||
|
else await renameClip(owner, id, body.name || '')
|
||||||
}
|
}
|
||||||
if (hasName) await renameClip(owner, id, body.name || '')
|
|
||||||
return publicLibrary(event)
|
return publicLibrary(event)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const folderId = library.folders.find(folder => folder.unlocked)?.id
|
const folderId = library.folders.find(folder => folder.unlocked)?.id
|
||||||
if (folderId) await importMissingComfyVideos(owner, folderId)
|
if (folderId) await importMissingComfyVideos(owner, folderId)
|
||||||
await ensureClipThumbnails(owner)
|
await ensureClipThumbnails(owner)
|
||||||
|
await sweepOwnerExtensionStills(owner)
|
||||||
} catch {
|
} catch {
|
||||||
return publicLibrary(event)
|
return publicLibrary(event)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,5 +33,5 @@ export default defineEventHandler(async (event) => {
|
|||||||
height: Number(fields.height || 0),
|
height: Number(fields.height || 0),
|
||||||
hideInput: fields.hideInput === 'true'
|
hideInput: fields.hideInput === 'true'
|
||||||
})
|
})
|
||||||
return { still, ...publicLibrary(event) }
|
return { still: still || undefined, ...publicLibrary(event) }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
},
|
},
|
||||||
"141": {
|
"141": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"image": ""
|
"image": "Mokker (1).jpeg"
|
||||||
},
|
},
|
||||||
"class_type": "LoadImage",
|
"class_type": "LoadImage",
|
||||||
"_meta": {
|
"_meta": {
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
},
|
},
|
||||||
"142": {
|
"142": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"image": ""
|
"image": "Mokker (1).jpeg"
|
||||||
},
|
},
|
||||||
"class_type": "LoadImage",
|
"class_type": "LoadImage",
|
||||||
"_meta": {
|
"_meta": {
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
},
|
},
|
||||||
"143": {
|
"143": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"image": ""
|
"image": "Mokker (1).jpeg"
|
||||||
},
|
},
|
||||||
"class_type": "LoadImage",
|
"class_type": "LoadImage",
|
||||||
"_meta": {
|
"_meta": {
|
||||||
@@ -107,7 +107,10 @@
|
|||||||
"127",
|
"127",
|
||||||
1
|
1
|
||||||
],
|
],
|
||||||
"length": 241,
|
"length": [
|
||||||
|
"174",
|
||||||
|
1
|
||||||
|
],
|
||||||
"ref_image_size": "match",
|
"ref_image_size": "match",
|
||||||
"clip": [
|
"clip": [
|
||||||
"170",
|
"170",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"1": {
|
"1": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"unet_name": "qwen_image_edit_2509_fp8_e4m3fn.safetensors",
|
"unet_name": "split_files\\diffusion_models\\qwen_image_edit_2509_fp8_e4m3fn.safetensors",
|
||||||
"weight_dtype": "default"
|
"weight_dtype": "default"
|
||||||
},
|
},
|
||||||
"class_type": "UNETLoader",
|
"class_type": "UNETLoader",
|
||||||
@@ -39,21 +39,22 @@
|
|||||||
"class_type": "LoadImage",
|
"class_type": "LoadImage",
|
||||||
"_meta": { "title": "Load input still" }
|
"_meta": { "title": "Load input still" }
|
||||||
},
|
},
|
||||||
"5": {
|
"17": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"text": "",
|
"prompt": "",
|
||||||
"clip": ["2", 0]
|
"clip": ["2", 0],
|
||||||
|
"vae": ["3", 0],
|
||||||
|
"image": ["4", 0]
|
||||||
},
|
},
|
||||||
"class_type": "CLIPTextEncode",
|
"class_type": "TextEncodeQwenImageEdit",
|
||||||
"_meta": { "title": "Positive prompt" }
|
"_meta": { "title": "Qwen Image Edit prompt" }
|
||||||
},
|
},
|
||||||
"6": {
|
"19": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"text": "blurry, low quality, deformed, bad anatomy, watermark, text, extra fingers",
|
"conditioning": ["17", 0]
|
||||||
"clip": ["2", 0]
|
|
||||||
},
|
},
|
||||||
"class_type": "CLIPTextEncode",
|
"class_type": "ConditioningZeroOut",
|
||||||
"_meta": { "title": "Negative prompt" }
|
"_meta": { "title": "Zero negative" }
|
||||||
},
|
},
|
||||||
"7": {
|
"7": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
@@ -70,10 +71,10 @@
|
|||||||
"cfg": 1,
|
"cfg": 1,
|
||||||
"sampler_name": "euler",
|
"sampler_name": "euler",
|
||||||
"scheduler": "simple",
|
"scheduler": "simple",
|
||||||
"denoise": 0.75,
|
"denoise": 1,
|
||||||
"model": ["11", 0],
|
"model": ["11", 0],
|
||||||
"positive": ["5", 0],
|
"positive": ["17", 0],
|
||||||
"negative": ["6", 0],
|
"negative": ["19", 0],
|
||||||
"latent_image": ["7", 0]
|
"latent_image": ["7", 0]
|
||||||
},
|
},
|
||||||
"class_type": "KSampler",
|
"class_type": "KSampler",
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { sweepAllExtensionStills } from '../utils/library'
|
||||||
|
|
||||||
|
export default defineNitroPlugin(() => {
|
||||||
|
try {
|
||||||
|
const result = sweepAllExtensionStills()
|
||||||
|
if (result.deleted) {
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
src: 'library',
|
||||||
|
event: 'purge-extension-stills',
|
||||||
|
owners: result.owners,
|
||||||
|
deleted: result.deleted,
|
||||||
|
filenames: result.filenames.slice(0, 40)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
src: 'library',
|
||||||
|
event: 'purge-extension-stills-error',
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
+164
-12
@@ -1,17 +1,22 @@
|
|||||||
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||||
|
import { getComfyHost } from '~/server/utils/comfy'
|
||||||
|
|
||||||
|
const imageHostAls = new AsyncLocalStorage<string>()
|
||||||
let imageComfyHostOverride = ''
|
let imageComfyHostOverride = ''
|
||||||
|
|
||||||
|
export type ImageEditBox = 'beast' | 'sidecar'
|
||||||
|
|
||||||
|
export function withImageComfyHost<T>(host: string, fn: () => Promise<T>) {
|
||||||
|
return imageHostAls.run(String(host || '').replace(/\/$/, ''), fn)
|
||||||
|
}
|
||||||
|
|
||||||
export function setImageComfyHostOverride(url: string) {
|
export function setImageComfyHostOverride(url: string) {
|
||||||
imageComfyHostOverride = String(url || '').replace(/\/$/, '')
|
imageComfyHostOverride = String(url || '').replace(/\/$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getImageComfyHost() {
|
function normalizeHost(raw: string, port = '') {
|
||||||
if (imageComfyHostOverride) return imageComfyHostOverride
|
let host = String(raw || '').trim().replace(/\/$/, '')
|
||||||
const config = useRuntimeConfig()
|
if (!host) return ''
|
||||||
let host = String(config.imageComfyHost || process.env.IMAGE_COMFY_HOST || '').trim().replace(/\/$/, '')
|
|
||||||
const port = String(config.imageComfyPort || process.env.IMAGE_COMFY_PORT || '').trim()
|
|
||||||
if (!host) {
|
|
||||||
throw createError({ statusCode: 500, statusMessage: 'IMAGE_COMFY_HOST is not configured' })
|
|
||||||
}
|
|
||||||
if (!/^https?:\/\//i.test(host)) host = `http://${host}`
|
if (!/^https?:\/\//i.test(host)) host = `http://${host}`
|
||||||
try {
|
try {
|
||||||
const url = new URL(host)
|
const url = new URL(host)
|
||||||
@@ -22,13 +27,46 @@ export function getImageComfyHost() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function imageComfyConfigured() {
|
export function getBeastImageHost() {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
return Boolean(String(config.imageComfyHost || process.env.IMAGE_COMFY_HOST || '').trim())
|
const explicit = String(config.imageComfyPrimaryHost || process.env.IMAGE_COMFY_PRIMARY_HOST || '').trim()
|
||||||
|
if (explicit) return normalizeHost(explicit)
|
||||||
|
try {
|
||||||
|
return getComfyHost()
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSidecarImageHost() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const fallback = String(config.imageComfyFallbackHost || process.env.IMAGE_COMFY_FALLBACK_HOST || '').trim()
|
||||||
|
if (fallback) return normalizeHost(fallback)
|
||||||
|
const legacy = String(config.imageComfyHost || process.env.IMAGE_COMFY_HOST || '').trim()
|
||||||
|
const port = String(config.imageComfyPort || process.env.IMAGE_COMFY_PORT || '').trim()
|
||||||
|
return normalizeHost(legacy, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getImageComfyHost() {
|
||||||
|
const fromAls = imageHostAls.getStore()
|
||||||
|
if (fromAls) return fromAls
|
||||||
|
if (imageComfyHostOverride) return imageComfyHostOverride
|
||||||
|
return getBeastImageHost() || getSidecarImageHost()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imageComfyConfigured() {
|
||||||
|
return Boolean(getBeastImageHost() || getSidecarImageHost())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameImageHost(a: string, b: string) {
|
||||||
|
return Boolean(a && b && a.replace(/\/$/, '') === b.replace(/\/$/, ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function imageComfyFetch(path: string, init?: RequestInit) {
|
export async function imageComfyFetch(path: string, init?: RequestInit) {
|
||||||
const host = getImageComfyHost()
|
const host = getImageComfyHost()
|
||||||
|
if (!host) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: 'No image ComfyUI host is configured' })
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return await fetch(`${host}${path}`, init)
|
return await fetch(`${host}${path}`, init)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -88,11 +126,15 @@ export async function queueImagePrompt(graph: unknown, clientId: string) {
|
|||||||
return payload as { prompt_id: string; number?: number }
|
return payload as { prompt_id: string; number?: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function interruptImageComfy() {
|
export async function interruptImageComfy(host?: string) {
|
||||||
if (!imageComfyConfigured()) return false
|
if (!imageComfyConfigured() && !host) return false
|
||||||
|
const run = async () => {
|
||||||
const res = await imageComfyFetch('/interrupt', { method: 'POST' })
|
const res = await imageComfyFetch('/interrupt', { method: 'POST' })
|
||||||
return res.ok
|
return res.ok
|
||||||
}
|
}
|
||||||
|
if (host) return withImageComfyHost(host, run)
|
||||||
|
return run()
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchImageHistory(promptId: string) {
|
export async function fetchImageHistory(promptId: string) {
|
||||||
const res = await imageComfyFetch(`/history/${encodeURIComponent(promptId)}`)
|
const res = await imageComfyFetch(`/history/${encodeURIComponent(promptId)}`)
|
||||||
@@ -253,3 +295,113 @@ export async function waitForImageEdit(opts: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function purgeImageEnabled() {
|
||||||
|
return useRuntimeConfig().purgeComfyOutputs !== false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSidecarFile(file: { filename: string; subfolder?: string; type?: string }) {
|
||||||
|
const payload = {
|
||||||
|
filename: file.filename,
|
||||||
|
subfolder: file.subfolder || '',
|
||||||
|
type: file.type || 'output'
|
||||||
|
}
|
||||||
|
for (const path of ['/delete', '/aigen/purge']) {
|
||||||
|
try {
|
||||||
|
const res = await imageComfyFetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: AbortSignal.timeout(4000)
|
||||||
|
})
|
||||||
|
if (res.ok) return true
|
||||||
|
} catch {
|
||||||
|
/* Comfy may not expose this route */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purgeImageOnDesktop(opts: {
|
||||||
|
inputName?: string
|
||||||
|
inputSubfolder?: string
|
||||||
|
output?: { filename: string; subfolder: string; type: string }
|
||||||
|
}) {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const host = getImageComfyHost()
|
||||||
|
const sidecar = getSidecarImageHost()
|
||||||
|
const useSidecarAgent = sidecar && sameImageHost(host, sidecar)
|
||||||
|
const controlUrl = String(
|
||||||
|
useSidecarAgent
|
||||||
|
? (config.imageComfyControlUrl || process.env.IMAGE_COMFY_CONTROL_URL || '')
|
||||||
|
: (config.comfyControlUrl || process.env.COMFY_CONTROL_URL || '')
|
||||||
|
).replace(/\/$/, '')
|
||||||
|
const token = String(
|
||||||
|
useSidecarAgent
|
||||||
|
? (config.imageComfyControlToken || process.env.IMAGE_COMFY_CONTROL_TOKEN || '')
|
||||||
|
: (config.comfyControlToken || process.env.COMFY_CONTROL_TOKEN || '')
|
||||||
|
)
|
||||||
|
if (!controlUrl) return
|
||||||
|
try {
|
||||||
|
await fetch(`${controlUrl}/purge`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
imageName: opts.inputName || '',
|
||||||
|
imageSubfolder: opts.inputSubfolder || '',
|
||||||
|
output: opts.output || null
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(4000)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
/* Sidecar host agent is optional; Comfy HTTP delete is the primary path. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function purgeImageComfyArtifacts(opts: {
|
||||||
|
output?: { filename: string; subfolder: string; type: string }
|
||||||
|
inputName?: string
|
||||||
|
inputSubfolder?: string
|
||||||
|
promptId?: string
|
||||||
|
}) {
|
||||||
|
if (!purgeImageEnabled()) return
|
||||||
|
const files: { filename: string; subfolder: string; type: string }[] = []
|
||||||
|
if (opts.output?.filename) {
|
||||||
|
files.push({
|
||||||
|
filename: opts.output.filename,
|
||||||
|
subfolder: opts.output.subfolder || '',
|
||||||
|
type: opts.output.type || 'output'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (opts.inputName) {
|
||||||
|
files.push({
|
||||||
|
filename: opts.inputName,
|
||||||
|
subfolder: opts.inputSubfolder || '',
|
||||||
|
type: 'input'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const file of files) {
|
||||||
|
await deleteSidecarFile(file)
|
||||||
|
}
|
||||||
|
await purgeImageOnDesktop({
|
||||||
|
inputName: opts.inputName,
|
||||||
|
inputSubfolder: opts.inputSubfolder,
|
||||||
|
output: opts.output
|
||||||
|
})
|
||||||
|
if (opts.promptId) {
|
||||||
|
try {
|
||||||
|
await imageComfyFetch('/history', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ delete: [opts.promptId] }),
|
||||||
|
signal: AbortSignal.timeout(4000)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getImageComfyHost, setImageComfyHostOverride, imageComfyConfigured } from '~/server/utils/imageComfy'
|
import { getImageComfyHost, setImageComfyHostOverride, imageComfyConfigured, getBeastImageHost, getSidecarImageHost, sameImageHost, withImageComfyHost, type ImageEditBox } from '~/server/utils/imageComfy'
|
||||||
|
|
||||||
export type ImageComfyState = 'online' | 'booting' | 'busy' | 'starting' | 'offline'
|
export type ImageComfyState = 'online' | 'booting' | 'busy' | 'starting' | 'offline'
|
||||||
|
|
||||||
@@ -6,11 +6,18 @@ export interface ImageComfyStatus {
|
|||||||
state: ImageComfyState
|
state: ImageComfyState
|
||||||
message: string
|
message: string
|
||||||
host: string
|
host: string
|
||||||
|
box?: ImageEditBox
|
||||||
processRunning?: boolean
|
processRunning?: boolean
|
||||||
queueRunning?: number
|
queueRunning?: number
|
||||||
queuePending?: number
|
queuePending?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImageEditTarget {
|
||||||
|
host: string
|
||||||
|
box: ImageEditBox
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
type StatusFn = (status: ImageComfyStatus) => void
|
type StatusFn = (status: ImageComfyStatus) => void
|
||||||
|
|
||||||
let gate: Promise<unknown> = Promise.resolve()
|
let gate: Promise<unknown> = Promise.resolve()
|
||||||
@@ -51,6 +58,7 @@ export async function checkImageComfyHttp(timeoutMs = 2500, host?: string) {
|
|||||||
if (!imageComfyConfigured()) return { ok: false as const, host: '', status: 0, stats: null }
|
if (!imageComfyConfigured()) return { ok: false as const, host: '', status: 0, stats: null }
|
||||||
resolved = getImageComfyHost()
|
resolved = getImageComfyHost()
|
||||||
}
|
}
|
||||||
|
if (!resolved) return { ok: false as const, host: '', status: 0, stats: null }
|
||||||
const res = await fetch(`${resolved}/system_stats`, { signal: AbortSignal.timeout(timeoutMs) })
|
const res = await fetch(`${resolved}/system_stats`, { signal: AbortSignal.timeout(timeoutMs) })
|
||||||
if (!res.ok) return { ok: false as const, host: resolved, status: res.status, stats: null }
|
if (!res.ok) return { ok: false as const, host: resolved, status: res.status, stats: null }
|
||||||
const stats = await res.json().catch(() => null)
|
const stats = await res.json().catch(() => null)
|
||||||
@@ -60,9 +68,9 @@ export async function checkImageComfyHttp(timeoutMs = 2500, host?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchImageQueue() {
|
export async function fetchQueueAt(host: string) {
|
||||||
|
if (!host) return { running: 0, pending: 0 }
|
||||||
try {
|
try {
|
||||||
const host = getImageComfyHost()
|
|
||||||
const res = await fetch(`${host}/queue`, { signal: AbortSignal.timeout(2500) })
|
const res = await fetch(`${host}/queue`, { signal: AbortSignal.timeout(2500) })
|
||||||
if (!res.ok) return { running: 0, pending: 0 }
|
if (!res.ok) return { running: 0, pending: 0 }
|
||||||
const payload = await res.json() as { queue_running?: unknown[]; queue_pending?: unknown[] }
|
const payload = await res.json() as { queue_running?: unknown[]; queue_pending?: unknown[] }
|
||||||
@@ -75,6 +83,10 @@ export async function fetchImageQueue() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchImageQueue() {
|
||||||
|
return fetchQueueAt(getImageComfyHost())
|
||||||
|
}
|
||||||
|
|
||||||
async function controlRequest(path: string, method = 'GET') {
|
async function controlRequest(path: string, method = 'GET') {
|
||||||
const { controlUrl, controlToken } = settings()
|
const { controlUrl, controlToken } = settings()
|
||||||
if (!controlUrl) return null
|
if (!controlUrl) return null
|
||||||
@@ -216,3 +228,70 @@ export function ensureImageComfyReady(onStatus: StatusFn = () => undefined) {
|
|||||||
gate = run.then(() => undefined, () => undefined)
|
gate = run.then(() => undefined, () => undefined)
|
||||||
return run
|
return run
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function pickImageEditHost(onStatus: StatusFn = () => undefined): Promise<ImageEditTarget> {
|
||||||
|
const beast = getBeastImageHost()
|
||||||
|
const sidecar = getSidecarImageHost()
|
||||||
|
if (!beast && !sidecar) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 503,
|
||||||
|
statusMessage: 'No image ComfyUI host is configured. Set COMFY_HOST (Beast) and/or IMAGE_COMFY_HOST (Sidecar).'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (beast) {
|
||||||
|
const health = await checkImageComfyHttp(2500, beast)
|
||||||
|
if (health.ok) {
|
||||||
|
const queue = await fetchQueueAt(beast)
|
||||||
|
if (queue.running === 0) {
|
||||||
|
onStatus({
|
||||||
|
state: 'online',
|
||||||
|
message: 'Beast is free. Editing on this machine.',
|
||||||
|
host: beast,
|
||||||
|
box: 'beast',
|
||||||
|
queueRunning: 0,
|
||||||
|
queuePending: queue.pending
|
||||||
|
})
|
||||||
|
return { host: beast, box: 'beast', label: 'Beast' }
|
||||||
|
}
|
||||||
|
const canOverflow = Boolean(sidecar && !sameImageHost(beast, sidecar))
|
||||||
|
onStatus({
|
||||||
|
state: 'busy',
|
||||||
|
message: canOverflow
|
||||||
|
? 'Beast is busy. Rolling over to Sidecar...'
|
||||||
|
: `Beast is busy (${queue.running} running). Waiting for a free slot...`,
|
||||||
|
host: beast,
|
||||||
|
box: 'beast',
|
||||||
|
queueRunning: queue.running,
|
||||||
|
queuePending: queue.pending
|
||||||
|
})
|
||||||
|
if (!canOverflow) {
|
||||||
|
await withImageComfyHost(beast, () => waitWhileBusy(onStatus))
|
||||||
|
return { host: beast, box: 'beast', label: 'Beast' }
|
||||||
|
}
|
||||||
|
} else if (sidecar && !sameImageHost(beast, sidecar)) {
|
||||||
|
onStatus({
|
||||||
|
state: 'offline',
|
||||||
|
message: 'Beast is offline. Trying Sidecar...',
|
||||||
|
host: beast,
|
||||||
|
box: 'beast'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sidecar && (!beast || !sameImageHost(beast, sidecar))) {
|
||||||
|
onStatus({
|
||||||
|
state: 'starting',
|
||||||
|
message: 'Checking Sidecar for this edit...',
|
||||||
|
host: sidecar,
|
||||||
|
box: 'sidecar'
|
||||||
|
})
|
||||||
|
await withImageComfyHost(sidecar, () => ensureImageComfyReady(onStatus))
|
||||||
|
return { host: sidecar, box: 'sidecar', label: 'Sidecar' }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 503,
|
||||||
|
statusMessage: 'Beast is unavailable and Sidecar is not configured.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,31 +20,40 @@ export interface EditParams {
|
|||||||
|
|
||||||
export function buildEditWorkflow(params: EditParams) {
|
export function buildEditWorkflow(params: EditParams) {
|
||||||
const graph = structuredClone(template) as WorkflowGraph
|
const graph = structuredClone(template) as WorkflowGraph
|
||||||
if (graph['4']) graph['4'].inputs.image = params.imageName
|
const loadImage = nodeByClass(graph, 'LoadImage') || graph['4']
|
||||||
if (graph['5']) graph['5'].inputs.text = params.prompt
|
const qwenPrompt = nodeByClass(graph, 'TextEncodeQwenImageEdit') || graph['17']
|
||||||
if (graph['6']) graph['6'].inputs.text = (params.negative || DEFAULT_EDIT_NEGATIVE).trim() || DEFAULT_EDIT_NEGATIVE
|
const sampler = nodeByClass(graph, 'KSampler') || graph['8']
|
||||||
if (graph['8']) {
|
const save = nodeByClass(graph, 'SaveImage') || graph['10']
|
||||||
graph['8'].inputs.seed = params.seed
|
if (loadImage) loadImage.inputs.image = params.imageName
|
||||||
graph['8'].inputs.steps = params.steps
|
if (qwenPrompt) {
|
||||||
graph['8'].inputs.cfg = params.cfg
|
if ('prompt' in qwenPrompt.inputs) qwenPrompt.inputs.prompt = params.prompt
|
||||||
graph['8'].inputs.denoise = params.denoise
|
if ('text' in qwenPrompt.inputs) qwenPrompt.inputs.text = params.prompt
|
||||||
graph['8'].inputs.sampler_name = params.samplerName || 'euler'
|
|
||||||
graph['8'].inputs.scheduler = params.scheduler || 'simple'
|
|
||||||
}
|
}
|
||||||
if (graph['10'] && params.filenamePrefix) {
|
if (sampler) {
|
||||||
graph['10'].inputs.filename_prefix = params.filenamePrefix
|
sampler.inputs.seed = params.seed
|
||||||
|
sampler.inputs.steps = params.steps
|
||||||
|
sampler.inputs.cfg = params.cfg
|
||||||
|
sampler.inputs.denoise = params.denoise
|
||||||
|
sampler.inputs.sampler_name = params.samplerName || 'euler'
|
||||||
|
sampler.inputs.scheduler = params.scheduler || 'simple'
|
||||||
}
|
}
|
||||||
|
if (save && params.filenamePrefix) save.inputs.filename_prefix = params.filenamePrefix
|
||||||
return graph
|
return graph
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nodeByClass(graph: WorkflowGraph, classType: string, index = 0) {
|
||||||
|
const matches = Object.values(graph).filter(node => node?.class_type === classType)
|
||||||
|
return matches[index]
|
||||||
|
}
|
||||||
|
|
||||||
export const EDIT_NODE_LABELS: Record<string, string> = {
|
export const EDIT_NODE_LABELS: Record<string, string> = {
|
||||||
'1': 'Loading Qwen Edit UNET',
|
'1': 'Loading Qwen Edit UNET',
|
||||||
'11': 'Applying Lightning LoRA',
|
'11': 'Applying Lightning LoRA',
|
||||||
'2': 'Loading Qwen CLIP',
|
'2': 'Loading Qwen CLIP',
|
||||||
'3': 'Loading Qwen VAE',
|
'3': 'Loading Qwen VAE',
|
||||||
'4': 'Loading input still',
|
'4': 'Loading input still',
|
||||||
'5': 'Encoding prompt',
|
'17': 'Encoding Qwen edit prompt',
|
||||||
'6': 'Encoding negative',
|
'19': 'Preparing negative',
|
||||||
'7': 'Encoding still',
|
'7': 'Encoding still',
|
||||||
'8': 'Sampling edit',
|
'8': 'Sampling edit',
|
||||||
'9': 'Decoding still',
|
'9': 'Decoding still',
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface Job {
|
|||||||
clipId?: string
|
clipId?: string
|
||||||
stillId?: string
|
stillId?: string
|
||||||
hideThumbnail?: boolean
|
hideThumbnail?: boolean
|
||||||
|
imageComfyHost?: string
|
||||||
library?: {
|
library?: {
|
||||||
ownerKey: string
|
ownerKey: string
|
||||||
folderId: string
|
folderId: string
|
||||||
|
|||||||
+221
-20
@@ -1,5 +1,5 @@
|
|||||||
import { createHash } from 'node:crypto'
|
import { createHash } from 'node:crypto'
|
||||||
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
|
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
|
||||||
@@ -39,6 +39,9 @@ export interface LibraryClip {
|
|||||||
parentClipId?: string
|
parentClipId?: string
|
||||||
chainIndex?: number
|
chainIndex?: number
|
||||||
workflow?: 'v1' | 'v2'
|
workflow?: 'v1' | 'v2'
|
||||||
|
stillId?: string
|
||||||
|
sound?: boolean
|
||||||
|
hideInput?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LibraryStill {
|
export interface LibraryStill {
|
||||||
@@ -211,16 +214,19 @@ function ensureOwner(owner: string) {
|
|||||||
function readCatalog(owner: string): Catalog {
|
function readCatalog(owner: string): Catalog {
|
||||||
ensureOwner(owner)
|
ensureOwner(owner)
|
||||||
const path = catalogPath(owner)
|
const path = catalogPath(owner)
|
||||||
|
let catalog: Catalog
|
||||||
try {
|
try {
|
||||||
return normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
|
catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
|
||||||
} catch {
|
} catch {
|
||||||
if (existsSync(path)) {
|
if (existsSync(path)) {
|
||||||
throw createError({ statusCode: 500, statusMessage: 'Library catalog could not be read' })
|
throw createError({ statusCode: 500, statusMessage: 'Library catalog could not be read' })
|
||||||
}
|
}
|
||||||
const created = emptyCatalog()
|
catalog = emptyCatalog()
|
||||||
writeFileSync(path, JSON.stringify(created, null, 2))
|
writeFileSync(path, JSON.stringify(catalog, null, 2))
|
||||||
return created
|
return catalog
|
||||||
}
|
}
|
||||||
|
if (purgeStrayExtensionStills(owner, catalog).count) writeCatalog(owner, catalog)
|
||||||
|
return catalog
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeCatalog(owner: string, catalog: Catalog) {
|
function writeCatalog(owner: string, catalog: Catalog) {
|
||||||
@@ -249,6 +255,10 @@ export function clipTitle(clip: Pick<LibraryClip, 'name' | 'prompt'>) {
|
|||||||
return fromPrompt || 'Untitled clip'
|
return fromPrompt || 'Untitled clip'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function familyBaseName(name: string) {
|
||||||
|
return String(name || '').trim().replace(/\s+p\d+$/i, '')
|
||||||
|
}
|
||||||
|
|
||||||
export function nextClipPartName(name: string) {
|
export function nextClipPartName(name: string) {
|
||||||
const trimmed = (name || '').trim()
|
const trimmed = (name || '').trim()
|
||||||
if (!trimmed) return ''
|
if (!trimmed) return ''
|
||||||
@@ -259,6 +269,30 @@ export function nextClipPartName(name: string) {
|
|||||||
return `${base.slice(0, Math.max(1, 80 - suffix.length))}${suffix}`
|
return `${base.slice(0, Math.max(1, 80 - suffix.length))}${suffix}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sortFamilyParts(a: LibraryClip, b: LibraryClip) {
|
||||||
|
const chain = (a.chainIndex || 0) - (b.chainIndex || 0)
|
||||||
|
if (chain) return chain
|
||||||
|
return a.createdAt - b.createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clipsInSameFamily(catalog: Catalog, clip: LibraryClip) {
|
||||||
|
const folderClips = catalog.clips.filter(item => item.folderId === clip.folderId)
|
||||||
|
const base = familyBaseName(clip.name || '').toLowerCase()
|
||||||
|
const ids = new Set<string>([clip.id])
|
||||||
|
if (clip.familyId) {
|
||||||
|
for (const item of folderClips) {
|
||||||
|
if (item.familyId === clip.familyId) ids.add(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (base) {
|
||||||
|
for (const item of folderClips) {
|
||||||
|
if (item.familyId && item.familyId !== clip.familyId) continue
|
||||||
|
if (familyBaseName(item.name || '').toLowerCase() === base) ids.add(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return folderClips.filter(item => ids.has(item.id))
|
||||||
|
}
|
||||||
|
|
||||||
export function safeDownloadName(name: string) {
|
export function safeDownloadName(name: string) {
|
||||||
const cleaned = name.replace(/[<>:"/\\|?*'`\u0000-\u001f]/g, '-').replace(/\s+/g, ' ').trim()
|
const cleaned = name.replace(/[<>:"/\\|?*'`\u0000-\u001f]/g, '-').replace(/\s+/g, ' ').trim()
|
||||||
return cleaned.slice(0, 80) || 'aigen'
|
return cleaned.slice(0, 80) || 'aigen'
|
||||||
@@ -322,7 +356,7 @@ export function publicLibrary(event: H3Event) {
|
|||||||
hasThumbnail: !clip.hideThumbnail && existsSync(clipThumbPath(owner, clip.id)),
|
hasThumbnail: !clip.hideThumbnail && existsSync(clipThumbPath(owner, clip.id)),
|
||||||
hasVideo: existsSync(clipVideoPath(owner, clip.id))
|
hasVideo: existsSync(clipVideoPath(owner, clip.id))
|
||||||
})),
|
})),
|
||||||
stills: catalog.stills.filter(still => unlockedIds.has(still.folderId)),
|
stills: catalog.stills.filter(still => unlockedIds.has(still.folderId) && !isStrayExtensionStill(catalog, still)),
|
||||||
drafts: catalog.drafts
|
drafts: catalog.drafts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -652,6 +686,123 @@ function bytesHash(data: Buffer) {
|
|||||||
return createHash('sha256').update(data).digest('hex')
|
return createHash('sha256').update(data).digest('hex')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fileBytesHash(path: string) {
|
||||||
|
if (!existsSync(path)) return ''
|
||||||
|
try {
|
||||||
|
return bytesHash(readFileSync(path))
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExtensionClip(clip: LibraryClip) {
|
||||||
|
return Boolean(clip.parentClipId) || (clip.chainIndex || 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastFrameStillName(name: string) {
|
||||||
|
return /last[\s._-]*frame/i.test(String(name || '').trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPipelineFrameFilename(name: string) {
|
||||||
|
return lastFrameStillName(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clipIdFromStillFilename(name: string) {
|
||||||
|
const match = String(name || '').trim().match(/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\.[a-z0-9]+)?$/i)
|
||||||
|
return match ? match[1].toLowerCase() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStrayExtensionStill(catalog: Catalog, still: LibraryStill) {
|
||||||
|
if (lastFrameStillName(still.filename)) return true
|
||||||
|
const clipId = clipIdFromStillFilename(still.filename)
|
||||||
|
if (!clipId) return false
|
||||||
|
return catalog.clips.some(clip => clip.id.toLowerCase() === clipId && isExtensionClip(clip))
|
||||||
|
}
|
||||||
|
|
||||||
|
function originalInputHashes(owner: string, catalog: Catalog) {
|
||||||
|
const hashes = new Set<string>()
|
||||||
|
for (const clip of catalog.clips) {
|
||||||
|
if (isExtensionClip(clip)) continue
|
||||||
|
const hash = fileBytesHash(clipThumbPath(owner, clip.id))
|
||||||
|
if (hash) hashes.add(hash)
|
||||||
|
}
|
||||||
|
return hashes
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionFrameHashes(owner: string, catalog: Catalog) {
|
||||||
|
const hashes = new Set<string>()
|
||||||
|
for (const clip of catalog.clips) {
|
||||||
|
if (!isExtensionClip(clip)) continue
|
||||||
|
const last = fileBytesHash(clipLastFramePath(owner, clip.id))
|
||||||
|
if (last) hashes.add(last)
|
||||||
|
const rawThumb = fileBytesHash(join(clipDir(owner, clip.id), 'thumb'))
|
||||||
|
if (rawThumb) hashes.add(rawThumb)
|
||||||
|
}
|
||||||
|
return hashes
|
||||||
|
}
|
||||||
|
|
||||||
|
function allLastFrameHashes(owner: string, catalog: Catalog) {
|
||||||
|
const hashes = new Set<string>()
|
||||||
|
for (const clip of catalog.clips) {
|
||||||
|
const last = fileBytesHash(clipLastFramePath(owner, clip.id))
|
||||||
|
if (last) hashes.add(last)
|
||||||
|
}
|
||||||
|
return hashes
|
||||||
|
}
|
||||||
|
|
||||||
|
function purgeStrayExtensionStills(owner: string, catalog: Catalog) {
|
||||||
|
const protectedIds = new Set((catalog.drafts || []).map(draft => draft.stillId).filter(Boolean) as string[])
|
||||||
|
const originalHashes = originalInputHashes(owner, catalog)
|
||||||
|
const extIds = new Set(catalog.clips.filter(isExtensionClip).map(clip => clip.id.toLowerCase()))
|
||||||
|
const extHashes = extensionFrameHashes(owner, catalog)
|
||||||
|
const lastHashes = allLastFrameHashes(owner, catalog)
|
||||||
|
const keep: LibraryStill[] = []
|
||||||
|
const drop: LibraryStill[] = []
|
||||||
|
for (const still of catalog.stills) {
|
||||||
|
const clipId = clipIdFromStillFilename(still.filename)
|
||||||
|
const namedLastFrame = lastFrameStillName(still.filename)
|
||||||
|
const namedExtClip = Boolean(clipId && extIds.has(clipId))
|
||||||
|
const matchesPipelineFrame = Boolean(still.hash && (lastHashes.has(still.hash) || extHashes.has(still.hash)))
|
||||||
|
const isOriginal = Boolean(still.hash && originalHashes.has(still.hash))
|
||||||
|
const dropThis = !protectedIds.has(still.id)
|
||||||
|
&& (namedLastFrame || namedExtClip || (matchesPipelineFrame && !isOriginal))
|
||||||
|
if (dropThis) drop.push(still)
|
||||||
|
else keep.push(still)
|
||||||
|
}
|
||||||
|
if (!drop.length) return { count: 0, filenames: [] as string[] }
|
||||||
|
catalog.stills = keep
|
||||||
|
for (const still of drop) {
|
||||||
|
rmSync(stillPath(owner, still.id), { force: true })
|
||||||
|
}
|
||||||
|
return { count: drop.length, filenames: drop.map(still => still.filename) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sweepOwnerExtensionStills(owner: string) {
|
||||||
|
return mutate(owner, (catalog) => purgeStrayExtensionStills(owner, catalog))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sweepAllExtensionStills() {
|
||||||
|
const users = join(libraryRoot(), 'users')
|
||||||
|
let owners = 0
|
||||||
|
const filenames: string[] = []
|
||||||
|
if (!existsSync(users)) return { owners, deleted: 0, filenames }
|
||||||
|
for (const entry of readdirSync(users, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue
|
||||||
|
owners += 1
|
||||||
|
const path = catalogPath(entry.name)
|
||||||
|
if (!existsSync(path)) continue
|
||||||
|
try {
|
||||||
|
const catalog = normalizeCatalog(JSON.parse(readFileSync(path, 'utf8')))
|
||||||
|
const removed = purgeStrayExtensionStills(entry.name, catalog)
|
||||||
|
if (removed.count) writeCatalog(entry.name, catalog)
|
||||||
|
filenames.push(...removed.filenames)
|
||||||
|
} catch {
|
||||||
|
/* leave a bad catalog for the next authenticated read */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { owners, deleted: filenames.length, filenames }
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveStill(params: {
|
export async function saveStill(params: {
|
||||||
ownerKey: string
|
ownerKey: string
|
||||||
folderId: string
|
folderId: string
|
||||||
@@ -660,13 +811,15 @@ export async function saveStill(params: {
|
|||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
hideInput?: boolean
|
hideInput?: boolean
|
||||||
}) {
|
}): Promise<LibraryStill | null> {
|
||||||
|
if (lastFrameStillName(params.filename)) return null
|
||||||
const catalog = readCatalog(params.ownerKey)
|
const catalog = readCatalog(params.ownerKey)
|
||||||
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
||||||
if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' })
|
if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' })
|
||||||
const hash = bytesHash(params.data)
|
const hash = bytesHash(params.data)
|
||||||
const existing = catalog.stills.find(item => item.folderId === folder.id && item.hash === hash)
|
const existing = catalog.stills.find(item => item.folderId === folder.id && item.hash === hash)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
if (lastFrameStillName(existing.filename)) return null
|
||||||
if (typeof params.hideInput === 'boolean' && existing.hideInput !== params.hideInput) {
|
if (typeof params.hideInput === 'boolean' && existing.hideInput !== params.hideInput) {
|
||||||
await mutate(params.ownerKey, (next) => {
|
await mutate(params.ownerKey, (next) => {
|
||||||
const item = next.stills.find(still => still.id === existing.id)
|
const item = next.stills.find(still => still.id === existing.id)
|
||||||
@@ -676,6 +829,9 @@ export async function saveStill(params: {
|
|||||||
}
|
}
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
|
if (allLastFrameHashes(params.ownerKey, catalog).has(hash) || extensionFrameHashes(params.ownerKey, catalog).has(hash)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
const still: LibraryStill = {
|
const still: LibraryStill = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
folderId: folder.id,
|
folderId: folder.id,
|
||||||
@@ -814,6 +970,8 @@ export async function saveClip(params: {
|
|||||||
parentClipId?: string
|
parentClipId?: string
|
||||||
chainIndex?: number
|
chainIndex?: number
|
||||||
workflow?: 'v1' | 'v2'
|
workflow?: 'v1' | 'v2'
|
||||||
|
stillId?: string
|
||||||
|
sound?: boolean
|
||||||
}) {
|
}) {
|
||||||
const catalog = readCatalog(params.ownerKey)
|
const catalog = readCatalog(params.ownerKey)
|
||||||
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
||||||
@@ -839,7 +997,10 @@ export async function saveClip(params: {
|
|||||||
familyId: params.familyId,
|
familyId: params.familyId,
|
||||||
parentClipId: params.parentClipId,
|
parentClipId: params.parentClipId,
|
||||||
chainIndex: params.chainIndex,
|
chainIndex: params.chainIndex,
|
||||||
workflow: params.workflow
|
workflow: params.workflow,
|
||||||
|
stillId: params.stillId,
|
||||||
|
sound: params.sound,
|
||||||
|
hideInput: params.hideInput === true
|
||||||
}
|
}
|
||||||
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
|
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
|
||||||
const videoPath = clipVideoPath(params.ownerKey, clip.id)
|
const videoPath = clipVideoPath(params.ownerKey, clip.id)
|
||||||
@@ -858,18 +1019,8 @@ export async function saveClip(params: {
|
|||||||
} else {
|
} else {
|
||||||
await extractVideoThumb(clipVideoPath(params.ownerKey, clip.id), join(clipDir(params.ownerKey, clip.id), 'thumb.jpg'))
|
await extractVideoThumb(clipVideoPath(params.ownerKey, clip.id), join(clipDir(params.ownerKey, clip.id), 'thumb.jpg'))
|
||||||
}
|
}
|
||||||
const saveInputStill = Boolean(params.thumb?.length) && !params.parentClipId && !(params.chainIndex || 0)
|
const isInitial = !params.parentClipId && !(params.chainIndex || 0)
|
||||||
if (saveInputStill) {
|
if (!isInitial) clip.stillId = undefined
|
||||||
await saveStill({
|
|
||||||
ownerKey: params.ownerKey,
|
|
||||||
folderId: folder.id,
|
|
||||||
filename: `${clip.id}.png`,
|
|
||||||
data: params.thumb!,
|
|
||||||
width: params.width,
|
|
||||||
height: params.height,
|
|
||||||
hideInput: params.hideInput
|
|
||||||
})
|
|
||||||
}
|
|
||||||
await mutate(params.ownerKey, (next) => {
|
await mutate(params.ownerKey, (next) => {
|
||||||
next.clips.unshift(clip)
|
next.clips.unshift(clip)
|
||||||
})
|
})
|
||||||
@@ -882,6 +1033,12 @@ export function getClip(owner: string, id: string) {
|
|||||||
return clip
|
return clip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stampFamilyId(family: LibraryClip[], fallback?: string) {
|
||||||
|
const familyId = family.find(item => item.familyId)?.familyId || fallback || crypto.randomUUID()
|
||||||
|
for (const item of family) item.familyId = familyId
|
||||||
|
return familyId
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteClip(owner: string, id: string) {
|
export function deleteClip(owner: string, id: string) {
|
||||||
return mutate(owner, (catalog) => {
|
return mutate(owner, (catalog) => {
|
||||||
const clip = catalog.clips.find(item => item.id === id)
|
const clip = catalog.clips.find(item => item.id === id)
|
||||||
@@ -892,6 +1049,20 @@ export function deleteClip(owner: string, id: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteClipFamily(owner: string, id: string) {
|
||||||
|
return mutate(owner, (catalog) => {
|
||||||
|
const clip = catalog.clips.find(item => item.id === id)
|
||||||
|
if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' })
|
||||||
|
const family = clipsInSameFamily(catalog, clip)
|
||||||
|
const ids = new Set(family.map(item => item.id))
|
||||||
|
catalog.clips = catalog.clips.filter(item => !ids.has(item.id))
|
||||||
|
for (const item of family) {
|
||||||
|
rmSync(clipDir(owner, item.id), { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
return family
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function moveClip(owner: string, id: string, folderId: string) {
|
export function moveClip(owner: string, id: string, folderId: string) {
|
||||||
return mutate(owner, (catalog) => {
|
return mutate(owner, (catalog) => {
|
||||||
const clip = catalog.clips.find(item => item.id === id)
|
const clip = catalog.clips.find(item => item.id === id)
|
||||||
@@ -903,6 +1074,19 @@ export function moveClip(owner: string, id: string, folderId: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function moveClipFamily(owner: string, id: string, folderId: string) {
|
||||||
|
return mutate(owner, (catalog) => {
|
||||||
|
const clip = catalog.clips.find(item => item.id === id)
|
||||||
|
if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' })
|
||||||
|
const folder = catalog.folders.find(item => item.id === folderId)
|
||||||
|
if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' })
|
||||||
|
const family = clipsInSameFamily(catalog, clip)
|
||||||
|
stampFamilyId(family, clip.familyId)
|
||||||
|
for (const item of family) item.folderId = folder.id
|
||||||
|
return family
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function renameClip(owner: string, id: string, name: string) {
|
export function renameClip(owner: string, id: string, name: string) {
|
||||||
const trimmed = name.trim().slice(0, 80)
|
const trimmed = name.trim().slice(0, 80)
|
||||||
if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'A name is required' })
|
if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'A name is required' })
|
||||||
@@ -914,6 +1098,23 @@ export function renameClip(owner: string, id: string, name: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renameClipFamily(owner: string, id: string, name: string) {
|
||||||
|
const trimmed = name.trim().slice(0, 80)
|
||||||
|
if (!trimmed) throw createError({ statusCode: 400, statusMessage: 'A name is required' })
|
||||||
|
return mutate(owner, (catalog) => {
|
||||||
|
const clip = catalog.clips.find(item => item.id === id)
|
||||||
|
if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' })
|
||||||
|
const family = [...clipsInSameFamily(catalog, clip)].sort(sortFamilyParts)
|
||||||
|
const base = familyBaseName(trimmed) || trimmed
|
||||||
|
stampFamilyId(family, clip.familyId)
|
||||||
|
family.forEach((item, index) => {
|
||||||
|
const suffix = index === 0 ? '' : ` p${index + 1}`
|
||||||
|
item.name = `${base.slice(0, Math.max(1, 80 - suffix.length))}${suffix}`
|
||||||
|
})
|
||||||
|
return family
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function downloadComfyVideo(video: { filename: string; subfolder: string; type: string }) {
|
export async function downloadComfyVideo(video: { filename: string; subfolder: string; type: string }) {
|
||||||
const subfolders = [...new Set([video.subfolder, 'video', ''])]
|
const subfolders = [...new Set([video.subfolder, 'video', ''])]
|
||||||
let lastStatus = 0
|
let lastStatus = 0
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export interface PendingJob {
|
|||||||
familyId?: string
|
familyId?: string
|
||||||
parentClipId?: string
|
parentClipId?: string
|
||||||
chainIndex?: number
|
chainIndex?: number
|
||||||
|
stillId?: string
|
||||||
|
sound?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function pendingRoot() {
|
function pendingRoot() {
|
||||||
@@ -142,7 +144,9 @@ export async function completePendingIfReady(pending: PendingJob) {
|
|||||||
comfyFilename: video.filename,
|
comfyFilename: video.filename,
|
||||||
familyId: pending.familyId,
|
familyId: pending.familyId,
|
||||||
parentClipId: pending.parentClipId,
|
parentClipId: pending.parentClipId,
|
||||||
chainIndex: pending.chainIndex
|
chainIndex: pending.chainIndex,
|
||||||
|
stillId: pending.stillId,
|
||||||
|
sound: pending.sound
|
||||||
})
|
})
|
||||||
await purgeComfyArtifacts({ video, imageName: pending.imageName, imageSubfolder: pending.imageSubfolder, promptId: pending.promptId })
|
await purgeComfyArtifacts({ video, imageName: pending.imageName, imageSubfolder: pending.imageSubfolder, promptId: pending.promptId })
|
||||||
deletePendingJob(pending.jobId)
|
deletePendingJob(pending.jobId)
|
||||||
|
|||||||
@@ -183,7 +183,9 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
|
|||||||
hideThumbnail: job.library.hideThumbnail,
|
hideThumbnail: job.library.hideThumbnail,
|
||||||
hideInput: job.library.hideInput,
|
hideInput: job.library.hideInput,
|
||||||
video: buffer,
|
video: buffer,
|
||||||
thumb: job.library.thumb,
|
thumb: (job.library.parentClipId || (job.library.chainIndex || 0) > 0 || /last[\s._-]*frame/i.test(job.library.imageName || ''))
|
||||||
|
? undefined
|
||||||
|
: job.library.thumb,
|
||||||
comfyFilename: video.filename,
|
comfyFilename: video.filename,
|
||||||
cfg: job.library.cfg,
|
cfg: job.library.cfg,
|
||||||
fps: job.library.fps,
|
fps: job.library.fps,
|
||||||
@@ -192,7 +194,9 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
|
|||||||
familyId: job.library.familyId || (job.library.familyId = crypto.randomUUID()),
|
familyId: job.library.familyId || (job.library.familyId = crypto.randomUUID()),
|
||||||
parentClipId: job.library.parentClipId,
|
parentClipId: job.library.parentClipId,
|
||||||
chainIndex: job.library.chainIndex,
|
chainIndex: job.library.chainIndex,
|
||||||
workflow: job.library.workflow
|
workflow: job.library.workflow,
|
||||||
|
stillId: job.library.stillId,
|
||||||
|
sound: job.library.sound
|
||||||
})
|
})
|
||||||
job.clipId = clip.id
|
job.clipId = clip.id
|
||||||
job.savedPromptId = job.promptId
|
job.savedPromptId = job.promptId
|
||||||
|
|||||||
+23
-22
@@ -1,6 +1,7 @@
|
|||||||
// Nitro bundles these JSON graphs into the production server output.
|
// Nitro bundles these JSON graphs into the production server output.
|
||||||
import workflowV1 from '../assets/workflow_minimax_video.json'
|
import workflowV1 from '../assets/workflow_minimax_video.json'
|
||||||
import workflowV2 from '../assets/workflow_minimax_video_v2.json'
|
import workflowV2 from '../assets/workflow_minimax_video_v2.json'
|
||||||
|
import { buildIdentityPrompt } from '~/utils/identityPrompt'
|
||||||
|
|
||||||
export type VideoWorkflowId = 'v1' | 'v2'
|
export type VideoWorkflowId = 'v1' | 'v2'
|
||||||
|
|
||||||
@@ -69,8 +70,10 @@ export function buildWorkflow(params: GenerateParams) {
|
|||||||
const graph = structuredClone(templateFor(version))
|
const graph = structuredClone(templateFor(version))
|
||||||
const width = snap32(params.width)
|
const width = snap32(params.width)
|
||||||
const height = snap32(params.height)
|
const height = snap32(params.height)
|
||||||
const refs = (params.referenceImageNames || []).map(name => String(name || '').trim()).filter(Boolean).slice(0, 4)
|
const refs = Array.from({ length: 4 }, (_, index) => String(params.referenceImageNames?.[index] || '').trim())
|
||||||
const useRefs = version === 'v2' && params.useIdentityRefs === true && refs.length > 0
|
const extraPictures = refs.flatMap((name, index) => name ? [index + 2] : [])
|
||||||
|
const useRefs = version === 'v2' && params.useIdentityRefs === true
|
||||||
|
const graphPrompt = useRefs ? buildIdentityPrompt(params.prompt, extraPictures) : params.prompt
|
||||||
|
|
||||||
for (const [id, node] of Object.entries(graph)) {
|
for (const [id, node] of Object.entries(graph)) {
|
||||||
if (IMAGE_CLASSES.has(node.class_type) && 'image' in node.inputs && id === '114') {
|
if (IMAGE_CLASSES.has(node.class_type) && 'image' in node.inputs && id === '114') {
|
||||||
@@ -78,19 +81,19 @@ export function buildWorkflow(params: GenerateParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (PROMPT_CLASSES.has(node.class_type) || node.class_type.includes('Qwen3')) {
|
if (PROMPT_CLASSES.has(node.class_type) || node.class_type.includes('Qwen3')) {
|
||||||
if ('prompt' in node.inputs) node.inputs.prompt = params.prompt
|
if ('prompt' in node.inputs) node.inputs.prompt = graphPrompt
|
||||||
if ('text' in node.inputs) node.inputs.text = params.prompt
|
if ('text' in node.inputs) node.inputs.text = graphPrompt
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.class_type === 'MiniMaxH3ImageToVideo') {
|
if (node.class_type === 'MiniMaxH3ImageToVideo') {
|
||||||
node.inputs.prompt = params.prompt
|
node.inputs.prompt = graphPrompt
|
||||||
if (typeof node.inputs.width === 'number') node.inputs.width = width
|
if (typeof node.inputs.width === 'number') node.inputs.width = width
|
||||||
if (typeof node.inputs.height === 'number') node.inputs.height = height
|
if (typeof node.inputs.height === 'number') node.inputs.height = height
|
||||||
if (typeof node.inputs.length === 'number') node.inputs.length = params.length
|
if (typeof node.inputs.length === 'number') node.inputs.length = params.length
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.class_type === 'MiniMaxH3ReferenceToVideo') {
|
if (node.class_type === 'MiniMaxH3ReferenceToVideo') {
|
||||||
node.inputs.prompt = params.prompt
|
node.inputs.prompt = graphPrompt
|
||||||
if (!Array.isArray(node.inputs.length)) node.inputs.length = ['174', 1]
|
if (!Array.isArray(node.inputs.length)) node.inputs.length = ['174', 1]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +193,7 @@ export function buildWorkflow(params: GenerateParams) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (version === 'v2') applyV2IdentityPath(graph, params, useRefs, refs)
|
if (version === 'v2') applyV2IdentityPath(graph, params, useRefs, refs, graphPrompt)
|
||||||
|
|
||||||
if (params.sound === false) {
|
if (params.sound === false) {
|
||||||
delete graph['105:23']
|
delete graph['105:23']
|
||||||
@@ -207,33 +210,31 @@ function applyV2IdentityPath(
|
|||||||
graph: WorkflowGraph,
|
graph: WorkflowGraph,
|
||||||
params: GenerateParams,
|
params: GenerateParams,
|
||||||
useRefs: boolean,
|
useRefs: boolean,
|
||||||
refs: string[]
|
refs: string[],
|
||||||
|
graphPrompt: string
|
||||||
) {
|
) {
|
||||||
if (useRefs && graph['155'] && graph['167'] && graph['168']) {
|
if (useRefs && graph['155'] && graph['167'] && graph['168']) {
|
||||||
graph['167'].inputs.latent_image = ['155', 1]
|
graph['167'].inputs.latent_image = ['155', 1]
|
||||||
graph['168'].inputs.conditioning = ['155', 0]
|
graph['168'].inputs.conditioning = ['155', 0]
|
||||||
graph['155'].inputs.prompt = params.prompt
|
graph['155'].inputs.prompt = graphPrompt
|
||||||
graph['155'].inputs.length = ['174', 1]
|
graph['155'].inputs.length = ['174', 1]
|
||||||
delete graph['173']
|
delete graph['173']
|
||||||
|
if (graph['169']?.inputs) {
|
||||||
|
graph['169'].inputs.unet_name = 'minimax_h3_ref2va_pruned_int8_convrot.safetensors'
|
||||||
|
}
|
||||||
|
if (params.turbo && graph['176']?.inputs) {
|
||||||
|
graph['176'].inputs.lora_name = 'minimax_h3_ref2v_turbo_4step_v0.1_comfyui_bf16.safetensors'
|
||||||
|
}
|
||||||
V2_REF_LOADERS.forEach((id, index) => {
|
V2_REF_LOADERS.forEach((id, index) => {
|
||||||
const name = refs[index]
|
const name = refs[index]
|
||||||
const inputKey = `ref_images.ref_image_${index + 1}`
|
|
||||||
if (name && graph[id]) {
|
if (name && graph[id]) {
|
||||||
graph[id].inputs.image = name
|
graph[id].inputs.image = name
|
||||||
} else {
|
return
|
||||||
|
}
|
||||||
delete graph[id]
|
delete graph[id]
|
||||||
if (graph['155']?.inputs) delete graph['155'].inputs[inputKey]
|
if (graph['155']?.inputs) delete graph['155'].inputs[`ref_images.ref_image_${index + 1}`]
|
||||||
}
|
|
||||||
})
|
})
|
||||||
graph['v2:cfg'] = {
|
if (graph['150']?.inputs) graph['150'].inputs.value = true
|
||||||
class_type: 'FluxGuidance',
|
|
||||||
inputs: {
|
|
||||||
guidance: params.cfg,
|
|
||||||
conditioning: ['155', 0]
|
|
||||||
},
|
|
||||||
_meta: { title: 'Guidance scale' }
|
|
||||||
}
|
|
||||||
graph['168'].inputs.conditioning = ['v2:cfg', 0]
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
function extraPictureNumbers(extraRefs: number | number[]) {
|
||||||
|
if (Array.isArray(extraRefs)) {
|
||||||
|
return [...new Set(extraRefs.map(Number).filter(n => n >= 2 && n <= 5))].sort((a, b) => a - b)
|
||||||
|
}
|
||||||
|
const extras = Math.max(0, Math.min(4, extraRefs))
|
||||||
|
return Array.from({ length: extras }, (_, i) => i + 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function identityBoilerplate(extraRefs: number | number[] = 0) {
|
||||||
|
const extras = extraPictureNumbers(extraRefs)
|
||||||
|
const secondary = extras.length === 0
|
||||||
|
? ''
|
||||||
|
: extras.length === 1
|
||||||
|
? ` <Picture ${extras[0]}> is a secondary reference angle for profile details.`
|
||||||
|
: ` ${extras.map(n => `<Picture ${n}>`).join(' and ')} are secondary reference angles for profile features only.`
|
||||||
|
return `<Picture 1> is the exact facial identity, hairstyle, body proportions and clothing of the main character. Preserve this identity with high fidelity even when the subject temporarily leaves the frame and returns.${secondary}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function looksLikeIdentityPrompt(text: string) {
|
||||||
|
return /primary identity anchor|exact facial identity/i.test(String(text || ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildIdentityPrompt(actionPrompt: string, extraRefs: number | number[] = 0) {
|
||||||
|
const action = String(actionPrompt || '').trim()
|
||||||
|
if (looksLikeIdentityPrompt(action)) return action
|
||||||
|
const boilerplate = identityBoilerplate(extraRefs)
|
||||||
|
return action ? `${boilerplate}\n\n${action}` : boilerplate
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IDENTITY_ACTION_TEMPLATE = `[SHOT CONFIGURATION]
|
||||||
|
- Shot Type: Medium close-up, 50mm lens, eye-level
|
||||||
|
- Camera Movement: Slow tracking dolly-in following the subject
|
||||||
|
- Lighting: Warm golden-hour rim lighting with soft directional fill from screen left
|
||||||
|
- Environment/Scenery: Dense pine forest, soft ambient ground mist
|
||||||
|
|
||||||
|
[SUBJECT DIRECTION & ACTION]
|
||||||
|
The subject from <Picture 1> walks slowly through the path, glancing toward camera-left in awe. She turns slightly, speaking softly: "Look at the way the light hits the trees."
|
||||||
|
|
||||||
|
[AUDIO CUES]
|
||||||
|
Ambient forest breeze, soft footsteps on twigs, warm cinematic background strings.`
|
||||||
+1
-1
@@ -6,7 +6,7 @@ export interface ParsedShot {
|
|||||||
|
|
||||||
export function parseShotScript(text: string): ParsedShot[] {
|
export function parseShotScript(text: string): ParsedShot[] {
|
||||||
const src = String(text || '').replace(/\r\n/g, '\n')
|
const src = String(text || '').replace(/\r\n/g, '\n')
|
||||||
const marker = /shot\s+(\d+)\b/gi
|
const marker = /^[ \t]*shot\s+(\d+)\b/gim
|
||||||
const markers: { n: number; start: number; end: number }[] = []
|
const markers: { n: number; start: number; end: number }[] = []
|
||||||
for (const match of src.matchAll(marker)) {
|
for (const match of src.matchAll(marker)) {
|
||||||
markers.push({
|
markers.push({
|
||||||
|
|||||||
Reference in New Issue
Block a user