diff --git a/docs/extension-quality.md b/docs/extension-quality.md new file mode 100644 index 0000000..be0eb5a --- /dev/null +++ b/docs/extension-quality.md @@ -0,0 +1,21 @@ +# Extension quality fixes + +The installed Comfy service had no `BilateralFilterImage` node, and the cleanup graph requested a different consistency-LoRA filename from the installed `klein-consistency-V2.safetensors`. Refinement failures previously continued with an unrefined frame. Lossless frame capture also defaulted off, and crossfade stitching re-encoded the already assembled video on every extension. + +## Changes + +- Cleanup now uses the original frame directly with the installed Flux components. The prompt preserves the source art style instead of requesting photorealism, and the cleanup blend is reduced from 85% to 35%. Model generation workflows remain unchanged. +- Requested refinement must succeed. Failures stop the extension with a visible error; turning refinement off remains an explicit choice. Cancellation tracks the refinement prompt and is checked before submitting the video. +- Lossless decoded last-frame capture defaults on, while explicit opt-outs remain respected. Only the dedicated anchor output can become a lossless anchor; unrelated previews cannot be mistaken for it. When no anchor exists, extraction prefers the last original video segment. +- Each saved generated clip retains its original encoded segment in `sources/`. Extensions retain their ancestors' source files using hard links, or copies when hard links are unavailable. These files survive deletion of parent library items. +- Both normal completion and restart recovery assemble the complete sequence from original sources in one FFmpeg encode. The crossfade fallback uses the same originals. The prior assembled export is not fed through repeated encodes. +- Legacy clips without retained sources become a fixed baseline. Previously lost detail cannot be recovered. Keeping sources increases storage use; assembly time still grows with total video length. + +Last-frame conditioning, model selection, and LTX extension behavior remain in place. These changes remove avoidable quality loss; they cannot eliminate generative drift across many extensions. + +## Verification + +- All 17 cleanup node types were confirmed available through the running Comfy service's read-only metadata endpoint. +- Regression tests verify eight generations of source retention, preservation after deleting a parent, legacy baselines, assembly filter construction, anchor selection, and default/explicit capture settings. +- Seven tiny synthetic clips with audio were assembled and decoded successfully using both crossfade and concatenate fallback on CPU. No AI generation ran. +- Full automated test suite and production build passed. diff --git a/pages/index.vue b/pages/index.vue index 26aeee5..3e807dd 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -1112,12 +1112,12 @@
@@ -2868,7 +2868,7 @@ const renameInput = ref(null) const unlockPassword = ref('') const queueAutoRun = ref(false) const refineExtensionFrame = ref(true) -const saveLosslessAnchor = ref(false) +const saveLosslessAnchor = ref(true) const refinementDenoise = ref(REFINEMENT_DENOISE_DEFAULT) const queuePendingCount = ref(0) const activeQueueId = ref('') @@ -5723,7 +5723,7 @@ async function restoreStudioPayload(kind: string, payload: { )) queueAutoRun.value = payload.queueAutoRun === true refineExtensionFrame.value = payload.refineExtensionFrame !== false - saveLosslessAnchor.value = payload.saveLosslessAnchor === true + saveLosslessAnchor.value = payload.saveLosslessAnchor !== false refinementDenoise.value = clampRefinementDenoise(payload.refinementDenoise, REFINEMENT_DENOISE_DEFAULT) globalLocks.value = payload.globalLocks || '' familyPermanenceRefs.value = payload.permanenceRefs || [] diff --git a/server/api/extend.post.ts b/server/api/extend.post.ts index 11ce2f9..6fc28df 100644 --- a/server/api/extend.post.ts +++ b/server/api/extend.post.ts @@ -105,7 +105,7 @@ export default defineEventHandler(async (event) => { queueAutoRun: false, extendFromClipId: clipId, refineExtensionFrame: parseRefineExtensionFrame(body?.refineExtensionFrame, true), - saveLosslessAnchor: parseSaveLosslessAnchor(body?.saveLosslessAnchor, false), + saveLosslessAnchor: parseSaveLosslessAnchor(body?.saveLosslessAnchor, true), refinementDenoise: clampRefinementDenoise(body?.refinementDenoise, REFINEMENT_DENOISE_DEFAULT), globalLocks: source.globalLocks, permanenceRefs: source.permanenceRefs, diff --git a/server/api/generate.post.ts b/server/api/generate.post.ts index 5b21b49..6205e13 100644 --- a/server/api/generate.post.ts +++ b/server/api/generate.post.ts @@ -257,7 +257,7 @@ export default defineEventHandler(async (event) => { const familyId = crypto.randomUUID() const queueAutoRun = fields.queueAutoRun === 'true' const refineExtensionFrame = parseRefineExtensionFrame(fields.refineExtensionFrame, true) - const saveLosslessAnchor = parseSaveLosslessAnchor(fields.saveLosslessAnchor, false) + const saveLosslessAnchor = parseSaveLosslessAnchor(fields.saveLosslessAnchor, true) const refinementDenoise = clampRefinementDenoise(fields.refinementDenoise, REFINEMENT_DENOISE_DEFAULT) const studio = await addStudioJob({ ownerKey, diff --git a/server/assets/extension_frame_refine.json b/server/assets/extension_frame_refine.json index 80e4c2b..b04b638 100644 --- a/server/assets/extension_frame_refine.json +++ b/server/assets/extension_frame_refine.json @@ -4,16 +4,6 @@ "class_type": "LoadImage", "_meta": { "title": "Handoff Frame" } }, - "2": { - "inputs": { - "images": ["1", 0], - "size": 2, - "sigma_color": 0.12, - "sigma_space": 1.5 - }, - "class_type": "BilateralFilterImage", - "_meta": { "title": "Bilateral Deblock" } - }, "3": { "inputs": { "unet_name": "flux-2-klein-base-9b-fp8.safetensors", @@ -38,7 +28,7 @@ }, "6": { "inputs": { - "lora_name": "Flux2-Klein-9B-consistency-V2.safetensors", + "lora_name": "klein-consistency-V2.safetensors", "strength_model": 0.55, "strength_clip": 0.55, "model": ["3", 0], @@ -49,7 +39,7 @@ }, "7": { "inputs": { - "text": "photoreal still frame, natural high-frequency texture, clean edges, no compression blocks, no mosquito noise", + "text": "Preserve the source image, its exact subject, composition, colors and art style. Subtle cleanup of compression artifacts only. Retain fine texture and edges.", "clip": ["6", 1] }, "class_type": "CLIPTextEncode", @@ -73,7 +63,7 @@ }, "10": { "inputs": { - "pixels": ["2", 0], + "pixels": ["1", 0], "vae": ["5", 0] }, "class_type": "VAEEncode", @@ -148,11 +138,11 @@ "inputs": { "image1": ["1", 0], "image2": ["18", 0], - "blend_factor": 0.85, + "blend_factor": 0.35, "blend_mode": "normal" }, "class_type": "ImageBlend", - "_meta": { "title": "Blend 0.85" } + "_meta": { "title": "Conservative cleanup blend" } }, "20": { "inputs": { diff --git a/server/utils/extendChain.ts b/server/utils/extendChain.ts index a593e61..f82c4e7 100644 --- a/server/utils/extendChain.ts +++ b/server/utils/extendChain.ts @@ -125,7 +125,7 @@ export async function beginExtendFromClip(params: { const height = source.height || 768 const clipName = String(params.name || '').trim() || nextFamilyPartName(params.ownerKey, source) const refineExtensionFrame = parseRefineExtensionFrame(params.refineExtensionFrame, true) - const saveLosslessAnchor = parseSaveLosslessAnchor(params.saveLosslessAnchor, false) + const saveLosslessAnchor = parseSaveLosslessAnchor(params.saveLosslessAnchor, true) const refinementDenoise = clampRefinementDenoise(params.refinementDenoise, REFINEMENT_DENOISE_DEFAULT) const job = createJob() @@ -252,7 +252,6 @@ async function runExtend( }) }) - const done = watchComfyJob(job) if (params.refineExtensionFrame !== false) { emitJob(job, { type: 'status', @@ -268,19 +267,18 @@ async function runExtend( seed: params.seed, clientId: job.clientId, jobId: job.id, + onQueued: (promptId) => { job.promptId = promptId }, onStatus: (message) => emitJob(job, { type: 'status', message, progress: 4 }) }) writeFileSync(params.framePath, frame) } catch (error) { - const detail = error instanceof Error ? error.message : String(error) - emitJob(job, { - type: 'status', - message: `Frame refine skipped (${detail}). Using source frame.`, - progress: 4 - }) + throw new Error(`Extension frame refinement failed: ${error instanceof Error ? error.message : String(error)}. Retry after fixing refinement, or explicitly turn it off.`) } } + if (job.status === 'cancelled' || job.status === 'error') throw new Error('Extension stopped before video generation') + job.promptId = undefined + const done = watchComfyJob(job) job.status = 'uploading' emitJob(job, { type: 'status', message: 'Uploading last frame to ComfyUI...', progress: 5 }) const uploaded = await uploadImage({ filename: 'last_frame.png', data: frame, type: 'image/png' }, job.id) @@ -310,7 +308,7 @@ async function runExtend( workflow: job.library?.workflow, duration: job.library?.duration, loraStack: params.loraStack || job.library?.loraStack || params.loraName || job.library?.loraName, - saveLosslessAnchor: params.saveLosslessAnchor === true || job.library?.saveLosslessAnchor === true + saveLosslessAnchor: (params.saveLosslessAnchor ?? job.library?.saveLosslessAnchor) !== false }) const queued = await queuePrompt(graph, job.clientId) diff --git a/server/utils/extensionFrame.ts b/server/utils/extensionFrame.ts index 793fd72..0edbc78 100644 --- a/server/utils/extensionFrame.ts +++ b/server/utils/extensionFrame.ts @@ -1,8 +1,10 @@ +import { videoSourcePaths } from './videoSources' import { copyFileSync, existsSync, statSync, writeFileSync } from 'node:fs' import refineTemplate from '../assets/extension_frame_refine.json' import { clipAnchorFramePath, clipVideoPath, downloadComfyImage } from '~/server/utils/library' import { extractLastFrame } from '~/server/utils/ffmpeg' import { comfyFilenamePrefix, fetchHistory, queuePrompt, uploadImage } from '~/server/utils/comfy' +import { ensureComfyLoraNames, resolveGraphLoraNames } from '~/server/utils/loras' import { extractEditedImage } from '~/server/utils/imageComfy' import { clampRefinementDenoise, @@ -35,7 +37,8 @@ export async function resolveExtensionHandoffFrame(opts: { copyFileSync(anchorPath, opts.destPath) return { path: opts.destPath, source: 'anchor' as const } } - await extractLastFrame(opts.sourceVideoPath, opts.destPath) + const originalSources = videoSourcePaths(opts.sourceVideoPath) + await extractLastFrame(originalSources[originalSources.length - 1], opts.destPath) if (!isValidAnchorFile(opts.destPath)) { throw new Error('Could not extract the last frame of the clip being extended') } @@ -60,11 +63,12 @@ export async function persistClipAnchorFrame(opts: { } const video = opts.videoPath || clipVideoPath(opts.ownerKey, opts.clipId) if (!existsSync(video)) return '' - await extractLastFrame(video, dest) + const originalSources = videoSourcePaths(video) + await extractLastFrame(originalSources[originalSources.length - 1], dest) return isValidAnchorFile(dest) ? dest : '' } -function buildRefineGraph(opts: { +export function buildRefineGraph(opts: { imageName: string denoise: number seed: number @@ -95,7 +99,7 @@ async function waitForRefineImage(promptId: string, timeoutMs = 120_000) { } /** - * Bilateral + low-denoise Flux cleanup before the video I2V start frame. + * Low-denoise Flux cleanup before the video I2V start frame. * Runs as its own Comfy prompt so MiniMax does not share VRAM with Klein. * When refine is off, callers skip this and upload the PNG directly (fast path). */ @@ -106,6 +110,7 @@ export async function refineExtensionHandoffFrame(opts: { clientId: string jobId: string onStatus?: (message: string) => void + onQueued?: (promptId: string) => void }) { const denoise = clampRefinementDenoise(opts.denoise, REFINEMENT_DENOISE_DEFAULT) opts.onStatus?.('Refining extension handoff frame...') @@ -121,7 +126,10 @@ export async function refineExtensionHandoffFrame(opts: { seed: opts.seed ?? Math.floor(Math.random() * 2_147_483_647), filenamePrefix: `aigen/extension_refine/${prefix}` }) + await ensureComfyLoraNames('image') + resolveGraphLoraNames(graph, 'image') const queued = await queuePrompt(graph, opts.clientId) + opts.onQueued?.(queued.prompt_id) const image = await waitForRefineImage(queued.prompt_id) const buffer = await downloadComfyImage(image) if (!buffer.length || buffer.length < MIN_ANCHOR_BYTES) { @@ -134,7 +142,7 @@ export function extractAnchorImageFromHistory(history: Record | if (!history || !promptId) return null const entry = history[promptId] as { outputs?: Record } | undefined const outputs = entry?.outputs - if (!outputs || typeof outputs !== 'object') return extractEditedImage(history, promptId) + if (!outputs || typeof outputs !== 'object') return null const anchor = outputs.aigen_anchor_save as { images?: Array<{ filename?: string; subfolder?: string; type?: string }> } | undefined const file = anchor?.images?.[0] if (file?.filename) { @@ -144,7 +152,7 @@ export function extractAnchorImageFromHistory(history: Record | type: file.type || 'output' } } - return extractEditedImage(history, promptId) + return null } export async function downloadAnchorImage(image: { filename: string; subfolder: string; type: string }) { diff --git a/server/utils/ffmpeg.ts b/server/utils/ffmpeg.ts index 726c2c5..4124b64 100644 --- a/server/utils/ffmpeg.ts +++ b/server/utils/ffmpeg.ts @@ -284,48 +284,6 @@ async function assertStitchLength(part1Path: string, part2Path: string, outputPa } } -async function xfadeStitch(part1Path: string, part2Path: string, outputPath: string) { - const [duration1, duration2, size, audio1, audio2] = await Promise.all([ - probeDuration(part1Path), - probeDuration(part2Path), - probeSize(part1Path), - probeHasAudio(part1Path), - probeHasAudio(part2Path) - ]) - const fade = Math.min(XFADE_DURATION, Math.max(0.04, duration1 - 0.08), Math.max(0.04, duration2 - 0.08)) - const offset = Math.max(0, Number((duration1 - fade - 0.04).toFixed(3))) - const prepare1 = `scale=${size.width}:${size.height}:flags=bicubic,format=yuv420p,setpts=PTS-STARTPTS` - const prepare2 = `select='gte(n,1)',setpts=PTS-STARTPTS,scale=${size.width}:${size.height}:flags=bicubic,format=yuv420p` - const videoFilter = `[0:v]${prepare1}[v0];[1:v]${prepare2}[v1];[v0][v1]xfade=transition=fade:duration=${fade.toFixed(3)}:offset=${offset},setpts=PTS-STARTPTS[v]` - const runVideo = async (withAudio: boolean) => { - const args = [ - '-y', - '-i', part1Path, - '-i', part2Path, - '-filter_complex', withAudio - ? `${videoFilter};[0:a]asetpts=PTS-STARTPTS[a0];[1:a]asetpts=PTS-STARTPTS[a1];[a0][a1]acrossfade=d=${fade.toFixed(3)}:c1=tri:c2=tri[a]` - : videoFilter, - '-map', '[v]', - ...(withAudio ? ['-map', '[a]'] : []), - ...encodeArgs(outputPath, withAudio) - ] - await runFfmpeg(args, 300000) - } - if (audio1 && audio2) { - try { - await runVideo(true) - } catch { - await runVideo(false) - } - } else { - await runVideo(false) - } - if (!existsSync(outputPath)) { - throw new Error('FFmpeg did not write the blended video') - } - await assertStitchLength(part1Path, part2Path, outputPath, fade) -} - async function concatReencode(part1Path: string, part2Path: string, outputPath: string) { const audio1 = await probeHasAudio(part1Path) const audio2 = await probeHasAudio(part2Path) @@ -366,18 +324,61 @@ export async function concatMp4(part1Path: string, part2Path: string, outputPath } } -export async function stitchExtension(params: { part1Path: string; part2: Buffer; tmpDir: string }) { +export function buildSourceAssembly(durations: number[], width: number, height: number, fps: number, audio: boolean, blend: boolean) { + const filters: string[] = [] + durations.forEach((_, index) => { + filters.push(`[${index}:v]${index ? "select='gte(n,1)'," : ''}setpts=PTS-STARTPTS,scale=${width}:${height}:flags=lanczos,fps=${fps},format=yuv420p,settb=AVTB[v${index}]`) + if (audio) filters.push(`[${index}:a]aresample=48000,asetpts=PTS-STARTPTS[a${index}]`) + }) + let duration = durations[0] + let video = 'v0', sound = 'a0' + for (let index = 1; index < durations.length; index++) { + const fade = blend ? Math.min(XFADE_DURATION, Math.max(0.04, duration - 0.08), Math.max(0.04, durations[index] - 0.08)) : 0 + const nextVideo = `joinv${index}`, nextAudio = `joina${index}` + const offset = Math.max(0, duration - fade) + filters.push(`[${video}][v${index}]` + (blend + ? `xfade=transition=fade:duration=${fade.toFixed(6)}:offset=${offset.toFixed(6)}` + : 'concat=n=2:v=1:a=0') + `[${nextVideo}]`) + if (audio) filters.push(`[${sound}][a${index}]` + (blend + ? `acrossfade=d=${fade.toFixed(6)}:c1=tri:c2=tri` + : 'concat=n=2:v=0:a=1') + `[${nextAudio}]`) + duration += durations[index] - 1 / fps - fade + video = nextVideo; sound = nextAudio + } + return { filter: filters.join(';'), video, sound, duration } +} + +async function assembleOriginalSources(paths: string[], outputPath: string, blend: boolean) { + const [durations, size, audioFlags, frameRate] = await Promise.all([ + Promise.all(paths.map(probeDuration)), probeSize(paths[0]), + Promise.all(paths.map(probeHasAudio)), + runFfprobe(['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=r_frame_rate', '-of', 'default=noprint_wrappers=1:nokey=1', paths[0]]) + ]) + const [numerator, denominator = 1] = frameRate.split('/').map(Number) + const fps = numerator / denominator + if (!Number.isFinite(fps) || fps <= 0) throw new Error('Cannot read the source video frame rate') + const audio = audioFlags.every(Boolean) + const graph = buildSourceAssembly(durations, size.width, size.height, fps, audio, blend) + await runFfmpeg(['-y', ...paths.flatMap(path => ['-i', path]), '-filter_complex_threads', '1', + '-filter_complex', graph.filter, '-map', `[${graph.video}]`, ...(audio ? ['-map', `[${graph.sound}]`] : []), + ...encodeArgs(outputPath, audio)], 600000) + const actual = await probeDuration(outputPath) + if (Math.abs(actual - graph.duration) > Math.max(0.5, graph.duration * 0.02)) throw new Error('Assembled video length does not match the original segments') +} + +export async function stitchExtension(params: { part1Path: string; part2: Buffer; tmpDir: string; sourcePaths?: string[] }) { const part2Path = join(params.tmpDir, `part2_${Date.now()}.mp4`) const finalPath = join(params.tmpDir, 'final_stitched_video.mp4') writeFileSync(part2Path, params.part2) if (!existsSync(part2Path) || !params.part2.length) { throw new Error('Extension segment was missing before stitch') } + const sources = [...(params.sourcePaths?.length ? params.sourcePaths : [params.part1Path]), part2Path] try { - await xfadeStitch(params.part1Path, part2Path, finalPath) + await assembleOriginalSources(sources, finalPath, true) } catch (error) { - console.error('[stitch] xfade failed, concatenating:', (error as Error).message) - await concatReencode(params.part1Path, part2Path, finalPath) + console.error('[stitch] source crossfade failed; joining the same original sources:', (error as Error).message) + await assembleOriginalSources(sources, finalPath, false) } const stitched = readFileSync(finalPath) for (const extra of [part2Path, join(params.tmpDir, 'concat_list.txt'), join(params.tmpDir, 'last_frame.png')]) { diff --git a/server/utils/library.ts b/server/utils/library.ts index 6713f99..d3b6973 100644 --- a/server/utils/library.ts +++ b/server/utils/library.ts @@ -1,3 +1,4 @@ +import { preserveVideoSources } from './videoSources' import { createHash } from 'node:crypto' import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, statSync, createReadStream, openSync, readSync, closeSync } from 'node:fs' import { writeFile } from 'node:fs/promises' @@ -1689,6 +1690,8 @@ export async function saveClip(params: { promptPre?: string promptPost?: string segmentFirstFrame?: Buffer + sourceSegments?: string[] + originalSegment?: Buffer }) { const catalog = readCatalog(params.ownerKey) const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0] @@ -1729,6 +1732,7 @@ export async function saveClip(params: { const videoPath = clipVideoPath(params.ownerKey, clip.id) await writeFile(videoPath, params.video) await remuxFaststart(videoPath) + if (params.originalSegment) preserveVideoSources(videoPath, params.sourceSegments || [], params.originalSegment) if (params.segmentFirstFrame?.length && params.segmentFirstFrame.length >= 64) { const firstPath = clipFirstFramePath(params.ownerKey, clip.id) writeFileSync(firstPath, params.segmentFirstFrame) diff --git a/server/utils/pending.ts b/server/utils/pending.ts index 984d38f..351862d 100644 --- a/server/utils/pending.ts +++ b/server/utils/pending.ts @@ -1,3 +1,4 @@ +import { videoSourcePaths } from './videoSources' import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import type { Job } from '~/server/utils/jobs' @@ -295,6 +296,11 @@ export async function completePendingIfReady(pending: PendingJob) { const video = extractVideo(history, pending.promptId) if (!video) return null let buffer = await downloadComfyVideo(video) + const originalSegment = buffer + const parentVideo = pending.parentClipId ? clipVideoPath(pending.ownerKey, pending.parentClipId) : '' + const sourceSegments = pending.extendPart1Path + ? videoSourcePaths(parentVideo && existsSync(parentVideo) ? parentVideo : pending.extendPart1Path) + : [] if (inFlight()) return null const afterDownload = alreadySaved() if (afterDownload) return afterDownload @@ -313,13 +319,13 @@ export async function completePendingIfReady(pending: PendingJob) { buffer = await stitchExtension({ part1Path: pending.extendPart1Path, part2: buffer, + sourcePaths: sourceSegments, tmpDir: pending.extendTmpDir }) } catch (error) { removeExtendTemp(pending.extendTmpDir) throw error } - removeExtendTemp(pending.extendTmpDir) } const afterStitch = alreadySaved() if (afterStitch) return afterStitch @@ -349,9 +355,12 @@ export async function completePendingIfReady(pending: PendingJob) { permanenceRefs: mergePermanenceRefs(pending.permanenceRefs, pending.shotPermanenceRefs?.[pending.chainIndex || 0]), loraName: pending.loraName, loraStack: pending.loraStack, - segmentFirstFrame + segmentFirstFrame, + originalSegment, + sourceSegments }) - if (pending.saveLosslessAnchor === true) { + if (pending.extendTmpDir) removeExtendTemp(pending.extendTmpDir) + if (pending.saveLosslessAnchor !== false) { try { const { persistClipAnchorFrame, extractAnchorImageFromHistory, downloadAnchorImage } = await import('~/server/utils/extensionFrame') let saved = false diff --git a/server/utils/studioQueue.ts b/server/utils/studioQueue.ts index cc1dcdd..ff48d6d 100644 --- a/server/utils/studioQueue.ts +++ b/server/utils/studioQueue.ts @@ -1503,7 +1503,7 @@ async function startStudioJobReserved(item: StudioJob) { queueAutoRun: extensions.length ? payload.queueAutoRun : false, queueBudget: extensions.length && payload.queueAutoRun ? extensions.length : 0, refineExtensionFrame: payload.refineExtensionFrame !== false, - saveLosslessAnchor: payload.saveLosslessAnchor === true, + saveLosslessAnchor: payload.saveLosslessAnchor !== false, refinementDenoise: payload.refinementDenoise, globalLocks: payload.globalLocks, permanenceRefs: payload.permanenceRefs, diff --git a/server/utils/videoChain.ts b/server/utils/videoChain.ts index 80a7345..251c4c8 100644 --- a/server/utils/videoChain.ts +++ b/server/utils/videoChain.ts @@ -221,7 +221,7 @@ export async function queueMiniMax( duration: params.duration, useIdentityRefs: params.useIdentityRefs, referenceImageNames: params.useIdentityRefs ? referenceNames : [], - saveLosslessAnchor: job.library?.saveLosslessAnchor === true, + saveLosslessAnchor: job.library?.saveLosslessAnchor !== false, ...persistLoraFields(params.loraStack || params.loraName) }) @@ -414,18 +414,15 @@ export async function continueQueuedExtensions( denoise: job.library.refinementDenoise, clientId: job.clientId, jobId: job.id, + onQueued: (promptId) => { job.promptId = promptId }, onStatus: (message) => emitChainJob(job, { type: 'status', message, progress: 3 }) }) writeFileSync(framePath, frame) } catch (error) { - const detail = error instanceof Error ? error.message : String(error) - emitChainJob(job, { - type: 'status', - message: `Frame refine skipped (${detail}). Using source frame.`, - progress: 3 - }) + throw new Error(`Extension frame refinement failed: ${error instanceof Error ? error.message : String(error)}. Retry after fixing refinement, or explicitly turn it off.`) } } + assertJobActive(job) job.library.extendPart1Path = part1Path job.library.prompt = composePromptParts(wrappers.promptPre, ext.prompt, wrappers.promptPost) job.library.promptMid = ext.prompt diff --git a/server/utils/videoSources.ts b/server/utils/videoSources.ts new file mode 100644 index 0000000..f740310 --- /dev/null +++ b/server/utils/videoSources.ts @@ -0,0 +1,23 @@ +import { existsSync, mkdirSync, readdirSync, linkSync, copyFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + +/** Each saved clip owns its sources, so deleting a parent cannot break extensions. */ +export function videoSourcePaths(videoPath: string) { + const dir = join(dirname(videoPath), 'sources') + if (existsSync(dir)) { + const paths = readdirSync(dir).filter(name => /^\d{6}\.mp4$/.test(name)).sort().map(name => join(dir, name)) + if (paths.length) return paths + } + // Legacy clips become a fixed baseline; their past compression is irreversible. + return [videoPath] +} + +export function preserveVideoSources(videoPath: string, parents: string[], segment: Buffer) { + const dir = join(dirname(videoPath), 'sources') + mkdirSync(dir, { recursive: true }) + parents.forEach((source, index) => { + const dest = join(dir, `${String(index).padStart(6, '0')}.mp4`) + try { linkSync(source, dest) } catch { copyFileSync(source, dest) } + }) + writeFileSync(join(dir, `${String(parents.length).padStart(6, '0')}.mp4`), segment) +} diff --git a/server/utils/watch.ts b/server/utils/watch.ts index d07649d..159f719 100644 --- a/server/utils/watch.ts +++ b/server/utils/watch.ts @@ -1,3 +1,4 @@ +import { videoSourcePaths } from './videoSources' import { existsSync } from 'node:fs' import type { Job, JobEvent } from '~/server/utils/jobs' import { getJob, restoreJob } from '~/server/utils/jobs' @@ -221,6 +222,11 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr }) if (job.library) { let buffer = await downloadComfyVideo(video) + const originalSegment = buffer + const parentVideo = job.library.parentClipId ? clipVideoPath(job.library.ownerKey, job.library.parentClipId) : '' + const sourceSegments = job.library.extendPart1Path + ? videoSourcePaths(parentVideo && existsSync(parentVideo) ? parentVideo : job.library.extendPart1Path) + : [] let segmentFirstFrame: Buffer | undefined if (stitching) { if (!existsSync(job.library.extendPart1Path!)) { @@ -236,6 +242,7 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr buffer = await stitchExtension({ part1Path: job.library.extendPart1Path!, part2: buffer, + sourcePaths: sourceSegments, tmpDir: job.library.extendTmpDir! }) } @@ -273,9 +280,11 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr permanenceRefs: clipRefsFromJob(job.library), loraName: job.library.loraName, loraStack: job.library.loraStack, - segmentFirstFrame + segmentFirstFrame, + originalSegment, + sourceSegments }) - if (job.library.saveLosslessAnchor === true) { + if (job.library.saveLosslessAnchor !== false) { try { const { persistClipAnchorFrame, extractAnchorImageFromHistory, downloadAnchorImage } = await import('~/server/utils/extensionFrame') let saved = false diff --git a/tests/extension-quality.test.mjs b/tests/extension-quality.test.mjs new file mode 100644 index 0000000..196663f --- /dev/null +++ b/tests/extension-quality.test.mjs @@ -0,0 +1,64 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import ts from 'typescript' + +const require = createRequire(import.meta.url) +function load(path, resolve = require) { + const source = readFileSync(new URL(path, import.meta.url), 'utf8') + const code = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText + const exports = {} + new Function('require', 'exports', code)(resolve, exports) + return exports +} +const sources = load('../server/utils/videoSources.ts') +const { buildSourceAssembly } = load('../server/utils/ffmpeg.ts') + +test('eight extensions retain original bytes, not previously encoded assemblies, including after parent deletion', () => { + const root = mkdtempSync(join(tmpdir(), 'aigen-sources-')) + try { + let previous + for (let i = 0; i < 9; i++) { + const dir = join(root, String(i)); mkdirSync(dir) + const video = join(dir, 'video.mp4') + writeFileSync(video, `lossy-assembly-${i}`) + sources.preserveVideoSources(video, previous ? sources.videoSourcePaths(previous) : [], Buffer.from(`original-${i}`)) + previous = video + } + rmSync(join(root, '0'), { recursive: true }) + assert.deepEqual(sources.videoSourcePaths(previous).map(path => readFileSync(path, 'utf8')), Array.from({ length: 9 }, (_, i) => `original-${i}`)) + } finally { rmSync(root, { recursive: true, force: true }) } +}) + +test('legacy video is a fixed baseline and assembly reads each original source once', () => { + assert.deepEqual(sources.videoSourcePaths(join(tmpdir(), 'legacy-video.mp4')), [join(tmpdir(), 'legacy-video.mp4')]) + const graph = buildSourceAssembly(Array(7).fill(5), 512, 512, 24, true, true) + assert.equal((graph.filter.match(/xfade=transition/g) || []).length, 6) + assert.equal((graph.filter.match(/acrossfade=/g) || []).length, 6) + for (let i = 0; i < 7; i++) assert.equal(graph.filter.split(`[${i}:v]`).length - 1, 1) + assert.equal(graph.video, 'joinv6') + assert.ok(Math.abs(graph.duration - (35 - 6 * (0.12 + 1 / 24))) < 0.00001) + const fallback = buildSourceAssembly([5, 5, 5], 512, 512, 24, false, false) + assert.equal((fallback.filter.match(/concat=n=2/g) || []).length, 2) +}) + +test('refinement uses available standard nodes, and an unrelated saved image is never mistaken for a last frame', () => { + const graph = JSON.parse(readFileSync(new URL('../server/assets/extension_frame_refine.json', import.meta.url))) + assert.equal(Object.values(graph).some(node => node.class_type === 'BilateralFilterImage'), false) + assert.equal(graph['6'].inputs.lora_name, 'klein-consistency-V2.safetensors') + assert.deepEqual(graph['10'].inputs.pixels, ['1', 0]) + assert.ok(graph['19'].inputs.blend_factor < 0.5) + const helpers = load('../server/utils/extensionFrame.ts', id => id.endsWith('.json') ? { default: graph } : id.startsWith('node:') ? require(id) : {}) + assert.equal(helpers.extractAnchorImageFromHistory({ job: { outputs: { preview: { images: [{ filename: 'wrong.png' }] } } } }, 'job'), null) + assert.equal(helpers.extractAnchorImageFromHistory({ job: { outputs: { aigen_anchor_save: { images: [{ filename: 'last.png' }] } } } }, 'job').filename, 'last.png') +}) + +test('lossless handoff defaults on while explicit opt-out is preserved', () => { + const { parseSaveLosslessAnchor } = load('../utils/extensionFrame.ts') + assert.equal(parseSaveLosslessAnchor(undefined), true) + assert.equal(parseSaveLosslessAnchor(false), false) + assert.equal(parseSaveLosslessAnchor('false'), false) +}) diff --git a/utils/extensionFrame.ts b/utils/extensionFrame.ts index 5bdb6c8..34c6a7f 100644 --- a/utils/extensionFrame.ts +++ b/utils/extensionFrame.ts @@ -17,6 +17,6 @@ export function parseRefineExtensionFrame(raw: unknown, fallback = true) { return fallback } -export function parseSaveLosslessAnchor(raw: unknown, fallback = false) { +export function parseSaveLosslessAnchor(raw: unknown, fallback = true) { return parseRefineExtensionFrame(raw, fallback) }