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:
+4
-3
@@ -702,6 +702,7 @@ interface LibraryClip {
|
||||
seed: number
|
||||
hideThumbnail: boolean
|
||||
hasThumbnail?: boolean
|
||||
hasVideo?: boolean
|
||||
createdAt: number
|
||||
cfg?: 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 groups = new Map<string, LibraryClip[]>()
|
||||
for (const clip of visibleClips.value) {
|
||||
@@ -974,9 +975,9 @@ function clipsIn(id: string) {
|
||||
}
|
||||
|
||||
function clipFamilyKey(clip: LibraryClip) {
|
||||
if (clip.familyId) return clip.familyId
|
||||
const base = String(clip.name || '').trim().replace(/\s+p\d+$/i, '')
|
||||
if (base) return `name:${clip.folderId}:${base.toLowerCase()}`
|
||||
if (clip.familyId) return clip.familyId
|
||||
return `solo:${clip.id}`
|
||||
}
|
||||
|
||||
@@ -985,7 +986,7 @@ function sortClipParts(a: LibraryClip, b: LibraryClip) {
|
||||
if (durationDelta) return durationDelta
|
||||
const chainDelta = (b.chainIndex || 0) - (a.chainIndex || 0)
|
||||
if (chainDelta) return chainDelta
|
||||
return b.createdAt - a.createdAt
|
||||
return a.createdAt - b.createdAt
|
||||
}
|
||||
|
||||
function formatClipDuration(value?: number) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -403,7 +403,14 @@ async function runGeneration(
|
||||
assertJobActive(job)
|
||||
|
||||
copyFileSync(currentPath, part1Path)
|
||||
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
@@ -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) {
|
||||
|
||||
@@ -78,6 +78,8 @@ export interface Job {
|
||||
}
|
||||
error?: string
|
||||
socketReady?: boolean
|
||||
saving?: boolean
|
||||
savedPromptId?: string
|
||||
segmentBuffer?: Buffer
|
||||
events: JobEvent[]
|
||||
listeners: Set<(event: JobEvent) => void>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+25
-6
@@ -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,10 +135,18 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
|
||||
}
|
||||
|
||||
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 video = extractVideo(history, job.promptId)
|
||||
if (!video) return false
|
||||
if (!video) {
|
||||
finishing = false
|
||||
job.saving = false
|
||||
return false
|
||||
}
|
||||
if (settled) return false
|
||||
settled = true
|
||||
cleanup()
|
||||
try {
|
||||
@@ -184,6 +194,7 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
|
||||
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)
|
||||
@@ -197,6 +208,7 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
|
||||
if (!persist) {
|
||||
job.segmentBuffer = buffer
|
||||
deletePendingJob(job.id)
|
||||
job.saving = false
|
||||
emitLocal({
|
||||
type: 'checkpoint',
|
||||
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
|
||||
})
|
||||
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'
|
||||
@@ -240,12 +254,17 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
|
||||
}
|
||||
resolve()
|
||||
return true
|
||||
} catch (error) {
|
||||
finishing = false
|
||||
job.saving = false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
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