Recover finished Comfy videos after relay restarts.
Persist queued jobs to disk, import leftover MP4s from Comfy history, and do not mark a run complete until the saved video exists. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+53
-52
@@ -34,55 +34,47 @@ export function watchComfyJob(job: Job): Promise<void> {
|
||||
try { ws.close() } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const finish = async (error?: string) => {
|
||||
const fail = async (error: string) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
job.status = job.status === 'cancelled' ? 'cancelled' : 'error'
|
||||
job.error = classifyError(error)
|
||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||
if (job.id) deletePendingJob(job.id)
|
||||
resolve()
|
||||
}
|
||||
|
||||
const succeed = async () => {
|
||||
if (settled || !job.promptId) return false
|
||||
const history = await fetchHistory(job.promptId)
|
||||
const video = extractVideo(history, job.promptId)
|
||||
if (!video) return false
|
||||
settled = true
|
||||
cleanup()
|
||||
try {
|
||||
if (error) {
|
||||
job.status = job.status === 'cancelled' ? 'cancelled' : 'error'
|
||||
job.error = classifyError(error)
|
||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||
return
|
||||
}
|
||||
emitJob(job, { type: 'status', message: 'Fetching output...', progress: 96 })
|
||||
const history = await fetchHistory(job.promptId || '')
|
||||
const video = extractVideo(history, job.promptId || '')
|
||||
if (!video) {
|
||||
job.status = 'error'
|
||||
job.error = 'Workflow finished but no MP4 was found in ComfyUI history.'
|
||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||
return
|
||||
}
|
||||
job.video = video
|
||||
emitJob(job, { type: 'status', message: 'Saving to library...', progress: 98 })
|
||||
if (job.library) {
|
||||
emitJob(job, { type: 'status', message: 'Saving to library...', progress: 98 })
|
||||
try {
|
||||
const buffer = await downloadComfyVideo(video)
|
||||
const clip = await saveClip({
|
||||
ownerKey: job.library.ownerKey,
|
||||
folderId: job.library.folderId,
|
||||
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,
|
||||
hideThumbnail: job.library.hideThumbnail,
|
||||
video: buffer,
|
||||
thumb: job.library.hideThumbnail ? null : job.library.thumb
|
||||
})
|
||||
job.clipId = clip.id
|
||||
job.hideThumbnail = clip.hideThumbnail
|
||||
job.library.thumb = undefined
|
||||
} catch (saveError) {
|
||||
const message = saveError instanceof Error ? saveError.message : String(saveError)
|
||||
job.status = 'error'
|
||||
job.error = `Video generated but library save failed: ${message}`
|
||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||
return
|
||||
}
|
||||
const buffer = await downloadComfyVideo(video)
|
||||
const clip = await saveClip({
|
||||
ownerKey: job.library.ownerKey,
|
||||
folderId: job.library.folderId,
|
||||
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,
|
||||
hideThumbnail: job.library.hideThumbnail,
|
||||
video: buffer,
|
||||
thumb: job.library.hideThumbnail ? null : job.library.thumb,
|
||||
comfyFilename: video.filename
|
||||
})
|
||||
job.clipId = clip.id
|
||||
job.hideThumbnail = clip.hideThumbnail
|
||||
job.library.thumb = undefined
|
||||
}
|
||||
job.status = 'complete'
|
||||
emitJob(job, {
|
||||
@@ -95,9 +87,15 @@ export function watchComfyJob(job: Job): Promise<void> {
|
||||
clipId: job.clipId,
|
||||
hideThumbnail: job.hideThumbnail
|
||||
})
|
||||
} finally {
|
||||
resolve()
|
||||
deletePendingJob(job.id)
|
||||
} catch (saveError) {
|
||||
const message = saveError instanceof Error ? saveError.message : String(saveError)
|
||||
job.status = 'error'
|
||||
job.error = `Video generated but library save failed: ${message}`
|
||||
emitJob(job, { type: 'error', error: job.error, message: job.error })
|
||||
}
|
||||
resolve()
|
||||
return true
|
||||
}
|
||||
|
||||
const pollHistory = async () => {
|
||||
@@ -105,11 +103,11 @@ export function watchComfyJob(job: Job): Promise<void> {
|
||||
try {
|
||||
const inspected = inspectHistory(await fetchHistory(job.promptId), job.promptId)
|
||||
if (inspected.error) {
|
||||
await finish(inspected.error)
|
||||
await fail(inspected.error)
|
||||
return
|
||||
}
|
||||
if (inspected.video || inspected.completed) {
|
||||
await finish()
|
||||
if (inspected.video) {
|
||||
await succeed()
|
||||
}
|
||||
} catch {
|
||||
// Transient ComfyUI history misses are expected while the graph is still running.
|
||||
@@ -117,7 +115,7 @@ export function watchComfyJob(job: Job): Promise<void> {
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
void finish('Timed out waiting for ComfyUI (15 minutes).')
|
||||
void fail('Timed out waiting for ComfyUI (15 minutes).')
|
||||
}, 15 * 60 * 1000)
|
||||
|
||||
pollTimer = setInterval(() => {
|
||||
@@ -168,7 +166,10 @@ export function watchComfyJob(job: Job): Promise<void> {
|
||||
if (payload.type === 'executing') {
|
||||
const node = data.node === null || data.node === undefined ? null : String(data.node)
|
||||
if (node === null) {
|
||||
await finish()
|
||||
const saved = await succeed()
|
||||
if (!saved) {
|
||||
emitJob(job, { type: 'status', message: 'Waiting for ComfyUI to save the MP4...', progress: 96 })
|
||||
}
|
||||
return
|
||||
}
|
||||
const label = NODE_LABELS[node] || `Running node ${node}`
|
||||
@@ -183,12 +184,12 @@ export function watchComfyJob(job: Job): Promise<void> {
|
||||
|
||||
if (payload.type === 'execution_error') {
|
||||
const message = String(data.exception_message || data.message || 'ComfyUI node execution failed')
|
||||
await finish(message)
|
||||
await fail(message)
|
||||
}
|
||||
|
||||
if (payload.type === 'execution_interrupted') {
|
||||
job.status = 'cancelled'
|
||||
await finish('Job interrupted.')
|
||||
await fail('Job interrupted.')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user