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>
67 lines
3.8 KiB
JavaScript
67 lines
3.8 KiB
JavaScript
import { restoreInput } from './contracts.mjs'
|
||
export const aspectOptions = [{id:'auto',label:'Automatic',hint:'Match start or hero still'},{id:'16:9',label:'16:9',hint:'1344 × 768 · padded'},{id:'9:16',label:'9:16',hint:'768 × 1344 · padded'},{id:'1:1',label:'1:1',hint:'960 × 960 · padded'}]
|
||
export function resolveSize(aspect, source, fallback = {width:1344,height:768}) {
|
||
const presets = {'16:9':[1344,768],'9:16':[768,1344],'1:1':[960,960]}
|
||
if (presets[aspect]) return {width:presets[aspect][0],height:presets[aspect][1]}
|
||
if (!source?.width || !source?.height) return {width:fallback.width || 1344,height:fallback.height || 768}
|
||
const factor = Math.min(1344/Math.max(source.width,source.height),768/Math.min(source.width,source.height))
|
||
return {width:Math.max(256,Math.round(source.width*factor/32)*32),height:Math.max(256,Math.round(source.height*factor/32)*32)}
|
||
}
|
||
export function bindClip(form, clip, xaigen = false) {
|
||
if (!clip?.id) return form
|
||
const saved = clip.studio2 || clip.job || {}
|
||
const settings = saved.settings || clip.settings || {}
|
||
const next = {...form, startClipId:clip.id, mode:'extend', settings:{...form.settings}}
|
||
next.settings.aspect=['auto','16:9','9:16','1:1'].includes(settings.aspect)?settings.aspect:(form.settings?.aspect || 'auto')
|
||
for (const key of ['duration','width','height','fps','loraStack','turbo','steps','cfg']) {
|
||
if (settings[key] == null) continue
|
||
try { next.settings[key] = typeof structuredClone === 'function' ? structuredClone(settings[key]) : JSON.parse(JSON.stringify(settings[key])) }
|
||
catch { next.settings[key] = settings[key] }
|
||
}
|
||
next.engine = xaigen && (saved.engine || clip.engine)==='ltx' ? 'ltx' : 'minimax'
|
||
if (saved.identityStillId) next.identityStillId = saved.identityStillId
|
||
for (const key of ['lockFace','lockOutfit']) if (typeof saved[key] === 'boolean') next[key] = saved[key]
|
||
return next
|
||
}
|
||
export function restoreAsset(form, item) {
|
||
if (item.kind==='clip' || item.studio2?.kind==='video') {
|
||
const next=bindClip(form,item,form.engine==='ltx')
|
||
const seed=item.studio2?.settings?.seed ?? item.settings?.seed ?? item.seed
|
||
if (seed != null) next.settings.seed=seed
|
||
next.settings.seedMode='random'
|
||
return {...next,mode:'extend'}
|
||
}
|
||
const restored = restoreInput(item)
|
||
const next = {...form,...restored,settings:{...form.settings,...restored.settings}}
|
||
next.settings.seedMode='random'
|
||
if (item.kind === 'clip' || item.studio2?.kind === 'video') return {...bindClip(next,item),mode:'extend'}
|
||
next.imageAId=item.id
|
||
next.startClipId=''
|
||
next.settings.aspect=item.studio2?.settings?.aspect || item.settings?.aspect || 'auto'
|
||
return next
|
||
}
|
||
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 } }
|
||
}
|
||
export function changeSource(form) {
|
||
return form.mode==='extend' ? form : {...form,startClipId:'',settings:{...form.settings,aspect:'auto'}}
|
||
}
|
||
|
||
export function useAsHero(form, item) { return {...form,identityStillId:item.id} }
|
||
export function useAsSource(form, item) { return {...form,imageAId:item.id} }
|
||
export function restoreAll(form,item,xaigen=false) {
|
||
const restored=restoreInput(item),next={...form,...restored,settings:{...form.settings,...restored.settings,seedMode:'random'}}
|
||
if(item.kind==='clip') return {...bindClip(next,item,xaigen),mode:'extend'}
|
||
next.mode=restored.mode==='iterate'?'edit':restored.mode || 'edit'
|
||
next.imageAId=item.id
|
||
next.startClipId=''
|
||
return next
|
||
}
|