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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user