From 064f1db93358d5bbcc5379cab34646a5c02c8ae9 Mon Sep 17 00:00:00 2001 From: Towsty Date: Sat, 12 Sep 2026 21:05:11 -0500 Subject: [PATCH] Read production LoRA names in Studio 2 --- components/studio2/Bench.vue | 2 +- docs/studio2-lora-catalog.md | 11 +++++++++++ server/api/studio-2/library.get.ts | 4 +++- server/api/studio-2/loras.get.ts | 4 ++-- server/utils/studio2/export-lora-catalog.py | 22 +++++++++++++++++++++ server/utils/studio2/lora-catalog.ts | 15 ++++++++++++++ tests/studio2-lora-catalog.test.mjs | 19 ++++++++++++++++++ 7 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 docs/studio2-lora-catalog.md create mode 100644 server/utils/studio2/export-lora-catalog.py create mode 100644 server/utils/studio2/lora-catalog.ts create mode 100644 tests/studio2-lora-catalog.test.mjs diff --git a/components/studio2/Bench.vue b/components/studio2/Bench.vue index aee76d9..7589d9d 100644 --- a/components/studio2/Bench.vue +++ b/components/studio2/Bench.vue @@ -48,7 +48,7 @@
diff --git a/docs/studio2-lora-catalog.md b/docs/studio2-lora-catalog.md new file mode 100644 index 0000000..a40ae55 --- /dev/null +++ b/docs/studio2-lora-catalog.md @@ -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. diff --git a/server/api/studio-2/library.get.ts b/server/api/studio-2/library.get.ts index 607ceb3..81170e8 100644 --- a/server/api/studio-2/library.get.ts +++ b/server/api/studio-2/library.get.ts @@ -1,7 +1,9 @@ import { existsSync } from 'node:fs'; +import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog'; import { publicLibrary, stillPath, clipVideoPath } from '../../utils/library'; export default defineEventHandler(event => { const { owner } = assertLibraryOwner(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)))}; }); diff --git a/server/api/studio-2/loras.get.ts b/server/api/studio-2/loras.get.ts index 4cb1871..8c63ea5 100644 --- a/server/api/studio-2/loras.get.ts +++ b/server/api/studio-2/loras.get.ts @@ -3,7 +3,7 @@ import { join } from 'node:path'; import { cachedLoras } from '~/shared/studio2/lora-cache.mjs'; import { listStudioLoras } from '../../utils/loras'; import { studio2Root } from '../../utils/studio2/store'; -import { getLoraAliases, getLoraTriggers } from '../../utils/library'; +import { studio2LoraCatalog } from '../../utils/studio2/lora-catalog'; export default defineEventHandler(async event => { const {owner}=assertLibraryOwner(event),path=join(studio2Root(),`loras-${owner}.cache`); 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);}, live:()=>listStudioLoras({fresh:true}),fresh:getQuery(event).fresh==='1' }); - return {...listed,aliases:getLoraAliases(owner),triggers:getLoraTriggers(owner)}; + return {...listed,...studio2LoraCatalog(owner)}; }); diff --git a/server/utils/studio2/export-lora-catalog.py b/server/utils/studio2/export-lora-catalog.py new file mode 100644 index 0000000..b89aa94 --- /dev/null +++ b/server/utils/studio2/export-lora-catalog.py @@ -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:]) diff --git a/server/utils/studio2/lora-catalog.ts b/server/utils/studio2/lora-catalog.ts new file mode 100644 index 0000000..455aefc --- /dev/null +++ b/server/utils/studio2/lora-catalog.ts @@ -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; } +} diff --git a/tests/studio2-lora-catalog.test.mjs b/tests/studio2-lora-catalog.test.mjs new file mode 100644 index 0000000..0d37c9b --- /dev/null +++ b/tests/studio2-lora-catalog.test.mjs @@ -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') +})