Read production LoRA names in Studio 2
This commit is contained in:
@@ -48,7 +48,7 @@
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -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.
|
||||
@@ -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)))};
|
||||
});
|
||||
|
||||
@@ -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)};
|
||||
});
|
||||
|
||||
@@ -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:])
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
Reference in New Issue
Block a user