diff --git a/components/studio2/Details.vue b/components/studio2/Details.vue index a0ea6bf..09cdafa 100644 --- a/components/studio2/Details.vue +++ b/components/studio2/Details.vue @@ -17,6 +17,7 @@ const rows=computed(()=>{const m=props.metadata||{},s=m.settings||{};const time= {label:'Identity still',value:m.identityStillId||'None'},{label:'Face lock',value:m.lockFace!==false?'On':'Off'},{label:'Outfit lock',value:m.lockOutfit!==false?'On':'Off'}, {label:'Aspect',value:s.aspect||'auto'},{label:'FPS',value:s.fps||24},{label:'Hide input',value:m.hideInput?'On':'Off'}, {label:'Start frame',value:m.startFrameSource?JSON.stringify(m.startFrameSource):'None'}, + {label:'Handoff source',value:m.handoffSource || 'None'},{label:'Handoff fitted (resampled)',value:m.fitted==null?'Not recorded':m.fitted?'Yes':'No'}, {label:'End still',value:m.endStillId||'None'},{label:'Guide stills',value:m.guides?.length?JSON.stringify(m.guides):'None'}, {label:'Compiled prompt',value:m.compiledPrompt},{label:'Queued',value:time(m.queuedAt)},{label:'Started',value:time(m.startedAt)},{label:'Saved',value:time(m.savedAt)}, {label:'GPU seconds',value:m.gpuSeconds},{label:'Wall seconds',value:m.wallTime},{label:'Comfy prompt ID',value:m.promptId},{label:'Host cleanup',value:m.purgeResult} diff --git a/server/utils/studio2/media.ts b/server/utils/studio2/media.ts index c414dca..2dd1e46 100644 --- a/server/utils/studio2/media.ts +++ b/server/utils/studio2/media.ts @@ -2,6 +2,7 @@ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { runFfmpeg } from '../ffmpeg' +import { imageDimensions } from '../resolution' export async function fitStill(data: Buffer, width: number, height: number) { const dir=mkdtempSync(join(tmpdir(),'studio2-fit-')) try { @@ -11,3 +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. */ +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 +} diff --git a/server/utils/studio2/runner.ts b/server/utils/studio2/runner.ts index a91bd7e..1164132 100644 --- a/server/utils/studio2/runner.ts +++ b/server/utils/studio2/runner.ts @@ -1,7 +1,7 @@ import { stylePrompt } from '~/shared/studio2/styles.mjs'; import { purge } from './cleanup'; import { watchProgress } from './progress'; -import { fitStill } from './media'; +import { fitStill, needsFit } from './media'; import { resolveRequestSize } from './size'; import { queueSeeds } from '~/shared/studio2/seed.mjs'; import { readFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs'; @@ -29,9 +29,18 @@ type DiskFile = { type: string; }; function update(r: any, state: string) { r.state = state; r.updatedAt = Date.now(); saveRecord(r); } +// Labels whose pixels must never be resampled: the hero identity still, and the +// extend/video start frame (which is either the exact anchor PNG or an already-canvas-sized +// extracted last frame). Fitting these every hop is what destroys identity across chained extends. +const NEVER_FIT = new Set(['hero', 'start']); async function upload(r: any, name: string, data: Buffer): Promise { const prefix = String(useRuntimeConfig().comfyFilenamePrefix).replace(/\/$/, '') + `/studio2/${r.id}/${r.index}`; - if (name !== 'hero') data = await fitStill(data, r.request.settings.width, r.request.settings.height); + let fitted = false; + if (!NEVER_FIT.has(name) && needsFit(data, r.request.settings.width, r.request.settings.height)) { + data = await fitStill(data, r.request.settings.width, r.request.settings.height); + fitted = true; + } + if (name === 'start') { r.handoffFitted = fitted; saveRecord(r); } const body = new FormData(); body.append('image', new Blob([new Uint8Array(data)]), name + '.png'); body.append('subfolder', prefix); @@ -71,7 +80,8 @@ async function prepareGraph(r: any) { const dir = join(studio2Root(), r.id); mkdirSync(dir, { recursive: true }); const dest = join(dir, 'handoff.png'); - await resolveExtensionHandoffFrame({ ownerKey: r.owner, sourceClipId: q.startClipId, sourceVideoPath: clipVideoPath(r.owner, q.startClipId), destPath: dest }); + const handoff = await resolveExtensionHandoffFrame({ ownerKey: r.owner, sourceClipId: q.startClipId, sourceVideoPath: clipVideoPath(r.owner, q.startClipId), destPath: dest }); + r.handoffSource = handoff.source; start = await upload(r, 'start', readFileSync(dest)); unlinkSync(dest); } @@ -208,7 +218,7 @@ async function run(r: any, job: Job) { saveRecord(r); r.savedAt = Date.now(); r.wallTime = (r.savedAt - r.startedAt) / 1000; - const metadata = { ...structuredClone(q), id: r.id, sourceStillId:r.sourceStillId,heroReferenceAttached:r.heroReferenceAttached,sampleLatent:r.sampleLatent,sampleDenoise:r.sampleDenoise,kind: video ? 'video' : 'image', graphId: r.graphId, promptId: r.promptId, queuedAt: r.queuedAt, startedAt: r.startedAt, savedAt: r.savedAt, gpuSeconds: r.gpuSeconds, wallTime: r.wallTime, outputWidth: asset.width || s.width, outputHeight: asset.height || s.height, purgeResult: 'Left on host' }; + const metadata = { ...structuredClone(q), id: r.id, sourceStillId:r.sourceStillId,heroReferenceAttached:r.heroReferenceAttached,sampleLatent:r.sampleLatent,sampleDenoise:r.sampleDenoise,kind: video ? 'video' : 'image', graphId: r.graphId, promptId: r.promptId, queuedAt: r.queuedAt, startedAt: r.startedAt, savedAt: r.savedAt, gpuSeconds: r.gpuSeconds, wallTime: r.wallTime, outputWidth: asset.width || s.width, outputHeight: asset.height || s.height, purgeResult: 'Left on host', handoffSource: r.handoffSource || null, fitted: video && q.startClipId ? !!r.handoffFitted : null }; await attachStudio2Metadata(r.owner, asset.id, metadata); if (!r.outputs.some((o:any)=>o.id===asset.id)) r.outputs.push({id:asset.id,kind:video?'clip':'still',studio2:metadata}); saveRecord(r); diff --git a/shared/studio2/input.mjs b/shared/studio2/input.mjs index e222397..30b8929 100644 --- a/shared/studio2/input.mjs +++ b/shared/studio2/input.mjs @@ -44,6 +44,9 @@ export function extendSelected(form, clip, xaigen = false) { const next = { ...form, mode: 'extend', engine: xaigen && form.engine === 'ltx' ? 'ltx' : 'minimax' } const target = clip?.id ? clip : null if (!target?.id) return next + // Already bound to this clip: switching task must not stomp the user's overrides + // (locks, CFG, etc.) by re-copying the clip's saved settings over them. + if (form.startClipId === target.id) return next try { return bindClip(next, target, xaigen) } catch { return { ...next, startClipId: target.id } } } diff --git a/tests/studio2-extend-fit.test.mjs b/tests/studio2-extend-fit.test.mjs new file mode 100644 index 0000000..b3070a0 --- /dev/null +++ b/tests/studio2-extend-fit.test.mjs @@ -0,0 +1,116 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { readFileSync, mkdtempSync, writeFileSync, existsSync } 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 { runFfmpeg } = load('../server/utils/ffmpeg.ts', id => id.startsWith('node:') ? require(id) : {}) +const { imageDimensions } = load('../server/utils/resolution.ts') +const { fitStill, needsFit } = load('../server/utils/studio2/media.ts', id => + id.startsWith('node:') ? require(id) : id === '../ffmpeg' ? { runFfmpeg } : id === '../resolution' ? { imageDimensions } : {} +) +const { isValidAnchorFile } = load('../server/utils/extensionFrame.ts', id => + id.endsWith('.json') ? { default: {} } : id.startsWith('node:') ? require(id) : {} +) + +// A fake PNG carrying a real signature + IHDR width/height for imageDimensions() to read. +// This is not a decodable image; tests never run ffmpeg against it (fit is either skipped +// or mocked), they only need imageDimensions() to report the right size. +function fakePng(width, height) { + const buf = Buffer.alloc(64) // isValidAnchorFile requires >= 64 bytes + buf.set([0x89, 0x50, 0x4e, 0x47], 0) // PNG signature start + buf.writeUInt32BE(width, 16) + buf.writeUInt32BE(height, 20) + return buf +} +const PNG_32x32 = fakePng(32, 32) + +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) +function extractUpload(scope) { + const nodes = tree.statements.filter(n => + (ts.isFunctionDeclaration(n) && n.name?.text === 'upload') || + (ts.isVariableStatement(n) && n.getText(tree).startsWith('const NEVER_FIT')) + ) + const js = nodes.map(n => ts.transpileModule(n.getText(tree), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText).join('\n') + return new Function(...Object.keys(scope), `${js};return upload`)(...Object.values(scope)) +} + +function fixture() { + const uploaded = [] + const record = { id: 'r', index: 0, files: [], request: { settings: { width: 32, height: 32 } } } + const scope = { + fitStill: async (data) => { throw new Error('fitStill must not be called for an already-canvas-sized handoff frame') }, + needsFit, + saveRecord() {}, + useRuntimeConfig: () => ({ comfyFilenamePrefix: 'preview/MiniMax_H3' }), + comfyFetch: async (_url, opts) => { + uploaded.push(opts.body) + return { ok: true, json: async () => ({ name: 'start.png', subfolder: 'preview/MiniMax_H3/studio2/r/0' }) } + }, + scopedFile: () => true + } + return { scope, record, uploaded } +} + +test('upload() never fits the start/hero labels when the still already matches the job canvas', async () => { + const f = fixture() + const upload = extractUpload(f.scope) + await upload(f.record, 'start', PNG_32x32) + assert.equal(f.record.handoffFitted, false) + assert.equal(f.record.files.length, 1) +}) + +test('upload() never fits the hero label even when it mismatches the canvas aspect', async () => { + const f = fixture() + f.record.request.settings = { width: 1344, height: 768 } + const upload = extractUpload(f.scope) + // PNG_32x32 is square, canvas is 16:9 -- would trigger a fit for any other label. + await upload(f.record, 'hero', PNG_32x32) + assert.equal(f.record.handoffFitted, undefined) // only tracked for 'start' +}) + +test('upload() still fits a source still whose aspect truly differs from the job canvas', async () => { + const f = fixture() + f.record.request.settings = { width: 1344, height: 768 } + let fitCalled = false + f.scope.fitStill = async (data) => { fitCalled = true; return data } + const upload = extractUpload(f.scope) + await upload(f.record, 'compose', PNG_32x32) + assert.equal(fitCalled, true) +}) + +test('needsFit is false for an exact canvas match and true for a real aspect mismatch', () => { + assert.equal(needsFit(PNG_32x32, 32, 32), false) + assert.equal(needsFit(PNG_32x32, 1344, 768), true) +}) + +test('two extends at identical width/height: the anchor PNG reaches Comfy byte-identical, no ffmpeg fit', async () => { + const dir = mkdtempSync(join(tmpdir(), 'studio2-anchor-')) + const anchorPath = join(dir, 'anchor-last.png') + writeFileSync(anchorPath, PNG_32x32) + assert.ok(isValidAnchorFile(anchorPath)) + + for (const hop of [0, 1]) { + const f = fixture() + const upload = extractUpload(f.scope) + const anchorBytes = readFileSync(anchorPath) + const key = await upload(f.record, 'start', anchorBytes) + assert.equal(f.record.handoffFitted, false, `hop ${hop} must not resample the anchor frame`) + const sentBody = f.uploaded[0] + const sentBlob = sentBody.get('image') + const sentBytes = Buffer.from(await sentBlob.arrayBuffer()) + assert.deepEqual(sentBytes, anchorBytes, `hop ${hop} must upload the anchor PNG byte-identical`) + assert.ok(key.length > 0) + } +})