diff --git a/pages/index.vue b/pages/index.vue index f3152b3..b5dcb28 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -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() 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) { diff --git a/server/api/extend.post.ts b/server/api/extend.post.ts index 7f18df4..54475f9 100644 --- a/server/api/extend.post.ts +++ b/server/api/extend.post.ts @@ -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 } diff --git a/server/api/generate.post.ts b/server/api/generate.post.ts index 3ee02bf..aff0d8c 100644 --- a/server/api/generate.post.ts +++ b/server/api/generate.post.ts @@ -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 diff --git a/server/utils/ffmpeg.ts b/server/utils/ffmpeg.ts index a153927..5c27ace 100644 --- a/server/utils/ffmpeg.ts +++ b/server/utils/ffmpeg.ts @@ -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) { diff --git a/server/utils/jobs.ts b/server/utils/jobs.ts index b7702ff..57bbe4c 100644 --- a/server/utils/jobs.ts +++ b/server/utils/jobs.ts @@ -78,6 +78,8 @@ export interface Job { } error?: string socketReady?: boolean + saving?: boolean + savedPromptId?: string segmentBuffer?: Buffer events: JobEvent[] listeners: Set<(event: JobEvent) => void> diff --git a/server/utils/library.ts b/server/utils/library.ts index 1dc579d..c461147 100644 --- a/server/utils/library.ts +++ b/server/utils/library.ts @@ -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 diff --git a/server/utils/pending.ts b/server/utils/pending.ts index 38618c1..2aa5fe9 100644 --- a/server/utils/pending.ts +++ b/server/utils/pending.ts @@ -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, diff --git a/server/utils/watch.ts b/server/utils/watch.ts index f50a8fc..979b15d 100644 --- a/server/utils/watch.ts +++ b/server/utils/watch.ts @@ -71,8 +71,10 @@ export function emitChainJob(job: Job, event: JobEvent, samplePct?: number) { export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Promise { 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 | null = null let timeout: ReturnType | 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() })