Read production LoRA names in Studio 2

This commit is contained in:
Towsty
2026-09-12 21:05:11 -05:00
parent fb87a50a39
commit 064f1db933
7 changed files with 73 additions and 4 deletions
+1 -1
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="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-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="{...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-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>
+11
View File
@@ -0,0 +1,11 @@
# Production LoRA names in Studio 2
LoRA keys are user library data, not Git branch files. Separate dev storage does not contain production aliases automatically.
The dev host runs `xaigen-dev-lora-catalog.path`, watching the production user's catalog directory. Its oneshot service runs `server/utils/studio2/export-lora-catalog.py SOURCE_CATALOG DESTINATION` on changes. The installed helper is `/usr/local/lib/xaigen-dev/export-lora-catalog.py`.
The destination is `studio2/shared-lora-catalog.cache` inside the existing dev library volume. It contains **only** `loraAliases` and `loraTriggers`. No production catalog, media, passwords, or tokens are mounted into dev. The exporter writes atomically and skips unchanged content.
Both Studio 2 library and LoRA endpoints read this file on each request, normalize identity keys, and prefer production values over local duplicates. Unmapped dev entries remain available. If the export is unavailable, local settings remain usable. `STUDIO2_LORA_CATALOG_PATH` optionally overrides the export path.
The existing library poll refreshes labels and triggers without waking Comfy or restarting a generation. A one-time settings API import seeds local fallback labels and triggers. Main and Studio 1 code are unchanged.
+3 -1
View File
@@ -1,7 +1,9 @@
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog';
import { publicLibrary, stillPath, clipVideoPath } from '../../utils/library'; import { publicLibrary, stillPath, clipVideoPath } from '../../utils/library';
export default defineEventHandler(event => { export default defineEventHandler(event => {
const { owner } = assertLibraryOwner(event); const { owner } = assertLibraryOwner(event);
const library = publicLibrary(event); const library = publicLibrary(event);
return {...library, stills:library.stills.filter(s=>existsSync(stillPath(owner,s.id))), clips:library.clips.filter(c=>existsSync(clipVideoPath(owner,c.id)))}; const catalog=studio2LoraCatalog(owner);
return {...library,loraAliases:catalog.aliases,loraTriggers:catalog.triggers, stills:library.stills.filter(s=>existsSync(stillPath(owner,s.id))), clips:library.clips.filter(c=>existsSync(clipVideoPath(owner,c.id)))};
}); });
+2 -2
View File
@@ -3,7 +3,7 @@ import { join } from 'node:path';
import { cachedLoras } from '~/shared/studio2/lora-cache.mjs'; import { cachedLoras } from '~/shared/studio2/lora-cache.mjs';
import { listStudioLoras } from '../../utils/loras'; import { listStudioLoras } from '../../utils/loras';
import { studio2Root } from '../../utils/studio2/store'; import { studio2Root } from '../../utils/studio2/store';
import { getLoraAliases, getLoraTriggers } from '../../utils/library'; import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog';
export default defineEventHandler(async event => { export default defineEventHandler(async event => {
const {owner}=assertLibraryOwner(event),path=join(studio2Root(),`loras-${owner}.cache`); const {owner}=assertLibraryOwner(event),path=join(studio2Root(),`loras-${owner}.cache`);
const listed=await cachedLoras({ const listed=await cachedLoras({
@@ -11,5 +11,5 @@ export default defineEventHandler(async event => {
write:value=>{writeFileSync(path+'.tmp',JSON.stringify(value));renameSync(path+'.tmp',path);}, write:value=>{writeFileSync(path+'.tmp',JSON.stringify(value));renameSync(path+'.tmp',path);},
live:()=>listStudioLoras({fresh:true}),fresh:getQuery(event).fresh==='1' live:()=>listStudioLoras({fresh:true}),fresh:getQuery(event).fresh==='1'
}); });
return {...listed,aliases:getLoraAliases(owner),triggers:getLoraTriggers(owner)}; return {...listed,...studio2LoraCatalog(owner)};
}); });
@@ -0,0 +1,22 @@
"""Export only LoRA labels/triggers; invoked by the host's catalog change watcher."""
import json
import os
import sys
from pathlib import Path
def export(source, destination):
catalog = json.loads(Path(source).read_text())
result = {key: {name: value for name, value in catalog.get(key, {}).items()
if isinstance(value, str)} for key in ('loraAliases', 'loraTriggers')}
target = Path(destination)
content = json.dumps(result, ensure_ascii=False)
if target.exists() and target.read_text() == content:
return
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix('.tmp')
temporary.write_text(content)
os.chmod(temporary, 0o644)
temporary.replace(target)
if __name__ == '__main__':
export(*sys.argv[1:])
+15
View File
@@ -0,0 +1,15 @@
import {readFileSync} from 'node:fs';
import {getLoraAliases,getLoraTriggers} from '../library';
import {loraIdentityKey} from '~/utils/loras';
import {join} from 'node:path';
import {studio2Root} from './store';
/** Optional metadata-only production export; no production media/catalog mount. */
export function studio2LoraCatalog(owner: string) {
const local={aliases:getLoraAliases(owner),triggers:getLoraTriggers(owner)};
try {
const source=JSON.parse(readFileSync(process.env.STUDIO2_LORA_CATALOG_PATH || join(studio2Root(),'shared-lora-catalog.cache'),'utf8'));
const normalize=(map: any)=>Object.fromEntries(Object.entries(map || {}).filter(([,value])=>typeof value==='string').map(([key,value])=>[loraIdentityKey(key),value]));
return {aliases:{...local.aliases,...normalize(source.loraAliases)},triggers:{...local.triggers,...normalize(source.loraTriggers)}};
} catch { return local; }
}
+19
View File
@@ -0,0 +1,19 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {readFileSync} from 'node:fs'
import ts from 'typescript'
import {loraIdentityKey,loraFriendlyName,loraTriggerFor} from '../utils/loras.ts'
test('Studio 2 uses production labels/triggers and sees later changes without a restart',()=>{
const source=readFileSync(new URL('../server/utils/studio2/lora-catalog.ts',import.meta.url),'utf8').replace(/^import .*;\r?\n/gm,'').replace('export function','function')
const js=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText
let exported={loraAliases:{'XAIgen-klein_portrait.safetensors':'My portrait'},loraTriggers:{'xaigen-klein_portrait.safetensors':'portrait token'}}
const read=new Function('readFileSync','getLoraAliases','getLoraTriggers','loraIdentityKey','join','studio2Root',js+';return studio2LoraCatalog')(()=>{if(!exported)throw new Error('missing');return JSON.stringify(exported)},()=>({'klein_portrait.safetensors':'Old local name'}),()=>({}),loraIdentityKey,(...parts)=>parts.join('/'),()=>'/dev/studio2')
let catalog=read('owner')
assert.equal(loraFriendlyName('folder/xaigen-klein_portrait.safetensors',catalog.aliases),'My portrait')
assert.equal(loraTriggerFor('klein_portrait.safetensors',catalog.triggers),'portrait token')
exported.loraAliases['XAIgen-klein_portrait.safetensors']='Updated portrait'
assert.equal(loraFriendlyName('klein_portrait.safetensors',read('owner').aliases),'Updated portrait')
exported=null
assert.equal(loraFriendlyName('klein_portrait.safetensors',read('owner').aliases),'Old local name')
})