Stop ghost extension clips and recover last-frame extract after an interrupt.

Only one Comfy finish path can save a clip, hide catalog entries whose MP4 is missing, and retry last-frame extract so a truncated file does not dump FFmpeg noise into the UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-26 11:34:44 -05:00
co-authored by Cursor
parent db8c9aabba
commit 551c29a62a
8 changed files with 201 additions and 125 deletions
+127 -108
View File
@@ -71,8 +71,10 @@ export function emitChainJob(job: Job, event: JobEvent, samplePct?: number) {
export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Promise<void> {
const persist = options.persist !== false
job.socketReady = false
job.saving = false
const ws = new WebSocket(comfyWsUrl(job.clientId))
let settled = false
let finishing = false
let pollTimer: ReturnType<typeof setInterval> | null = null
let timeout: ReturnType<typeof setTimeout> | null = null
@@ -111,7 +113,7 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
}
const idleLeft = IDLE_MS - (Date.now() - lastActivity)
timeout = setTimeout(() => {
if (settled) return
if (settled || finishing) return
if (Date.now() - lastActivity >= IDLE_MS) {
void fail('Timed out waiting for ComfyUI progress (30 minutes with no updates). The desktop job may still be running.')
return
@@ -121,7 +123,7 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
}
const fail = async (error: string) => {
if (settled) return
if (settled || finishing) return
settled = true
cleanup()
job.status = job.status === 'cancelled' ? 'cancelled' : 'error'
@@ -133,119 +135,136 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
}
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()
if (settled || finishing || !job.promptId) return false
finishing = true
job.saving = true
try {
job.video = video
const stitching = Boolean(job.library?.extendPart1Path && job.library.extendTmpDir)
emitLocal({
type: 'status',
message: stitching ? 'Extracting last frame & stitching extension...' : (persist ? 'Saving to library...' : 'Downloading segment...'),
progress: stitching ? 97 : 98
})
if (job.library) {
let buffer = await downloadComfyVideo(video)
if (stitching) {
if (!existsSync(job.library.extendPart1Path!)) {
throw new Error('Extension source clip was missing during stitch')
}
buffer = await stitchExtension({
part1Path: job.library.extendPart1Path!,
part2: buffer,
tmpDir: job.library.extendTmpDir!
})
}
const clip = await saveClip({
ownerKey: job.library.ownerKey,
folderId: job.library.folderId,
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,
hideThumbnail: job.library.hideThumbnail,
hideInput: job.library.hideInput,
video: buffer,
thumb: job.library.thumb,
comfyFilename: video.filename,
cfg: job.library.cfg,
fps: job.library.fps,
samplerName: job.library.samplerName,
scheduler: job.library.scheduler,
familyId: job.library.familyId || (job.library.familyId = crypto.randomUUID()),
parentClipId: job.library.parentClipId,
chainIndex: job.library.chainIndex
})
job.clipId = clip.id
job.hideThumbnail = clip.hideThumbnail
job.library.thumb = undefined
job.library.name = nextClipPartName(clip.name)
job.library.parentClipId = clip.id
await purgeComfyArtifacts({
video,
imageName: job.library.imageName,
imageSubfolder: job.library.imageSubfolder,
promptId: job.promptId
})
if (!persist) {
job.segmentBuffer = buffer
deletePendingJob(job.id)
emitLocal({
type: 'checkpoint',
message: stitching ? 'Extension checkpoint saved' : 'Initial segment saved',
progress: 99,
clipId: clip.id,
hideThumbnail: job.hideThumbnail,
folderLocked: job.library.folderLocked
})
resolve()
return true
}
removeExtendTemp(job.library.extendTmpDir)
job.library.extendTmpDir = undefined
const history = await fetchHistory(job.promptId)
const video = extractVideo(history, job.promptId)
if (!video) {
finishing = false
job.saving = false
return false
}
job.status = 'complete'
emitJob(job, {
type: 'complete',
message: job.library?.folderLocked
? 'Saved to the locked folder. Unlock it to view.'
: (job.library?.extendSourceClipId || (job.library?.chainTotal || 1) > 1 ? 'Extended video ready' : 'Video ready'),
progress: 100,
filename: job.library?.folderLocked ? undefined : video.filename,
subfolder: job.library?.folderLocked ? undefined : video.subfolder,
mediaType: job.library?.folderLocked ? undefined : video.type,
clipId: job.clipId,
hideThumbnail: job.hideThumbnail,
folderLocked: job.library?.folderLocked,
chainStep: job.library?.chainStep,
chainTotal: job.library?.chainTotal,
chainLabel: job.library?.chainLabel
})
deletePendingJob(job.id)
} catch (saveError) {
removeExtendTemp(job.library?.extendTmpDir)
const message = saveError instanceof Error ? saveError.message : String(saveError)
job.status = 'error'
job.error = persist
? `Video generated but library save failed: ${message}`
: `Segment finished but could not be prepared: ${message}`
emitChainJob(job, { type: 'error', error: job.error, message: job.error })
if (settled) return false
settled = true
cleanup()
try {
job.video = video
const stitching = Boolean(job.library?.extendPart1Path && job.library.extendTmpDir)
emitLocal({
type: 'status',
message: stitching ? 'Extracting last frame & stitching extension...' : (persist ? 'Saving to library...' : 'Downloading segment...'),
progress: stitching ? 97 : 98
})
if (job.library) {
let buffer = await downloadComfyVideo(video)
if (stitching) {
if (!existsSync(job.library.extendPart1Path!)) {
throw new Error('Extension source clip was missing during stitch')
}
buffer = await stitchExtension({
part1Path: job.library.extendPart1Path!,
part2: buffer,
tmpDir: job.library.extendTmpDir!
})
}
const clip = await saveClip({
ownerKey: job.library.ownerKey,
folderId: job.library.folderId,
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,
hideThumbnail: job.library.hideThumbnail,
hideInput: job.library.hideInput,
video: buffer,
thumb: job.library.thumb,
comfyFilename: video.filename,
cfg: job.library.cfg,
fps: job.library.fps,
samplerName: job.library.samplerName,
scheduler: job.library.scheduler,
familyId: job.library.familyId || (job.library.familyId = crypto.randomUUID()),
parentClipId: job.library.parentClipId,
chainIndex: job.library.chainIndex
})
job.clipId = clip.id
job.savedPromptId = job.promptId
job.hideThumbnail = clip.hideThumbnail
job.library.thumb = undefined
job.library.name = nextClipPartName(clip.name)
job.library.parentClipId = clip.id
await purgeComfyArtifacts({
video,
imageName: job.library.imageName,
imageSubfolder: job.library.imageSubfolder,
promptId: job.promptId
})
if (!persist) {
job.segmentBuffer = buffer
deletePendingJob(job.id)
job.saving = false
emitLocal({
type: 'checkpoint',
message: stitching ? 'Extension checkpoint saved' : 'Initial segment saved',
progress: 99,
clipId: clip.id,
hideThumbnail: job.hideThumbnail,
folderLocked: job.library.folderLocked
})
resolve()
return true
}
removeExtendTemp(job.library.extendTmpDir)
job.library.extendTmpDir = undefined
}
job.status = 'complete'
emitJob(job, {
type: 'complete',
message: job.library?.folderLocked
? 'Saved to the locked folder. Unlock it to view.'
: (job.library?.extendSourceClipId || (job.library?.chainTotal || 1) > 1 ? 'Extended video ready' : 'Video ready'),
progress: 100,
filename: job.library?.folderLocked ? undefined : video.filename,
subfolder: job.library?.folderLocked ? undefined : video.subfolder,
mediaType: job.library?.folderLocked ? undefined : video.type,
clipId: job.clipId,
hideThumbnail: job.hideThumbnail,
folderLocked: job.library?.folderLocked,
chainStep: job.library?.chainStep,
chainTotal: job.library?.chainTotal,
chainLabel: job.library?.chainLabel
})
deletePendingJob(job.id)
job.saving = false
} catch (saveError) {
job.saving = false
removeExtendTemp(job.library?.extendTmpDir)
const message = saveError instanceof Error ? saveError.message : String(saveError)
job.status = 'error'
job.error = persist
? `Video generated but library save failed: ${message}`
: `Segment finished but could not be prepared: ${message}`
emitChainJob(job, { type: 'error', error: job.error, message: job.error })
}
resolve()
return true
} catch (error) {
finishing = false
job.saving = false
throw error
}
resolve()
return true
}
let dropMisses = 0
const pollHistory = async () => {
if (settled || !job.promptId) return
if (settled || finishing || !job.promptId) return
try {
const inspected = inspectHistory(await fetchHistory(job.promptId), job.promptId)
if (inspected.error) {
@@ -282,7 +301,7 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
})
ws.addEventListener('close', () => {
if (settled || !job.promptId) return
if (settled || finishing || !job.promptId) return
void pollHistory()
})