Files
aigen/shared/studio2/input.mjs
T
TowstyandCursor 65c69b779a 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>
2026-09-11 21:12:24 -05:00

67 lines
3.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}