From 401e6c26a2b01bb78a4d4d4e12a79da08b1cfc02 Mon Sep 17 00:00:00 2001 From: Towsty Date: Sat, 12 Sep 2026 20:17:58 -0500 Subject: [PATCH] Remember Studio 2 LoRA stacks for each engine --- components/studio2/Bench.vue | 16 +++++-- server/api/studio-2/jobs.post.ts | 2 + shared/studio2/input.mjs | 31 +++++++------ shared/studio2/lora-memory.mjs | 43 +++++++++++++++++ tests/studio2-bench.test.mjs | 2 +- tests/studio2-controls.test.mjs | 2 +- tests/studio2-identity-edit.test.mjs | 2 +- tests/studio2-lora-memory.test.mjs | 69 ++++++++++++++++++++++++++++ 8 files changed, 145 insertions(+), 22 deletions(-) create mode 100644 shared/studio2/lora-memory.mjs create mode 100644 tests/studio2-lora-memory.test.mjs diff --git a/components/studio2/Bench.vue b/components/studio2/Bench.vue index af9dbc6..aee76d9 100644 --- a/components/studio2/Bench.vue +++ b/components/studio2/Bench.vue @@ -48,7 +48,7 @@
@@ -58,6 +58,8 @@ diff --git a/server/api/studio-2/jobs.post.ts b/server/api/studio-2/jobs.post.ts index 666add0..2a91e1d 100644 --- a/server/api/studio-2/jobs.post.ts +++ b/server/api/studio-2/jobs.post.ts @@ -1,3 +1,4 @@ +import {filterLoraStackForUse} from '~/utils/loras'; import { validateRequest } from '~/shared/studio2/contracts.mjs'; import { resolveRequestSize } from '../../utils/studio2/size'; import { parsePostedLoraStack, assertImageV2LoraStack } from '../../utils/loras'; @@ -16,6 +17,7 @@ export default defineEventHandler(async (event) => { if (video && request.engine !== 'minimax') throw createError({ statusCode: 400, statusMessage: 'Use the existing xAIGen studio for LTX. Studio 2 video currently supports MiniMax.' }); request.settings.loraStack = parsePostedLoraStack(request.settings.loraStack, video ? 'video' : 'image'); + request.settings.loraStack = filterLoraStackForUse(request.settings.loraStack, request.engine, useRuntimeConfig().public.studio === 'xaigen'); if (!video) assertImageV2LoraStack(request.settings.loraStack, request.engine); assertFolderAccess(event, request.folderId); diff --git a/shared/studio2/input.mjs b/shared/studio2/input.mjs index 30b8929..e421b61 100644 --- a/shared/studio2/input.mjs +++ b/shared/studio2/input.mjs @@ -1,3 +1,4 @@ +import {restoreLoras,loraUse} from './lora-memory.mjs' 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}) { @@ -7,7 +8,7 @@ export function resolveSize(aspect, source, fallback = {width:1344,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) { +export function bindClip(form, clip, xaigen = false, memory) { if (!clip?.id) return form const saved = clip.studio2 || clip.job || {} const settings = saved.settings || clip.settings || {} @@ -21,11 +22,11 @@ export function bindClip(form, clip, xaigen = false) { 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 + return restoreLoras(next,settings.loraStack,xaigen,memory) } -export function restoreAsset(form, item) { +export function restoreAsset(form, item, xaigen = false, memory) { if (item.kind==='clip' || item.studio2?.kind==='video') { - const next=bindClip(form,item,form.engine==='ltx') + const next=bindClip(form,item,xaigen,memory) const seed=item.studio2?.settings?.seed ?? item.settings?.seed ?? item.seed if (seed != null) next.settings.seed=seed next.settings.seedMode='random' @@ -34,20 +35,21 @@ export function restoreAsset(form, item) { 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.engine=restored.engine==='krea'?'krea':'flux' + next.mode=['generate','edit','compose','refine','iterate'].includes(restored.mode)?restored.mode:'edit' next.imageAId=item.id next.startClipId='' next.settings.aspect=item.studio2?.settings?.aspect || item.settings?.aspect || 'auto' - return next + return restoreLoras(next,item.studio2?.settings?.loraStack ?? item.settings?.loraStack,xaigen,memory) } -export function extendSelected(form, clip, xaigen = false) { - const next = { ...form, mode: 'extend', engine: xaigen && form.engine === 'ltx' ? 'ltx' : 'minimax' } +export function extendSelected(form, clip, xaigen = false, memory) { + const next = { ...form, settings:{...form.settings}, mode: 'extend', engine: xaigen && form.engine === 'ltx' ? 'ltx' : 'minimax' } const target = clip?.id ? clip : null - if (!target?.id) return next + if (!target?.id) return restoreLoras(next,loraUse(form)===loraUse(next)?form.settings?.loraStack:[],xaigen,memory) // 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) } + if (form.startClipId === target.id) return restoreLoras(next,loraUse(form)===loraUse(next)?form.settings?.loraStack:[],xaigen,memory) + try { return bindClip(next, target, xaigen, memory) } catch { return { ...next, startClipId: target.id } } } export function changeSource(form) { @@ -56,11 +58,12 @@ export function changeSource(form) { 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) { +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),mode:'extend'} + if(item.kind==='clip') return {...bindClip(next,item,xaigen,memory),mode:'extend'} next.mode=restored.mode==='iterate'?'edit':restored.mode || 'edit' + next.engine=restored.engine==='krea'?'krea':'flux' next.imageAId=item.id next.startClipId='' - return next + return restoreLoras(next,item.studio2?.settings?.loraStack ?? item.settings?.loraStack,xaigen,memory) } diff --git a/shared/studio2/lora-memory.mjs b/shared/studio2/lora-memory.mjs new file mode 100644 index 0000000..5bb5351 --- /dev/null +++ b/shared/studio2/lora-memory.mjs @@ -0,0 +1,43 @@ +import {filterLoraStackForUse} from '../../utils/loras.ts' + +export function loraUse(form) { + const video=['video','extend'].includes(form.mode) + return video ? (form.engine==='ltx'?'ltx':'minimax') : (form.engine==='krea'?'krea':'flux') +} + +// One controller per bench. Storage is optional (SSR/private browsing still works). +export function createLoraMemory(xaigen, storage) { + const stacks={} + let active=null + const key=`studio2-lora-stacks:${xaigen?'xaigen':'aigen'}` + try { Object.assign(stacks,JSON.parse(storage?.getItem(key)||'{}')) } catch {} + const load=use=>filterLoraStackForUse(stacks[use],use,xaigen) + const save=(use,stack)=>{ + stacks[use]=filterLoraStackForUse(stack,use,xaigen) + try { storage?.setItem(key,JSON.stringify(stacks)) } catch {} + } + return { + sync(form) { + const use=loraUse(form) + if(active!==use) { + // Every stack edit is remembered by the bench watcher before a later switch. + active=use + form.settings.loraStack=load(use) + } + save(use,form.settings.loraStack) + }, + restore(form,stack) { + const use=loraUse(form) + form.settings.loraStack=stack?.length ? filterLoraStackForUse(stack,use,xaigen) : load(use) + active=use + save(use,form.settings.loraStack) + return form + } + } +} + +export function restoreLoras(form, stack, xaigen=false, memory) { + if(memory) return memory.restore(form,stack) + form.settings.loraStack=filterLoraStackForUse(stack,loraUse(form),xaigen) + return form +} diff --git a/tests/studio2-bench.test.mjs b/tests/studio2-bench.test.mjs index 73fea15..fb91564 100644 --- a/tests/studio2-bench.test.mjs +++ b/tests/studio2-bench.test.mjs @@ -41,7 +41,7 @@ test('cached LoRAs survive sleeping GPU and failed explicit refresh',async()=>{ test('source/hero actions are independent and full input restores structured settings',()=>{ const form={identityStillId:'hero',imageAId:'source',promptSections:{action:'old'},settings:{}} assert.equal(useAsHero(form,{id:'new'}).imageAId,'source');assert.equal(useAsSource(form,{id:'new'}).identityStillId,'hero') - const saved={mode:'edit',identityStillId:'h',imageAId:'s',imageStyles:{positive:['Comic'],negative:['Skin pores']},promptSections:{action:'New',cameraBeats:[{moves:['hold']}]},settings:{steps:24,loraStack:[{name:'a',strengthModel:.4,strengthClip:.6}]}} + const saved={mode:'edit',identityStillId:'h',imageAId:'s',imageStyles:{positive:['Comic'],negative:['Skin pores']},promptSections:{action:'New',cameraBeats:[{moves:['hold']}]},settings:{steps:24,loraStack:[{name:'example.safetensors',strengthModel:.4,strengthClip:.6}]}} const restored=restoreAll(form,{id:'result',studio2:saved});assert.equal(restored.imageAId,'result');assert.equal(restored.identityStillId,'h');assert.deepEqual(restored.settings.loraStack,saved.settings.loraStack);assert.deepEqual(restored.imageStyles,saved.imageStyles);assert.equal(restored.promptSections.action,'New');const clip=restoreAll(form,{id:'clip',kind:'clip',studio2:{...saved,mode:'video',engine:'minimax'}});assert.equal(clip.startClipId,'clip');assert.equal(clip.mode,'extend');assert.equal(clip.promptSections.action,'New') }) test('image chips compile positive and negative independently; video chips do not leak',()=>{ diff --git a/tests/studio2-controls.test.mjs b/tests/studio2-controls.test.mjs index 1a1f2af..0ace347 100644 --- a/tests/studio2-controls.test.mjs +++ b/tests/studio2-controls.test.mjs @@ -21,7 +21,7 @@ test('Use as input restores camera, sound, dialogue, music, LoRAs, aspect and lo }) test('extend binds completed clip, inherits settings and leaves delta prompt alone',()=>{ const form={...base,lockFace:true,lockOutfit:false,settings:{duration:5,width:1344,height:768},promptSections:{action:'Turn',cameraBeats:beats}} - const clip={id:'done',kind:'clip',studio2:{...base,lockFace:true,lockOutfit:false,settings:{duration:8,width:768,height:1344,aspect:'9:16',fps:24,steps:8,cfg:1.5,turbo:false,loraStack:[{name:'a',strengthModel:1,strengthClip:.5}]}}} + const clip={id:'done',kind:'clip',studio2:{...base,lockFace:true,lockOutfit:false,settings:{duration:8,width:768,height:1344,aspect:'9:16',fps:24,steps:8,cfg:1.5,turbo:false,loraStack:[{name:'example.safetensors',strengthModel:1,strengthClip:.5}]}}} const bound=bindClip(form,clip);assert.equal(bound.startClipId,'done');assert.deepEqual(bound.settings,clip.studio2.settings);assert.equal(bound.promptSections.action,'Turn');assert.equal(bound.identityStillId,'hero');assert.equal(bound.lockOutfit,false) assert.equal(restoreAsset(form,clip).startClipId,'done');assert.equal(bindClip(form,{id:'legacy'}).lockOutfit,false) const overridden={...bound,lockOutfit:false,settings:{...bound.settings,cfg:2.5}} diff --git a/tests/studio2-identity-edit.test.mjs b/tests/studio2-identity-edit.test.mjs index 526a9f3..e2c30f4 100644 --- a/tests/studio2-identity-edit.test.mjs +++ b/tests/studio2-identity-edit.test.mjs @@ -37,7 +37,7 @@ test('image requests omit video sections while form keeps them',()=>{ }) test('Extend normalizes engine and inherits clip without replacing action or camera',()=>{ const form={mode:'edit',engine:'flux',imageAId:'still',promptSections:{action:'New action',cameraBeats:[{moves:['hold']}]},settings:{loraStack:[]}} - const clip={id:'clip',kind:'clip',studio2:{engine:'minimax',identityStillId:'hero',lockOutfit:false,promptSections:{action:'Old action'},settings:{duration:8,aspect:'1:1',width:960,height:960,fps:24,turbo:false,steps:8,cfg:1.5,loraStack:[{name:'a',strengthModel:.6,strengthClip:.3}]}}} + const clip={id:'clip',kind:'clip',studio2:{engine:'minimax',identityStillId:'hero',lockOutfit:false,promptSections:{action:'Old action'},settings:{duration:8,aspect:'1:1',width:960,height:960,fps:24,turbo:false,steps:8,cfg:1.5,loraStack:[{name:'example.safetensors',strengthModel:.6,strengthClip:.3}]}}} const next=extendSelected(form,clip);assert.equal(next.engine,'minimax');assert.equal(next.startClipId,'clip');assert.deepEqual(next.settings,clip.studio2.settings) assert.deepEqual(next.promptSections,form.promptSections);assert.equal(next.lockOutfit,false) assert.equal(changeSource({...next,imageAId:'new-still'}).startClipId,'clip');assert.equal(changeSource(next).mode,'extend') diff --git a/tests/studio2-lora-memory.test.mjs b/tests/studio2-lora-memory.test.mjs new file mode 100644 index 0000000..10064df --- /dev/null +++ b/tests/studio2-lora-memory.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import {reactive,watch} from 'vue' +import {inferLoraUse,filterLorasForUse,filterLorasForStudio,filterLoraStackForUse} from '../utils/loras.ts' +import {createLoraMemory,loraUse} from '../shared/studio2/lora-memory.mjs' +import {restoreAll,restoreAsset,bindClip,extendSelected,useAsHero,useAsSource} from '../shared/studio2/input.mjs' +const stack=name=>[{name:name+'.safetensors',strengthModel:.6,strengthClip:.35}] + +test('engine token matrix uses paths and names, omits unknown and system picker entries',()=>{ + const cases={'xaigen-klein_car':'flux','klein/style':'flux','XAIgen-flux_test':'flux','Flux2-Klein/test':'flux','MiniMax/car':'minimax','folder/LTX_style':'ltx','xiagen-Krea_look':'krea','xaigen-krea/style':'krea','unlabelled':null} + for(const [name,use] of Object.entries(cases)) { + assert.deepEqual(inferLoraUse(name+'.safetensors'),use?[use]:null) + for(const current of ['flux','krea','minimax','ltx']) assert.equal(filterLorasForUse([name+'.safetensors'],current).length,use===current?1:0) + } + const restricted=['xaigen-klein.safetensors','XAIgen-MiniMax.safetensors','xiagen-Krea.safetensors'] + assert.deepEqual(filterLorasForStudio(restricted,false),[]) + assert.deepEqual(filterLoraStackForUse(stack('xaigen-klein'),'flux',false),[]) + assert.deepEqual(filterLoraStackForUse(stack('LTX'),'ltx',false),[]) + for(const [name,use] of [['minimax_h3_fl2v_turbo_8step_v1.0_comfyui_bf16','minimax'],['ltx-2.3-22b-distilled','ltx'],['Flux2-Klein-9B-consistency-V2','flux']]) assert.deepEqual(filterLorasForUse([name+'.safetensors'],use),[]) + assert.deepEqual(filterLoraStackForUse(stack('unlabelled'),'flux',true),stack('unlabelled')) +}) + +function bench() { + const data=new Map(),storage={getItem:k=>data.get(k),setItem:(k,v)=>data.set(k,v)} + const memory=createLoraMemory(true,storage) + const form=reactive({mode:'generate',engine:'flux',settings:{loraStack:[]}}) + const stop=watch(()=>[loraUse(form),form.settings.loraStack],()=>memory.sync(form),{deep:true,flush:'sync'}) + memory.sync(form) + return {form,memory,storage,stop} +} +test('reactive task switches remember four independent stacks and both strengths',()=>{ + const {form,memory,storage,stop}=bench() + form.settings.loraStack=stack('klein_car_bee') + form.settings.loraStack[0].strengthClip=.8 + form.mode='video';form.engine='minimax' + assert.deepEqual(form.settings.loraStack,[]) + form.settings.loraStack=stack('MiniMax_style') + form.engine='ltx';assert.deepEqual(form.settings.loraStack,[]) + form.settings.loraStack=stack('LTX_style') + form.mode='edit';form.engine='flux' + assert.equal(form.settings.loraStack[0].name,'klein_car_bee.safetensors') + assert.equal(form.settings.loraStack[0].strengthClip,.8) + form.engine='krea';assert.deepEqual(form.settings.loraStack,[]) + form.settings.loraStack=stack('Krea_style') + form.mode='video';form.engine='minimax' + assert.deepEqual(form.settings.loraStack,stack('MiniMax_style')) + Object.assign(form,useAsHero(form,{id:'image'}),useAsSource(form,{id:'source'})) + assert.deepEqual(form.settings.loraStack,stack('MiniMax_style')) + const fresh=createLoraMemory(true,storage),other={mode:'generate',engine:'krea',settings:{loraStack:[]}} + fresh.sync(other);assert.deepEqual(other.settings.loraStack,stack('Krea_style')) + stop() +}) +test('Use as input and Extend copy saved stacks, filter wrong engines, and fall back only when saved empty',()=>{ + const {form,memory,stop}=bench() + form.settings.loraStack=stack('klein_remembered') + const still={id:'still',kind:'still',studio2:{engine:'flux',mode:'edit',settings:{loraStack:[...stack('klein_saved'),...stack('MiniMax_wrong')]}}} + Object.assign(form,restoreAll(form,still,true,memory)) + assert.deepEqual(form.settings.loraStack,stack('klein_saved')) + const clip={id:'clip',kind:'clip',studio2:{engine:'minimax',settings:{loraStack:[...stack('MiniMax_saved'),...stack('klein_wrong')]}}} + Object.assign(form,extendSelected(form,clip,true,memory)) + assert.deepEqual(form.settings.loraStack,stack('MiniMax_saved')) + Object.assign(form,restoreAll(form,{...still,studio2:{...still.studio2,settings:{loraStack:[]}}},true,memory)) + assert.deepEqual(form.settings.loraStack,stack('klein_saved')) + Object.assign(form,bindClip(form,{id:'empty',settings:{loraStack:[]}},true,memory)) + assert.deepEqual(form.settings.loraStack,stack('MiniMax_saved')) + const legacy=restoreAsset(form,{id:'old-still',kind:'still',engine:'krea',settings:{loraStack:stack('Krea_saved')}},true,memory) + assert.equal(legacy.engine,'krea');assert.deepEqual(legacy.settings.loraStack,stack('Krea_saved')) + stop() +})