Stop resampling the extend start frame every hop
upload() ran fitStill (ffmpeg scale+pad) on every image except hero, including the start/handoff still. Four chained extends resampled the anchor frame four times and destroyed identity even with the hero reference and lock text intact. - upload(): only fit when the still's aspect truly differs from the job canvas (needsFit); never fit hero or start regardless. - Track handoffSource (anchor|extract) and whether the start frame was fitted; surface both in job metadata and Details. - extendSelected(): re-binding an already-selected clip on task switch must not stomp the user's overridden locks/settings back to the clip's saved values (regression introduced by the last hotfix push). - Add tests/studio2-extend-fit.test.mjs: two extends at identical width/height reach Comfy with the anchor PNG byte-identical, no ffmpeg fit. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user