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
} }
+7
View File
@@ -403,7 +403,14 @@ async function runGeneration(
assertJobActive(job) assertJobActive(job)
copyFileSync(currentPath, part1Path) copyFileSync(currentPath, part1Path)
try {
await extractLastFrame(part1Path, framePath) 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,
+25 -6
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,10 +135,18 @@ 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
finishing = true
job.saving = true
try {
const history = await fetchHistory(job.promptId) const history = await fetchHistory(job.promptId)
const video = extractVideo(history, job.promptId) const video = extractVideo(history, job.promptId)
if (!video) return false if (!video) {
finishing = false
job.saving = false
return false
}
if (settled) return false
settled = true settled = true
cleanup() cleanup()
try { try {
@@ -184,6 +194,7 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
chainIndex: job.library.chainIndex chainIndex: job.library.chainIndex
}) })
job.clipId = clip.id job.clipId = clip.id
job.savedPromptId = job.promptId
job.hideThumbnail = clip.hideThumbnail job.hideThumbnail = clip.hideThumbnail
job.library.thumb = undefined job.library.thumb = undefined
job.library.name = nextClipPartName(clip.name) job.library.name = nextClipPartName(clip.name)
@@ -197,6 +208,7 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
if (!persist) { if (!persist) {
job.segmentBuffer = buffer job.segmentBuffer = buffer
deletePendingJob(job.id) deletePendingJob(job.id)
job.saving = false
emitLocal({ emitLocal({
type: 'checkpoint', type: 'checkpoint',
message: stitching ? 'Extension checkpoint saved' : 'Initial segment saved', message: stitching ? 'Extension checkpoint saved' : 'Initial segment saved',
@@ -229,7 +241,9 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
chainLabel: job.library?.chainLabel chainLabel: job.library?.chainLabel
}) })
deletePendingJob(job.id) deletePendingJob(job.id)
job.saving = false
} catch (saveError) { } catch (saveError) {
job.saving = false
removeExtendTemp(job.library?.extendTmpDir) removeExtendTemp(job.library?.extendTmpDir)
const message = saveError instanceof Error ? saveError.message : String(saveError) const message = saveError instanceof Error ? saveError.message : String(saveError)
job.status = 'error' job.status = 'error'
@@ -240,12 +254,17 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
} }
resolve() resolve()
return true return true
} catch (error) {
finishing = false
job.saving = false
throw error
}
} }
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()
}) })