Let image edit run while video Comfy is busy.

Split video/edit job state so the sidecar can edit concurrently, and hide v2 identity-ref thumbnails when the input still is hidden.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-26 14:44:56 -05:00
co-authored by Cursor
parent c55a48a57e
commit f54e8da857
6 changed files with 475 additions and 248 deletions
+416 -217
View File
@@ -11,15 +11,15 @@
</div> </div>
<div class="flex items-center gap-3 text-sm"> <div class="flex items-center gap-3 text-sm">
<span <span
class="hidden sm:inline-flex items-center gap-2 rounded-full border px-3 py-1" class="items-center gap-2 rounded-full border px-3 py-1"
:class="comfyBadge.className" :class="[studioMode === 'video' ? 'hidden sm:inline-flex' : 'hidden xl:inline-flex opacity-60', comfyBadge.className]"
> >
<span class="h-2 w-2 rounded-full" :class="comfyBadge.dot" /> <span class="h-2 w-2 rounded-full" :class="comfyBadge.dot" />
{{ comfyBadge.label }} {{ comfyBadge.label }}
</span> </span>
<span <span
class="hidden lg:inline-flex items-center gap-2 rounded-full border px-3 py-1" class="items-center gap-2 rounded-full border px-3 py-1"
:class="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 sidecar'"
> >
<span class="h-2 w-2 rounded-full" :class="imageComfyBadge.dot" /> <span class="h-2 w-2 rounded-full" :class="imageComfyBadge.dot" />
@@ -137,7 +137,7 @@
<button <button
type="button" type="button"
class="text-xs text-red-300 hover:text-red-200" class="text-xs text-red-300 hover:text-red-200"
:disabled="busy" :disabled="videoBusy"
@click="removeQueuedExtension(item.id)" @click="removeQueuedExtension(item.id)"
> >
Remove Remove
@@ -154,7 +154,7 @@
<button <button
type="button" type="button"
class="w-full rounded-2xl border border-dashed border-white/15 px-4 py-2.5 text-sm text-zinc-300 transition hover:border-amber-300/40 hover:text-amber-100 disabled:cursor-not-allowed disabled:opacity-50" class="w-full rounded-2xl border border-dashed border-white/15 px-4 py-2.5 text-sm text-zinc-300 transition hover:border-amber-300/40 hover:text-amber-100 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="busy" :disabled="videoBusy"
@click="queueExtension" @click="queueExtension"
> >
+ Queue Extension + Queue Extension
@@ -272,11 +272,18 @@
> >
<button <button
type="button" type="button"
class="block w-full text-left" class="relative block w-full text-left"
@click="openPicker(slot - 1)" @click="openPicker(slot - 1)"
> >
<span
v-if="identityPreviews[slot - 1] && hideInputPreview"
class="relative flex h-24 items-center justify-center px-2 text-center text-[11px] text-zinc-300"
>
<span class="sensitive-glow absolute inset-1 rounded-lg" />
<span class="relative">Ref {{ slot }} · hidden</span>
</span>
<img <img
v-if="identityPreviews[slot - 1]" v-else-if="identityPreviews[slot - 1]"
:src="identityPreviews[slot - 1]" :src="identityPreviews[slot - 1]"
alt="" alt=""
class="h-24 w-full object-cover" class="h-24 w-full object-cover"
@@ -403,6 +410,7 @@
<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> Sidecar: <span class="text-zinc-200">{{ imageComfyHost || 'not configured' }}</span>
· {{ imageComfyBadge.label }}
· Qwen Image Edit Lightning (4-step LoRA) · Qwen Image Edit Lightning (4-step LoRA)
</p> </p>
<label class="block text-sm"> <label class="block text-sm">
@@ -435,13 +443,13 @@
<button <button
type="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" 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 || (studioMode !== 'edit' && !queueReady)" :disabled="studioMode === 'edit' ? editBlocked : videoGenerateDisabled"
@click="studioMode === 'edit' ? editImage() : generate()" @click="studioMode === 'edit' ? editImage() : generate()"
> >
{{ studioMode === 'edit' ? (busy ? 'Editing…' : 'Edit image') : generateLabel }} {{ studioMode === 'edit' ? (editBusy ? 'Editing…' : 'Edit image') : generateLabel }}
</button> </button>
<button <button
v-if="busy" v-if="studioMode === 'edit' ? editBusy : videoBusy"
type="button" type="button"
class="rounded-2xl border border-red-400/40 px-4 py-3 text-red-300" class="rounded-2xl border border-red-400/40 px-4 py-3 text-red-300"
@click="cancelJob" @click="cancelJob"
@@ -449,6 +457,7 @@
Cancel / Interrupt Cancel / Interrupt
</button> </button>
</div> </div>
<p v-if="studioMode === 'edit' && editBlockReason" class="text-xs text-zinc-400">{{ editBlockReason }}</p>
<div v-if="drafts.length" class="rounded-2xl border border-amber-300/20 bg-amber-400/5 p-4"> <div v-if="drafts.length" class="rounded-2xl border border-amber-300/20 bg-amber-400/5 p-4">
<h3 class="text-sm font-semibold text-amber-100">Held jobs</h3> <h3 class="text-sm font-semibold text-amber-100">Held jobs</h3>
@@ -473,12 +482,20 @@
<div ref="outputPanel" class="glass rounded-3xl p-5 sm:p-6"> <div ref="outputPanel" class="glass rounded-3xl p-5 sm:p-6">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<h2 class="font-display text-2xl font-bold">Output</h2> <h2 class="font-display text-2xl font-bold">Output</h2>
<p class="text-xs text-zinc-500"> <p v-if="studioMode === 'edit'" class="text-xs text-zinc-500">
{{ editElapsedLabel }}
· step {{ editStep }}/{{ editMaxStep }}
</p>
<p v-else class="text-xs text-zinc-500">
{{ elapsedLabel }} {{ elapsedLabel }}
<template v-if="chainTotal > 1"> · chain {{ chainStep }}/{{ chainTotal }}</template> <template v-if="chainTotal > 1"> · chain {{ chainStep }}/{{ chainTotal }}</template>
· step {{ step }}/{{ maxStep || (turbo ? 8 : 20) }} · step {{ step }}/{{ maxStep || (turbo ? 8 : 20) }}
</p> </p>
</div> </div>
<p v-if="studioMode === 'edit' && videoBusy" class="mt-2 text-xs text-zinc-500">Video generation continues in the background.</p>
<p v-else-if="studioMode === 'video' && editBusy" class="mt-2 text-xs text-zinc-500">Image edit continues in the background.</p>
<div v-show="studioMode === 'video'">
<div class="mt-4"> <div class="mt-4">
<div class="mb-2 flex items-center justify-between text-sm"> <div class="mb-2 flex items-center justify-between text-sm">
<span :class="statusBusy ? 'text-amber-200' : 'text-zinc-300'">{{ statusMessage }}</span> <span :class="statusBusy ? 'text-amber-200' : 'text-zinc-300'">{{ statusMessage }}</span>
@@ -488,7 +505,7 @@
Step {{ chainStep }}/{{ chainTotal }}{{ chainLabel ? `: ${chainLabel}` : '' }} Step {{ chainStep }}/{{ chainTotal }}{{ chainLabel ? `: ${chainLabel}` : '' }}
<template v-if="overallProgress"> · {{ overallProgress }}% overall</template> <template v-if="overallProgress"> · {{ overallProgress }}% overall</template>
</p> </p>
<ol v-if="busy && activeChainPlan.length > 1" class="mb-3 space-y-1"> <ol v-if="videoBusy && activeChainPlan.length > 1" class="mb-3 space-y-1">
<li <li
v-for="(part, index) in activeChainPlan" v-for="(part, index) in activeChainPlan"
:key="index" :key="index"
@@ -509,16 +526,9 @@
class="relative mt-5 overflow-hidden rounded-2xl bg-black/50" class="relative mt-5 overflow-hidden rounded-2xl bg-black/50"
:class="{ 'sensitive-glow': awaitingReveal || lockedSave }" :class="{ 'sensitive-glow': awaitingReveal || lockedSave }"
:style="{ aspectRatio: `${playerSize.width} / ${playerSize.height}` }" :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 <video
v-else-if="videoUrl && !lockedSave" v-if="videoUrl && !lockedSave"
ref="player" ref="player"
:src="videoUrl" :src="videoUrl"
class="h-full w-full bg-black" class="h-full w-full bg-black"
@@ -546,33 +556,17 @@
<span class="text-sm text-amber-100/80">Unlock that folder in the library to view this clip</span> <span class="text-sm text-amber-100/80">Unlock that folder in the library to view this clip</span>
</div> </div>
<div <div
v-else-if="!videoUrl && !editResultUrl" v-else-if="!videoUrl"
class="flex h-full min-h-[12rem] items-center justify-center text-sm text-zinc-500" class="flex h-full min-h-[12rem] items-center justify-center text-sm text-zinc-500"
> >
{{ studioMode === 'edit' ? 'Edited still appears here' : 'Generated MP4 appears here' }} Generated MP4 appears here
</div> </div>
</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"> <div v-if="videoUrl && !lockedSave" class="mt-4 flex flex-wrap gap-3">
<a <a
v-if="!awaitingReveal" v-if="!awaitingReveal"
:href="videoUrl" :href="videoUrl"
:download="`${outputDownloadName}.mp4`" :download="`${videoDownloadName}.mp4`"
class="inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50" class="inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50"
> >
Download MP4 Download MP4
@@ -617,7 +611,7 @@
<button <button
type="button" type="button"
class="w-full rounded-2xl bg-amber-400 px-4 py-2.5 text-sm font-semibold text-zinc-950 transition hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50" class="w-full rounded-2xl bg-amber-400 px-4 py-2.5 text-sm font-semibold text-zinc-950 transition hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!extendPrompt.trim() || lastFrameLoading" :disabled="!extendPrompt.trim() || lastFrameLoading || videoBusy"
@click="generateExtension" @click="generateExtension"
> >
Generate Extension Generate Extension
@@ -625,6 +619,70 @@
</div> </div>
</div> </div>
<div v-show="studioMode === 'edit'">
<div class="mt-4">
<div class="mb-2 flex items-center justify-between text-sm">
<span :class="editStatusBusy ? 'text-amber-200' : 'text-zinc-300'">{{ editStatusMessage }}</span>
<span class="text-amber-200">{{ editProgress }}%</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-zinc-800">
<div class="h-full rounded-full bg-amber-400 progress-glow transition-all duration-300" :style="{ width: editProgress + '%' }" />
</div>
</div>
<div
class="relative mt-5 overflow-hidden rounded-2xl bg-black/50"
:class="{ 'sensitive-glow': editAwaitingReveal || editLockedSave }"
style="min-height: 12rem"
>
<img
v-if="editResultUrl && !editLockedSave"
:src="editResultUrl"
alt="Edited still"
class="max-h-96 w-full bg-black object-contain"
:class="{ 'opacity-0': editAwaitingReveal }"
>
<button
v-if="editAwaitingReveal && !editLockedSave"
type="button"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-amber-100"
@click="editAwaitingReveal = false"
>
<span class="font-display text-lg font-bold">Ready</span>
<span class="text-sm text-amber-100/80">Click to reveal · preview hidden</span>
</button>
<div
v-else-if="editLockedSave"
class="absolute inset-0 flex flex-col items-center justify-center gap-2 px-6 text-center text-amber-100"
>
<span class="font-display text-lg font-bold">Saved to a locked folder</span>
<span class="text-sm text-amber-100/80">Unlock that folder in the library to view this still</span>
</div>
<div
v-else-if="!editResultUrl"
class="flex h-full min-h-[12rem] items-center justify-center text-sm text-zinc-500"
>
Edited still appears here
</div>
</div>
<div v-if="editResultUrl && !editLockedSave && !editAwaitingReveal" class="mt-4 flex flex-wrap gap-3">
<a
:href="editResultUrl"
:download="`${editDownloadName || '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>
</div>
<div class="glass rounded-3xl p-5 sm:p-6"> <div class="glass rounded-3xl p-5 sm:p-6">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<h2 class="font-display text-xl font-bold">Library</h2> <h2 class="font-display text-xl font-bold">Library</h2>
@@ -928,7 +986,8 @@ const prompt = ref('')
const shotScriptMode = ref(false) const shotScriptMode = ref(false)
const extensionQueue = ref<QueuedExtension[]>([]) const extensionQueue = ref<QueuedExtension[]>([])
const clipName = ref('') const clipName = ref('')
const outputDownloadName = ref('aigen') const videoDownloadName = ref('aigen')
const editDownloadName = ref('aigen-edit')
const aspect = ref<AspectId>('auto') const aspect = ref<AspectId>('auto')
const imageWidth = ref(0) const imageWidth = ref(0)
const imageHeight = ref(0) const imageHeight = ref(0)
@@ -950,7 +1009,9 @@ const hideThumbnail = ref(false)
const hideInputPreview = ref(false) const hideInputPreview = ref(false)
const autoplayEnabled = ref(false) const autoplayEnabled = ref(false)
const JOB_STORE = 'aigen-active-job' const JOB_STORE = 'aigen-active-job'
const busy = ref(false) const EDIT_JOB_STORE = 'aigen-active-edit-job'
const videoBusy = ref(false)
const editBusy = ref(false)
const studioMode = ref('video') const studioMode = ref('video')
const imageComfyOk = ref(false) const imageComfyOk = ref(false)
const imageComfyBusy = ref(false) const imageComfyBusy = ref(false)
@@ -971,11 +1032,20 @@ const expandedFamilies = ref<Record<string, boolean>>({})
const elapsedMs = ref(0) const elapsedMs = ref(0)
const videoUrl = ref('') const videoUrl = ref('')
const editResultUrl = ref('') const editResultUrl = ref('')
const editProgress = ref(0)
const editStatusMessage = ref('Waiting for a still.')
const editStatusBusy = ref(false)
const editStep = ref(0)
const editMaxStep = ref(4)
const editElapsedMs = ref(0)
const editAwaitingReveal = 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(0.75)
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 jobId = ref('') const videoJobId = ref('')
const editJobId = ref('')
const currentClipId = ref('') const currentClipId = ref('')
const extendOpen = ref(false) const extendOpen = ref(false)
const extendPrompt = ref('') const extendPrompt = ref('')
@@ -1018,20 +1088,28 @@ const pickerHeading = computed(() => {
if (pickerSlot.value === 'main') return 'Choose an image' if (pickerSlot.value === 'main') return 'Choose an image'
return `Choose identity ref ${Number(pickerSlot.value) + 1}` return `Choose identity ref ${Number(pickerSlot.value) + 1}`
}) })
let timer: ReturnType<typeof setInterval> | null = null let videoTimer: ReturnType<typeof setInterval> | null = null
let source: EventSource | null = null let editTimer: ReturnType<typeof setInterval> | null = null
let pollTimer: ReturnType<typeof setInterval> | null = null let videoSource: EventSource | null = null
let editSource: EventSource | null = null
let videoPollTimer: ReturnType<typeof setInterval> | null = null
let editPollTimer: ReturnType<typeof setInterval> | null = null
let healthTimer: ReturnType<typeof setInterval> | null = null let healthTimer: ReturnType<typeof setInterval> | null = null
let settledUi = false let videoSettledUi = false
let listenGen = 0 let editSettledUi = false
let videoListenGen = 0
let editListenGen = 0
let activeDraftId = '' let activeDraftId = ''
const elapsedLabel = computed(() => { function formatElapsed(ms: number) {
const seconds = Math.floor(elapsedMs.value / 1000) const seconds = Math.floor(ms / 1000)
const mm = String(Math.floor(seconds / 60)).padStart(2, '0') const mm = String(Math.floor(seconds / 60)).padStart(2, '0')
const ss = String(seconds % 60).padStart(2, '0') const ss = String(seconds % 60).padStart(2, '0')
return `${mm}:${ss}` return `${mm}:${ss}`
}) }
const elapsedLabel = computed(() => formatElapsed(elapsedMs.value))
const editElapsedLabel = computed(() => formatElapsed(editElapsedMs.value))
const comfyBadge = computed(() => { const comfyBadge = computed(() => {
if (!comfyOk.value) { if (!comfyOk.value) {
@@ -1135,9 +1213,25 @@ const queueReady = computed(() => {
if (shotScriptMode.value) return parsedShots.value.length > 0 && parsedShots.value.every(shot => shot.prompt.trim()) if (shotScriptMode.value) return parsedShots.value.length > 0 && parsedShots.value.every(shot => shot.prompt.trim())
return extensionQueue.value.every(item => item.prompt.trim()) return extensionQueue.value.every(item => item.prompt.trim())
}) })
const videoGenerateDisabled = computed(() => {
return videoBusy.value || !file.value || !prompt.value.trim() || !folderId.value || !queueReady.value
})
const editBlocked = computed(() => {
if (editBusy.value) return true
if (!file.value || !prompt.value.trim() || !folderId.value) return true
if (!imageComfyConfigured.value || !imageComfyOk.value) return true
if (imageComfyBusy.value) return true
return false
})
const editBlockReason = computed(() => {
if (!imageComfyConfigured.value) return 'Image edit sidecar is not configured.'
if (!imageComfyOk.value) return 'Edit Comfy is offline.'
if (imageComfyBusy.value && !editBusy.value) return 'Sidecar is busy with another prompt.'
return ''
})
const generateLabel = computed(() => { const generateLabel = computed(() => {
const n = shotScriptMode.value ? Math.max(0, parsedShots.value.length - 1) : extensionQueue.value.length const n = shotScriptMode.value ? Math.max(0, parsedShots.value.length - 1) : extensionQueue.value.length
if (busy.value) return 'Generating…' if (videoBusy.value) return 'Generating…'
if (!n) return 'Generate Video' if (!n) return 'Generate Video'
return n === 1 ? 'Generate Initial + 1 Extension' : `Generate Initial + ${n} Extensions` return n === 1 ? 'Generate Initial + 1 Extension' : `Generate Initial + ${n} Extensions`
}) })
@@ -1221,7 +1315,7 @@ function chainPartDone(index: number) {
} }
function chainPartCurrent(index: number) { function chainPartCurrent(index: number) {
return busy.value && index === completedChainStep.value return videoBusy.value && index === completedChainStep.value
} }
function chainPartClass(index: number) { function chainPartClass(index: number) {
@@ -1509,30 +1603,35 @@ function onDrop(event: DragEvent) {
void persistDroppedStill(next) void persistDroppedStill(next)
} }
function startTimer(offset = 0) { function startTimer(kind: 'video' | 'edit', offset = 0) {
elapsedMs.value = offset const elapsed = kind === 'edit' ? editElapsedMs : elapsedMs
stopTimer() elapsed.value = offset
stopTimer(kind)
const started = Date.now() - offset const started = Date.now() - offset
timer = setInterval(() => { const handle = setInterval(() => {
elapsedMs.value = Date.now() - started elapsed.value = Date.now() - started
}, 250) }, 250)
if (kind === 'edit') editTimer = handle
else videoTimer = handle
} }
function persistActiveJob(id: string, hidden: boolean, folderLocked: boolean) { function persistActiveJob(kind: 'video' | 'edit', id: string, hidden: boolean, folderLocked: boolean) {
const key = kind === 'edit' ? EDIT_JOB_STORE : JOB_STORE
try { try {
localStorage.setItem(JOB_STORE, JSON.stringify({ jobId: id, hideThumbnail: hidden, folderLocked, savedAt: Date.now() })) localStorage.setItem(key, JSON.stringify({ jobId: id, hideThumbnail: hidden, folderLocked, savedAt: Date.now() }))
} catch { /* ignore */ } } catch { /* ignore */ }
} }
function clearActiveJob() { function clearActiveJob(kind?: 'video' | 'edit') {
try { try {
localStorage.removeItem(JOB_STORE) if (!kind || kind === 'video') localStorage.removeItem(JOB_STORE)
if (!kind || kind === 'edit') localStorage.removeItem(EDIT_JOB_STORE)
} catch { /* ignore */ } } catch { /* ignore */ }
} }
function readStoredJob() { function readStoredJob(key = JOB_STORE) {
try { try {
const raw = localStorage.getItem(JOB_STORE) const raw = localStorage.getItem(key)
if (!raw) return null if (!raw) return null
const parsed = JSON.parse(raw) as { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean } const parsed = JSON.parse(raw) as { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean }
return parsed?.jobId ? parsed : null return parsed?.jobId ? parsed : null
@@ -1541,31 +1640,68 @@ function readStoredJob() {
} }
} }
async function resumeActiveJob() { async function resumeTrack(kind: 'video' | 'edit', id: string, hidden: boolean, locked: boolean) {
const stored = readStoredJob()
const active = await $fetch<{ jobId?: string | null; hideThumbnail?: boolean; folderLocked?: boolean; status?: string; elapsedMs?: number } | null>('/api/generate/active').catch(() => null)
const id = stored?.jobId || active?.jobId
if (!id) return
const hidden = Boolean(stored?.hideThumbnail || active?.hideThumbnail)
const locked = Boolean(stored?.folderLocked || active?.folderLocked)
try { try {
const snap = await $fetch<Record<string, any>>(`/api/generate/${id}`) const snap = await $fetch<Record<string, any>>(`/api/generate/${id}`)
jobId.value = id if (kind === 'edit') {
editJobId.value = id
editSettledUi = false
} else {
videoJobId.value = id
concealOutput.value = hidden concealOutput.value = hidden
settledUi = false videoSettledUi = false
applyJobEvent(snap, hidden, locked, id) }
applyJobEvent(kind, snap, hidden, locked, id)
const running = snap.status === 'queued' || snap.status === 'uploading' || snap.status === 'running' const running = snap.status === 'queued' || snap.status === 'uploading' || snap.status === 'running'
if (running && !settledUi) { const settled = kind === 'edit' ? editSettledUi : videoSettledUi
busy.value = true if (running && !settled) {
if (kind === 'edit') {
editBusy.value = true
editStatusMessage.value = snap.message || 'Reconnecting to sidecar ComfyUI...'
} else {
videoBusy.value = true
statusMessage.value = snap.message || 'Reconnecting to ComfyUI...' statusMessage.value = snap.message || 'Reconnecting to ComfyUI...'
startTimer(Number(snap.elapsedMs || 0)) }
persistActiveJob(id, hidden, locked) startTimer(kind, Number(snap.elapsedMs || 0))
listen(id, hidden, locked) persistActiveJob(kind, id, hidden, locked)
} else if (settledUi) { listen(kind, id, hidden, locked)
clearActiveJob() } else if (settled) {
clearActiveJob(kind)
} }
} catch { } catch {
clearActiveJob() clearActiveJob(kind)
}
}
async function resumeActiveJob() {
const storedVideo = readStoredJob(JOB_STORE)
const storedEdit = readStoredJob(EDIT_JOB_STORE)
const active = await $fetch<{
jobId?: string | null
video?: { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean }
edit?: { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean }
hideThumbnail?: boolean
folderLocked?: boolean
} | null>('/api/generate/active').catch(() => null)
const videoId = storedVideo?.jobId || active?.video?.jobId || (!active?.edit?.jobId ? active?.jobId : null) || ''
const editId = storedEdit?.jobId || active?.edit?.jobId || ''
if (videoId) {
await resumeTrack(
'video',
videoId,
Boolean(storedVideo?.hideThumbnail || active?.video?.hideThumbnail || active?.hideThumbnail),
Boolean(storedVideo?.folderLocked || active?.video?.folderLocked || active?.folderLocked)
)
}
if (editId) {
await resumeTrack(
'edit',
editId,
Boolean(storedEdit?.hideThumbnail || active?.edit?.hideThumbnail),
Boolean(storedEdit?.folderLocked || active?.edit?.folderLocked)
)
} }
} }
@@ -1586,9 +1722,15 @@ function concealAndPause() {
} }
} }
function stopTimer() { function stopTimer(kind?: 'video' | 'edit') {
if (timer) clearInterval(timer) if (!kind || kind === 'video') {
timer = null if (videoTimer) clearInterval(videoTimer)
videoTimer = null
}
if (!kind || kind === 'edit') {
if (editTimer) clearInterval(editTimer)
editTimer = null
}
} }
function clipFileName(name: string) { function clipFileName(name: string) {
@@ -1741,13 +1883,12 @@ async function openExtend(item?: LibraryClip) {
async function generateExtension() { async function generateExtension() {
const id = clipIdFromPlayer() const id = clipIdFromPlayer()
if (!id || !extendPrompt.value.trim()) return if (!id || !extendPrompt.value.trim()) return
const switching = busy.value const switching = videoBusy.value
statusBusy.value = false statusBusy.value = false
busy.value = true videoBusy.value = true
watchingLibrary.value = false watchingLibrary.value = false
listenGen += 1 stopListen('video')
stopListen() videoSettledUi = false
settledUi = false
progress.value = 1 progress.value = 1
awaitingReveal.value = false awaitingReveal.value = false
concealOutput.value = hideThumbnail.value || currentClip.value?.hideThumbnail === true concealOutput.value = hideThumbnail.value || currentClip.value?.hideThumbnail === true
@@ -1759,7 +1900,7 @@ async function generateExtension() {
completedChainStep.value = 0 completedChainStep.value = 0
activeChainPlan.value = [] activeChainPlan.value = []
statusMessage.value = 'Extracting last frame & stitching extension...' statusMessage.value = 'Extracting last frame & stitching extension...'
startTimer() startTimer('video')
try { try {
const started = await $fetch<{ jobId: string; steps: number; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/extend', { const started = await $fetch<{ jobId: string; steps: number; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/extend', {
method: 'POST', method: 'POST',
@@ -1771,17 +1912,17 @@ async function generateExtension() {
}) })
closeExtend() closeExtend()
if (switching) toast('Switched to this extension. The other job will keep saving to the library.') if (switching) toast('Switched to this extension. The other job will keep saving to the library.')
jobId.value = started.jobId videoJobId.value = started.jobId
maxStep.value = started.steps maxStep.value = started.steps
settledUi = false videoSettledUi = false
persistActiveJob(started.jobId, started.hideThumbnail === true || concealOutput.value, started.folderLocked === true) persistActiveJob('video', started.jobId, started.hideThumbnail === true || concealOutput.value, started.folderLocked === true)
listen(started.jobId, started.hideThumbnail === true || concealOutput.value, started.folderLocked === true) listen('video', started.jobId, started.hideThumbnail === true || concealOutput.value, started.folderLocked === true)
void pollComfyHealth() void pollComfyHealth()
} catch (error: any) { } catch (error: any) {
busy.value = false videoBusy.value = false
statusBusy.value = false statusBusy.value = false
stopTimer() stopTimer('video')
clearActiveJob() clearActiveJob('video')
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start extension' const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start extension'
statusMessage.value = message statusMessage.value = message
toast(message) toast(message)
@@ -1798,8 +1939,8 @@ function openClip(item: LibraryClip) {
awaitingReveal.value = item.hideThumbnail awaitingReveal.value = item.hideThumbnail
playerSize.value = { width: item.width || 1344, height: item.height || 768 } playerSize.value = { width: item.width || 1344, height: item.height || 768 }
browseFolderId.value = item.folderId browseFolderId.value = item.folderId
outputDownloadName.value = clipFileName(item.name || item.prompt.split(/[.!?\n]/)[0] || 'aigen') videoDownloadName.value = clipFileName(item.name || item.prompt.split(/[.!?\n]/)[0] || 'aigen')
if (!busy.value) { if (!videoBusy.value) {
statusMessage.value = item.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library' statusMessage.value = item.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library'
progress.value = 100 progress.value = 100
} }
@@ -1875,7 +2016,7 @@ async function saveRename(item: LibraryClip) {
})) }))
cancelRename() cancelRename()
if (videoUrl.value.includes(item.id)) { if (videoUrl.value.includes(item.id)) {
outputDownloadName.value = clipFileName(name) videoDownloadName.value = clipFileName(name)
} }
statusMessage.value = `Renamed to ${name}` statusMessage.value = `Renamed to ${name}`
} catch (error: any) { } catch (error: any) {
@@ -1912,23 +2053,17 @@ async function editImage() {
toast('Choose a library folder before editing.') toast('Choose a library folder before editing.')
return return
} }
statusBusy.value = false editStatusBusy.value = false
busy.value = true editBusy.value = true
listenGen += 1 stopListen('edit')
stopListen() editSettledUi = false
settledUi = false editProgress.value = 2
progress.value = 2
videoUrl.value = ''
editResultUrl.value = '' editResultUrl.value = ''
currentClipId.value = '' editAwaitingReveal.value = false
closeExtend() editLockedSave.value = false
awaitingReveal.value = false editStatusMessage.value = 'Checking sidecar ComfyUI...'
concealOutput.value = hideInputPreview.value editMaxStep.value = editSteps.value
lockedSave.value = false startTimer('edit')
watchingLibrary.value = false
statusMessage.value = 'Checking sidecar ComfyUI...'
maxStep.value = editSteps.value
startTimer()
try { try {
const body = new FormData() const body = new FormData()
body.append('image', file.value) body.append('image', file.value)
@@ -1941,23 +2076,24 @@ async function editImage() {
body.append('folderId', folderId.value) body.append('folderId', folderId.value)
body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value)) body.append('hideInput', String(showPrivacyToggles.value && hideInputPreview.value))
body.append('name', clipName.value.trim()) body.append('name', clipName.value.trim())
outputDownloadName.value = clipFileName(clipName.value.trim() || prompt.value.split(/[.!?\n]/)[0] || 'aigen-edit') editDownloadName.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', { const started = await $fetch<{ jobId: string; steps: number; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/edit', {
method: 'POST', method: 'POST',
body body
}) })
jobId.value = started.jobId editJobId.value = started.jobId
maxStep.value = started.steps editMaxStep.value = started.steps
persistActiveJob(started.jobId, started.hideThumbnail === true || hideInputPreview.value, started.folderLocked === true) persistActiveJob('edit', started.jobId, started.hideThumbnail === true || hideInputPreview.value, started.folderLocked === true)
listen(started.jobId, started.hideThumbnail === true || hideInputPreview.value, started.folderLocked === true) listen('edit', started.jobId, started.hideThumbnail === true || hideInputPreview.value, started.folderLocked === true)
void pollComfyHealth()
} catch (error: any) { } catch (error: any) {
settledUi = true editSettledUi = true
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Image edit failed' const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Image edit failed'
statusMessage.value = message editStatusMessage.value = message
toast(message) toast(message)
busy.value = false editBusy.value = false
statusBusy.value = false editStatusBusy.value = false
stopTimer() stopTimer('edit')
void pollComfyHealth() void pollComfyHealth()
} }
} }
@@ -1966,7 +2102,7 @@ async function useEditAsInput() {
if (!editResultUrl.value) return if (!editResultUrl.value) return
try { try {
const blob = await $fetch<Blob>(editResultUrl.value, { responseType: 'blob' }) const blob = await $fetch<Blob>(editResultUrl.value, { responseType: 'blob' })
readFile(new File([blob], `${outputDownloadName.value || 'edited'}.png`, { type: blob.type || 'image/png' })) readFile(new File([blob], `${editDownloadName.value || 'edited'}.png`, { type: blob.type || 'image/png' }))
toast('Edited still is now the input') toast('Edited still is now the input')
} catch { } catch {
toast('Could not load that still as input') toast('Could not load that still as input')
@@ -1994,13 +2130,11 @@ async function generate() {
? shots.slice(1).map(shot => ({ prompt: shot.prompt.trim(), duration: duration.value })) ? shots.slice(1).map(shot => ({ prompt: shot.prompt.trim(), duration: duration.value }))
: extensionQueue.value.map(item => ({ prompt: item.prompt.trim(), duration: item.duration })) : extensionQueue.value.map(item => ({ prompt: item.prompt.trim(), duration: item.duration }))
statusBusy.value = false statusBusy.value = false
busy.value = true videoBusy.value = true
listenGen += 1 stopListen('video')
stopListen() videoSettledUi = false
settledUi = false
progress.value = 1 progress.value = 1
videoUrl.value = '' videoUrl.value = ''
editResultUrl.value = ''
currentClipId.value = '' currentClipId.value = ''
closeExtend() closeExtend()
awaitingReveal.value = false awaitingReveal.value = false
@@ -2015,7 +2149,7 @@ async function generate() {
overallProgress.value = 0 overallProgress.value = 0
completedChainStep.value = 0 completedChainStep.value = 0
activeChainPlan.value = plannedChain.value.map(part => ({ ...part })) activeChainPlan.value = plannedChain.value.map(part => ({ ...part }))
startTimer() startTimer('video')
try { try {
const body = new FormData() const body = new FormData()
body.append('image', file.value) body.append('image', file.value)
@@ -2047,42 +2181,100 @@ async function generate() {
if (queued.length) { if (queued.length) {
body.append('extensions', JSON.stringify(queued)) body.append('extensions', JSON.stringify(queued))
} }
outputDownloadName.value = clipFileName(clipName.value.trim() || initialPrompt.split(/[.!?\n]/)[0] || 'aigen') videoDownloadName.value = clipFileName(clipName.value.trim() || initialPrompt.split(/[.!?\n]/)[0] || 'aigen')
const started = await $fetch<{ jobId: string; seed: number; width: number; height: number; steps: number; turbo: boolean; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/generate', { const started = await $fetch<{ jobId: string; seed: number; width: number; height: number; steps: number; turbo: boolean; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/generate', {
method: 'POST', method: 'POST',
body body
}) })
jobId.value = started.jobId videoJobId.value = started.jobId
maxStep.value = started.steps maxStep.value = started.steps
playerSize.value = { width: started.width, height: started.height } playerSize.value = { width: started.width, height: started.height }
settledUi = false videoSettledUi = false
persistActiveJob(started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true) persistActiveJob('video', started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
listen(started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true) listen('video', started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true)
void pollComfyHealth() void pollComfyHealth()
} catch (error: any) { } catch (error: any) {
busy.value = false videoBusy.value = false
statusBusy.value = false statusBusy.value = false
stopTimer() stopTimer('video')
clearActiveJob() clearActiveJob('video')
const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start generation' const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start generation'
statusMessage.value = message statusMessage.value = message
toast(message) toast(message)
} }
} }
function stopListen() { function stopListen(kind?: 'video' | 'edit') {
source?.close() if (!kind || kind === 'video') {
source = null videoSource?.close()
if (pollTimer) { videoSource = null
clearInterval(pollTimer) if (videoPollTimer) {
pollTimer = null clearInterval(videoPollTimer)
videoPollTimer = null
}
}
if (!kind || kind === 'edit') {
editSource?.close()
editSource = null
if (editPollTimer) {
clearInterval(editPollTimer)
editPollTimer = null
}
} }
} }
function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLocked: boolean, expectedJobId: string) { function applyEditEvent(payload: Record<string, any>, hidden: boolean, folderLocked: boolean) {
if (expectedJobId && jobId.value && expectedJobId !== jobId.value) return if (payload.message) editStatusMessage.value = payload.message
if (payload.jobId && expectedJobId && payload.jobId !== expectedJobId) return editStatusBusy.value = payload.type === 'busy' || payload.busy === true
if (typeof payload.progress === 'number') editProgress.value = payload.progress
if (typeof payload.step === 'number') editStep.value = payload.step
if (typeof payload.maxStep === 'number') editMaxStep.value = payload.maxStep
const failed = payload.type === 'error' || payload.status === 'error' || payload.status === 'cancelled'
const complete = payload.type === 'complete' || payload.status === 'complete'
if (editSettledUi) return
if (complete && payload.stillId) {
editSettledUi = true
const dest = folders.value.find(folder => folder.id === folderId.value)
const locked = dest
? Boolean(dest.protected && !dest.unlocked)
: (folderLocked || payload.folderLocked === true)
if (locked) {
editResultUrl.value = ''
editLockedSave.value = true
browseFolderId.value = folderId.value
editStatusMessage.value = 'Saved to the locked folder. Unlock it to view.'
} else {
editResultUrl.value = `/api/library/stills/${payload.stillId}`
editAwaitingReveal.value = Boolean(payload.hideThumbnail || hidden)
editLockedSave.value = false
editStatusMessage.value = 'Edit finished'
}
editBusy.value = false
editStatusBusy.value = false
stopTimer('edit')
stopListen('edit')
clearActiveJob('edit')
loadLibrary().catch(() => null)
void pollComfyHealth()
return
}
if (failed) {
editSettledUi = true
editBusy.value = false
editStatusBusy.value = false
stopTimer('edit')
stopListen('edit')
clearActiveJob('edit')
loadLibrary().catch(() => null)
void pollComfyHealth()
toast(payload.error || payload.message || 'Image edit failed')
}
}
function applyVideoEvent(payload: Record<string, any>, hidden: boolean, folderLocked: boolean) {
if (payload.message) statusMessage.value = payload.message if (payload.message) statusMessage.value = payload.message
statusBusy.value = payload.type === 'busy' || payload.busy === true statusBusy.value = payload.type === 'busy' || payload.busy === true
if (typeof payload.progress === 'number') progress.value = payload.progress if (typeof payload.progress === 'number') progress.value = payload.progress
@@ -2096,7 +2288,8 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
const failed = payload.type === 'error' || payload.status === 'error' || payload.status === 'cancelled' const failed = payload.type === 'error' || payload.status === 'error' || payload.status === 'cancelled'
const complete = payload.type === 'complete' || payload.status === 'complete' const complete = payload.type === 'complete' || payload.status === 'complete'
const deferred = payload.type === 'deferred' || payload.status === 'deferred' const deferred = payload.type === 'deferred' || payload.status === 'deferred'
if (settledUi) return if (videoSettledUi) return
if (payload.stillId) return
if (payload.type === 'checkpoint' && payload.clipId) { if (payload.type === 'checkpoint' && payload.clipId) {
completedChainStep.value = Math.max(completedChainStep.value, Number(payload.chainStep || chainStep.value) || 0) completedChainStep.value = Math.max(completedChainStep.value, Number(payload.chainStep || chainStep.value) || 0)
@@ -2111,53 +2304,31 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
} }
if (deferred) { if (deferred) {
settledUi = true videoSettledUi = true
busy.value = false videoBusy.value = false
statusBusy.value = false statusBusy.value = false
stopTimer() stopTimer('video')
stopListen() stopListen('video')
clearActiveJob() clearActiveJob('video')
loadLibrary().catch(() => null) loadLibrary().catch(() => null)
void pollComfyHealth() void pollComfyHealth()
toast(payload.message || 'Comfy was still busy. This job was saved under Held jobs.') toast(payload.message || 'Comfy was still busy. This job was saved under Held jobs.')
return return
} }
if (complete && (payload.clipId || payload.filename || payload.stillId)) { if (complete && (payload.clipId || payload.filename)) {
settledUi = true videoSettledUi = true
const dest = folders.value.find(folder => folder.id === folderId.value) const dest = folders.value.find(folder => folder.id === folderId.value)
const locked = dest const locked = dest
? Boolean(dest.protected && !dest.unlocked) ? Boolean(dest.protected && !dest.unlocked)
: (folderLocked || payload.folderLocked === true) : (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 const keepLibraryView = watchingLibrary.value && Boolean(currentClipId.value) && payload.clipId !== currentClipId.value
if (keepLibraryView) { if (keepLibraryView) {
busy.value = false videoBusy.value = false
statusBusy.value = false statusBusy.value = false
stopTimer() stopTimer('video')
stopListen() stopListen('video')
clearActiveJob() clearActiveJob('video')
activeDraftId = '' activeDraftId = ''
loadLibrary().catch(() => null) loadLibrary().catch(() => null)
void pollComfyHealth() void pollComfyHealth()
@@ -2187,11 +2358,11 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
concealOutput.value = awaitingReveal.value concealOutput.value = awaitingReveal.value
lockedSave.value = false lockedSave.value = false
} }
busy.value = false videoBusy.value = false
statusBusy.value = false statusBusy.value = false
stopTimer() stopTimer('video')
stopListen() stopListen('video')
clearActiveJob() clearActiveJob('video')
activeDraftId = '' activeDraftId = ''
loadLibrary().catch(() => null) loadLibrary().catch(() => null)
void pollComfyHealth() void pollComfyHealth()
@@ -2199,68 +2370,96 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
} }
if (failed) { if (failed) {
settledUi = true videoSettledUi = true
busy.value = false videoBusy.value = false
statusBusy.value = false statusBusy.value = false
stopTimer() stopTimer('video')
stopListen() stopListen('video')
clearActiveJob() clearActiveJob('video')
loadLibrary().catch(() => null) loadLibrary().catch(() => null)
void pollComfyHealth() void pollComfyHealth()
toast(payload.error || payload.message || 'Generation failed') toast(payload.error || payload.message || 'Generation failed')
} }
} }
function listen(id: string, hidden: boolean, folderLocked = false) { function applyJobEvent(kind: 'video' | 'edit', payload: Record<string, any>, hidden: boolean, folderLocked: boolean, expectedJobId: string) {
const gen = ++listenGen const currentId = kind === 'edit' ? editJobId.value : videoJobId.value
stopListen() if (expectedJobId && currentId && expectedJobId !== currentId) return
source = new EventSource(`/api/generate/${id}/stream`) if (payload.jobId && expectedJobId && payload.jobId !== expectedJobId) return
if (kind === 'edit') applyEditEvent(payload, hidden, folderLocked)
else applyVideoEvent(payload, hidden, folderLocked)
}
function listen(kind: 'video' | 'edit', id: string, hidden: boolean, folderLocked = false) {
stopListen(kind)
const isEdit = kind === 'edit'
const gen = isEdit ? ++editListenGen : ++videoListenGen
const source = new EventSource(`/api/generate/${id}/stream`)
if (isEdit) editSource = source
else videoSource = source
source.onmessage = (event) => { source.onmessage = (event) => {
if (gen !== listenGen) return if (gen !== (isEdit ? editListenGen : videoListenGen)) return
applyJobEvent(JSON.parse(event.data), hidden, folderLocked, id) applyJobEvent(kind, JSON.parse(event.data), hidden, folderLocked, id)
} }
const tick = async () => { const tick = async () => {
if (gen !== listenGen || settledUi) { const currentGen = isEdit ? editListenGen : videoListenGen
if (gen === listenGen && settledUi) stopListen() const settled = isEdit ? editSettledUi : videoSettledUi
if (gen !== currentGen || settled) {
if (gen === currentGen && settled) stopListen(kind)
return return
} }
try { try {
const snap = await $fetch<Record<string, any>>(`/api/generate/${id}`) const snap = await $fetch<Record<string, any>>(`/api/generate/${id}`)
if (gen !== listenGen) return if (gen !== (isEdit ? editListenGen : videoListenGen)) return
applyJobEvent(snap, hidden, folderLocked, id) applyJobEvent(kind, snap, hidden, folderLocked, id)
} catch { } catch {
if (gen !== listenGen) return if (gen !== (isEdit ? editListenGen : videoListenGen)) return
try { try {
const recovered = await $fetch<Record<string, any>>('/api/generate/recover', { const recovered = await $fetch<Record<string, any>>('/api/generate/recover', {
method: 'POST', method: 'POST',
body: { jobId: id } body: { jobId: id }
}) })
if (gen !== listenGen) return if (gen !== (isEdit ? editListenGen : videoListenGen)) return
applyJobEvent(recovered, hidden, folderLocked, id) applyJobEvent(kind, recovered, hidden, folderLocked, id)
} catch { } catch {
// Job snapshot can 404 briefly after a restart; keep polling while busy. // Job snapshot can 404 briefly after a restart; keep polling while busy.
} }
} }
} }
void tick() void tick()
pollTimer = setInterval(() => { const poll = setInterval(() => {
void tick() void tick()
}, 1000) }, 1000)
if (isEdit) editPollTimer = poll
else videoPollTimer = poll
} }
async function cancelJob() { async function cancelJob() {
const kind = studioMode.value === 'edit' ? 'edit' : 'video'
const id = kind === 'edit' ? editJobId.value : videoJobId.value
try { try {
await $fetch('/api/interrupt', { method: 'POST', body: { jobId: jobId.value } }) await $fetch('/api/interrupt', { method: 'POST', body: { jobId: id, kind } })
statusMessage.value = 'Job cancelled. ComfyUI is idle — generate again.' if (kind === 'edit') editStatusMessage.value = 'Edit cancelled. Sidecar is idle — edit 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')
statusMessage.value = 'Cancelled locally. Generate again when ComfyUI is free.' if (kind === 'edit') editStatusMessage.value = 'Cancelled locally. Edit again when the sidecar is free.'
else statusMessage.value = 'Cancelled locally. Generate again when ComfyUI is free.'
} }
settledUi = true if (kind === 'edit') {
busy.value = false editSettledUi = true
editBusy.value = false
editStatusBusy.value = false
stopTimer('edit')
stopListen('edit')
clearActiveJob('edit')
} else {
videoSettledUi = true
videoBusy.value = false
statusBusy.value = false statusBusy.value = false
stopTimer() stopTimer('video')
stopListen() stopListen('video')
clearActiveJob() clearActiveJob('video')
}
} }
</script> </script>
+1
View File
@@ -72,6 +72,7 @@ export default defineEventHandler(async (event) => {
const clipName = nextClipPartName(clipTitle(source)) const clipName = nextClipPartName(clipTitle(source))
const job = createJob() const job = createJob()
job.kind = 'video'
job.maxStep = steps job.maxStep = steps
job.hideThumbnail = source.hideThumbnail job.hideThumbnail = source.hideThumbnail
const tmpDir = extendTempDir(owner, job.id) const tmpDir = extendTempDir(owner, job.id)
+1
View File
@@ -146,6 +146,7 @@ export default defineEventHandler(async (event) => {
const chainTotal = 1 + extensions.length const chainTotal = 1 + extensions.length
const job = createJob() const job = createJob()
job.kind = 'video'
job.maxStep = steps job.maxStep = steps
job.hideThumbnail = hideThumbnail job.hideThumbnail = hideThumbnail
job.library = { job.library = {
+36 -12
View File
@@ -1,25 +1,43 @@
function livePayload(job: ReturnType<typeof getJob> & {}) {
if (!job) return null
return {
jobId: job.id,
kind: job.kind || 'video',
hideThumbnail: job.hideThumbnail === true,
folderLocked: job.library?.folderLocked === true,
...jobSnapshot(job)
}
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const owner = libraryOwnerKey(event) const owner = libraryOwnerKey(event)
const live = listJobs() const live = listJobs()
.filter(job => job.library?.ownerKey === owner && (job.status === 'queued' || job.status === 'uploading' || job.status === 'running')) .filter(job => job.library?.ownerKey === owner && (job.status === 'queued' || job.status === 'uploading' || job.status === 'running'))
.sort((a, b) => b.startedAt - a.startedAt)[0] .sort((a, b) => b.startedAt - a.startedAt)
if (live) { const videoLive = live.find(job => job.kind !== 'edit')
return { const editLive = live.find(job => job.kind === 'edit')
jobId: live.id,
hideThumbnail: live.hideThumbnail === true,
folderLocked: live.library?.folderLocked === true,
...jobSnapshot(live)
}
}
let video = videoLive ? livePayload(videoLive) : null
const edit = editLive ? livePayload(editLive) : null
if (!video) {
const pending = listPendingJobs() const pending = listPendingJobs()
.filter(job => job.ownerKey === owner) .filter(job => job.ownerKey === owner)
.sort((a, b) => b.startedAt - a.startedAt)[0] .sort((a, b) => b.startedAt - a.startedAt)[0]
if (pending) { if (pending) {
const resolved = await resolvePendingJob(pending).catch(() => null) const resolved = await resolvePendingJob(pending).catch(() => null)
if (resolved) return resolved if (resolved) {
return { video = {
jobId: pending.jobId, jobId: pending.jobId,
kind: 'video' as const,
hideThumbnail: pending.hideThumbnail === true,
folderLocked: pending.folderLocked === true,
...resolved
}
} else {
video = {
jobId: pending.jobId,
kind: 'video',
type: 'snapshot', type: 'snapshot',
status: 'running', status: 'running',
message: 'Reconnecting to ComfyUI...', message: 'Reconnecting to ComfyUI...',
@@ -30,6 +48,12 @@ export default defineEventHandler(async (event) => {
folderLocked: pending.folderLocked === true folderLocked: pending.folderLocked === true
} }
} }
}
}
return { jobId: null } return {
jobId: video?.jobId || edit?.jobId || null,
video,
edit
}
}) })
+3 -2
View File
@@ -1,5 +1,5 @@
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const body = await readBody<{ jobId?: string }>(event).catch(() => ({})) const body = await readBody<{ jobId?: string; kind?: string }>(event).catch(() => ({}))
const jobId = String(body?.jobId || '') const jobId = String(body?.jobId || '')
const job = jobId ? getJob(jobId) : undefined const job = jobId ? getJob(jobId) : undefined
if (job) { if (job) {
@@ -8,7 +8,8 @@ export default defineEventHandler(async (event) => {
removeExtendTemp(job.library?.extendTmpDir) removeExtendTemp(job.library?.extendTmpDir)
} }
if (jobId) deletePendingJob(jobId) if (jobId) deletePendingJob(jobId)
const ok = job?.kind === 'edit' const kind = job?.kind || (body?.kind === 'edit' ? 'edit' : 'video')
const ok = kind === 'edit'
? await interruptImageComfy() ? await interruptImageComfy()
: await interruptComfy() : await interruptComfy()
if (job) { if (job) {
+1
View File
@@ -124,6 +124,7 @@ export function listJobs() {
export function jobSnapshot(job: Job) { export function jobSnapshot(job: Job) {
return { return {
jobId: job.id, jobId: job.id,
kind: job.kind || 'video',
type: 'snapshot' as const, type: 'snapshot' as const,
status: job.status, status: job.status,
message: job.message, message: job.message,