From 7221b6a8cf8fbc6b868a678bd17970bf79bf9606 Mon Sep 17 00:00:00 2001 From: Towsty Date: Fri, 18 Sep 2026 12:45:48 -0500 Subject: [PATCH] Refresh Studio 2 LoRAs from live Comfy object_info on fresh=1. Co-authored-by: Cursor --- server/utils/loras.ts | 78 +++++++++++++++++++++++++---------- shared/studio2/lora-cache.mjs | 8 +++- tests/studio2-bench.test.mjs | 19 +++++++++ 3 files changed, 82 insertions(+), 23 deletions(-) diff --git a/server/utils/loras.ts b/server/utils/loras.ts index 07e776d..7db99d6 100644 --- a/server/utils/loras.ts +++ b/server/utils/loras.ts @@ -44,6 +44,7 @@ type LoraCache = { let cache: LoraCache | null = null let inflight: Promise | null = null let inflightFresh = false +let loadSeq = 0 function uniqueNames(values: unknown[]) { const seen = new Set() @@ -80,14 +81,44 @@ function namesFromObjectInfoNode(info: unknown) { return uniqueNames(names) } -async function fetchJson(path: string, via: 'video' | 'image') { +async function fetchJson(path: string, via: 'video' | 'image', timeoutMs = 8000) { const res = via === 'image' - ? await imageComfyFetch(path, { signal: AbortSignal.timeout(8000) }) - : await comfyFetch(path, { signal: AbortSignal.timeout(8000) }) + ? await imageComfyFetch(path, { signal: AbortSignal.timeout(timeoutMs) }) + : await comfyFetch(path, { signal: AbortSignal.timeout(timeoutMs) }) if (!res.ok) return null return res.json().catch(() => null) } +function collectObjectInfoLoras(all: Record) { + const image: string[] = [] + const video: string[] = [] + let any = false + for (const node of OBJECT_INFO_NODES) { + const info = all[node] + if (!info || typeof info !== 'object') continue + const names = namesFromObjectInfoNode(info) + if (!names.length) continue + any = true + if (node === LORA_LOADER) image.push(...names) + else video.push(...names) + if (node !== LORA_LOADER && node !== LORA_MODEL_ONLY) { + image.push(...names) + video.push(...names) + } + } + if (any) return { image: uniqueNames(image), video: uniqueNames(video) } + + const imageAll: string[] = [] + const videoAll: string[] = [] + for (const [classType, info] of Object.entries(all)) { + if (!/lora/i.test(classType)) continue + const names = namesFromObjectInfoNode(info) + if (classType === LORA_LOADER) imageAll.push(...names) + else videoAll.push(...names) + } + return { image: uniqueNames(imageAll), video: uniqueNames(videoAll) } +} + async function fetchModelsLoras(via: 'video' | 'image') { const payload = await fetchJson('/models/loras', via) if (Array.isArray(payload)) return uniqueNames(payload) @@ -97,7 +128,14 @@ async function fetchModelsLoras(via: 'video' | 'image') { return [] as string[] } -async function fetchObjectInfoLoras(via: 'video' | 'image') { +async function fetchObjectInfoLoras(via: 'video' | 'image', fresh = false) { + // Full /object_info is Comfy's model-list refresh (UI Refresh) and lists LoraLoader / model-only / rgthree / WanVideoLoraSelect. + if (fresh) { + const all = await fetchJson('/object_info', via, 45_000) + if (all && typeof all === 'object') return collectObjectInfoLoras(all as Record) + return { image: [] as string[], video: [] as string[] } + } + const image: string[] = [] const video: string[] = [] let any = false @@ -120,22 +158,14 @@ async function fetchObjectInfoLoras(via: 'video' | 'image') { return { image: uniqueNames(image), video: uniqueNames(video) } } - const all = await fetchJson('/object_info', via) + const all = await fetchJson('/object_info', via, 45_000) if (!all || typeof all !== 'object') return { image: [] as string[], video: [] as string[] } - const imageAll: string[] = [] - const videoAll: string[] = [] - for (const [classType, info] of Object.entries(all as Record)) { - if (!/lora/i.test(classType)) continue - const names = namesFromObjectInfoNode(info) - if (classType === LORA_LOADER) imageAll.push(...names) - else videoAll.push(...names) - } - return { image: uniqueNames(imageAll), video: uniqueNames(videoAll) } + return collectObjectInfoLoras(all as Record) } -async function discoverFromHost(via: 'video' | 'image') { +async function discoverFromHost(via: 'video' | 'image', fresh = false) { + const fromInfo = await fetchObjectInfoLoras(via, fresh).catch(() => ({ image: [] as string[], video: [] as string[] })) const models = await fetchModelsLoras(via).catch(() => [] as string[]) - const fromInfo = await fetchObjectInfoLoras(via).catch(() => ({ image: [] as string[], video: [] as string[] })) const image = uniqueNames([...fromInfo.image, ...models]) const video = uniqueNames([...fromInfo.video, ...models]) if (!image.length && video.length) return { image: video, video } @@ -147,17 +177,19 @@ async function loadLoraCache(options: { fresh?: boolean } = {}): Promise { const videoHost = comfyConfigured() ? getComfyHost() : '' const imageHost = getBeastImageHost() const same = Boolean(videoHost && imageHost && sameImageHost(videoHost, imageHost)) const video = videoHost - ? await discoverFromHost('video').catch(() => ({ image: [] as string[], video: [] as string[] })) + ? await discoverFromHost('video', fresh).catch(() => ({ image: [] as string[], video: [] as string[] })) : { image: [] as string[], video: [] as string[] } const image = imageHost && !same - ? await discoverFromHost('image').catch(() => ({ image: [] as string[], video: [] as string[] })) + ? await discoverFromHost('image', fresh).catch(() => ({ image: [] as string[], video: [] as string[] })) : video const next: LoraCache = { at: Date.now(), @@ -166,15 +198,19 @@ async function loadLoraCache(options: { fresh?: boolean } = {}): Promise { - inflight = null - inflightFresh = false + if (seq === loadSeq) { + inflight = null + inflightFresh = false + } }) return inflight } diff --git a/shared/studio2/lora-cache.mjs b/shared/studio2/lora-cache.mjs index edd979b..7989ae3 100644 --- a/shared/studio2/lora-cache.mjs +++ b/shared/studio2/lora-cache.mjs @@ -3,8 +3,12 @@ export async function cachedLoras({read,write,live,fresh=false}) { if (!fresh && (cached.image?.length || cached.video?.length)) return cached; try { const listed=await live(); - const next={image:listed.image?.length?listed.image:cached.image||[],video:listed.video?.length?listed.video:cached.video||[]}; - if(next.image.length || next.video.length) write(next); + const image=Array.isArray(listed?.image)?listed.image:[]; + const video=Array.isArray(listed?.video)?listed.video:[]; + // Comfy asleep / empty: keep disk. Otherwise replace — never merge stale names that freeze adds. + if (!image.length && !video.length) return cached; + const next={image,video}; + write(next); return next; } catch { return cached; } } diff --git a/tests/studio2-bench.test.mjs b/tests/studio2-bench.test.mjs index 14eb959..8734662 100644 --- a/tests/studio2-bench.test.mjs +++ b/tests/studio2-bench.test.mjs @@ -36,6 +36,25 @@ test('cached LoRAs survive sleeping GPU and failed explicit refresh',async()=>{ const opts={read:()=>cache,write:()=>assert.fail('Do not erase cache'),live:async()=>{queried++;throw Error('Comfy asleep')}} assert.deepEqual(await cachedLoras(opts),cache);assert.equal(queried,0);assert.deepEqual(await cachedLoras({...opts,fresh:true}),cache);assert.equal(queried,1) }) +test('fresh LoRA refresh replaces disk cache so new Comfy names appear',async()=>{ + let store={image:['old.safetensors'],video:['old-vid.safetensors']} + const next=await cachedLoras({ + read:()=>store, + write:value=>{store=value}, + live:async()=>({image:['old.safetensors','new-drop.safetensors'],video:['old-vid.safetensors']}), + fresh:true + }) + assert.deepEqual(next.image,['old.safetensors','new-drop.safetensors']) + assert.deepEqual(store.image,['old.safetensors','new-drop.safetensors']) + const removed=await cachedLoras({ + read:()=>store, + write:value=>{store=value}, + live:async()=>({image:['new-drop.safetensors'],video:[]}), + fresh:true + }) + assert.deepEqual(removed,{image:['new-drop.safetensors'],video:[]}) + assert.deepEqual(store,{image:['new-drop.safetensors'],video:[]}) +}) 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')