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
+1 -1
View File
@@ -98,7 +98,7 @@ export default defineEventHandler(async (event) => {
extendTmpDir: tmpDir,
extendPart1Path: part1Path,
extendSourceClipId: source.id,
familyId: crypto.randomUUID(),
familyId: source.familyId || crypto.randomUUID(),
parentClipId: source.id,
chainIndex: (source.chainIndex || 0) + 1
}
+8 -1
View File
@@ -403,7 +403,14 @@ async function runGeneration(
assertJobActive(job)
copyFileSync(currentPath, part1Path)
await extractLastFrame(part1Path, framePath)
try {
await extractLastFrame(part1Path, framePath)
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
throw new Error(detail.includes('last frame')
? detail
: `Could not extract the last frame for the next extension: ${detail}`)
}
const frame = readFileSync(framePath)
job.library.extendPart1Path = part1Path
job.library.prompt = ext.prompt
+23 -11
View File
@@ -63,18 +63,30 @@ export async function extractLastFrame(inputPath: string, outputPath: string) {
if (!existsSync(inputPath)) {
throw new Error('Source video is missing')
}
await runFfmpeg([
'-y',
'-sseof', '-1',
'-i', inputPath,
'-update', '1',
'-frames:v', '1',
'-q:v', '1',
outputPath
], 30000)
if (!existsSync(outputPath)) {
throw new Error('FFmpeg did not write the last frame')
const attempts: string[][] = []
try {
const duration = await probeDuration(inputPath)
const seek = Math.max(0, duration - 0.08)
attempts.push(['-y', '-ss', seek.toFixed(3), '-i', inputPath, '-frames:v', '1', '-an', '-q:v', '1', outputPath])
} catch {
// Truncated files often have no duration index; fall through to decode attempts.
}
attempts.push(
['-y', '-sseof', '-0.04', '-i', inputPath, '-update', '1', '-frames:v', '1', '-an', '-q:v', '1', outputPath],
['-y', '-i', inputPath, '-update', '1', '-an', '-q:v', '1', outputPath]
)
for (const args of attempts) {
try {
await runFfmpeg(args, 60000)
if (existsSync(outputPath)) return
} catch {
// Try the next seek strategy.
}
}
throw new Error('Could not extract the last frame. The video looks incomplete — interrupting Comfy mid-encode usually causes this. Earlier saved parts are still in the library.')
}
export async function probeHasAudio(filePath: string) {
+2
View File
@@ -78,6 +78,8 @@ export interface Job {
}
error?: string
socketReady?: boolean
saving?: boolean
savedPromptId?: string
segmentBuffer?: Buffer
events: JobEvent[]
listeners: Set<(event: JobEvent) => void>
+2 -1
View File
@@ -317,7 +317,8 @@ export function publicLibrary(event: H3Event) {
clips: catalog.clips.filter(clip => unlockedIds.has(clip.folderId)).map(clip => ({
...clip,
name: clipTitle(clip),
hasThumbnail: !clip.hideThumbnail && existsSync(clipThumbPath(owner, clip.id))
hasThumbnail: !clip.hideThumbnail && existsSync(clipThumbPath(owner, clip.id)),
hasVideo: existsSync(clipVideoPath(owner, clip.id))
})),
stills: catalog.stills.filter(still => unlockedIds.has(still.folderId)),
drafts: catalog.drafts
+34
View File
@@ -70,10 +70,42 @@ export function deletePendingJob(jobId: string) {
}
export async function completePendingIfReady(pending: PendingJob) {
const alreadySaved = () => {
const live = getJob(pending.jobId)
if (live?.savedPromptId === pending.promptId) {
deletePendingJob(pending.jobId)
return {
type: 'complete' as const,
status: 'complete' as const,
message: pending.folderLocked
? 'Saved to the locked folder. Unlock it to view.'
: (pending.extendPart1Path ? 'Extended video ready' : 'Video ready'),
progress: 100,
jobId: pending.jobId,
clipId: live.clipId,
hideThumbnail: pending.hideThumbnail,
folderLocked: pending.folderLocked
}
}
return null
}
const inFlight = () => {
const current = getJob(pending.jobId)
return Boolean(current && current.promptId === pending.promptId && current.saving)
}
if (inFlight()) return null
const saved = alreadySaved()
if (saved) return saved
const history = await fetchHistory(pending.promptId)
if (inFlight()) return null
const afterHistory = alreadySaved()
if (afterHistory) return afterHistory
const video = extractVideo(history, pending.promptId)
if (!video) return null
let buffer = await downloadComfyVideo(video)
if (inFlight()) return null
const afterDownload = alreadySaved()
if (afterDownload) return afterDownload
if (pending.extendPart1Path && pending.extendTmpDir) {
if (!existsSync(pending.extendPart1Path)) {
removeExtendTemp(pending.extendTmpDir)
@@ -91,6 +123,8 @@ export async function completePendingIfReady(pending: PendingJob) {
}
removeExtendTemp(pending.extendTmpDir)
}
const afterStitch = alreadySaved()
if (afterStitch) return afterStitch
const clip = await saveClip({
ownerKey: pending.ownerKey,
folderId: pending.folderId,
+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()
})