diff --git a/components/ImagePickerModal.vue b/components/ImagePickerModal.vue index 5e7049b..8cff0a1 100644 --- a/components/ImagePickerModal.vue +++ b/components/ImagePickerModal.vue @@ -7,7 +7,28 @@

Choose an image

Pick a still from this folder or upload a new one.

- +
+ + +
@@ -44,42 +65,114 @@
@@ -103,6 +196,7 @@ export interface PickerStill { width: number height: number createdAt: number + hideInput?: boolean } export interface PickerClip { @@ -119,19 +213,22 @@ const props = defineProps<{ stills: PickerStill[] clips: PickerClip[] folderId: string + hideInputPreview?: boolean }>() const emit = defineEmits<{ close: [] updated: [payload: unknown] error: [message: string] - select: [file: File] + select: [file: File, hidden: boolean] }>() const fileInput = ref(null) const dragging = ref(false) const password = ref('') const activeFolderId = ref(props.folderId) +const revealAll = ref(false) +const revealedIds = ref([]) function onKey(event: KeyboardEvent) { if (event.key === 'Escape') emit('close') @@ -148,7 +245,43 @@ watch(() => props.folderId, (id) => { const current = computed(() => props.folders.find(folder => folder.id === activeFolderId.value) || props.folders[0]) const visibleStills = computed(() => props.stills.filter(still => still.folderId === (current.value?.id || activeFolderId.value))) -const visibleClipThumbs = computed(() => props.clips.filter(clip => clip.folderId === (current.value?.id || activeFolderId.value) && !clip.hideThumbnail)) +const visibleClipThumbs = computed(() => props.clips.filter(clip => clip.folderId === (current.value?.id || activeFolderId.value))) +const hasHiddenItems = computed(() => { + return visibleStills.value.some(still => stillHidden(still)) || visibleClipThumbs.value.some(clip => clip.hideThumbnail) +}) + +function stillHidden(still: PickerStill) { + return still.hideInput === true || props.hideInputPreview === true +} + +function isRevealed(id: string) { + return revealAll.value || revealedIds.value.includes(id) +} + +function toggleReveal(id: string) { + if (revealAll.value) { + revealAll.value = false + revealedIds.value = visibleHiddenIds().filter(item => item !== id) + return + } + if (revealedIds.value.includes(id)) { + revealedIds.value = revealedIds.value.filter(item => item !== id) + } else { + revealedIds.value = [...revealedIds.value, id] + } +} + +function visibleHiddenIds() { + return [ + ...visibleStills.value.filter(still => stillHidden(still)).map(still => still.id), + ...visibleClipThumbs.value.filter(clip => clip.hideThumbnail).map(clip => 'clip-' + clip.id) + ] +} + +function toggleRevealAll() { + revealAll.value = !revealAll.value + revealedIds.value = [] +} async function unlock() { try { @@ -166,9 +299,10 @@ async function ingest(file: File) { const body = new FormData() body.append('image', file) body.append('folderId', current.value?.id || activeFolderId.value) + body.append('hideInput', String(props.hideInputPreview === true)) try { emit('updated', await $fetch('/api/library/stills', { method: 'POST', body })) - emit('select', file) + emit('select', file, props.hideInputPreview === true) } catch (error: any) { emit('error', error?.data?.statusMessage || 'Could not save image') } @@ -191,7 +325,7 @@ async function pickClip(clip: PickerClip) { try { const blob = await $fetch(`/api/library/clips/${clip.id}/thumb`, { responseType: 'blob' }) const file = new File([blob], `${clip.id}.png`, { type: blob.type || 'image/png' }) - emit('select', file) + emit('select', file, clip.hideThumbnail === true) } catch (error: any) { emit('error', error?.data?.statusMessage || 'Could not load that still') } @@ -201,7 +335,7 @@ async function pickStill(still: PickerStill) { try { const blob = await $fetch(`/api/library/stills/${still.id}`, { responseType: 'blob' }) const file = new File([blob], still.filename, { type: blob.type || 'image/png' }) - emit('select', file) + emit('select', file, still.hideInput === true) } catch (error: any) { emit('error', error?.data?.statusMessage || 'Could not load that still') } diff --git a/pages/index.vue b/pages/index.vue index 6536906..085e7f2 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -12,10 +12,10 @@
@@ -197,6 +208,24 @@ Cancel / Interrupt + +
+

Held jobs

+

Comfy was still busy after 3 minutes. Click one to restore the still, prompt, and settings.

+
    +
  • + + +
  • +
+
@@ -331,9 +360,24 @@

{{ item.width && item.height ? `${item.width}×${item.height}` : 'size unknown' }} · {{ item.steps || '—' }} steps · seed {{ item.seed || '—' }}

-
+
+
@@ -360,6 +404,7 @@ :stills="stills" :clips="clips" :folder-id="folderId" + :hide-input-preview="hideInputPreview" @close="pickerOpen = false" @updated="applyLibrary" @error="toast" @@ -398,6 +443,7 @@ interface LibraryStill { width: number height: number createdAt: number + hideInput?: boolean } interface LibraryClip { @@ -416,6 +462,26 @@ interface LibraryClip { createdAt: number } +interface RetryDraft { + id: string + createdAt: number + folderId: string + stillId?: string + stillFilename?: string + name?: string + prompt: string + aspect: string + width: number + height: number + steps: number + turbo: boolean + seed: number + duration: number + hideThumbnail: boolean + sound: boolean + hideInput?: boolean +} + const PRESETS: Record, { width: number; height: number }> = { '16:9': { width: 1344, height: 768 }, '9:16': { width: 768, height: 1344 }, @@ -453,6 +519,7 @@ const durationSliderPct = computed(() => { return ((durationSlider.value - DURATION_SLIDER_MIN) / (DURATION_SLIDER_MAX - DURATION_SLIDER_MIN)) * 100 }) const seedInput = ref('random') +const withSound = ref(true) const hideThumbnail = ref(false) const hideInputPreview = ref(false) const JOB_STORE = 'aigen-active-job' @@ -471,6 +538,7 @@ const lockedSave = ref(false) const playerSize = ref({ width: 1344, height: 768 }) const toasts = ref<{ id: string; message: string }[]>([]) const comfyOk = ref(false) +const comfyBusy = ref(false) const userName = ref('') const authEnabled = ref(false) const authMode = ref('') @@ -478,6 +546,7 @@ const instanceName = ref('AIGen') const folders = ref([]) const clips = ref([]) const stills = ref([]) +const drafts = ref([]) const folderId = ref('') const browseFolderId = ref('') const unlockPassword = ref('') @@ -486,8 +555,10 @@ const pickerOpen = ref(false) let timer: ReturnType | null = null let source: EventSource | null = null let pollTimer: ReturnType | null = null +let healthTimer: ReturnType | null = null let settledUi = false let listenGen = 0 +let activeDraftId = '' const elapsedLabel = computed(() => { const seconds = Math.floor(elapsedMs.value / 1000) @@ -496,6 +567,28 @@ const elapsedLabel = computed(() => { return `${mm}:${ss}` }) +const comfyBadge = computed(() => { + if (!comfyOk.value) { + return { + label: 'ComfyUI offline', + className: 'border-red-400/30 text-red-300', + dot: 'bg-red-400' + } + } + if (comfyBusy.value) { + return { + label: 'ComfyUI busy', + className: 'border-amber-400/40 text-amber-200', + dot: 'bg-amber-400' + } + } + return { + label: 'ComfyUI 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) } @@ -538,11 +631,12 @@ function applyThumbnailDefault() { hideThumbnail.value = currentFolder.value ? !currentFolder.value.thumbnailDefault : false } -function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[] } | unknown) { - const payload = (data || {}) as { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[] } +function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[] } | unknown) { + const payload = (data || {}) as { folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[] } folders.value = payload.folders || [] clips.value = payload.clips || [] stills.value = payload.stills || [] + drafts.value = payload.drafts || [] if (!folderId.value || !folders.value.some(folder => folder.id === folderId.value)) { folderId.value = folders.value[0]?.id || '' } @@ -552,30 +646,46 @@ function applyLibrary(data: { folders?: LibraryFolder[]; clips?: LibraryClip[]; } async function loadLibrary() { - const data = await $fetch<{ folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[] }>('/api/library') + const data = await $fetch<{ folders?: LibraryFolder[]; clips?: LibraryClip[]; stills?: LibraryStill[]; drafts?: RetryDraft[] }>('/api/library') applyLibrary(data) } +function applyComfyHealth(health: { comfy?: { ok?: boolean; busy?: boolean } } | null | undefined) { + comfyOk.value = Boolean(health?.comfy?.ok) + comfyBusy.value = Boolean(health?.comfy?.ok && health?.comfy?.busy) +} + +async function pollComfyHealth() { + const health = await $fetch<{ comfy?: { ok?: boolean; busy?: boolean } }>('/api/health').catch(() => ({ comfy: { ok: false, busy: false } })) + applyComfyHealth(health) +} + onMounted(async () => { try { hideInputPreview.value = localStorage.getItem('aigen-hide-input-preview') === 'true' + withSound.value = localStorage.getItem('aigen-generate-sound') !== 'false' } catch { /* ignore */ } window.addEventListener('pointerdown', onSitePointerDown, true) const [health, me] = await Promise.all([ - $fetch<{ comfy?: { ok?: boolean } }>('/api/health').catch(() => ({ comfy: { ok: false } })), + $fetch<{ comfy?: { ok?: boolean; busy?: boolean } }>('/api/health').catch(() => ({ comfy: { ok: false, busy: false } })), $fetch<{ user?: { name?: string; email?: string }; authEnabled?: boolean; authMode?: string; instanceName?: string }>('/api/auth/me').catch(() => ({ user: null })), - loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [] })) + loadLibrary().catch(() => applyLibrary({ folders: [], clips: [], stills: [], drafts: [] })) ]) - comfyOk.value = Boolean(health.comfy?.ok) + applyComfyHealth(health) userName.value = me.user?.name || me.user?.email || '' authEnabled.value = Boolean(me.authEnabled) authMode.value = me.authMode || '' instanceName.value = me.instanceName || 'AIGen' + healthTimer = setInterval(() => { + void pollComfyHealth() + }, 5000) await resumeActiveJob() }) onBeforeUnmount(() => { window.removeEventListener('pointerdown', onSitePointerDown, true) + if (healthTimer) clearInterval(healthTimer) + healthTimer = null stopTimer() stopListen() }) @@ -590,6 +700,12 @@ watch(hideInputPreview, (value) => { } catch { /* ignore */ } }) +watch(withSound, (value) => { + try { + localStorage.setItem('aigen-generate-sound', String(value)) + } catch { /* ignore */ } +}) + watch(duration, (value) => { if (!durationScrubbing.value) durationDraft.value = formatDuration(value) }) @@ -672,7 +788,8 @@ function readFile(next: File) { img.src = preview.value } -function onPickedImage(next: File) { +function onPickedImage(next: File, hidden = false) { + if (hidden) hideInputPreview.value = true readFile(next) pickerOpen.value = false } @@ -681,6 +798,7 @@ async function persistDroppedStill(next: File) { const body = new FormData() body.append('image', next) body.append('folderId', folderId.value) + body.append('hideInput', String(hideInputPreview.value)) try { applyLibrary(await $fetch('/api/library/stills', { method: 'POST', body })) } catch (error: any) { @@ -741,7 +859,7 @@ async function resumeActiveJob() { concealOutput.value = hidden settledUi = false applyJobEvent(snap, hidden, locked, id) - const running = snap.status === 'queued' || snap.status === 'uploading' || snap.status === 'running' || snap.type === 'snapshot' + const running = snap.status === 'queued' || snap.status === 'uploading' || snap.status === 'running' if (running && !settledUi) { busy.value = true statusMessage.value = snap.message || 'Reconnecting to ComfyUI...' @@ -793,6 +911,39 @@ function rerun(item: LibraryClip) { toast('Settings restored. Choose the same still if it is not already loaded.') } +async function loadDraft(draft: RetryDraft) { + activeDraftId = draft.id + prompt.value = draft.prompt + clipName.value = draft.name || '' + turbo.value = draft.turbo + seedInput.value = String(draft.seed) + aspect.value = (draft.aspect as AspectId) || 'auto' + hideThumbnail.value = draft.hideThumbnail + folderId.value = draft.folderId + withSound.value = draft.sound !== false + if (draft.hideInput) hideInputPreview.value = true + duration.value = clampDuration(Number(draft.duration)) + durationDraft.value = formatDuration(duration.value) + try { + const blob = await $fetch(`/api/library/drafts/${draft.id}/still`, { responseType: 'blob' }) + const filename = draft.stillFilename || 'held-still.png' + readFile(new File([blob], filename, { type: blob.type || 'image/png' })) + statusMessage.value = 'Held job restored. Generate when ComfyUI is free.' + } catch { + statusMessage.value = 'Held job settings restored. Re-drop the still if the image is missing.' + toast('Settings restored. Unlock the folder or re-drop the still if the image is missing.') + } +} + +async function dismissDraft(draft: RetryDraft) { + try { + applyLibrary(await $fetch(`/api/library/drafts/${draft.id}`, { method: 'DELETE' })) + if (activeDraftId === draft.id) activeDraftId = '' + } catch (error: any) { + toast(error?.data?.statusMessage || 'Could not dismiss held job') + } +} + async function unlockFolder(id: string) { try { const data = await $fetch('/api/library/unlock', { method: 'POST', body: { password: unlockPassword.value, folderId: id } }) @@ -860,6 +1011,26 @@ async function removeClip(item: LibraryClip) { } } +async function onMoveClip(item: LibraryClip, event: Event) { + const select = event.target as HTMLSelectElement + const destId = select.value + select.value = '' + if (!destId || destId === item.folderId) return + try { + applyLibrary(await $fetch(`/api/library/clips/${item.id}`, { + method: 'PATCH', + body: { folderId: destId } + })) + const dest = folders.value.find(folder => folder.id === destId) + if (dest?.unlocked) browseFolderId.value = destId + statusMessage.value = dest?.unlocked + ? `Moved to ${dest.name}` + : `Moved to ${dest?.name || 'folder'} (locked)` + } catch (error: any) { + toast(error?.data?.statusMessage || 'Could not move clip') + } +} + function onPlayed() { awaitingReveal.value = false } @@ -903,7 +1074,10 @@ async function generate() { body.append('seed', seedInput.value || 'random') body.append('folderId', folderId.value) body.append('hideThumbnail', String(hideThumbnail.value)) + body.append('hideInput', String(hideInputPreview.value)) + body.append('sound', String(withSound.value)) body.append('name', clipName.value.trim()) + if (activeDraftId) body.append('draftId', activeDraftId) outputDownloadName.value = clipFileName(clipName.value.trim() || prompt.value.trim().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', @@ -915,6 +1089,7 @@ async function generate() { settledUi = false persistActiveJob(started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true) listen(started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true) + void pollComfyHealth() } catch (error: any) { busy.value = false statusBusy.value = false @@ -947,8 +1122,22 @@ 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 (deferred) { + settledUi = true + busy.value = false + statusBusy.value = false + stopTimer() + stopListen() + clearActiveJob() + 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)) { settledUi = true const dest = folders.value.find(folder => folder.id === folderId.value) @@ -978,7 +1167,9 @@ function applyJobEvent(payload: Record, hidden: boolean, folderLock stopTimer() stopListen() clearActiveJob() + activeDraftId = '' loadLibrary().catch(() => null) + void pollComfyHealth() return } @@ -989,6 +1180,7 @@ function applyJobEvent(payload: Record, hidden: boolean, folderLock stopTimer() stopListen() clearActiveJob() + void pollComfyHealth() toast(payload.error || payload.message || 'Generation failed') } } @@ -1033,9 +1225,16 @@ function listen(id: string, hidden: boolean, folderLocked = false) { async function cancelJob() { try { await $fetch('/api/interrupt', { method: 'POST', body: { jobId: jobId.value } }) - statusMessage.value = 'Interrupt sent to ComfyUI' + statusMessage.value = 'Job cancelled. ComfyUI is idle — generate again.' } catch (error: any) { - toast(error?.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.' } + settledUi = true + busy.value = false + statusBusy.value = false + stopTimer() + stopListen() + clearActiveJob() } diff --git a/scripts/comfy-host-agent.mjs b/scripts/comfy-host-agent.mjs index a7300de..6fdad23 100644 --- a/scripts/comfy-host-agent.mjs +++ b/scripts/comfy-host-agent.mjs @@ -3,7 +3,7 @@ import net from 'node:net' import { execFile, spawn } from 'node:child_process' import { promisify } from 'node:util' import { readdirSync, existsSync, rmSync } from 'node:fs' -import { basename, join } from 'node:path' +import { basename, join, resolve, relative, isAbsolute } from 'node:path' const execFileAsync = promisify(execFile) const port = Number(process.env.COMFY_CONTROL_PORT || 8199) @@ -123,11 +123,28 @@ function sharedRoots() { } } +function inputRoots() { + const local = process.env.LOCALAPPDATA || '' + const roots = [ + process.env.COMFY_INPUT_DIR || join(local, 'Comfy-Desktop', 'ComfyUI-Shared', 'input') + ] + const installs = join(local, 'Comfy-Desktop', 'ComfyUI-Installs') + try { + for (const dir of readdirSync(installs, { withFileTypes: true })) { + if (!dir.isDirectory()) continue + roots.push(join(installs, dir.name, 'ComfyUI', 'input')) + } + } catch { /* ignore */ } + return roots +} + function safeFile(root, subfolder, filename) { const name = basename(String(filename || '')) if (!name || name === '.' || name === '..') return null - const target = join(root, String(subfolder || '').replace(/\.\./g, ''), name) - if (!target.startsWith(root)) return null + const rootAbs = resolve(root) + const target = resolve(rootAbs, String(subfolder || '').replace(/\.\./g, ''), name) + const rel = relative(rootAbs, target) + if (!rel || rel.startsWith('..') || isAbsolute(rel)) return null return target } @@ -156,9 +173,17 @@ function purgeDesktopFiles(body) { const roots = sharedRoots() const deleted = [] const imageName = String(body?.imageName || body?.filename || '') + const imageSub = String(body?.imageSubfolder || '') if (imageName) { - const inputPath = safeFile(roots.input, '', imageName) - if (removeFile(inputPath)) deleted.push(inputPath) + for (const root of inputRoots()) { + if (!existsSync(root)) continue + const nested = safeFile(root, imageSub, imageName) + if (removeFile(nested)) deleted.push(nested) + if (imageSub) { + const flat = safeFile(root, '', imageName) + if (removeFile(flat)) deleted.push(flat) + } + } } const video = body?.video || null const videoName = String(video?.filename || '') @@ -166,7 +191,7 @@ function purgeDesktopFiles(body) { const outputPath = safeFile(roots.output, video.subfolder || 'video', videoName) if (removeFile(outputPath)) deleted.push(outputPath) } - console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'purge', deleted: deleted.length, files: deleted.map((path) => basename(path)) })) + console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'purge', deleted: deleted.length, files: deleted.map((path) => basename(path)), imageName })) return { ok: true, deleted } } diff --git a/server/api/generate.post.ts b/server/api/generate.post.ts index 7568842..5ee0be3 100644 --- a/server/api/generate.post.ts +++ b/server/api/generate.post.ts @@ -1,3 +1,10 @@ +function isComfyBusyTimeout(error: unknown) { + const err = error as { statusCode?: number; status?: number; data?: { code?: string }; message?: string; statusMessage?: string } + if (err?.statusCode === 409 || err?.status === 409 || err?.data?.code === 'COMFY_BUSY') return true + const text = `${err?.statusMessage || ''} ${err?.message || ''}` + return text.includes('still busy after 3 minutes') +} + function parseDuration(raw: string) { const seconds = Number(raw) if (!Number.isFinite(seconds)) return 5 @@ -39,8 +46,11 @@ export default defineEventHandler(async (event) => { const seed = fields.seed && fields.seed !== 'random' ? Number(fields.seed) : Math.floor(Math.random() * 2_147_483_647) - const length = frameLength(parseDuration(fields.duration || '5')) + const durationSeconds = parseDuration(fields.duration || '5') + const length = frameLength(durationSeconds) const hideThumbnail = fields.hideThumbnail === 'true' + const hideInput = fields.hideInput === 'true' + const sound = fields.sound !== 'false' const clipName = (fields.name || '').trim().slice(0, 80) const ownerKey = libraryOwnerKey(event) const library = publicLibrary(event) @@ -53,13 +63,14 @@ export default defineEventHandler(async (event) => { const destFolder = library.folders.find(folder => folder.id === folderId) const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked) assertFolderExists(event, folderId) - await saveStill({ + const still = await saveStill({ ownerKey, folderId, filename: image.filename, data: image.data, width, - height + height, + hideInput }) const job = createJob() @@ -69,6 +80,7 @@ export default defineEventHandler(async (event) => { ownerKey, folderId, hideThumbnail, + hideInput, folderLocked, name: clipName, prompt, @@ -78,11 +90,46 @@ export default defineEventHandler(async (event) => { steps, turbo, seed, - thumb: image.data + thumb: image.data, + stillId: still.id, + stillFilename: still.filename, + duration: durationSeconds, + sound, + draftId: (fields.draftId || '').trim() || undefined } emitJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 }) - void runGeneration(job, { prompt, image, width, height, steps, seed, turbo, length }).catch((error) => { + void runGeneration(job, { prompt, image, width, height, steps, seed, turbo, length, sound }).catch(async (error) => { + if (isComfyBusyTimeout(error) && job.library) { + try { + const draft = await saveRetryDraft({ + ownerKey: job.library.ownerKey, + id: job.library.draftId, + folderId: job.library.folderId, + stillId: job.library.stillId, + stillFilename: job.library.stillFilename, + name: job.library.name, + prompt: job.library.prompt, + aspect: job.library.aspect, + width: job.library.width, + height: job.library.height, + steps: job.library.steps, + turbo: job.library.turbo, + seed: job.library.seed, + duration: job.library.duration || durationSeconds, + hideThumbnail: job.library.hideThumbnail, + hideInput: job.library.hideInput === true, + sound: job.library.sound !== false + }) + job.library.draftId = draft.id + job.status = 'deferred' + const message = 'ComfyUI was still busy after 3 minutes. Saved this job — click it under Held jobs to restore the form.' + emitJob(job, { type: 'deferred', draftId: draft.id, message, progress: job.progress }) + return + } catch { + // fall through to a normal error if the held job could not be saved + } + } const message = error instanceof Error ? error.message : String(error) job.status = 'error' job.error = message @@ -115,6 +162,7 @@ async function runGeneration( seed: number turbo: boolean length: number + sound: boolean } ) { await ensureComfyReady((status) => { @@ -131,8 +179,11 @@ async function runGeneration( const done = watchComfyJob(job) job.status = 'uploading' emitJob(job, { type: 'status', message: 'Uploading image to ComfyUI...', progress: 4 }) - const uploaded = await uploadImage(params.image) - if (job.library) job.library.imageName = uploaded.name + const uploaded = await uploadImage(params.image, job.id) + if (job.library) { + job.library.imageName = uploaded.name + job.library.imageSubfolder = uploaded.subfolder + } emitJob(job, { type: 'status', message: 'Queueing MiniMax H3 job...', progress: 6 }) await waitForComfySocket(job, 4000) @@ -145,12 +196,17 @@ async function runGeneration( seed: params.seed, turbo: params.turbo, length: params.length, - filenamePrefix: comfyFilenamePrefix() + filenamePrefix: comfyFilenamePrefix(), + sound: params.sound }) const queued = await queuePrompt(graph, job.clientId) job.promptId = queued.prompt_id job.status = 'running' + if (job.library?.draftId) { + await deleteRetryDraft(job.library.ownerKey, job.library.draftId).catch(() => null) + job.library.draftId = undefined + } if (job.library && job.promptId) { writePendingJob({ jobId: job.id, @@ -169,7 +225,8 @@ async function runGeneration( turbo: job.library.turbo, seed: job.library.seed, startedAt: job.startedAt, - imageName: job.library.imageName + imageName: job.library.imageName, + imageSubfolder: job.library.imageSubfolder }) } emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 }) diff --git a/server/api/generate/[id].get.ts b/server/api/generate/[id].get.ts index 9dfe3c5..cae3ec8 100644 --- a/server/api/generate/[id].get.ts +++ b/server/api/generate/[id].get.ts @@ -6,7 +6,7 @@ export default defineEventHandler(async (event) => { if (id) { const pending = readPendingJob(id) if (pending) { - const done = await completePendingIfReady(pending).catch(() => null) + const done = await resolvePendingJob(pending).catch(() => null) if (done) return done return { type: 'snapshot', diff --git a/server/api/generate/active.get.ts b/server/api/generate/active.get.ts index a7df404..a95969c 100644 --- a/server/api/generate/active.get.ts +++ b/server/api/generate/active.get.ts @@ -1,4 +1,4 @@ -export default defineEventHandler((event) => { +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')) @@ -16,6 +16,8 @@ export default defineEventHandler((event) => { .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', diff --git a/server/api/generate/recover.post.ts b/server/api/generate/recover.post.ts index bc15581..4c198a7 100644 --- a/server/api/generate/recover.post.ts +++ b/server/api/generate/recover.post.ts @@ -7,7 +7,7 @@ export default defineEventHandler(async (event) => { if (jobId) { const pending = readPendingJob(jobId) if (pending) { - const done = await completePendingIfReady(pending) + const done = await resolvePendingJob(pending) if (done) return done return { type: 'snapshot', diff --git a/server/api/health.get.ts b/server/api/health.get.ts index ba52cd6..8b194e1 100644 --- a/server/api/health.get.ts +++ b/server/api/health.get.ts @@ -1,11 +1,20 @@ +import { checkComfyHttp, fetchQueue } from '~/server/utils/comfyLifecycle' + export default defineEventHandler(async () => { - const comfy = await checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null })) + const [comfy, queue] = await Promise.all([ + checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null })), + fetchQueue() + ]) + const busy = Boolean(comfy.ok && queue.running > 0) return { ok: true, service: useRuntimeConfig().public.instanceName || 'aigen', comfy: { ok: comfy.ok, + busy, host: comfy.host || '', + queueRunning: queue.running, + queuePending: queue.pending, stats: comfy.ok ? comfy.stats : undefined } } diff --git a/server/api/interrupt.post.ts b/server/api/interrupt.post.ts index a12c586..8dc3012 100644 --- a/server/api/interrupt.post.ts +++ b/server/api/interrupt.post.ts @@ -1,10 +1,12 @@ export default defineEventHandler(async (event) => { const body = await readBody<{ jobId?: string }>(event).catch(() => ({})) - const job = body?.jobId ? getJob(body.jobId) : undefined + const jobId = String(body?.jobId || '') + const job = jobId ? getJob(jobId) : undefined if (job) { job.status = 'cancelled' emitJob(job, { type: 'status', message: 'Cancelling job...' }) } + if (jobId) deletePendingJob(jobId) const ok = await interruptComfy() if (job) { emitJob(job, { type: 'error', error: 'Job interrupted.', message: 'Job interrupted.' }) diff --git a/server/api/library/clips/[id].patch.ts b/server/api/library/clips/[id].patch.ts new file mode 100644 index 0000000..4aa5ef3 --- /dev/null +++ b/server/api/library/clips/[id].patch.ts @@ -0,0 +1,14 @@ +export default defineEventHandler(async (event) => { + const { owner } = assertLibraryOwner(event) + const id = String(getRouterParam(event, 'id') || '') + const body = await readBody<{ folderId?: string }>(event).catch(() => ({} as { folderId?: string })) + const folderId = String(body?.folderId || '') + if (!folderId) { + throw createError({ statusCode: 400, statusMessage: 'Choose a library folder' }) + } + const clip = getClip(owner, id) + assertFolderAccess(event, clip.folderId) + assertFolderExists(event, folderId) + await moveClip(owner, id, folderId) + return publicLibrary(event) +}) diff --git a/server/api/library/clips/[id]/thumb.get.ts b/server/api/library/clips/[id]/thumb.get.ts index 4bc3a5b..d44f216 100644 --- a/server/api/library/clips/[id]/thumb.get.ts +++ b/server/api/library/clips/[id]/thumb.get.ts @@ -5,9 +5,6 @@ export default defineEventHandler((event) => { const id = String(getRouterParam(event, 'id') || '') const clip = getClip(owner, id) assertFolderAccess(event, clip.folderId) - if (clip.hideThumbnail) { - throw createError({ statusCode: 404, statusMessage: 'Thumbnail hidden' }) - } const path = clipThumbPath(owner, clip.id) if (!existsSync(path)) { throw createError({ statusCode: 404, statusMessage: 'Thumbnail not found' }) diff --git a/server/api/library/drafts/[id].delete.ts b/server/api/library/drafts/[id].delete.ts new file mode 100644 index 0000000..94f8f6d --- /dev/null +++ b/server/api/library/drafts/[id].delete.ts @@ -0,0 +1,6 @@ +export default defineEventHandler(async (event) => { + const { owner } = assertLibraryOwner(event) + const id = String(getRouterParam(event, 'id') || '') + await deleteRetryDraft(owner, id) + return publicLibrary(event) +}) diff --git a/server/api/library/drafts/[id]/still.get.ts b/server/api/library/drafts/[id]/still.get.ts new file mode 100644 index 0000000..50f5dfe --- /dev/null +++ b/server/api/library/drafts/[id]/still.get.ts @@ -0,0 +1,19 @@ +import { existsSync, readFileSync } from 'node:fs' + +export default defineEventHandler((event) => { + const { owner } = assertLibraryOwner(event) + const id = String(getRouterParam(event, 'id') || '') + const draft = getRetryDraft(owner, id) + if (!draft.stillId) { + throw createError({ statusCode: 404, statusMessage: 'Held job has no still' }) + } + const still = getStill(owner, draft.stillId) + const path = stillPath(owner, still.id) + if (!existsSync(path)) { + throw createError({ statusCode: 404, statusMessage: 'Image file is missing' }) + } + const buf = readFileSync(path) + setHeader(event, 'Content-Type', imageContentType(buf)) + setHeader(event, 'Cache-Control', 'private, max-age=3600') + return buf +}) diff --git a/server/api/library/stills.post.ts b/server/api/library/stills.post.ts index c2cca06..32c1107 100644 --- a/server/api/library/stills.post.ts +++ b/server/api/library/stills.post.ts @@ -30,7 +30,8 @@ export default defineEventHandler(async (event) => { filename: image.filename, data: image.data, width: Number(fields.width || 0), - height: Number(fields.height || 0) + height: Number(fields.height || 0), + hideInput: fields.hideInput === 'true' }) return { still, ...publicLibrary(event) } }) diff --git a/server/plugins/resume-comfy.ts b/server/plugins/resume-comfy.ts index fe4e287..aa8b52a 100644 --- a/server/plugins/resume-comfy.ts +++ b/server/plugins/resume-comfy.ts @@ -1,5 +1,5 @@ export default defineNitroPlugin(() => { for (const pending of listPendingJobs()) { - void completePendingIfReady(pending).catch(() => null) + void resolvePendingJob(pending).catch(() => null) } }) diff --git a/server/utils/comfy.ts b/server/utils/comfy.ts index fb64416..8c3f7cd 100644 --- a/server/utils/comfy.ts +++ b/server/utils/comfy.ts @@ -43,17 +43,32 @@ export async function comfyFetch(path: string, init?: RequestInit) { } } -export async function uploadImage(file: { filename: string; data: Buffer; type?: string }) { +export async function uploadImage(file: { filename: string; data: Buffer; type?: string }, jobId?: string) { const body = new FormData() const blob = new Blob([new Uint8Array(file.data)], { type: file.type || 'application/octet-stream' }) - body.append('image', blob, file.filename) + const filename = comfyInputFilename(file.filename, jobId) + body.append('image', blob, filename) body.append('overwrite', 'true') body.append('type', 'input') const res = await comfyFetch('/upload/image', { method: 'POST', body }) if (!res.ok) { throw createError({ statusCode: 502, statusMessage: `Image upload failed (${res.status})` }) } - return (await res.json()) as { name: string; subfolder?: string; type?: string } + const uploaded = (await res.json()) as { name: string; subfolder?: string; type?: string } + return { + name: uploaded.name || filename, + subfolder: uploaded.subfolder || '', + type: uploaded.type || 'input' + } +} + +export function comfyInputFilename(original: string, jobId?: string) { + const raw = String(original || 'still.png') + const dot = raw.lastIndexOf('.') + const ext = (dot >= 0 ? raw.slice(dot) : '.png').replace(/[^.a-zA-Z0-9]/g, '') || '.png' + const base = (dot >= 0 ? raw.slice(0, dot) : raw).replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 48) || 'still' + const id = String(jobId || crypto.randomUUID()).replace(/-/g, '').slice(0, 10) + return `aigen_${id}_${base}${ext}` } export async function queuePrompt(graph: unknown, clientId: string) { @@ -173,14 +188,44 @@ export function extractClipMetaFromHistory(entry: unknown) { return meta } +export async function fetchLiveQueue() { + try { + const res = await comfyFetch('/queue', { signal: AbortSignal.timeout(2500) }) + if (!res.ok) return null + const payload = await res.json() as { queue_running?: unknown[]; queue_pending?: unknown[] } + return { + running: Array.isArray(payload.queue_running) ? payload.queue_running.length : 0, + pending: Array.isArray(payload.queue_pending) ? payload.queue_pending.length : 0 + } + } catch { + return null + } +} + +export async function isComfyPromptDropped(promptId: string) { + if (!promptId) return false + const queue = await fetchLiveQueue() + if (!queue) return false + if (queue.running > 0 || queue.pending > 0) return false + const history = await fetchHistory(promptId) + const entry = history?.[promptId] as { status?: { status_str?: string; completed?: boolean } } | undefined + if (!entry) return true + const inspected = inspectHistory(history, promptId) + if (inspected.video) return false + const status = entry.status?.status_str + if (status === 'error' || status === 'interrupted') return true + if (entry.status?.completed) return true + return true +} + export function inspectHistory(history: Record | null, promptId: string) { const video = extractVideo(history, promptId) if (!history) return { video: null, completed: false, error: null as string | null } const entry = (history[promptId] || history) as { status?: { status_str?: string; completed?: boolean } } - const error = entry?.status?.status_str === 'error' - ? 'ComfyUI reported an execution error' + const error = entry?.status?.status_str === 'error' || entry?.status?.status_str === 'interrupted' + ? (entry.status.status_str === 'interrupted' ? 'Job interrupted.' : 'ComfyUI reported an execution error') : null return { video, completed: Boolean(video), error } } @@ -217,6 +262,7 @@ export function purgeComfyEnabled() { async function purgeOnDesktop(opts: { video?: { filename: string; subfolder: string; type: string } imageName?: string + imageSubfolder?: string }) { const config = useRuntimeConfig() const controlUrl = String(config.comfyControlUrl || process.env.COMFY_CONTROL_URL || '').replace(/\/$/, '') @@ -232,6 +278,7 @@ async function purgeOnDesktop(opts: { }, body: JSON.stringify({ imageName: opts.imageName || '', + imageSubfolder: opts.imageSubfolder || '', video: opts.video || null }), signal: AbortSignal.timeout(8000) @@ -244,13 +291,14 @@ async function purgeOnDesktop(opts: { export async function purgeComfyArtifacts(opts: { video?: { filename: string; subfolder: string; type: string } imageName?: string + imageSubfolder?: string promptId?: string }) { if (!purgeComfyEnabled()) return await purgeOnDesktop(opts) const files: { filename: string; subfolder: string; type: string }[] = [] if (opts.video?.filename) files.push(opts.video) - if (opts.imageName) files.push({ filename: opts.imageName, subfolder: '', type: 'input' }) + if (opts.imageName) files.push({ filename: opts.imageName, subfolder: opts.imageSubfolder || '', type: 'input' }) for (const file of files) { try { await comfyFetch('/aigen/purge', { diff --git a/server/utils/comfyLifecycle.ts b/server/utils/comfyLifecycle.ts index 426ef29..e999878 100644 --- a/server/utils/comfyLifecycle.ts +++ b/server/utils/comfyLifecycle.ts @@ -59,7 +59,7 @@ export async function checkComfyHttp(timeoutMs = 2500, host = getComfyHost()) { } } -async function fetchQueue() { +export async function fetchQueue() { const host = getComfyHost() try { const res = await fetch(`${host}/queue`, { signal: AbortSignal.timeout(2500) }) @@ -189,7 +189,7 @@ async function waitWhileBusy(onStatus: StatusFn) { if (queue.running === 0) return onStatus({ state: 'busy', - message: `ComfyUI is busy with another prompt (${queue.running} running, ${queue.pending} queued). Waiting...`, + message: `ComfyUI is busy with another prompt (${queue.running} running, ${queue.pending} queued). Waiting up to 3 minutes, then this job will be saved.`, host, queueRunning: queue.running, queuePending: queue.pending @@ -199,7 +199,8 @@ async function waitWhileBusy(onStatus: StatusFn) { } throw createError({ statusCode: 409, - statusMessage: 'ComfyUI is busy executing another prompt. Try again in a moment.' + statusMessage: 'ComfyUI is still busy after 3 minutes. Your prompt, still, and settings were saved.', + data: { code: 'COMFY_BUSY' } }) } diff --git a/server/utils/jobs.ts b/server/utils/jobs.ts index f35ef21..4f372cc 100644 --- a/server/utils/jobs.ts +++ b/server/utils/jobs.ts @@ -1,4 +1,4 @@ -export type JobStatus = 'queued' | 'uploading' | 'running' | 'complete' | 'error' | 'cancelled' +export type JobStatus = 'queued' | 'uploading' | 'running' | 'complete' | 'error' | 'cancelled' | 'deferred' export interface JobEvent { type: string @@ -19,6 +19,7 @@ export interface JobEvent { queuePending?: number folderLocked?: boolean jobId?: string + draftId?: string } export interface Job { @@ -38,6 +39,7 @@ export interface Job { ownerKey: string folderId: string hideThumbnail: boolean + hideInput?: boolean folderLocked?: boolean name?: string prompt: string @@ -49,6 +51,12 @@ export interface Job { seed: number thumb?: Buffer imageName?: string + imageSubfolder?: string + stillId?: string + stillFilename?: string + duration?: number + sound?: boolean + draftId?: string } error?: string socketReady?: boolean @@ -105,7 +113,8 @@ export function jobSnapshot(job: Job) { clipId: job.clipId, hideThumbnail: job.hideThumbnail, error: job.error, - folderLocked: job.library?.folderLocked + folderLocked: job.library?.folderLocked, + draftId: job.library?.draftId } } diff --git a/server/utils/library.ts b/server/utils/library.ts index a15936b..4eda31f 100644 --- a/server/utils/library.ts +++ b/server/utils/library.ts @@ -40,6 +40,7 @@ export interface LibraryStill { height: number hash: string createdAt: number + hideInput: boolean } export interface PublicFolder { @@ -52,10 +53,31 @@ export interface PublicFolder { clipCount: number } +export interface RetryDraft { + id: string + createdAt: number + folderId: string + stillId?: string + name?: string + prompt: string + aspect: string + width: number + height: number + steps: number + turbo: boolean + seed: number + duration: number + hideThumbnail: boolean + sound: boolean + hideInput?: boolean + stillFilename?: string +} + interface Catalog { folders: LibraryFolder[] clips: LibraryClip[] stills: LibraryStill[] + drafts: RetryDraft[] } const writeChains = new Map>() @@ -93,7 +115,8 @@ function emptyCatalog(): Catalog { passwordHash: null }], clips: [], - stills: [] + stills: [], + drafts: [] } } @@ -107,6 +130,19 @@ function normalizeFolder(folder: LibraryFolder): LibraryFolder { } } +function normalizeStill(still: LibraryStill): LibraryStill { + return { + id: still.id, + folderId: still.folderId, + filename: still.filename, + width: still.width || 0, + height: still.height || 0, + hash: still.hash || '', + createdAt: still.createdAt || Date.now(), + hideInput: still.hideInput === true + } +} + function normalizeCatalog(parsed: Partial & { passwordHash?: string | null }): Catalog { const folders = Array.isArray(parsed.folders) && parsed.folders.length ? parsed.folders.map(normalizeFolder) @@ -119,7 +155,8 @@ function normalizeCatalog(parsed: Partial & { passwordHash?: string | n return { folders, clips: Array.isArray(parsed.clips) ? parsed.clips : [], - stills: Array.isArray(parsed.stills) ? parsed.stills : [] + stills: Array.isArray(parsed.stills) ? parsed.stills.map(normalizeStill) : [], + drafts: Array.isArray(parsed.drafts) ? parsed.drafts : [] } } @@ -250,7 +287,8 @@ export function publicLibrary(event: H3Event) { name: clipTitle(clip), hasThumbnail: !clip.hideThumbnail && existsSync(clipThumbPath(owner, clip.id)) })), - stills: catalog.stills.filter(still => unlockedIds.has(still.folderId)) + stills: catalog.stills.filter(still => unlockedIds.has(still.folderId)), + drafts: catalog.drafts } } @@ -571,13 +609,23 @@ export async function saveStill(params: { data: Buffer width?: number height?: number + hideInput?: boolean }) { const catalog = readCatalog(params.ownerKey) const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0] if (!folder) throw createError({ statusCode: 400, statusMessage: 'No library folder available' }) const hash = bytesHash(params.data) const existing = catalog.stills.find(item => item.folderId === folder.id && item.hash === hash) - if (existing) return existing + if (existing) { + if (typeof params.hideInput === 'boolean' && existing.hideInput !== params.hideInput) { + await mutate(params.ownerKey, (next) => { + const item = next.stills.find(still => still.id === existing.id) + if (item) item.hideInput = params.hideInput === true + }) + existing.hideInput = params.hideInput === true + } + return existing + } const still: LibraryStill = { id: crypto.randomUUID(), folderId: folder.id, @@ -585,7 +633,8 @@ export async function saveStill(params: { width: params.width || 0, height: params.height || 0, hash, - createdAt: Date.now() + createdAt: Date.now(), + hideInput: params.hideInput === true } mkdirSync(join(ownerRoot(params.ownerKey), 'stills'), { recursive: true }) writeFileSync(stillPath(params.ownerKey, still.id), params.data) @@ -595,6 +644,66 @@ export async function saveStill(params: { return still } +export async function saveRetryDraft(params: { + ownerKey: string + id?: string + folderId: string + stillId?: string + stillFilename?: string + name?: string + prompt: string + aspect: string + width: number + height: number + steps: number + turbo: boolean + seed: number + duration: number + hideThumbnail: boolean + sound: boolean + hideInput?: boolean +}) { + return mutate(params.ownerKey, (catalog) => { + const existing = params.id ? catalog.drafts.find(item => item.id === params.id) : undefined + const draft: RetryDraft = { + id: existing?.id || crypto.randomUUID(), + createdAt: existing?.createdAt || Date.now(), + folderId: params.folderId, + stillId: params.stillId, + stillFilename: params.stillFilename, + name: params.name, + prompt: params.prompt, + aspect: params.aspect, + width: params.width, + height: params.height, + steps: params.steps, + turbo: params.turbo, + seed: params.seed, + duration: params.duration, + hideThumbnail: params.hideThumbnail, + sound: params.sound, + hideInput: params.hideInput === true + } + catalog.drafts = [draft, ...catalog.drafts.filter(item => item.id !== draft.id)].slice(0, 20) + return draft + }) +} + +export function getRetryDraft(owner: string, id: string) { + const draft = readCatalog(owner).drafts.find(item => item.id === id) + if (!draft) throw createError({ statusCode: 404, statusMessage: 'Held job not found' }) + return draft +} + +export function deleteRetryDraft(owner: string, id: string) { + return mutate(owner, (catalog) => { + const draft = catalog.drafts.find(item => item.id === id) + if (!draft) throw createError({ statusCode: 404, statusMessage: 'Held job not found' }) + catalog.drafts = catalog.drafts.filter(item => item.id !== id) + return draft + }) +} + export function getStill(owner: string, id: string) { const still = readCatalog(owner).stills.find(item => item.id === id) if (!still) throw createError({ statusCode: 404, statusMessage: 'Image not found' }) @@ -630,6 +739,7 @@ export async function saveClip(params: { turbo: boolean seed: number hideThumbnail: boolean + hideInput?: boolean video: Buffer thumb?: Buffer | null comfyFilename?: string @@ -654,9 +764,9 @@ export async function saveClip(params: { } mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true }) writeFileSync(clipVideoPath(params.ownerKey, clip.id), params.video) - if (!params.hideThumbnail && params.thumb?.length) { + if (params.thumb?.length) { writeFileSync(join(clipDir(params.ownerKey, clip.id), 'thumb'), params.thumb) - } else if (!params.hideThumbnail) { + } else { await extractVideoThumb(clipVideoPath(params.ownerKey, clip.id), join(clipDir(params.ownerKey, clip.id), 'thumb.jpg')) } if (params.thumb?.length) { @@ -666,7 +776,8 @@ export async function saveClip(params: { filename: `${clip.id}.png`, data: params.thumb, width: params.width, - height: params.height + height: params.height, + hideInput: params.hideInput }) } await mutate(params.ownerKey, (next) => { @@ -691,6 +802,17 @@ export function deleteClip(owner: string, id: string) { }) } +export function moveClip(owner: string, id: string, folderId: string) { + return mutate(owner, (catalog) => { + const clip = catalog.clips.find(item => item.id === id) + if (!clip) throw createError({ statusCode: 404, statusMessage: 'Clip not found' }) + const folder = catalog.folders.find(item => item.id === folderId) + if (!folder) throw createError({ statusCode: 404, statusMessage: 'Folder not found' }) + clip.folderId = folder.id + return clip + }) +} + export async function downloadComfyVideo(video: { filename: string; subfolder: string; type: string }) { const subfolders = [...new Set([video.subfolder, 'video', ''])] let lastStatus = 0 diff --git a/server/utils/pending.ts b/server/utils/pending.ts index 6304011..b16506b 100644 --- a/server/utils/pending.ts +++ b/server/utils/pending.ts @@ -19,6 +19,7 @@ export interface PendingJob { seed: number startedAt: number imageName?: string + imageSubfolder?: string } function pendingRoot() { @@ -84,7 +85,7 @@ export async function completePendingIfReady(pending: PendingJob) { thumb: null, comfyFilename: video.filename }) - await purgeComfyArtifacts({ video, imageName: pending.imageName, promptId: pending.promptId }) + await purgeComfyArtifacts({ video, imageName: pending.imageName, imageSubfolder: pending.imageSubfolder, promptId: pending.promptId }) deletePendingJob(pending.jobId) return { type: 'complete' as const, @@ -100,3 +101,21 @@ export async function completePendingIfReady(pending: PendingJob) { folderLocked: pending.folderLocked } } + +export async function resolvePendingJob(pending: PendingJob) { + const done = await completePendingIfReady(pending) + if (done) return done + if (await isComfyPromptDropped(pending.promptId)) { + deletePendingJob(pending.jobId) + const message = 'ComfyUI dropped this job. Reloading the Comfy interface clears the queue. Generate again.' + return { + type: 'error' as const, + status: 'error' as const, + jobId: pending.jobId, + error: message, + message, + progress: 0 + } + } + return null +} diff --git a/server/utils/watch.ts b/server/utils/watch.ts index be43ada..df143f1 100644 --- a/server/utils/watch.ts +++ b/server/utils/watch.ts @@ -69,8 +69,9 @@ export function watchComfyJob(job: Job): Promise { turbo: job.library.turbo, seed: job.library.seed, hideThumbnail: job.library.hideThumbnail, + hideInput: job.library.hideInput, video: buffer, - thumb: job.library.hideThumbnail ? null : job.library.thumb, + thumb: job.library.thumb, comfyFilename: video.filename }) job.clipId = clip.id @@ -79,6 +80,7 @@ export function watchComfyJob(job: Job): Promise { await purgeComfyArtifacts({ video, imageName: job.library.imageName, + imageSubfolder: job.library.imageSubfolder, promptId: job.promptId }) } @@ -105,6 +107,8 @@ export function watchComfyJob(job: Job): Promise { return true } + let dropMisses = 0 + const pollHistory = async () => { if (settled || !job.promptId) return try { @@ -115,6 +119,15 @@ export function watchComfyJob(job: Job): Promise { } if (inspected.video) { await succeed() + return + } + if (await isComfyPromptDropped(job.promptId)) { + dropMisses += 1 + if (dropMisses >= 3) { + await fail('ComfyUI dropped this job. Reloading the Comfy interface clears the queue. Generate again.') + } + } else { + dropMisses = 0 } } catch { // Transient ComfyUI history misses are expected while the graph is still running. @@ -134,15 +147,9 @@ export function watchComfyJob(job: Job): Promise { emitJob(job, { type: 'status', message: 'Connected to ComfyUI', progress: Math.max(job.progress, 4) }) }) - ws.addEventListener('error', () => { - if (settled) return - emitJob(job, { - type: 'status', - message: job.promptId - ? 'Live socket unavailable, polling ComfyUI history...' - : 'Waiting for ComfyUI socket...', - progress: Math.max(job.progress, 4) - }) + ws.addEventListener('close', () => { + if (settled || !job.promptId) return + void pollHistory() }) ws.addEventListener('message', async (event) => { diff --git a/server/utils/workflow.ts b/server/utils/workflow.ts index 0d8f29e..72759b1 100644 --- a/server/utils/workflow.ts +++ b/server/utils/workflow.ts @@ -11,6 +11,7 @@ export interface GenerateParams { turbo: boolean length: number filenamePrefix?: string + sound?: boolean } type WorkflowNode = { class_type: string; inputs: Record; _meta?: { title?: string } } @@ -102,6 +103,12 @@ export function buildWorkflow(params: GenerateParams) { } } + if (params.sound === false) { + delete graph['105:23'] + delete graph['105:24'] + if (graph['105:91']?.inputs) delete graph['105:91'].inputs.audio + } + return graph }