Compare commits

..
2 Commits
Author SHA1 Message Date
Towsty 401e6c26a2 Remember Studio 2 LoRA stacks for each engine 2026-09-12 20:17:58 -05:00
Towsty 6a6931f04e Filter LoRA picker options by engine tokens 2026-09-12 20:17:58 -05:00
10 changed files with 168 additions and 36 deletions
+4 -1
View File
@@ -112,6 +112,8 @@ import {
loraFriendlyName, loraFriendlyName,
loraIdentityKey, loraIdentityKey,
loraMatchesImageEngine, loraMatchesImageEngine,
loraMatchesUse,
type LoraUse,
loraTriggerFor, loraTriggerFor,
makeLoraStackItem, makeLoraStackItem,
type LoraStackItem type LoraStackItem
@@ -130,6 +132,7 @@ const props = withDefaults(defineProps<{
triggers?: Record<string, string> triggers?: Record<string, string>
aliases?: Record<string, string> aliases?: Record<string, string>
engine?: 'flux' | 'krea' | '' engine?: 'flux' | 'krea' | ''
use?: LoraUse
}>(), { }>(), {
label: 'LoRA', label: 'LoRA',
filenameSubtitle: false, filenameSubtitle: false,
@@ -159,7 +162,7 @@ const availableNames = computed(() => {
const engine = props.engine === 'flux' || props.engine === 'krea' ? props.engine : null const engine = props.engine === 'flux' || props.engine === 'krea' ? props.engine : null
return props.names return props.names
.filter(name => !taken.has(loraIdentityKey(name))) .filter(name => !taken.has(loraIdentityKey(name)))
.filter(name => !engine || loraMatchesImageEngine(name, engine)) .filter(name => props.use ? loraMatchesUse(name, props.use) : !engine || loraMatchesImageEngine(name, engine))
.slice() .slice()
.sort((a, b) => labelFor(a).localeCompare(labelFor(b), undefined, { sensitivity: 'base' })) .sort((a, b) => labelFor(a).localeCompare(labelFor(b), undefined, { sensitivity: 'base' }))
}) })
+11 -5
View File
@@ -48,7 +48,7 @@
</div> </div>
<div v-if="settingsOpen || detailsOpen" class="fixed inset-0 z-50 bg-black/70" @click.self="settingsOpen=false;detailsOpen=false"> <div v-if="settingsOpen || detailsOpen" class="fixed inset-0 z-50 bg-black/70" @click.self="settingsOpen=false;detailsOpen=false">
<aside class="absolute inset-y-0 right-0 w-full max-w-md overflow-auto border-l border-white/15 bg-zinc-950 p-6"><button class="float-right" @click="settingsOpen=false;detailsOpen=false">Close</button> <aside class="absolute inset-y-0 right-0 w-full max-w-md overflow-auto border-l border-white/15 bg-zinc-950 p-6"><button class="float-right" @click="settingsOpen=false;detailsOpen=false">Close</button>
<template v-if="settingsOpen"><h2 class="mb-6 text-xl font-semibold">Generation settings</h2><label class="mb-4 block">Engine<select v-model="form.engine" class="mt-1 w-full rounded bg-zinc-800 p-2"><template v-if="video"><option value="minimax">MiniMax H3</option></template><template v-else><option value="flux">Flux Klein 9B Base</option><option value="krea" :disabled="['edit','compose','iterate'].includes(form.mode)">Krea 2 (Generate / Refine)</option></template></select></label><AspectRatioPick v-model="form.settings.aspect" heading="Aspect" :options="aspectOptions" /><p class="my-3 text-sm text-zinc-400">{{ resolvedSize.width }} × {{ resolvedSize.height }} · fit with padding, never stretch</p><label v-for="key in ['steps','cfg',...(video?['duration','fps']:[])]" :key="key" class="mb-4 block capitalize">{{ key }}<input v-model.number="form.settings[key]" type="number" class="mt-1 w-full rounded bg-zinc-800 p-2"></label><label class="mb-4 block">Seed · {{ form.settings.seedMode==='locked'?'Locked':'Random each job' }}<input :value="form.settings.seed" type="number" min="0" max="2147483647" class="mt-1 w-full rounded bg-zinc-800 p-2" @input="form.settings.seed=Number(($event.target as HTMLInputElement).value);form.settings.seedMode='locked'"></label><button class="mb-4 text-sm text-amber-200" @click="form.settings.seedMode='random';form.settings.seed=randomSeed()">Random</button><label v-if="video"><input v-model="form.settings.turbo" type="checkbox"> Turbo</label><LoraSelect v-model="form.settings.loraStack" class="mt-5" :names="video?loraNames.video:loraNames.image" :engine="video?'':form.engine" :clip-strength="true" filename-subtitle :aliases="loraNames.aliases || library.loraAliases" :triggers="loraNames.triggers || library.loraTriggers" missing-label="No cached LoRAs yet — Refresh to check Comfy" @insert-trigger="form.promptSections.action += ' '+$event" /><button class="mt-2 text-sm text-amber-200" @click="loadLoras(true)">Refresh LoRAs</button><details class="mt-6"><summary>Compiled prompt</summary><pre class="whitespace-pre-wrap text-xs text-zinc-400">{{ compiled }}</pre><p v-if="!video" class="mt-3 text-xs text-zinc-400">Negative: {{ stylePrompt(form.imageStyles,true) || 'None' }}</p></details></template> <template v-if="settingsOpen"><h2 class="mb-6 text-xl font-semibold">Generation settings</h2><label class="mb-4 block">Engine<select v-model="form.engine" class="mt-1 w-full rounded bg-zinc-800 p-2"><template v-if="video"><option value="minimax">MiniMax H3</option></template><template v-else><option value="flux">Flux Klein 9B Base</option><option value="krea" :disabled="['edit','compose','iterate'].includes(form.mode)">Krea 2 (Generate / Refine)</option></template></select></label><AspectRatioPick v-model="form.settings.aspect" heading="Aspect" :options="aspectOptions" /><p class="my-3 text-sm text-zinc-400">{{ resolvedSize.width }} × {{ resolvedSize.height }} · fit with padding, never stretch</p><label v-for="key in ['steps','cfg',...(video?['duration','fps']:[])]" :key="key" class="mb-4 block capitalize">{{ key }}<input v-model.number="form.settings[key]" type="number" class="mt-1 w-full rounded bg-zinc-800 p-2"></label><label class="mb-4 block">Seed · {{ form.settings.seedMode==='locked'?'Locked':'Random each job' }}<input :value="form.settings.seed" type="number" min="0" max="2147483647" class="mt-1 w-full rounded bg-zinc-800 p-2" @input="form.settings.seed=Number(($event.target as HTMLInputElement).value);form.settings.seedMode='locked'"></label><button class="mb-4 text-sm text-amber-200" @click="form.settings.seedMode='random';form.settings.seed=randomSeed()">Random</button><label v-if="video"><input v-model="form.settings.turbo" type="checkbox"> Turbo</label><LoraSelect v-model="form.settings.loraStack" class="mt-5" :names="pickerLoras" :use="currentLoraUse" :clip-strength="true" filename-subtitle :aliases="{...library.loraAliases,...loraNames.aliases}" :triggers="{...library.loraTriggers,...loraNames.triggers}" missing-label="No cached LoRAs yet — Refresh to check Comfy" @insert-trigger="form.promptSections.action += ' '+$event" /><button class="mt-2 text-sm text-amber-200" @click="loadLoras(true)">Refresh LoRAs</button><details class="mt-6"><summary>Compiled prompt</summary><pre class="whitespace-pre-wrap text-xs text-zinc-400">{{ compiled }}</pre><p v-if="!video" class="mt-3 text-xs text-zinc-400">Negative: {{ stylePrompt(form.imageStyles,true) || 'None' }}</p></details></template>
<template v-else><h2 class="mb-6 text-xl font-semibold">Output details</h2><Studio2Details :metadata="selected?.studio2" /></template> <template v-else><h2 class="mb-6 text-xl font-semibold">Output details</h2><Studio2Details :metadata="selected?.studio2" /></template>
</aside> </aside>
</div> </div>
@@ -58,6 +58,8 @@
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {createLoraMemory,loraUse} from '~/shared/studio2/lora-memory.mjs'
import {filterLorasForStudio,filterLorasForUse} from '~/utils/loras'
import {compilePrompt,sectionsFrom} from '~/shared/studio2/contracts.mjs' import {compilePrompt,sectionsFrom} from '~/shared/studio2/contracts.mjs'
import {aspectOptions,resolveSize,bindClip,restoreAsset,extendSelected,changeSource,restoreAll,useAsHero,useAsSource} from '~/shared/studio2/input.mjs' import {aspectOptions,resolveSize,bindClip,restoreAsset,extendSelected,changeSource,restoreAll,useAsHero,useAsSource} from '~/shared/studio2/input.mjs'
import {folderAssets,newestOutput,assetLabel,reconcileSelection,activeJobs,previewHidden} from '~/shared/studio2/viewer.mjs' import {folderAssets,newestOutput,assetLabel,reconcileSelection,activeJobs,previewHidden} from '~/shared/studio2/viewer.mjs'
@@ -74,6 +76,10 @@ const loraNames=ref<any>({image:[],video:[]}),now=ref(Date.now()),healthState=re
const gpu=computed(()=>gpuStatus(jobs.value,healthState.value,upscales.value)) const gpu=computed(()=>gpuStatus(jobs.value,healthState.value,upscales.value))
const library=ref<any>({folders:[],stills:[],clips:[]}),jobs=ref<any[]>([]),selected=ref<any>(null) const library=ref<any>({folders:[],stills:[],clips:[]}),jobs=ref<any[]>([]),selected=ref<any>(null)
const form=reactive<any>({mode:'generate',engine:'flux',imageStyles:{positive:[],negative:[]},lockFace:true,lockOutfit:true,hideInput:false,folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],promptSections:sectionsFrom({}),settings:{aspect:'auto',fps:24,steps:24,cfg:4,seed:null,seedMode:'random',duration:5,turbo:true,loraStack:[]}}) const form=reactive<any>({mode:'generate',engine:'flux',imageStyles:{positive:[],negative:[]},lockFace:true,lockOutfit:true,hideInput:false,folderId:'',identityStillId:'',imageAId:'',imageBId:'',maskId:'',startClipId:'',endStillId:'',guides:[],promptSections:sectionsFrom({}),settings:{aspect:'auto',fps:24,steps:24,cfg:4,seed:null,seedMode:'random',duration:5,turbo:true,loraStack:[]}})
let loraMemory=createLoraMemory(xaigen)
const currentLoraUse=computed(()=>loraUse(form))
const pickerLoras=computed(()=>!xaigen && currentLoraUse.value==='ltx'?[]:filterLorasForUse(filterLorasForStudio([...loraNames.value.image,...loraNames.value.video],xaigen),currentLoraUse.value))
watch(()=>[currentLoraUse.value,form.settings.loraStack],()=>loraMemory.sync(form),{deep:true,flush:'sync'})
const route=useRoute() const route=useRoute()
const tasks=['generate','edit','compose','refine','video','extend'],settingsOpen=ref(false),detailsOpen=ref(false),submitting=ref(false),error=ref(''),batchText=ref('') const tasks=['generate','edit','compose','refine','video','extend'],settingsOpen=ref(false),detailsOpen=ref(false),submitting=ref(false),error=ref(''),batchText=ref('')
const video=computed(()=>['video','extend'].includes(form.mode)),stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form.folderId)),clips=computed(()=>library.value.clips.filter((s:any)=>s.folderId===form.folderId)),compiled=computed(()=>compilePrompt(form.promptSections,video.value,form)) const video=computed(()=>['video','extend'].includes(form.mode)),stills=computed(()=>library.value.stills.filter((s:any)=>s.folderId===form.folderId)),clips=computed(()=>library.value.clips.filter((s:any)=>s.folderId===form.folderId)),compiled=computed(()=>compilePrompt(form.promptSections,video.value,form))
@@ -95,17 +101,17 @@ function chooseTask(task:string){
form.engine=xaigen && form.engine==='ltx'?'ltx':'minimax' form.engine=xaigen && form.engine==='ltx'?'ltx':'minimax'
const clip=selected.value?.kind==='clip'?selected.value:clips.value.find((c:any)=>c.id===form.startClipId) const clip=selected.value?.kind==='clip'?selected.value:clips.value.find((c:any)=>c.id===form.startClipId)
if(clip?.id){ if(clip?.id){
try{Object.assign(form,bindClip(form,clip,xaigen))} try{Object.assign(form,bindClip(form,clip,xaigen,loraMemory))}
catch(e:any){error.value=e?.message||'Could not copy clip settings. Previous clip is still bound.';form.startClipId=clip.id} catch(e:any){error.value=e?.message||'Could not copy clip settings. Previous clip is still bound.';form.startClipId=clip.id}
} }
return return
} }
const was=video.value;form.mode=task;form.startClipId='';if(video.value!==was){form.engine=video.value?'minimax':'flux';form.settings.steps=video.value?8:24;form.settings.cfg=video.value?1.5:4} const was=video.value;form.mode=task;form.startClipId='';if(video.value!==was){form.engine=video.value?'minimax':'flux';form.settings.steps=video.value?8:24;form.settings.cfg=video.value?1.5:4}
} }
function chooseClip(id:string){if(!id){form.startClipId='';return}const clip=clips.value.find((c:any)=>c.id===id);if(clip){try{Object.assign(form,bindClip(form,clip,xaigen))}catch(e:any){error.value=e?.message||'Could not copy clip settings.';form.startClipId=clip.id;form.mode='extend';form.engine=xaigen && form.engine==='ltx'?'ltx':'minimax'}}} function chooseClip(id:string){if(!id){form.startClipId='';return}const clip=clips.value.find((c:any)=>c.id===id);if(clip){try{Object.assign(form,bindClip(form,clip,xaigen,loraMemory))}catch(e:any){error.value=e?.message||'Could not copy clip settings.';form.startClipId=clip.id;form.mode='extend';form.engine=xaigen && form.engine==='ltx'?'ltx':'minimax'}}}
function selectOutput(item:any){selected.value=item;if(form.mode==='extend' && item?.kind==='clip')chooseClip(item.id)} function selectOutput(item:any){selected.value=item;if(form.mode==='extend' && item?.kind==='clip')chooseClip(item.id)}
function sourceChanged(){Object.assign(form,changeSource(form))} function sourceChanged(){Object.assign(form,changeSource(form))}
function restore(item:any){Object.assign(form,restoreAll(form,item,xaigen));form.guides=form.guides||[];batchText.value=''} function restore(item:any){Object.assign(form,restoreAll(form,item,xaigen,loraMemory));form.guides=form.guides||[];batchText.value=''}
function focusJob(job:any){selectOutput(job.outputs?.at(-1)||null)} function focusJob(job:any){selectOutput(job.outputs?.at(-1)||null)}
async function loadLoras(fresh=false){try{loraNames.value=await $fetch('/api/studio-2/loras'+(fresh?'?fresh=1':''))}catch{error.value='Could not refresh LoRAs. The last loaded list is still available.'}} async function loadLoras(fresh=false){try{loraNames.value=await $fetch('/api/studio-2/loras'+(fresh?'?fresh=1':''))}catch{error.value='Could not refresh LoRAs. The last loaded list is still available.'}}
function pickAsset(asset:any){const key=picker.value;if(key.startsWith('guide:'))form.guides[Number(key.split(':')[1])].stillId=asset?.id||'';else if(key==='startClipId')chooseClip(asset?.id||'');else form[key]=asset?.id||'';picker.value=''} function pickAsset(asset:any){const key=picker.value;if(key.startsWith('guide:'))form.guides[Number(key.split(':')[1])].stillId=asset?.id||'';else if(key==='startClipId')chooseClip(asset?.id||'');else form[key]=asset?.id||'';picker.value=''}
@@ -136,6 +142,6 @@ watch(()=>form.identityStillId,id=>{if(import.meta.client&&form.folderId)localSt
watch(hideOutput,value=>{if(import.meta.client)localStorage.setItem('studio2-hide-output',String(value))}) watch(hideOutput,value=>{if(import.meta.client)localStorage.setItem('studio2-hide-output',String(value))})
watch(()=>form.hideInput,value=>{if(import.meta.client)localStorage.setItem('studio2-hide-input',String(value))}) watch(()=>form.hideInput,value=>{if(import.meta.client)localStorage.setItem('studio2-hide-input',String(value))})
let timer:ReturnType<typeof setTimeout>,clock:ReturnType<typeof setInterval>,disposed=false let timer:ReturnType<typeof setTimeout>,clock:ReturnType<typeof setInterval>,disposed=false
onMounted(async()=>{form.settings.seed=randomSeed();form.hideInput=xaigen && localStorage.getItem('studio2-hide-input')==='true';hideOutput.value=xaigen && localStorage.getItem('studio2-hide-output')==='true';clock=setInterval(()=>{now.value=Date.now()},1000);void loadLoras();library.value=await $fetch('/api/studio-2/library');form.folderId=library.value.folders[0]?.id||'';const id=String(route.query.still||route.query.clip||'');const asset=[...library.value.stills,...library.value.clips].find((a:any)=>a.id===id);if(asset){selected.value={...asset,kind:route.query.clip?'clip':'still'};if(route.query.use==='1')restore(selected.value)};const poll=async()=>{try{await refresh();const health=await $fetch<any>('/api/health');healthState.value=health}catch(e:any){healthState.value=null;error.value=e.data?.statusMessage||e.message}if(!disposed)timer=setTimeout(poll,3000)};void poll()}) onMounted(async()=>{let storage;try{storage=localStorage}catch{};loraMemory=createLoraMemory(xaigen,storage);loraMemory.sync(form);form.settings.seed=randomSeed();form.hideInput=xaigen && localStorage.getItem('studio2-hide-input')==='true';hideOutput.value=xaigen && localStorage.getItem('studio2-hide-output')==='true';clock=setInterval(()=>{now.value=Date.now()},1000);void loadLoras();library.value=await $fetch('/api/studio-2/library');form.folderId=library.value.folders[0]?.id||'';const id=String(route.query.still||route.query.clip||'');const asset=[...library.value.stills,...library.value.clips].find((a:any)=>a.id===id);if(asset){selected.value={...asset,kind:route.query.clip?'clip':'still'};if(route.query.use==='1')restore(selected.value)};const poll=async()=>{try{await refresh();const health=await $fetch<any>('/api/health');healthState.value=health}catch(e:any){healthState.value=null;error.value=e.data?.statusMessage||e.message}if(!disposed)timer=setTimeout(poll,3000)};void poll()})
onBeforeUnmount(()=>{disposed=true;clearTimeout(timer);clearInterval(clock)}) onBeforeUnmount(()=>{disposed=true;clearTimeout(timer);clearInterval(clock)})
</script> </script>
+2
View File
@@ -1,3 +1,4 @@
import {filterLoraStackForUse} from '~/utils/loras';
import { validateRequest } from '~/shared/studio2/contracts.mjs'; import { validateRequest } from '~/shared/studio2/contracts.mjs';
import { resolveRequestSize } from '../../utils/studio2/size'; import { resolveRequestSize } from '../../utils/studio2/size';
import { parsePostedLoraStack, assertImageV2LoraStack } from '../../utils/loras'; import { parsePostedLoraStack, assertImageV2LoraStack } from '../../utils/loras';
@@ -16,6 +17,7 @@ export default defineEventHandler(async (event) => {
if (video && request.engine !== 'minimax') if (video && request.engine !== 'minimax')
throw createError({ statusCode: 400, statusMessage: 'Use the existing xAIGen studio for LTX. Studio 2 video currently supports 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 = parsePostedLoraStack(request.settings.loraStack, video ? 'video' : 'image');
request.settings.loraStack = filterLoraStackForUse(request.settings.loraStack, request.engine, useRuntimeConfig().public.studio === 'xaigen');
if (!video) if (!video)
assertImageV2LoraStack(request.settings.loraStack, request.engine); assertImageV2LoraStack(request.settings.loraStack, request.engine);
assertFolderAccess(event, request.folderId); assertFolderAccess(event, request.folderId);
+17 -14
View File
@@ -1,3 +1,4 @@
import {restoreLoras,loraUse} from './lora-memory.mjs'
import { restoreInput } from './contracts.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 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}) { 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)) 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)} 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 if (!clip?.id) return form
const saved = clip.studio2 || clip.job || {} const saved = clip.studio2 || clip.job || {}
const settings = saved.settings || clip.settings || {} 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' next.engine = xaigen && (saved.engine || clip.engine)==='ltx' ? 'ltx' : 'minimax'
if (saved.identityStillId) next.identityStillId = saved.identityStillId if (saved.identityStillId) next.identityStillId = saved.identityStillId
for (const key of ['lockFace','lockOutfit']) if (typeof saved[key] === 'boolean') next[key] = saved[key] 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') { 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 const seed=item.studio2?.settings?.seed ?? item.settings?.seed ?? item.seed
if (seed != null) next.settings.seed=seed if (seed != null) next.settings.seed=seed
next.settings.seedMode='random' next.settings.seedMode='random'
@@ -34,20 +35,21 @@ export function restoreAsset(form, item) {
const restored = restoreInput(item) const restored = restoreInput(item)
const next = {...form,...restored,settings:{...form.settings,...restored.settings}} const next = {...form,...restored,settings:{...form.settings,...restored.settings}}
next.settings.seedMode='random' 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.imageAId=item.id
next.startClipId='' next.startClipId=''
next.settings.aspect=item.studio2?.settings?.aspect || item.settings?.aspect || 'auto' 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) { export function extendSelected(form, clip, xaigen = false, memory) {
const next = { ...form, mode: 'extend', engine: xaigen && form.engine === 'ltx' ? 'ltx' : 'minimax' } const next = { ...form, settings:{...form.settings}, mode: 'extend', engine: xaigen && form.engine === 'ltx' ? 'ltx' : 'minimax' }
const target = clip?.id ? clip : null 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 // 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. // (locks, CFG, etc.) by re-copying the clip's saved settings over them.
if (form.startClipId === target.id) return next if (form.startClipId === target.id) return restoreLoras(next,loraUse(form)===loraUse(next)?form.settings?.loraStack:[],xaigen,memory)
try { return bindClip(next, target, xaigen) } try { return bindClip(next, target, xaigen, memory) }
catch { return { ...next, startClipId: target.id } } catch { return { ...next, startClipId: target.id } }
} }
export function changeSource(form) { export function changeSource(form) {
@@ -56,11 +58,12 @@ export function changeSource(form) {
export function useAsHero(form, item) { return {...form,identityStillId:item.id} } export function useAsHero(form, item) { return {...form,identityStillId:item.id} }
export function useAsSource(form, item) { return {...form,imageAId: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'}} 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.mode=restored.mode==='iterate'?'edit':restored.mode || 'edit'
next.engine=restored.engine==='krea'?'krea':'flux'
next.imageAId=item.id next.imageAId=item.id
next.startClipId='' next.startClipId=''
return next return restoreLoras(next,item.studio2?.settings?.loraStack ?? item.settings?.loraStack,xaigen,memory)
} }
+43
View File
@@ -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
}
+1 -1
View File
@@ -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',()=>{ test('source/hero actions are independent and full input restores structured settings',()=>{
const form={identityStillId:'hero',imageAId:'source',promptSections:{action:'old'},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') 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') 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',()=>{ test('image chips compile positive and negative independently; video chips do not leak',()=>{
+1 -1
View File
@@ -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',()=>{ 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 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) 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) 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}} const overridden={...bound,lockOutfit:false,settings:{...bound.settings,cfg:2.5}}
+1 -1
View File
@@ -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',()=>{ 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 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) 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.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') assert.equal(changeSource({...next,imageAId:'new-still'}).startClipId,'clip');assert.equal(changeSource(next).mode,'extend')
+69
View File
@@ -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()
})
+19 -13
View File
@@ -32,7 +32,7 @@ export function loraIdentityKey(name: string) {
export function isXaigenOnlyLora(name: string) { export function isXaigenOnlyLora(name: string) {
const base = loraBasename(name) const base = loraBasename(name)
// Include the common `xiagen-` typo so those files stay off AIGen too. // Include the common `xiagen-` typo so those files stay off AIGen too.
return /^(xai|xia)gen/i.test(base) || /klein_snofs|snofs_krea/i.test(base) return String(name || '').replace(/\\/g, '/').split('/').some(part => /^(xai|xia)gen/i.test(part)) || /klein_snofs|snofs_krea/i.test(base)
} }
/** Pick the real Comfy filename: exact path, then same basename ignoring `xaigen-`, then suffix/contains. */ /** Pick the real Comfy filename: exact path, then same basename ignoring `xaigen-`, then suffix/contains. */
@@ -293,22 +293,28 @@ export function isSystemGraphLora(name: string) {
return false return false
} }
/** Filename hints. `null` means the LoRA can appear in any picker. */ /** Engine tokens in paths or basenames; unlabelled files are not picker options. */
export function inferLoraUse(name: string): LoraUse[] | null { export function inferLoraUse(name: string): LoraUse[] | null {
const imageEngine = imageLoraEngineOf(name) const value = String(name || '').toLowerCase()
if (imageEngine) return [imageEngine] const uses: LoraUse[] = []
const id = loraIdentityKey(name) if (/minimax/.test(value)) uses.push('minimax')
if (/(^|[_-])ltx([_-]|$)|ltx-2/i.test(id)) return ['ltx'] if (/ltx/.test(value)) uses.push('ltx')
if (/minimax|h3_fl2v|fl2v_turbo/i.test(id)) return ['minimax'] if (/klein|flux/.test(value)) uses.push('flux')
return null if (/krea/.test(value)) uses.push('krea')
return uses.length ? uses : null
} }
export function loraMatchesUse(name: string, use: LoraUse) { export function loraMatchesUse(name: string, use: LoraUse) {
if (isSystemGraphLora(name)) return false return !isSystemGraphLora(name) && !!inferLoraUse(name)?.includes(use)
if (use === 'flux' || use === 'krea') return imageLoraEngineOf(name) === use }
const inferred = inferLoraUse(name)
if (!inferred) return true /** Saved stacks may contain unlabelled adapters; never carry a known wrong engine. */
return inferred.includes(use) export function filterLoraStackForUse(stack: unknown, use: LoraUse, xaigen: boolean) {
if (use === 'ltx' && !xaigen) return []
return filterLoraStackForStudio(stack, xaigen).filter(item => {
const uses = inferLoraUse(item.name)
return !isSystemGraphLora(item.name) && (!uses || uses.includes(use))
})
} }
export function filterLorasForUse(names: string[], use: LoraUse) { export function filterLorasForUse(names: string[], use: LoraUse) {