Restore Studio 2 Load hydrate, sticky negatives, and strip actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-21 12:23:20 -05:00
co-authored by Cursor
parent df5ef0949e
commit bb27af506e
10 changed files with 401 additions and 15 deletions
+66 -1
View File
@@ -1,5 +1,6 @@
import {restoreLoras,loraUse} from './lora-memory.mjs'
import { restoreInput } from './contracts.mjs'
import { restoreInput, sectionsFrom } from './contracts.mjs'
import { normalizeStyles } from './styles.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 resolveQwen21Size(aspect, source) {
const presets = {'16:9':[1024,576],'9:16':[576,1024],'1:1':[1024,1024]}
@@ -70,6 +71,70 @@ export function changeSource(form) {
export function useAsHero(form, item) { return {...form,identityStillId:item.id} }
export function useAsSource(form, item) { return {...form,imageAId:item.id} }
/** Hydrate the live bench from a saved Studio 2 request (Load). Does not enqueue. */
export function hydrateStudio2Job(form, saved, { stillIds = new Set(), clipIds = new Set(), xaigen = false, memory } = {}) {
const src = structuredClone(saved?.studio2 || saved || {})
const missing = []
const keepStill = (id, label) => {
const value = id || ''
if (!value) return ''
if (stillIds.has(value)) return value
missing.push(`${label} missing from library`)
return ''
}
const keepClip = (id, label) => {
const value = id || ''
if (!value) return ''
if (clipIds.has(value)) return value
missing.push(`${label} missing from library`)
return ''
}
const modeRaw = src.mode === 'iterate' ? 'edit' : src.mode
const mode = ['generate','edit','compose','refine','video','extend','music'].includes(modeRaw) ? modeRaw : 'edit'
let engine = src.engine === 'qwen21' ? 'qwen21' : src.engine === 'krea' ? 'krea' : src.engine === 'ltx' ? 'ltx' : src.engine === 'minimax' ? 'minimax' : 'flux'
if (['video','extend'].includes(mode)) engine = xaigen && engine === 'ltx' ? 'ltx' : 'minimax'
else if (engine === 'minimax' || engine === 'ltx') engine = 'flux'
const settings = structuredClone(src.settings || {})
const aspect = ['auto','16:9','9:16','1:1'].includes(settings.aspect) ? settings.aspect : 'auto'
const loraStack = Array.isArray(settings.loraStack) ? settings.loraStack.map(item => ({
name: item.name,
strengthModel: Number(item.strengthModel ?? item.strength ?? 1),
strengthClip: Number(item.strengthClip ?? item.strengthModel ?? item.strength ?? 1)
})) : []
const guides = (Array.isArray(src.guides) ? src.guides : []).map(g => {
const stillId = keepStill(g.stillId, 'guide still')
return stillId ? { stillId, frame: g.frame } : null
}).filter(Boolean)
const next = {
...form,
mode,
engine,
lockFace: src.lockFace !== false,
lockOutfit: src.lockOutfit !== false,
hideInput: !!src.hideInput,
folderId: src.folderId || form.folderId,
identityStillId: keepStill(src.identityStillId, 'hero'),
imageAId: keepStill(src.imageAId, 'start'),
imageBId: keepStill(src.imageBId, 'second still'),
maskId: keepStill(src.maskId, 'mask'),
startClipId: keepClip(src.startClipId, 'start clip'),
endStillId: keepStill(src.endStillId, 'end still'),
guides,
imageStyles: normalizeStyles(src.imageStyles || { positive: [], negative: [] }),
promptSections: sectionsFrom(src.promptSections || { action: src.compiledPrompt || src.prompt || '' }),
settings: {
...form.settings,
...settings,
aspect,
seed: settings.seed ?? form.settings?.seed ?? null,
seedMode: 'locked',
loraStack
}
}
return { form: restoreLoras(next, loraStack, xaigen, memory), missing: missing[0] || '', warnings: missing }
}
export function restoreAll(form,item,xaigen=false,memory) {
const restored=restoreInput(item),next={...form,...restored,settings:{...form.settings,...restored.settings,seedMode:'random'}}
if(item.kind==='clip') return {...bindClip(next,item,xaigen,memory),mode:'extend'}
+7 -1
View File
@@ -1,6 +1,12 @@
export const styleGroups={Render:['Photorealistic','Pixar','3D Disney','Comic','Watercolor','Oil painting'],Quality:['Realistic skin','Skin pores','Fine detail','Natural texture'],Lighting:['Golden hour','Soft studio','Window light','Overcast','Cinematic lighting']}
export const defaultNegatives=['blurry','deformed','low quality','text','watermark']
export function normalizeStyles(value={}) { const known=[...Object.values(styleGroups).flat(),...defaultNegatives];const positive=[...new Set((value.positive||[]).filter(s=>known.includes(s)))];return {positive,negative:[...new Set((value.negative||[]).filter(s=>known.includes(s)&&!positive.includes(s)))]} }
export function normalizeStyles(value={}) {
const known=[...Object.values(styleGroups).flat(),...defaultNegatives]
const positive=[...new Set((value.positive||[]).filter(s=>known.includes(s)))]
// Freeform negatives are allowed (sticky custom chips); positives stay on the known style list.
const negative=[...new Set((value.negative||[]).map(s=>String(s||'').trim()).filter(s=>s&&!positive.includes(s)))]
return {positive,negative}
}
export function toggleStyle(value,chip,negative=false) { const next=normalizeStyles(value),key=negative?'negative':'positive',other=negative?'positive':'negative';next[other]=next[other].filter(s=>s!==chip);next[key]=next[key].includes(chip)?next[key].filter(s=>s!==chip):[...next[key],chip];return next }
export function stylePrompt(value,negative=false){return normalizeStyles(value)[negative?'negative':'positive'].join(', ')}
export function parseBatch(value){return String(value||'').split(/\r?\n\s*\r?\n/).map(s=>s.trim()).filter(Boolean)}