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:
+127
-108
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user