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
+4 -3
View File
@@ -702,6 +702,7 @@ interface LibraryClip {
seed: number seed: number
hideThumbnail: boolean hideThumbnail: boolean
hasThumbnail?: boolean hasThumbnail?: boolean
hasVideo?: boolean
createdAt: number createdAt: number
cfg?: number cfg?: number
fps?: number fps?: number
@@ -950,7 +951,7 @@ const plannedChain = computed(() => {
] ]
}) })
const visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value)) const visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value && clip.hasVideo !== false))
const libraryGroups = computed(() => { const libraryGroups = computed(() => {
const groups = new Map<string, LibraryClip[]>() const groups = new Map<string, LibraryClip[]>()
for (const clip of visibleClips.value) { for (const clip of visibleClips.value) {
@@ -974,9 +975,9 @@ function clipsIn(id: string) {
} }
function clipFamilyKey(clip: LibraryClip) { function clipFamilyKey(clip: LibraryClip) {
if (clip.familyId) return clip.familyId
const base = String(clip.name || '').trim().replace(/\s+p\d+$/i, '') const base = String(clip.name || '').trim().replace(/\s+p\d+$/i, '')
if (base) return `name:${clip.folderId}:${base.toLowerCase()}` if (base) return `name:${clip.folderId}:${base.toLowerCase()}`
if (clip.familyId) return clip.familyId
return `solo:${clip.id}` return `solo:${clip.id}`
} }
@@ -985,7 +986,7 @@ function sortClipParts(a: LibraryClip, b: LibraryClip) {
if (durationDelta) return durationDelta if (durationDelta) return durationDelta
const chainDelta = (b.chainIndex || 0) - (a.chainIndex || 0) const chainDelta = (b.chainIndex || 0) - (a.chainIndex || 0)
if (chainDelta) return chainDelta if (chainDelta) return chainDelta
return b.createdAt - a.createdAt return a.createdAt - b.createdAt
} }
function formatClipDuration(value?: number) { function formatClipDuration(value?: number) {
+1 -1
View File
@@ -98,7 +98,7 @@ export default defineEventHandler(async (event) => {
extendTmpDir: tmpDir, extendTmpDir: tmpDir,
extendPart1Path: part1Path, extendPart1Path: part1Path,
extendSourceClipId: source.id, extendSourceClipId: source.id,
familyId: crypto.randomUUID(), familyId: source.familyId || crypto.randomUUID(),
parentClipId: source.id, parentClipId: source.id,
chainIndex: (source.chainIndex || 0) + 1 chainIndex: (source.chainIndex || 0) + 1
} }
+8 -1
View File
@@ -403,7 +403,14 @@ async function runGeneration(
assertJobActive(job) assertJobActive(job)
copyFileSync(currentPath, part1Path) 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) const frame = readFileSync(framePath)
job.library.extendPart1Path = part1Path job.library.extendPart1Path = part1Path
job.library.prompt = ext.prompt job.library.prompt = ext.prompt
+23 -11
View File
@@ -63,18 +63,30 @@ export async function extractLastFrame(inputPath: string, outputPath: string) {
if (!existsSync(inputPath)) { if (!existsSync(inputPath)) {
throw new Error('Source video is missing') throw new Error('Source video is missing')
} }
await runFfmpeg([
'-y', const attempts: string[][] = []
'-sseof', '-1', try {
'-i', inputPath, const duration = await probeDuration(inputPath)
'-update', '1', const seek = Math.max(0, duration - 0.08)
'-frames:v', '1', attempts.push(['-y', '-ss', seek.toFixed(3), '-i', inputPath, '-frames:v', '1', '-an', '-q:v', '1', outputPath])
'-q:v', '1', } catch {
outputPath // Truncated files often have no duration index; fall through to decode attempts.
], 30000)
if (!existsSync(outputPath)) {
throw new Error('FFmpeg did not write the last frame')
} }
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) { export async function probeHasAudio(filePath: string) {
+2
View File
@@ -78,6 +78,8 @@ export interface Job {
} }
error?: string error?: string
socketReady?: boolean socketReady?: boolean
saving?: boolean
savedPromptId?: string
segmentBuffer?: Buffer segmentBuffer?: Buffer
events: JobEvent[] events: JobEvent[]
listeners: Set<(event: JobEvent) => void> 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 => ({ clips: catalog.clips.filter(clip => unlockedIds.has(clip.folderId)).map(clip => ({
...clip, ...clip,
name: clipTitle(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)), stills: catalog.stills.filter(still => unlockedIds.has(still.folderId)),
drafts: catalog.drafts drafts: catalog.drafts
+34
View File
@@ -70,10 +70,42 @@ export function deletePendingJob(jobId: string) {
} }
export async function completePendingIfReady(pending: PendingJob) { 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) const history = await fetchHistory(pending.promptId)
if (inFlight()) return null
const afterHistory = alreadySaved()
if (afterHistory) return afterHistory
const video = extractVideo(history, pending.promptId) const video = extractVideo(history, pending.promptId)
if (!video) return null if (!video) return null
let buffer = await downloadComfyVideo(video) let buffer = await downloadComfyVideo(video)
if (inFlight()) return null
const afterDownload = alreadySaved()
if (afterDownload) return afterDownload
if (pending.extendPart1Path && pending.extendTmpDir) { if (pending.extendPart1Path && pending.extendTmpDir) {
if (!existsSync(pending.extendPart1Path)) { if (!existsSync(pending.extendPart1Path)) {
removeExtendTemp(pending.extendTmpDir) removeExtendTemp(pending.extendTmpDir)
@@ -91,6 +123,8 @@ export async function completePendingIfReady(pending: PendingJob) {
} }
removeExtendTemp(pending.extendTmpDir) removeExtendTemp(pending.extendTmpDir)
} }
const afterStitch = alreadySaved()
if (afterStitch) return afterStitch
const clip = await saveClip({ const clip = await saveClip({
ownerKey: pending.ownerKey, ownerKey: pending.ownerKey,
folderId: pending.folderId, 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> { export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Promise<void> {
const persist = options.persist !== false const persist = options.persist !== false
job.socketReady = false job.socketReady = false
job.saving = false
const ws = new WebSocket(comfyWsUrl(job.clientId)) const ws = new WebSocket(comfyWsUrl(job.clientId))
let settled = false let settled = false
let finishing = false
let pollTimer: ReturnType<typeof setInterval> | null = null let pollTimer: ReturnType<typeof setInterval> | null = null
let timeout: ReturnType<typeof setTimeout> | 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) const idleLeft = IDLE_MS - (Date.now() - lastActivity)
timeout = setTimeout(() => { timeout = setTimeout(() => {
if (settled) return if (settled || finishing) return
if (Date.now() - lastActivity >= IDLE_MS) { 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.') void fail('Timed out waiting for ComfyUI progress (30 minutes with no updates). The desktop job may still be running.')
return return
@@ -121,7 +123,7 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
} }
const fail = async (error: string) => { const fail = async (error: string) => {
if (settled) return if (settled || finishing) return
settled = true settled = true
cleanup() cleanup()
job.status = job.status === 'cancelled' ? 'cancelled' : 'error' job.status = job.status === 'cancelled' ? 'cancelled' : 'error'
@@ -133,119 +135,136 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
} }
const succeed = async () => { const succeed = async () => {
if (settled || !job.promptId) return false if (settled || finishing || !job.promptId) return false
const history = await fetchHistory(job.promptId) finishing = true
const video = extractVideo(history, job.promptId) job.saving = true
if (!video) return false
settled = true
cleanup()
try { try {
job.video = video const history = await fetchHistory(job.promptId)
const stitching = Boolean(job.library?.extendPart1Path && job.library.extendTmpDir) const video = extractVideo(history, job.promptId)
emitLocal({ if (!video) {
type: 'status', finishing = false
message: stitching ? 'Extracting last frame & stitching extension...' : (persist ? 'Saving to library...' : 'Downloading segment...'), job.saving = false
progress: stitching ? 97 : 98 return false
})
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
} }
job.status = 'complete' if (settled) return false
emitJob(job, { settled = true
type: 'complete', cleanup()
message: job.library?.folderLocked try {
? 'Saved to the locked folder. Unlock it to view.' job.video = video
: (job.library?.extendSourceClipId || (job.library?.chainTotal || 1) > 1 ? 'Extended video ready' : 'Video ready'), const stitching = Boolean(job.library?.extendPart1Path && job.library.extendTmpDir)
progress: 100, emitLocal({
filename: job.library?.folderLocked ? undefined : video.filename, type: 'status',
subfolder: job.library?.folderLocked ? undefined : video.subfolder, message: stitching ? 'Extracting last frame & stitching extension...' : (persist ? 'Saving to library...' : 'Downloading segment...'),
mediaType: job.library?.folderLocked ? undefined : video.type, progress: stitching ? 97 : 98
clipId: job.clipId, })
hideThumbnail: job.hideThumbnail, if (job.library) {
folderLocked: job.library?.folderLocked, let buffer = await downloadComfyVideo(video)
chainStep: job.library?.chainStep, if (stitching) {
chainTotal: job.library?.chainTotal, if (!existsSync(job.library.extendPart1Path!)) {
chainLabel: job.library?.chainLabel throw new Error('Extension source clip was missing during stitch')
}) }
deletePendingJob(job.id) buffer = await stitchExtension({
} catch (saveError) { part1Path: job.library.extendPart1Path!,
removeExtendTemp(job.library?.extendTmpDir) part2: buffer,
const message = saveError instanceof Error ? saveError.message : String(saveError) tmpDir: job.library.extendTmpDir!
job.status = 'error' })
job.error = persist }
? `Video generated but library save failed: ${message}` const clip = await saveClip({
: `Segment finished but could not be prepared: ${message}` ownerKey: job.library.ownerKey,
emitChainJob(job, { type: 'error', error: job.error, message: job.error }) 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 let dropMisses = 0
const pollHistory = async () => { const pollHistory = async () => {
if (settled || !job.promptId) return if (settled || finishing || !job.promptId) return
try { try {
const inspected = inspectHistory(await fetchHistory(job.promptId), job.promptId) const inspected = inspectHistory(await fetchHistory(job.promptId), job.promptId)
if (inspected.error) { if (inspected.error) {
@@ -282,7 +301,7 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
}) })
ws.addEventListener('close', () => { ws.addEventListener('close', () => {
if (settled || !job.promptId) return if (settled || finishing || !job.promptId) return
void pollHistory() void pollHistory()
}) })