From d8f42ecc6854f408c5100392fd7940b63630eb9f Mon Sep 17 00:00:00 2001 From: Towsty Date: Fri, 11 Sep 2026 23:11:02 -0500 Subject: [PATCH] Tighten needsFit to exact canvas size, guard against empty renders needsFit only compared aspect ratio, so a same-aspect-but-larger still (e.g. a 4032x2268 phone photo against a 1344x768 canvas) would skip fitting and hand MiniMaxH3AddGuide a still that does not match the job's own latent size. Require an exact width+height match instead; anything else (including unreadable dimensions) still fits. Also guard the video save path: an empty Comfy download, an empty stitched assembly, or a stitched result with no readable duration now fails the job with a clear error instead of silently saving/attaching a broken zero-length clip. Co-authored-by: Cursor --- server/utils/studio2/media.ts | 10 +++++----- server/utils/studio2/runner.ts | 15 +++++++++++++-- tests/studio2-extend-fit.test.mjs | 29 ++++++++++++++++++++++++++++- tests/studio2-runner.test.mjs | 14 ++++++++++++-- 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/server/utils/studio2/media.ts b/server/utils/studio2/media.ts index 2dd1e46..76e9209 100644 --- a/server/utils/studio2/media.ts +++ b/server/utils/studio2/media.ts @@ -12,12 +12,12 @@ export async function fitStill(data: Buffer, width: number, height: number) { return readFileSync(output) } finally { rmSync(dir,{recursive:true,force:true}) } } -/** Only scale/pad when the still's aspect truly differs from the job canvas. - * A matching frame (typical extend handoff) must reach Comfy byte-identical. */ +/** Only skip the scale+pad when the still already matches the job canvas pixel-for-pixel. + * A same-aspect-but-larger still (e.g. a 4032x2268 phone photo against a 1344x768 canvas) + * still needs fitting: MiniMaxH3AddGuide expects an image sized to the job's own latent, + * not merely the same aspect ratio. Unreadable dimensions always fit (safe default). */ export function needsFit(data: Buffer, width: number, height: number) { const size = imageDimensions(data) if (!size?.width || !size?.height) return true - if (size.width === width && size.height === height) return false - const target = width / height, actual = size.width / size.height - return Math.abs(target - actual) > 0.01 + return !(size.width === width && size.height === height) } diff --git a/server/utils/studio2/runner.ts b/server/utils/studio2/runner.ts index 1164132..2deaa11 100644 --- a/server/utils/studio2/runner.ts +++ b/server/utils/studio2/runner.ts @@ -4,7 +4,7 @@ import { watchProgress } from './progress'; import { fitStill, needsFit } from './media'; import { resolveRequestSize } from './size'; import { queueSeeds } from '~/shared/studio2/seed.mjs'; -import { readFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs'; +import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; import template from '../../assets/studio2_minimax_native.json'; import { nativeVideoGraph, attachHeroReference, applyResolvedImageSize, sampleKleinSource } from '~/shared/studio2/graphs.mjs'; @@ -19,7 +19,7 @@ import { buildImageV2Workflow, resolveKreaGenerateAssets } from '../imageWorkflo import { resolveGraphLoraNames, applyUserLoraToGraph, ensureComfyLoraNames } from '../loras'; import { getClip, getStill, stillPath, clipVideoPath, saveStill, saveClip, downloadComfyImage, downloadComfyVideo, attachStudio2Metadata } from '../library'; import { imageDimensions } from '../resolution'; -import { stitchExtension } from '../ffmpeg'; +import { stitchExtension, probeDuration } from '../ffmpeg'; import { videoSourcePaths } from '../videoSources'; import { resolveExtensionHandoffFrame, persistClipAnchorFrame } from '../extensionFrame'; import { acquireSharedGpu, sharedGpuHeaders } from '../sharedGpu'; @@ -202,10 +202,21 @@ async function run(r: any, job: Job) { let asset: any = r.savedAssetId ? (video ? getClip(r.owner, r.savedAssetId) : getStill(r.owner, r.savedAssetId)) : null; if (!asset && video) { const data = await downloadComfyVideo(file); + if (!data.length || data.length < 64) + throw new Error('Comfy wrote an empty video file (<64 bytes); the job did not render. Nothing was saved.'); const sourceSegments = q.startClipId ? videoSourcePaths(clipVideoPath(r.owner, q.startClipId)) : []; const tmp = join(studio2Root(), r.id); mkdirSync(tmp, { recursive: true }); const assembled = q.startClipId ? await stitchExtension({ part1Path: clipVideoPath(r.owner, q.startClipId), part2: data, tmpDir: tmp, sourcePaths: sourceSegments }) : data; + if (!assembled.length || assembled.length < 64) + throw new Error('The assembled video file is empty (<64 bytes); the job did not render. Nothing was saved.'); + const checkPath = join(tmp, 'duration-check.mp4'); + writeFileSync(checkPath, assembled); + let duration = 0; + try { duration = await probeDuration(checkPath); } + finally { try { unlinkSync(checkPath); } catch { /* ignore */ } } + if (!Number.isFinite(duration) || duration <= 0) + throw new Error('The rendered video has no readable duration; the job did not render. Nothing was saved.'); asset = await saveClip({ ownerKey: r.owner, folderId: q.folderId, prompt: q.compiledPrompt, ...s, aspect: s.aspect || 'auto', hideThumbnail: false, video: assembled, originalSegment: data, sourceSegments, fps: s.fps || 24, sound: true, familyId: r.familyId, parentClipId: q.startClipId || undefined, chainIndex: r.index, comfyFilename: file.filename }); } else if (!asset) { diff --git a/tests/studio2-extend-fit.test.mjs b/tests/studio2-extend-fit.test.mjs index b3070a0..09f3462 100644 --- a/tests/studio2-extend-fit.test.mjs +++ b/tests/studio2-extend-fit.test.mjs @@ -34,6 +34,10 @@ function fakePng(width, height) { return buf } const PNG_32x32 = fakePng(32, 32) +// A 16:9 phone photo -- same aspect as the 1344x768 job canvas but a much larger pixel size. +// This is the exact shape of the regression: same-aspect-different-size must still be fit. +const PNG_PHONE_16x9 = fakePng(4032, 2268) +const CANVAS_16x9 = { width: 1344, height: 768 } const text = readFileSync(new URL('../server/utils/studio2/runner.ts', import.meta.url), 'utf8') const tree = ts.createSourceFile('runner.ts', text, ts.ScriptTarget.Latest, true) @@ -90,9 +94,32 @@ test('upload() still fits a source still whose aspect truly differs from the job assert.equal(fitCalled, true) }) -test('needsFit is false for an exact canvas match and true for a real aspect mismatch', () => { +test('needsFit is false only for an exact width+height match; same-aspect-different-size still needs fitting', () => { assert.equal(needsFit(PNG_32x32, 32, 32), false) assert.equal(needsFit(PNG_32x32, 1344, 768), true) + // Same 16:9 aspect as the canvas, but 3x the pixel size -- must still fit. + assert.equal(needsFit(PNG_PHONE_16x9, CANVAS_16x9.width, CANVAS_16x9.height), true) + assert.equal(needsFit(PNG_PHONE_16x9, 4032, 2268), false) + assert.equal(needsFit(Buffer.alloc(64), CANVAS_16x9.width, CANVAS_16x9.height), true) // unreadable dims always fit +}) + +test('New Video (mode video, no startClipId): a same-aspect phone still uploaded as "source" is fitted to the job canvas', async () => { + const f = fixture() + f.record.request.settings = { ...CANVAS_16x9 } + let fitCalled = false + f.scope.fitStill = async (data, w, h) => { fitCalled = true; assert.equal(w, CANVAS_16x9.width); assert.equal(h, CANVAS_16x9.height); return Buffer.alloc(64) } + const upload = extractUpload(f.scope) + await upload(f.record, 'source', PNG_PHONE_16x9) + assert.equal(fitCalled, true, 'a 4032x2268 still against a 1344x768 canvas must still be fitted despite matching aspect') +}) + +test('Extend (startClipId set): an anchor already sized to the job canvas uploaded as "start" is not fitted', async () => { + const f = fixture() + f.record.request.settings = { ...CANVAS_16x9 } + const anchorAtCanvasSize = fakePng(CANVAS_16x9.width, CANVAS_16x9.height) + const upload = extractUpload(f.scope) // f.scope.fitStill throws if called -- see fixture() + await upload(f.record, 'start', anchorAtCanvasSize) + assert.equal(f.record.handoffFitted, false) }) test('two extends at identical width/height: the anchor PNG reaches Comfy byte-identical, no ffmpeg fit', async () => { diff --git a/tests/studio2-runner.test.mjs b/tests/studio2-runner.test.mjs index 5259405..5ca33d0 100644 --- a/tests/studio2-runner.test.mjs +++ b/tests/studio2-runner.test.mjs @@ -21,8 +21,8 @@ function fixture(mode='video') { prepareGraph:async r=>{queued.push({hero:r.request.identityStillId,start:r.request.startClipId,action:r.request.promptSections.action});return {}},queuePrompt:async()=>({prompt_id:`p${++id}`}), fetchHistory:async p=>({[p]:{status:{status_str:'success'},outputs:{}}}),comfyFetch:()=>{throw new Error('Unexpected network call')}, extractVideo:()=>({filename:'video.mp4',subfolder:'preview/studio2/r',type:'output'}),extractEditedImage:()=>({filename:'image.png',subfolder:'preview/studio2/r',type:'output'}), - downloadComfyVideo:async()=>Buffer.from('video'),downloadComfyImage:async()=>Buffer.from('image'),imageDimensions:()=>({width:960,height:960}), - videoSourcePaths:()=>['source'],clipVideoPath:()=>'/fake/source',studio2Root:()=>'/fake',join,mkdirSync(){},stitchExtension:async()=>Buffer.from('stitched'), + downloadComfyVideo:async()=>Buffer.alloc(128,'v'),downloadComfyImage:async()=>Buffer.from('image'),imageDimensions:()=>({width:960,height:960}), + videoSourcePaths:()=>['source'],clipVideoPath:()=>'/fake/source',studio2Root:()=>'/fake',join,mkdirSync(){},writeFileSync(){},unlinkSync(){},probeDuration:async()=>5,stitchExtension:async()=>Buffer.alloc(128,'s'), saveClip:async p=>{const a={id:`asset${saved.length}`,...p};saved.push(a);return a},saveStill:async p=>{const a={id:`asset${saved.length}`,...p};saved.push(a);return a}, attachStudio2Metadata:async(owner,id,data)=>{assert.ok(saved.some(a=>a.id===id));assert.equal(data.identityStillId,'hero')}, purge:async r=>{assert.ok(saved.length>purged.length);purged.push(r.index);r.purgeResult='Cleared input + output'}, @@ -40,6 +40,16 @@ for(const mode of ['video','iterate']) test(`${mode} runner saves each output be test('failed library save never purges host media',async()=>{ const f=fixture();f.scope.saveClip=async()=>{throw new Error('Disk full')};await runner(f.scope)(f.record,f.job);assert.equal(f.record.state,'failed');assert.equal(f.purged.length,0) }) +test('an empty Comfy video output fails the job with a real error and never saves or attaches a clip',async()=>{ + const f=fixture();f.scope.downloadComfyVideo=async()=>Buffer.alloc(0) + await runner(f.scope)(f.record,f.job) + assert.equal(f.record.state,'failed');assert.match(f.record.error,/empty video file/);assert.equal(f.saved.length,0);assert.equal(f.record.outputs.length,0) +}) +test('a stitched extension with no readable duration fails the job and never saves or attaches a clip',async()=>{ + const f=fixture('video');f.scope.probeDuration=async()=>NaN + await runner(f.scope)(f.record,f.job) + assert.equal(f.record.state,'failed');assert.match(f.record.error,/no readable duration/);assert.equal(f.saved.length,0);assert.equal(f.record.outputs.length,0) +}) test('resume uses persisted Comfy prompt and never submits it again',async()=>{ const f=fixture();f.record.request.batch=[];f.record.promptId='existing';f.record.files=[];f.record.index=0 f.scope.queuePrompt=async()=>{throw new Error('Duplicate submit')};await runner(f.scope)(f.record,f.job)