Ship MiniMax v2 identity refs and Qwen sidecar edits.

Keep v1 as the default video graph, relay image edits to the Qwen Lightning box, and keep /api/health up if one Comfy host is unset.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-26 14:29:15 -05:00
co-authored by Cursor
parent 551c29a62a
commit c55a48a57e
22 changed files with 2061 additions and 75 deletions
+387 -22
View File
@@ -6,7 +6,7 @@
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-400 text-zinc-950 font-display font-extrabold">A</div>
<div>
<h1 class="font-display text-xl font-bold leading-none">{{ instanceName }}</h1>
<p class="text-[11px] uppercase tracking-[0.22em] text-zinc-500">MiniMax H3 · ComfyUI relay</p>
<p class="text-[11px] uppercase tracking-[0.22em] text-zinc-500">MiniMax H3 · Qwen Edit · ComfyUI</p>
</div>
</div>
<div class="flex items-center gap-3 text-sm">
@@ -17,6 +17,14 @@
<span class="h-2 w-2 rounded-full" :class="comfyBadge.dot" />
{{ comfyBadge.label }}
</span>
<span
class="hidden lg:inline-flex items-center gap-2 rounded-full border px-3 py-1"
:class="imageComfyBadge.className"
:title="imageComfyHost || 'Image edit sidecar'"
>
<span class="h-2 w-2 rounded-full" :class="imageComfyBadge.dot" />
{{ imageComfyBadge.label }}
</span>
<span class="hidden md:block text-zinc-400">{{ userName }}</span>
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white" @click="settingsOpen = true">
Settings
@@ -29,8 +37,28 @@
<main class="mx-auto grid max-w-7xl gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<section class="glass rounded-3xl p-5 sm:p-6 space-y-5">
<div>
<div class="mb-3 flex flex-wrap gap-2">
<button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="studioMode === 'video' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="studioMode = 'video'"
>
Video
</button>
<button
type="button"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="studioMode === 'edit' ? 'border-amber-300/70 bg-amber-400/10 text-amber-100' : 'border-white/10 text-zinc-400 hover:text-white'"
@click="studioMode = 'edit'"
>
Image edit
</button>
</div>
<h2 class="font-display text-2xl font-bold">Input</h2>
<p class="text-sm text-zinc-400">Drop a still, describe the motion, then relay the job to the local desktop.</p>
<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, describe the motion, then relay the job to the local desktop.' }}</p>
</div>
<div
@@ -39,7 +67,7 @@
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="onDrop"
@click="pickerOpen = true"
@click="openPicker('main')"
>
<div v-if="preview && !hideInputPreview" class="relative">
<img :src="preview" alt="Source still" class="max-h-72 w-full rounded-xl object-contain bg-black/40">
@@ -59,8 +87,8 @@
<div>
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
<label class="text-sm font-medium text-zinc-300">Motion & scene prompt</label>
<label class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
<label class="text-sm font-medium text-zinc-300">{{ studioMode === 'edit' ? 'Edit prompt' : 'Motion & scene prompt' }}</label>
<label v-if="studioMode === 'video'" class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
<span class="relative inline-flex h-5 w-9 shrink-0 items-center">
<input v-model="shotScriptMode" type="checkbox" class="peer sr-only" role="switch">
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
@@ -69,18 +97,20 @@
Shot script
</label>
</div>
<p v-if="shotScriptMode" 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.
</p>
<textarea
v-model="prompt"
:rows="shotScriptMode ? 12 : 6"
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
:placeholder="shotScriptMode
:placeholder="studioMode === 'edit'
? 'Make the lighting warmer. Replace the background with a rainy street at night. Keep the face unchanged.'
: (shotScriptMode
? 'A woman walks into the village.\n\nshot 2\nShe crosses the stone bridge at dusk.\n\nshot 3\nShe opens the shop door and steps inside.'
: 'Camera slowly pushes in. Soft daylight, fabric ripples, dust motes in the air. Audio: quiet room tone, distant birds.'"
: 'Camera slowly pushes in. Soft daylight, fabric ripples, dust motes in the air. Audio: quiet room tone, distant birds.')"
/>
<div v-if="shotScriptMode" 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>
<div
v-for="(shot, index) in parsedShots"
@@ -96,7 +126,7 @@
</div>
</div>
<div v-if="!shotScriptMode" class="space-y-3">
<div v-if="studioMode === 'video' && !shotScriptMode" class="space-y-3">
<div
v-for="(item, index) in extensionQueue"
:key="item.id"
@@ -171,7 +201,7 @@
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</span>
</label>
<label class="flex cursor-pointer items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
<label v-if="studioMode === 'video'" class="flex cursor-pointer items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
<span>
<span class="block font-medium text-zinc-200">Generate sound</span>
<span class="mt-0.5 block text-xs text-zinc-500">Off skips the audio VAE so Comfy encodes a silent clip.</span>
@@ -182,7 +212,7 @@
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</span>
</label>
<label class="flex cursor-pointer items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
<label v-if="studioMode === 'video'" class="flex cursor-pointer items-center justify-between gap-3 rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 text-sm">
<span>
<span class="block font-medium text-zinc-200">Autoplay</span>
<span class="mt-0.5 block text-xs text-zinc-500">Off leaves finished clips paused until you press play.</span>
@@ -197,7 +227,79 @@
</div>
</div>
<details class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
<div v-if="studioMode === 'video'" class="space-y-3">
<div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">MiniMax graph</p>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm"
:class="videoWorkflow === 'v1' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="videoWorkflow = 'v1'"
>
<span class="block font-semibold">v1 · validated</span>
<span class="text-xs text-zinc-400">First-frame image to video</span>
</button>
<button
type="button"
class="rounded-xl border px-3 py-2 text-left text-sm"
:class="videoWorkflow === 'v2' ? 'border-amber-300 bg-amber-400/10 text-amber-100' : 'border-white/10 hover:border-white/20'"
@click="videoWorkflow = 'v2'"
>
<span class="block font-semibold">v2 · identity refs</span>
<span class="text-xs text-zinc-400">Optional lock with up to 4 stills</span>
</button>
</div>
</div>
<div v-if="videoWorkflow === 'v2'" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3 space-y-3">
<label class="flex cursor-pointer items-center justify-between gap-3 text-sm">
<span>
<span class="block font-medium text-zinc-200">Use identity references</span>
<span class="mt-0.5 block text-xs text-zinc-500">Off uses first-frame only. On sends extra stills into Ref2VA.</span>
</span>
<span class="relative inline-flex h-6 w-11 shrink-0 items-center">
<input v-model="useIdentityRefs" type="checkbox" class="peer sr-only" role="switch">
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
<span class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</span>
</label>
<div v-if="useIdentityRefs" class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div
v-for="slot in 4"
:key="slot"
class="relative min-h-24 overflow-hidden rounded-xl border border-dashed border-white/15 bg-zinc-950/60"
>
<button
type="button"
class="block w-full text-left"
@click="openPicker(slot - 1)"
>
<img
v-if="identityPreviews[slot - 1]"
:src="identityPreviews[slot - 1]"
alt=""
class="h-24 w-full object-cover"
>
<span v-else class="flex h-24 items-center justify-center px-2 text-center text-[11px] text-zinc-500">
Ref {{ slot }}
</span>
</button>
<button
v-if="identityRefs[slot - 1]"
type="button"
class="absolute right-1 top-1 rounded-full bg-zinc-950/80 px-2 py-0.5 text-[10px] text-zinc-300"
@click="clearIdentityRef(slot - 1)"
>
Remove
</button>
</div>
</div>
<p v-if="useIdentityRefs" class="text-xs text-zinc-500">Extra stills lock people and objects. The first-frame still above stays the opening frame. Identity refs are not reused on queued extensions.</p>
</div>
</div>
<details v-if="studioMode === 'video'" class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
<summary class="cursor-pointer text-sm font-medium">Generation settings</summary>
<div class="mt-4 space-y-4">
<div>
@@ -296,14 +398,47 @@
</div>
</details>
<details v-else class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
<summary class="cursor-pointer text-sm font-medium">Image edit settings</summary>
<div class="mt-4 space-y-4">
<p class="text-xs text-zinc-500">
Sidecar: <span class="text-zinc-200">{{ imageComfyHost || 'not configured' }}</span>
· Qwen Image Edit Lightning (4-step LoRA)
</p>
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">Negative prompt</span>
<textarea v-model="editNegative" rows="2" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" />
</label>
<div class="grid gap-4 sm:grid-cols-2">
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">Steps · {{ editSteps }}</span>
<input v-model.number="editSteps" type="range" min="1" max="8" step="1" class="w-full">
</label>
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">CFG · {{ editCfg }}</span>
<input v-model.number="editCfg" type="range" min="0.5" max="4" step="0.1" class="w-full">
</label>
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">Denoise · {{ editDenoise }}</span>
<input v-model.number="editDenoise" type="range" min="0.05" max="1" step="0.05" class="w-full">
</label>
<label class="block text-sm">
<span class="mb-1 block text-zinc-400">Seed</span>
<input v-model="seedInput" class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm" placeholder="random">
</label>
</div>
<p class="text-xs text-zinc-500">Lower denoise keeps more of the original still. Lightning is tuned for 4 steps, CFG 1, denoise around 0.75.</p>
</div>
</details>
<div class="flex flex-wrap gap-3">
<button
type="button"
class="flex-1 rounded-2xl bg-amber-400 px-5 py-3 font-semibold text-zinc-950 transition hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="busy || !file || !prompt.trim() || !folderId || !queueReady"
@click="generate"
:disabled="busy || !file || !prompt.trim() || !folderId || (studioMode !== 'edit' && !queueReady)"
@click="studioMode === 'edit' ? editImage() : generate()"
>
{{ generateLabel }}
{{ studioMode === 'edit' ? (busy ? 'Editing…' : 'Edit image') : generateLabel }}
</button>
<button
v-if="busy"
@@ -375,8 +510,15 @@
:class="{ 'sensitive-glow': awaitingReveal || lockedSave }"
:style="{ aspectRatio: `${playerSize.width} / ${playerSize.height}` }"
>
<img
v-if="editResultUrl && !lockedSave"
:src="editResultUrl"
alt="Edited still"
class="h-full w-full bg-black object-contain"
:class="{ 'opacity-0': awaitingReveal }"
>
<video
v-if="videoUrl && !lockedSave"
v-else-if="videoUrl && !lockedSave"
ref="player"
:src="videoUrl"
class="h-full w-full bg-black"
@@ -404,12 +546,28 @@
<span class="text-sm text-amber-100/80">Unlock that folder in the library to view this clip</span>
</div>
<div
v-else-if="!videoUrl"
v-else-if="!videoUrl && !editResultUrl"
class="flex h-full min-h-[12rem] items-center justify-center text-sm text-zinc-500"
>
Generated MP4 appears here
{{ studioMode === 'edit' ? 'Edited still appears here' : 'Generated MP4 appears here' }}
</div>
</div>
<div v-if="editResultUrl && !lockedSave && !awaitingReveal" class="mt-4 flex flex-wrap gap-3">
<a
:href="editResultUrl"
:download="`${outputDownloadName || 'aigen-edit'}.png`"
class="inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50"
>
Download still
</a>
<button
type="button"
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
@click="useEditAsInput"
>
Use as input still
</button>
</div>
<div v-if="videoUrl && !lockedSave" class="mt-4 flex flex-wrap gap-3">
<a
v-if="!awaitingReveal"
@@ -646,6 +804,7 @@
:stills="stills"
:folder-id="folderId"
:hide-input-preview="hideInputPreview"
:heading="pickerHeading"
@close="pickerOpen = false"
@updated="applyLibrary"
@error="toast"
@@ -712,6 +871,7 @@ interface LibraryClip {
familyId?: string
parentClipId?: string
chainIndex?: number
workflow?: 'v1' | 'v2'
}
interface RetryDraft {
@@ -737,6 +897,7 @@ interface RetryDraft {
samplerName?: string
scheduler?: string
extensions?: { prompt: string; duration: number }[]
workflow?: 'v1' | 'v2'
}
interface QueuedExtension {
@@ -790,6 +951,11 @@ const hideInputPreview = ref(false)
const autoplayEnabled = ref(false)
const JOB_STORE = 'aigen-active-job'
const busy = ref(false)
const studioMode = ref('video')
const imageComfyOk = ref(false)
const imageComfyBusy = ref(false)
const imageComfyConfigured = ref(false)
const imageComfyHost = ref('')
const progress = ref(0)
const statusMessage = ref('Waiting for a still.')
const statusBusy = ref(false)
@@ -804,6 +970,11 @@ const activeChainPlan = ref<{ label: string; prompt: string; duration: number }[
const expandedFamilies = ref<Record<string, boolean>>({})
const elapsedMs = ref(0)
const videoUrl = ref('')
const editResultUrl = ref('')
const editSteps = ref(4)
const editCfg = ref(1)
const editDenoise = ref(0.75)
const editNegative = ref('blurry, low quality, deformed, bad anatomy, watermark, text, extra fingers')
const jobId = ref('')
const currentClipId = ref('')
const extendOpen = ref(false)
@@ -838,6 +1009,15 @@ const renameInput = ref<HTMLInputElement | null>(null)
const unlockPassword = ref('')
const settingsOpen = ref(false)
const pickerOpen = ref(false)
const pickerSlot = ref<'main' | number>('main')
const videoWorkflow = ref('v1')
const useIdentityRefs = ref(false)
const identityRefs = ref<(File | null)[]>([null, null, null, null])
const identityPreviews = ref(['', '', '', ''])
const pickerHeading = computed(() => {
if (pickerSlot.value === 'main') return 'Choose an image'
return `Choose identity ref ${Number(pickerSlot.value) + 1}`
})
let timer: ReturnType<typeof setInterval> | null = null
let source: EventSource | null = null
let pollTimer: ReturnType<typeof setInterval> | null = null
@@ -875,6 +1055,35 @@ const comfyBadge = computed(() => {
}
})
const imageComfyBadge = computed(() => {
if (!imageComfyConfigured.value) {
return {
label: 'Edit sidecar unset',
className: 'border-white/10 text-zinc-500',
dot: 'bg-zinc-500'
}
}
if (!imageComfyOk.value) {
return {
label: 'Edit Comfy offline',
className: 'border-red-400/30 text-red-300',
dot: 'bg-red-400'
}
}
if (imageComfyBusy.value) {
return {
label: 'Edit Comfy busy',
className: 'border-amber-400/40 text-amber-200',
dot: 'bg-amber-400'
}
}
return {
label: 'Edit Comfy online',
className: 'border-emerald-400/30 text-emerald-300',
dot: 'bg-emerald-400'
}
})
function snap32(value: number) {
return Math.max(32, Math.round(value / 32) * 32)
}
@@ -1060,9 +1269,16 @@ async function loadLibrary() {
applyLibrary(data)
}
function applyComfyHealth(health: { comfy?: { ok?: boolean; busy?: boolean } } | null | undefined) {
function applyComfyHealth(health: {
comfy?: { ok?: boolean; busy?: boolean }
imageComfy?: { ok?: boolean; busy?: boolean; configured?: boolean; host?: string }
} | null | undefined) {
comfyOk.value = Boolean(health?.comfy?.ok)
comfyBusy.value = Boolean(health?.comfy?.ok && health?.comfy?.busy)
imageComfyConfigured.value = Boolean(health?.imageComfy?.configured)
imageComfyOk.value = Boolean(health?.imageComfy?.ok)
imageComfyBusy.value = Boolean(health?.imageComfy?.ok && health?.imageComfy?.busy)
imageComfyHost.value = String(health?.imageComfy?.host || '')
}
async function pollComfyHealth() {
@@ -1075,6 +1291,8 @@ onMounted(async () => {
hideInputPreview.value = localStorage.getItem('aigen-hide-input-preview') === 'true'
withSound.value = localStorage.getItem('aigen-generate-sound') !== 'false'
autoplayEnabled.value = localStorage.getItem('aigen-autoplay') === 'true'
videoWorkflow.value = localStorage.getItem('aigen-video-workflow') === 'v2' ? 'v2' : 'v1'
useIdentityRefs.value = localStorage.getItem('aigen-use-identity-refs') === 'true'
} catch { /* ignore */ }
applyInstancePrivacy()
window.addEventListener('pointerdown', onSitePointerDown, true)
@@ -1129,6 +1347,18 @@ watch(autoplayEnabled, (value) => {
}
})
watch(videoWorkflow, (value) => {
try {
localStorage.setItem('aigen-video-workflow', value)
} catch { /* ignore */ }
})
watch(useIdentityRefs, (value) => {
try {
localStorage.setItem('aigen-use-identity-refs', String(value))
} catch { /* ignore */ }
})
watch(turbo, (on) => {
if (!cfgTouched.value) cfg.value = on ? CFG_TURBO : CFG_QUALITY
})
@@ -1221,10 +1451,42 @@ function readFile(next: File) {
img.src = preview.value
}
function openPicker(slot: 'main' | number = 'main') {
pickerSlot.value = slot
pickerOpen.value = true
}
function setIdentityRef(index: number, next: File) {
const prev = identityPreviews.value[index]
if (prev) URL.revokeObjectURL(prev)
const copies = identityRefs.value.slice()
copies[index] = next
identityRefs.value = copies
const previews = identityPreviews.value.slice()
previews[index] = URL.createObjectURL(next)
identityPreviews.value = previews
}
function clearIdentityRef(index: number) {
const prev = identityPreviews.value[index]
if (prev) URL.revokeObjectURL(prev)
const copies = identityRefs.value.slice()
copies[index] = null
identityRefs.value = copies
const previews = identityPreviews.value.slice()
previews[index] = ''
identityPreviews.value = previews
}
function onPickedImage(next: File, hidden = false) {
const slot = pickerSlot.value
pickerOpen.value = false
if (slot !== 'main') {
setIdentityRef(Number(slot), next)
return
}
if (hidden && showPrivacyToggles.value) hideInputPreview.value = true
readFile(next)
pickerOpen.value = false
}
async function persistDroppedStill(next: File) {
@@ -1364,6 +1626,7 @@ async function loadDraft(draft: RetryDraft) {
withSound.value = draft.sound !== false
if (draft.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
duration.value = clampDuration(Number(draft.duration))
if (draft.workflow === 'v2') videoWorkflow.value = 'v2'
extensionQueue.value = (draft.extensions || []).map(item => ({
id: crypto.randomUUID(),
prompt: String(item.prompt || ''),
@@ -1643,6 +1906,73 @@ function removeQueuedExtension(id: string) {
extensionQueue.value = extensionQueue.value.filter(item => item.id !== id)
}
async function editImage() {
if (!file.value || !prompt.value.trim()) return
if (!folderId.value) {
toast('Choose a library folder before editing.')
return
}
statusBusy.value = false
busy.value = true
listenGen += 1
stopListen()
settledUi = false
progress.value = 2
videoUrl.value = ''
editResultUrl.value = ''
currentClipId.value = ''
closeExtend()
awaitingReveal.value = false
concealOutput.value = hideInputPreview.value
lockedSave.value = false
watchingLibrary.value = false
statusMessage.value = 'Checking sidecar ComfyUI...'
maxStep.value = editSteps.value
startTimer()
try {
const body = new FormData()
body.append('image', file.value)
body.append('prompt', prompt.value.trim())
body.append('negative', editNegative.value)
body.append('steps', String(editSteps.value))
body.append('cfg', String(editCfg.value))
body.append('denoise', String(editDenoise.value))
body.append('seed', seedInput.value || 'random')
body.append('folderId', folderId.value)
body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
body.append('name', clipName.value.trim())
outputDownloadName.value = clipFileName(clipName.value.trim() || prompt.value.split(/[.!?\n]/)[0] || 'aigen-edit')
const started = await $fetch<{ jobId: string; steps: number; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/edit', {
method: 'POST',
body
})
jobId.value = started.jobId
maxStep.value = started.steps
persistActiveJob(started.jobId, started.hideThumbnail === true || hideInputPreview.value, started.folderLocked === true)
listen(started.jobId, started.hideThumbnail === true || hideInputPreview.value, started.folderLocked === true)
} catch (error: any) {
settledUi = true
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Image edit failed'
statusMessage.value = message
toast(message)
busy.value = false
statusBusy.value = false
stopTimer()
void pollComfyHealth()
}
}
async function useEditAsInput() {
if (!editResultUrl.value) return
try {
const blob = await $fetch<Blob>(editResultUrl.value, { responseType: 'blob' })
readFile(new File([blob], `${outputDownloadName.value || 'edited'}.png`, { type: blob.type || 'image/png' }))
toast('Edited still is now the input')
} catch {
toast('Could not load that still as input')
}
}
async function generate() {
if (!file.value || !prompt.value.trim()) return
if (!queueReady.value) {
@@ -1653,6 +1983,11 @@ async function generate() {
toast('Choose a library folder before generating.')
return
}
const identityFiles = identityRefs.value.filter((item): item is File => Boolean(item))
if (videoWorkflow.value === 'v2' && useIdentityRefs.value && !identityFiles.length) {
toast('Add at least one identity reference still, or turn references off.')
return
}
const shots = shotScriptMode.value ? parsedShots.value : null
const initialPrompt = (shots?.[0]?.prompt || prompt.value).trim()
const queued = shots
@@ -1665,6 +2000,7 @@ async function generate() {
settledUi = false
progress.value = 1
videoUrl.value = ''
editResultUrl.value = ''
currentClipId.value = ''
closeExtend()
awaitingReveal.value = false
@@ -1700,6 +2036,13 @@ async function generate() {
body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
body.append('sound', String(withSound.value))
body.append('name', clipName.value.trim())
body.append('workflow', videoWorkflow.value)
body.append('useIdentityRefs', String(videoWorkflow.value === 'v2' && useIdentityRefs.value))
if (videoWorkflow.value === 'v2' && useIdentityRefs.value) {
for (const ref of identityFiles) {
body.append('refImage', ref)
}
}
if (activeDraftId) body.append('draftId', activeDraftId)
if (queued.length) {
body.append('extensions', JSON.stringify(queued))
@@ -1780,12 +2123,34 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
return
}
if (complete && (payload.clipId || payload.filename)) {
if (complete && (payload.clipId || payload.filename || payload.stillId)) {
settledUi = true
const dest = folders.value.find(folder => folder.id === folderId.value)
const locked = dest
? Boolean(dest.protected && !dest.unlocked)
: (folderLocked || payload.folderLocked === true)
if (payload.stillId) {
if (locked) {
editResultUrl.value = ''
lockedSave.value = true
browseFolderId.value = folderId.value
statusMessage.value = 'Saved to the locked folder. Unlock it to view.'
} else {
editResultUrl.value = `/api/library/stills/${payload.stillId}`
awaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
concealOutput.value = awaitingReveal.value
lockedSave.value = false
statusMessage.value = 'Edit finished'
}
busy.value = false
statusBusy.value = false
stopTimer()
stopListen()
clearActiveJob()
loadLibrary().catch(() => null)
void pollComfyHealth()
return
}
const keepLibraryView = watchingLibrary.value && Boolean(currentClipId.value) && payload.clipId !== currentClipId.value
if (keepLibraryView) {
busy.value = false