From f54e8da8575c6c5068d98b4e01ee18c58a00189e Mon Sep 17 00:00:00 2001 From: Towsty Date: Wed, 26 Aug 2026 14:44:56 -0500 Subject: [PATCH] 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 --- pages/index.vue | 639 ++++++++++++++++++++---------- server/api/extend.post.ts | 1 + server/api/generate.post.ts | 1 + server/api/generate/active.get.ts | 76 ++-- server/api/interrupt.post.ts | 5 +- server/utils/jobs.ts | 1 + 6 files changed, 475 insertions(+), 248 deletions(-) diff --git a/pages/index.vue b/pages/index.vue index 7bd1d2c..940ff35 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -11,15 +11,15 @@
+

{{ editBlockReason }}

Held jobs

@@ -473,12 +482,20 @@

Output

-

+

+ {{ editElapsedLabel }} + · step {{ editStep }}/{{ editMaxStep }} +

+

{{ elapsedLabel }} · step {{ step }}/{{ maxStep || (turbo ? 8 : 20) }}

+

Video generation continues in the background.

+

Image edit continues in the background.

+ +
{{ statusMessage }} @@ -488,7 +505,7 @@ Step {{ chainStep }}/{{ chainTotal }}{{ chainLabel ? `: ${chainLabel}` : '' }}

-
    +
    1. - Edited still
- {{ studioMode === 'edit' ? 'Edited still appears here' : 'Generated MP4 appears here' }} + Generated MP4 appears here
-
- - Download still - - -
+
+ +
+
@@ -928,7 +986,8 @@ const prompt = ref('') const shotScriptMode = ref(false) const extensionQueue = ref([]) const clipName = ref('') -const outputDownloadName = ref('aigen') +const videoDownloadName = ref('aigen') +const editDownloadName = ref('aigen-edit') const aspect = ref('auto') const imageWidth = ref(0) const imageHeight = ref(0) @@ -950,7 +1009,9 @@ const hideThumbnail = ref(false) const hideInputPreview = ref(false) const autoplayEnabled = ref(false) 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 imageComfyOk = ref(false) const imageComfyBusy = ref(false) @@ -971,11 +1032,20 @@ const expandedFamilies = ref>({}) const elapsedMs = ref(0) const videoUrl = 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 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 videoJobId = ref('') +const editJobId = ref('') const currentClipId = ref('') const extendOpen = ref(false) const extendPrompt = ref('') @@ -1018,20 +1088,28 @@ const pickerHeading = computed(() => { if (pickerSlot.value === 'main') return 'Choose an image' return `Choose identity ref ${Number(pickerSlot.value) + 1}` }) -let timer: ReturnType | null = null -let source: EventSource | null = null -let pollTimer: ReturnType | null = null +let videoTimer: ReturnType | null = null +let editTimer: ReturnType | null = null +let videoSource: EventSource | null = null +let editSource: EventSource | null = null +let videoPollTimer: ReturnType | null = null +let editPollTimer: ReturnType | null = null let healthTimer: ReturnType | null = null -let settledUi = false -let listenGen = 0 +let videoSettledUi = false +let editSettledUi = false +let videoListenGen = 0 +let editListenGen = 0 let activeDraftId = '' -const elapsedLabel = computed(() => { - const seconds = Math.floor(elapsedMs.value / 1000) +function formatElapsed(ms: number) { + const seconds = Math.floor(ms / 1000) const mm = String(Math.floor(seconds / 60)).padStart(2, '0') const ss = String(seconds % 60).padStart(2, '0') return `${mm}:${ss}` -}) +} + +const elapsedLabel = computed(() => formatElapsed(elapsedMs.value)) +const editElapsedLabel = computed(() => formatElapsed(editElapsedMs.value)) const comfyBadge = computed(() => { 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()) 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 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' return n === 1 ? 'Generate Initial + 1 Extension' : `Generate Initial + ${n} Extensions` }) @@ -1221,7 +1315,7 @@ function chainPartDone(index: number) { } function chainPartCurrent(index: number) { - return busy.value && index === completedChainStep.value + return videoBusy.value && index === completedChainStep.value } function chainPartClass(index: number) { @@ -1509,30 +1603,35 @@ function onDrop(event: DragEvent) { void persistDroppedStill(next) } -function startTimer(offset = 0) { - elapsedMs.value = offset - stopTimer() +function startTimer(kind: 'video' | 'edit', offset = 0) { + const elapsed = kind === 'edit' ? editElapsedMs : elapsedMs + elapsed.value = offset + stopTimer(kind) const started = Date.now() - offset - timer = setInterval(() => { - elapsedMs.value = Date.now() - started + const handle = setInterval(() => { + elapsed.value = Date.now() - started }, 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 { - 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 */ } } -function clearActiveJob() { +function clearActiveJob(kind?: 'video' | 'edit') { try { - localStorage.removeItem(JOB_STORE) + if (!kind || kind === 'video') localStorage.removeItem(JOB_STORE) + if (!kind || kind === 'edit') localStorage.removeItem(EDIT_JOB_STORE) } catch { /* ignore */ } } -function readStoredJob() { +function readStoredJob(key = JOB_STORE) { try { - const raw = localStorage.getItem(JOB_STORE) + const raw = localStorage.getItem(key) if (!raw) return null const parsed = JSON.parse(raw) as { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean } return parsed?.jobId ? parsed : null @@ -1541,31 +1640,68 @@ function readStoredJob() { } } -async function resumeActiveJob() { - 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) +async function resumeTrack(kind: 'video' | 'edit', id: string, hidden: boolean, locked: boolean) { try { const snap = await $fetch>(`/api/generate/${id}`) - jobId.value = id - concealOutput.value = hidden - settledUi = false - applyJobEvent(snap, hidden, locked, id) + if (kind === 'edit') { + editJobId.value = id + editSettledUi = false + } else { + videoJobId.value = id + concealOutput.value = hidden + videoSettledUi = false + } + applyJobEvent(kind, snap, hidden, locked, id) const running = snap.status === 'queued' || snap.status === 'uploading' || snap.status === 'running' - if (running && !settledUi) { - busy.value = true - statusMessage.value = snap.message || 'Reconnecting to ComfyUI...' - startTimer(Number(snap.elapsedMs || 0)) - persistActiveJob(id, hidden, locked) - listen(id, hidden, locked) - } else if (settledUi) { - clearActiveJob() + const settled = kind === 'edit' ? editSettledUi : videoSettledUi + 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...' + } + startTimer(kind, Number(snap.elapsedMs || 0)) + persistActiveJob(kind, id, hidden, locked) + listen(kind, id, hidden, locked) + } else if (settled) { + clearActiveJob(kind) } } 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() { - if (timer) clearInterval(timer) - timer = null +function stopTimer(kind?: 'video' | 'edit') { + if (!kind || kind === 'video') { + if (videoTimer) clearInterval(videoTimer) + videoTimer = null + } + if (!kind || kind === 'edit') { + if (editTimer) clearInterval(editTimer) + editTimer = null + } } function clipFileName(name: string) { @@ -1741,13 +1883,12 @@ async function openExtend(item?: LibraryClip) { async function generateExtension() { const id = clipIdFromPlayer() if (!id || !extendPrompt.value.trim()) return - const switching = busy.value + const switching = videoBusy.value statusBusy.value = false - busy.value = true + videoBusy.value = true watchingLibrary.value = false - listenGen += 1 - stopListen() - settledUi = false + stopListen('video') + videoSettledUi = false progress.value = 1 awaitingReveal.value = false concealOutput.value = hideThumbnail.value || currentClip.value?.hideThumbnail === true @@ -1759,7 +1900,7 @@ async function generateExtension() { completedChainStep.value = 0 activeChainPlan.value = [] statusMessage.value = 'Extracting last frame & stitching extension...' - startTimer() + startTimer('video') try { const started = await $fetch<{ jobId: string; steps: number; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/extend', { method: 'POST', @@ -1771,17 +1912,17 @@ async function generateExtension() { }) closeExtend() 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 - settledUi = false - persistActiveJob(started.jobId, started.hideThumbnail === true || concealOutput.value, started.folderLocked === true) - listen(started.jobId, started.hideThumbnail === true || concealOutput.value, started.folderLocked === true) + videoSettledUi = false + persistActiveJob('video', started.jobId, started.hideThumbnail === true || concealOutput.value, started.folderLocked === true) + listen('video', started.jobId, started.hideThumbnail === true || concealOutput.value, started.folderLocked === true) void pollComfyHealth() } catch (error: any) { - busy.value = false + videoBusy.value = false statusBusy.value = false - stopTimer() - clearActiveJob() + stopTimer('video') + clearActiveJob('video') const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start extension' statusMessage.value = message toast(message) @@ -1798,8 +1939,8 @@ function openClip(item: LibraryClip) { awaitingReveal.value = item.hideThumbnail playerSize.value = { width: item.width || 1344, height: item.height || 768 } browseFolderId.value = item.folderId - outputDownloadName.value = clipFileName(item.name || item.prompt.split(/[.!?\n]/)[0] || 'aigen') - if (!busy.value) { + videoDownloadName.value = clipFileName(item.name || item.prompt.split(/[.!?\n]/)[0] || 'aigen') + if (!videoBusy.value) { statusMessage.value = item.hideThumbnail ? 'Hidden clip ready' : 'Loaded from library' progress.value = 100 } @@ -1875,7 +2016,7 @@ async function saveRename(item: LibraryClip) { })) cancelRename() if (videoUrl.value.includes(item.id)) { - outputDownloadName.value = clipFileName(name) + videoDownloadName.value = clipFileName(name) } statusMessage.value = `Renamed to ${name}` } catch (error: any) { @@ -1912,23 +2053,17 @@ async function editImage() { toast('Choose a library folder before editing.') return } - statusBusy.value = false - busy.value = true - listenGen += 1 - stopListen() - settledUi = false - progress.value = 2 - videoUrl.value = '' + editStatusBusy.value = false + editBusy.value = true + stopListen('edit') + editSettledUi = false + editProgress.value = 2 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() + editAwaitingReveal.value = false + editLockedSave.value = false + editStatusMessage.value = 'Checking sidecar ComfyUI...' + editMaxStep.value = editSteps.value + startTimer('edit') try { const body = new FormData() body.append('image', file.value) @@ -1941,23 +2076,24 @@ async function editImage() { 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') + 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', { 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) + editJobId.value = started.jobId + editMaxStep.value = started.steps + persistActiveJob('edit', 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) { - settledUi = true + editSettledUi = true const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Image edit failed' - statusMessage.value = message + editStatusMessage.value = message toast(message) - busy.value = false - statusBusy.value = false - stopTimer() + editBusy.value = false + editStatusBusy.value = false + stopTimer('edit') void pollComfyHealth() } } @@ -1966,7 +2102,7 @@ async function useEditAsInput() { if (!editResultUrl.value) return try { const blob = await $fetch(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') } catch { 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 })) : extensionQueue.value.map(item => ({ prompt: item.prompt.trim(), duration: item.duration })) statusBusy.value = false - busy.value = true - listenGen += 1 - stopListen() - settledUi = false + videoBusy.value = true + stopListen('video') + videoSettledUi = false progress.value = 1 videoUrl.value = '' - editResultUrl.value = '' currentClipId.value = '' closeExtend() awaitingReveal.value = false @@ -2015,7 +2149,7 @@ async function generate() { overallProgress.value = 0 completedChainStep.value = 0 activeChainPlan.value = plannedChain.value.map(part => ({ ...part })) - startTimer() + startTimer('video') try { const body = new FormData() body.append('image', file.value) @@ -2047,42 +2181,100 @@ async function generate() { if (queued.length) { 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', { method: 'POST', body }) - jobId.value = started.jobId + videoJobId.value = started.jobId maxStep.value = started.steps playerSize.value = { width: started.width, height: started.height } - settledUi = false - persistActiveJob(started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true) - listen(started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true) + videoSettledUi = false + persistActiveJob('video', started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true) + listen('video', started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true) void pollComfyHealth() } catch (error: any) { - busy.value = false + videoBusy.value = false statusBusy.value = false - stopTimer() - clearActiveJob() + stopTimer('video') + clearActiveJob('video') const message = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Failed to start generation' statusMessage.value = message toast(message) } } -function stopListen() { - source?.close() - source = null - if (pollTimer) { - clearInterval(pollTimer) - pollTimer = null +function stopListen(kind?: 'video' | 'edit') { + if (!kind || kind === 'video') { + videoSource?.close() + videoSource = null + if (videoPollTimer) { + clearInterval(videoPollTimer) + videoPollTimer = null + } + } + if (!kind || kind === 'edit') { + editSource?.close() + editSource = null + if (editPollTimer) { + clearInterval(editPollTimer) + editPollTimer = null + } } } -function applyJobEvent(payload: Record, hidden: boolean, folderLocked: boolean, expectedJobId: string) { - if (expectedJobId && jobId.value && expectedJobId !== jobId.value) return - if (payload.jobId && expectedJobId && payload.jobId !== expectedJobId) return +function applyEditEvent(payload: Record, hidden: boolean, folderLocked: boolean) { + if (payload.message) editStatusMessage.value = payload.message + 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, hidden: boolean, folderLocked: boolean) { if (payload.message) statusMessage.value = payload.message statusBusy.value = payload.type === 'busy' || payload.busy === true if (typeof payload.progress === 'number') progress.value = payload.progress @@ -2096,7 +2288,8 @@ function applyJobEvent(payload: Record, hidden: boolean, folderLock const failed = payload.type === 'error' || payload.status === 'error' || payload.status === 'cancelled' const complete = payload.type === 'complete' || payload.status === 'complete' const deferred = payload.type === 'deferred' || payload.status === 'deferred' - if (settledUi) return + if (videoSettledUi) return + if (payload.stillId) return if (payload.type === 'checkpoint' && payload.clipId) { completedChainStep.value = Math.max(completedChainStep.value, Number(payload.chainStep || chainStep.value) || 0) @@ -2111,53 +2304,31 @@ function applyJobEvent(payload: Record, hidden: boolean, folderLock } if (deferred) { - settledUi = true - busy.value = false + videoSettledUi = true + videoBusy.value = false statusBusy.value = false - stopTimer() - stopListen() - clearActiveJob() + stopTimer('video') + stopListen('video') + clearActiveJob('video') loadLibrary().catch(() => null) void pollComfyHealth() toast(payload.message || 'Comfy was still busy. This job was saved under Held jobs.') return } - if (complete && (payload.clipId || payload.filename || payload.stillId)) { - settledUi = true + if (complete && (payload.clipId || payload.filename)) { + videoSettledUi = 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 + videoBusy.value = false statusBusy.value = false - stopTimer() - stopListen() - clearActiveJob() + stopTimer('video') + stopListen('video') + clearActiveJob('video') activeDraftId = '' loadLibrary().catch(() => null) void pollComfyHealth() @@ -2187,11 +2358,11 @@ function applyJobEvent(payload: Record, hidden: boolean, folderLock concealOutput.value = awaitingReveal.value lockedSave.value = false } - busy.value = false + videoBusy.value = false statusBusy.value = false - stopTimer() - stopListen() - clearActiveJob() + stopTimer('video') + stopListen('video') + clearActiveJob('video') activeDraftId = '' loadLibrary().catch(() => null) void pollComfyHealth() @@ -2199,68 +2370,96 @@ function applyJobEvent(payload: Record, hidden: boolean, folderLock } if (failed) { - settledUi = true - busy.value = false + videoSettledUi = true + videoBusy.value = false statusBusy.value = false - stopTimer() - stopListen() - clearActiveJob() + stopTimer('video') + stopListen('video') + clearActiveJob('video') loadLibrary().catch(() => null) void pollComfyHealth() toast(payload.error || payload.message || 'Generation failed') } } -function listen(id: string, hidden: boolean, folderLocked = false) { - const gen = ++listenGen - stopListen() - source = new EventSource(`/api/generate/${id}/stream`) +function applyJobEvent(kind: 'video' | 'edit', payload: Record, hidden: boolean, folderLocked: boolean, expectedJobId: string) { + const currentId = kind === 'edit' ? editJobId.value : videoJobId.value + if (expectedJobId && currentId && expectedJobId !== currentId) return + 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) => { - if (gen !== listenGen) return - applyJobEvent(JSON.parse(event.data), hidden, folderLocked, id) + if (gen !== (isEdit ? editListenGen : videoListenGen)) return + applyJobEvent(kind, JSON.parse(event.data), hidden, folderLocked, id) } const tick = async () => { - if (gen !== listenGen || settledUi) { - if (gen === listenGen && settledUi) stopListen() + const currentGen = isEdit ? editListenGen : videoListenGen + const settled = isEdit ? editSettledUi : videoSettledUi + if (gen !== currentGen || settled) { + if (gen === currentGen && settled) stopListen(kind) return } try { const snap = await $fetch>(`/api/generate/${id}`) - if (gen !== listenGen) return - applyJobEvent(snap, hidden, folderLocked, id) + if (gen !== (isEdit ? editListenGen : videoListenGen)) return + applyJobEvent(kind, snap, hidden, folderLocked, id) } catch { - if (gen !== listenGen) return + if (gen !== (isEdit ? editListenGen : videoListenGen)) return try { const recovered = await $fetch>('/api/generate/recover', { method: 'POST', body: { jobId: id } }) - if (gen !== listenGen) return - applyJobEvent(recovered, hidden, folderLocked, id) + if (gen !== (isEdit ? editListenGen : videoListenGen)) return + applyJobEvent(kind, recovered, hidden, folderLocked, id) } catch { // Job snapshot can 404 briefly after a restart; keep polling while busy. } } } void tick() - pollTimer = setInterval(() => { + const poll = setInterval(() => { void tick() }, 1000) + if (isEdit) editPollTimer = poll + else videoPollTimer = poll } async function cancelJob() { + const kind = studioMode.value === 'edit' ? 'edit' : 'video' + const id = kind === 'edit' ? editJobId.value : videoJobId.value try { - await $fetch('/api/interrupt', { method: 'POST', body: { jobId: jobId.value } }) - statusMessage.value = 'Job cancelled. ComfyUI is idle — generate again.' + await $fetch('/api/interrupt', { method: 'POST', body: { jobId: id, kind } }) + 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) { 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.' + } + if (kind === 'edit') { + editSettledUi = true + editBusy.value = false + editStatusBusy.value = false + stopTimer('edit') + stopListen('edit') + clearActiveJob('edit') + } else { + videoSettledUi = true + videoBusy.value = false + statusBusy.value = false + stopTimer('video') + stopListen('video') + clearActiveJob('video') } - settledUi = true - busy.value = false - statusBusy.value = false - stopTimer() - stopListen() - clearActiveJob() } diff --git a/server/api/extend.post.ts b/server/api/extend.post.ts index dcb0232..5bed919 100644 --- a/server/api/extend.post.ts +++ b/server/api/extend.post.ts @@ -72,6 +72,7 @@ export default defineEventHandler(async (event) => { const clipName = nextClipPartName(clipTitle(source)) const job = createJob() + job.kind = 'video' job.maxStep = steps job.hideThumbnail = source.hideThumbnail const tmpDir = extendTempDir(owner, job.id) diff --git a/server/api/generate.post.ts b/server/api/generate.post.ts index 348b004..2b09568 100644 --- a/server/api/generate.post.ts +++ b/server/api/generate.post.ts @@ -146,6 +146,7 @@ export default defineEventHandler(async (event) => { const chainTotal = 1 + extensions.length const job = createJob() + job.kind = 'video' job.maxStep = steps job.hideThumbnail = hideThumbnail job.library = { diff --git a/server/api/generate/active.get.ts b/server/api/generate/active.get.ts index a95969c..35e06ae 100644 --- a/server/api/generate/active.get.ts +++ b/server/api/generate/active.get.ts @@ -1,35 +1,59 @@ +function livePayload(job: ReturnType & {}) { + 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) => { const owner = libraryOwnerKey(event) const live = listJobs() .filter(job => job.library?.ownerKey === owner && (job.status === 'queued' || job.status === 'uploading' || job.status === 'running')) - .sort((a, b) => b.startedAt - a.startedAt)[0] - if (live) { - return { - jobId: live.id, - hideThumbnail: live.hideThumbnail === true, - folderLocked: live.library?.folderLocked === true, - ...jobSnapshot(live) + .sort((a, b) => b.startedAt - a.startedAt) + const videoLive = live.find(job => job.kind !== 'edit') + const editLive = live.find(job => job.kind === 'edit') + + let video = videoLive ? livePayload(videoLive) : null + const edit = editLive ? livePayload(editLive) : null + + if (!video) { + const pending = listPendingJobs() + .filter(job => job.ownerKey === owner) + .sort((a, b) => b.startedAt - a.startedAt)[0] + if (pending) { + const resolved = await resolvePendingJob(pending).catch(() => null) + if (resolved) { + video = { + 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', + status: 'running', + message: 'Reconnecting to ComfyUI...', + progress: Math.max(8, Math.min(90, Math.round((Date.now() - pending.startedAt) / 1000))), + promptId: pending.promptId, + elapsedMs: Date.now() - pending.startedAt, + hideThumbnail: pending.hideThumbnail === true, + folderLocked: pending.folderLocked === true + } + } } } - const pending = listPendingJobs() - .filter(job => job.ownerKey === owner) - .sort((a, b) => b.startedAt - a.startedAt)[0] - if (pending) { - const resolved = await resolvePendingJob(pending).catch(() => null) - if (resolved) return resolved - return { - jobId: pending.jobId, - type: 'snapshot', - status: 'running', - message: 'Reconnecting to ComfyUI...', - progress: Math.max(8, Math.min(90, Math.round((Date.now() - pending.startedAt) / 1000))), - promptId: pending.promptId, - elapsedMs: Date.now() - pending.startedAt, - hideThumbnail: pending.hideThumbnail === true, - folderLocked: pending.folderLocked === true - } + return { + jobId: video?.jobId || edit?.jobId || null, + video, + edit } - - return { jobId: null } }) diff --git a/server/api/interrupt.post.ts b/server/api/interrupt.post.ts index fa37a7e..1d6a04a 100644 --- a/server/api/interrupt.post.ts +++ b/server/api/interrupt.post.ts @@ -1,5 +1,5 @@ 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 job = jobId ? getJob(jobId) : undefined if (job) { @@ -8,7 +8,8 @@ export default defineEventHandler(async (event) => { removeExtendTemp(job.library?.extendTmpDir) } 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 interruptComfy() if (job) { diff --git a/server/utils/jobs.ts b/server/utils/jobs.ts index e6c6822..685157b 100644 --- a/server/utils/jobs.ts +++ b/server/utils/jobs.ts @@ -124,6 +124,7 @@ export function listJobs() { export function jobSnapshot(job: Job) { return { jobId: job.id, + kind: job.kind || 'video', type: 'snapshot' as const, status: job.status, message: job.message,