From 86ffa9ba047750847be4b097f9a8f8c25ffb517d Mon Sep 17 00:00:00 2001 From: Towsty Date: Fri, 11 Sep 2026 19:04:03 -0500 Subject: [PATCH] Compile image style chips and restore complete input metadata --- server/utils/studio2/runner.ts | 3 ++- shared/studio2/contracts.mjs | 5 +++-- shared/studio2/input.mjs | 8 ++++++++ shared/studio2/styles.mjs | 5 +++++ tests/studio2-identity-edit.test.mjs | 3 ++- 5 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 shared/studio2/styles.mjs diff --git a/server/utils/studio2/runner.ts b/server/utils/studio2/runner.ts index 37cd235..a91bd7e 100644 --- a/server/utils/studio2/runner.ts +++ b/server/utils/studio2/runner.ts @@ -1,3 +1,4 @@ +import { stylePrompt } from '~/shared/studio2/styles.mjs'; import { purge } from './cleanup'; import { watchProgress } from './progress'; import { fitStill } from './media'; @@ -87,7 +88,7 @@ async function prepareGraph(r: any) { const mode = q.mode === 'iterate' ? (a ? 'edit' : 'generate') : q.mode; const found = q.engine === 'krea' ? await resolveKreaGenerateAssets() : null; const assets = found ? { kreaUnetName: found.unet, kreaClipName: found.clip, kreaVaeName: found.vae, kreaConceptLora: found.conceptLora } : {}; - graph = buildImageV2Workflow({ ...s, ...assets, engine: q.engine, mode, task: 'scene', prompt: q.compiledPrompt, imageAName: a, imageBName: mode === 'compose' ? b : undefined, maskName: mode === 'refine' ? mask : undefined, filenamePrefix: prefix + '/image' }).graph; + graph = buildImageV2Workflow({ ...s, ...assets, engine: q.engine, mode, task: 'scene', prompt: q.compiledPrompt, negative: stylePrompt(q.imageStyles, true), imageAName: a, imageBName: mode === 'compose' ? b : undefined, maskName: mode === 'refine' ? mask : undefined, filenamePrefix: prefix + '/image' }).graph; applyResolvedImageSize(graph,s); if (usesSourceLatent) sampleKleinSource(graph,.65); if (q.engine === 'flux') diff --git a/shared/studio2/contracts.mjs b/shared/studio2/contracts.mjs index 51d69fc..7009d97 100644 --- a/shared/studio2/contracts.mjs +++ b/shared/studio2/contracts.mjs @@ -1,3 +1,4 @@ +import {normalizeStyles,stylePrompt} from './styles.mjs' import { queueSeeds } from './seed.mjs' import { sectionsFrom, compileSections } from './prompt.mjs' export { sectionsFrom } from './prompt.mjs' @@ -14,7 +15,7 @@ export function compilePrompt(value, video = false, locks = {}) { const hasImage=!!(locks.imageAId || locks.identityStillId) const attached=hasImage && locks.engine !== 'krea' const reference=locks.mode!=='generate' && locks.identityStillId && locks.imageAId && locks.identityStillId!==locks.imageAId ? 'second (hero) reference' : 'image reference' - return [sectionsFrom(value).action, + return [sectionsFrom(value).action, stylePrompt(locks.imageStyles), attached && locks.lockFace !== false ? `Preserve the person’s face, glasses, hair, and body identity from the ${reference}.` : '', attached && locks.lockOutfit !== false ? `Preserve the wardrobe from the ${reference}.` : '', attached && locks.lockOutfit === false ? 'Clothing may follow the source and requested scene; do not restore the hero outfit.' : '' @@ -55,7 +56,7 @@ export function validateRequest(raw, xaigen = false) { } const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5 const shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0)) - return { mode, engine, shotSeeds, lockFace:raw.lockFace !== false, lockOutfit:raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:raw.identityStillId || '', imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '', + return { mode, engine, shotSeeds, imageStyles:video?{positive:[],negative:[]}:normalizeStyles(raw.imageStyles), hideOutput:!!raw.hideOutput, lockFace:raw.lockFace !== false, lockOutfit:raw.lockOutfit !== false, hideInput:!!raw.hideInput, folderId:String(raw.folderId), identityStillId:raw.identityStillId || '', imageAId:raw.imageAId || '', imageBId:raw.imageBId || '', maskId:raw.maskId || '', startClipId:raw.startClipId || '', endStillId:raw.endStillId || '', startFrameSource: raw.startClipId ? {kind:'previous-last-frame',clipId:raw.startClipId} : {kind:'still',stillId:raw.imageAId || ''}, promptSections, compiledPrompt:compilePrompt(promptSections,video,raw), settings: { aspect:['auto','16:9','9:16','1:1'].includes(raw.settings?.aspect)?raw.settings.aspect:'auto', fps:number('fps',24,1,60), width:number('width',1344,256,4096),height:number('height',768,256,4096),steps:number('steps',video?8:24,1,100),cfg:number('cfg',video?1.5:4,0,20),seedMode:raw.settings?.seedMode==='locked'?'locked':'random',seed:shotSeeds[0],duration:number('duration',5,1,15),turbo:raw.settings?.turbo !== false, loraStack:Array.isArray(raw.settings?.loraStack)?raw.settings.loraStack:[] }, diff --git a/shared/studio2/input.mjs b/shared/studio2/input.mjs index 122c1c3..6694cf6 100644 --- a/shared/studio2/input.mjs +++ b/shared/studio2/input.mjs @@ -46,3 +46,11 @@ export function extendSelected(form, clip, xaigen = false) { 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) { + const restored=restoreInput(item),next={...form,...restored,settings:{...form.settings,...restored.settings,seedMode:'random'}} + next.mode=restored.mode==='iterate'?'edit':restored.mode || (item.kind==='clip'?'video':'edit') + return next +} diff --git a/shared/studio2/styles.mjs b/shared/studio2/styles.mjs new file mode 100644 index 0000000..6387227 --- /dev/null +++ b/shared/studio2/styles.mjs @@ -0,0 +1,5 @@ +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 function normalizeStyles(value={}) { const known=Object.values(styleGroups).flat();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 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)} diff --git a/tests/studio2-identity-edit.test.mjs b/tests/studio2-identity-edit.test.mjs index b5aab45..323de93 100644 --- a/tests/studio2-identity-edit.test.mjs +++ b/tests/studio2-identity-edit.test.mjs @@ -1,3 +1,4 @@ +import {stylePrompt} from '../shared/studio2/styles.mjs' import test from 'node:test' import assert from 'node:assert/strict' import fs from 'node:fs' @@ -12,7 +13,7 @@ const fn=tree.statements.find(n=>ts.isFunctionDeclaration(n)&&n.name.text==='pre const js=ts.transpileModule(fn.getText(tree),{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText async function prepare(request){ const uploads=[] - const scope={useRuntimeConfig:()=>({comfyFilenamePrefix:'preview'}),upload:async(r,label)=>{uploads.push(label);return label+'.png'},readFileSync:()=>Buffer.from('fixture'),stillPath:()=>'',getStill:(_,id)=>({id}),resolveRequestSize:async()=>{},saveRecord(){}, + const scope={stylePrompt,useRuntimeConfig:()=>({comfyFilenamePrefix:'preview'}),upload:async(r,label)=>{uploads.push(label);return label+'.png'},readFileSync:()=>Buffer.from('fixture'),stillPath:()=>'',getStill:(_,id)=>({id}),resolveRequestSize:async()=>{},saveRecord(){}, buildImageV2Workflow:params=>{const graph=structuredClone(template);graph['1'].inputs.image=params.imageAName;return {graph}},sampleKleinSource,attachHeroReference,applyResolvedImageSize,ensureComfyLoraNames:async()=>{},resolveGraphLoraNames(){}} const run=new Function(...Object.keys(scope),`${js};return prepareGraph`)(...Object.values(scope)) const record={request,owner:'o',id:'job',index:0};return {graph:await run(record),record,uploads}