Refresh Studio 2 LoRAs from live Comfy object_info on fresh=1.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-18 12:45:48 -05:00
co-authored by Cursor
parent 36d24feb9c
commit 7221b6a8cf
3 changed files with 82 additions and 23 deletions
+55 -19
View File
@@ -44,6 +44,7 @@ type LoraCache = {
let cache: LoraCache | null = null let cache: LoraCache | null = null
let inflight: Promise<LoraCache> | null = null let inflight: Promise<LoraCache> | null = null
let inflightFresh = false let inflightFresh = false
let loadSeq = 0
function uniqueNames(values: unknown[]) { function uniqueNames(values: unknown[]) {
const seen = new Set<string>() const seen = new Set<string>()
@@ -80,14 +81,44 @@ function namesFromObjectInfoNode(info: unknown) {
return uniqueNames(names) 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' const res = via === 'image'
? await imageComfyFetch(path, { signal: AbortSignal.timeout(8000) }) ? await imageComfyFetch(path, { signal: AbortSignal.timeout(timeoutMs) })
: await comfyFetch(path, { signal: AbortSignal.timeout(8000) }) : await comfyFetch(path, { signal: AbortSignal.timeout(timeoutMs) })
if (!res.ok) return null if (!res.ok) return null
return res.json().catch(() => null) return res.json().catch(() => null)
} }
function collectObjectInfoLoras(all: Record<string, unknown>) {
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') { async function fetchModelsLoras(via: 'video' | 'image') {
const payload = await fetchJson('/models/loras', via) const payload = await fetchJson('/models/loras', via)
if (Array.isArray(payload)) return uniqueNames(payload) if (Array.isArray(payload)) return uniqueNames(payload)
@@ -97,7 +128,14 @@ async function fetchModelsLoras(via: 'video' | 'image') {
return [] as string[] 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<string, unknown>)
return { image: [] as string[], video: [] as string[] }
}
const image: string[] = [] const image: string[] = []
const video: string[] = [] const video: string[] = []
let any = false let any = false
@@ -120,22 +158,14 @@ async function fetchObjectInfoLoras(via: 'video' | 'image') {
return { image: uniqueNames(image), video: uniqueNames(video) } 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[] } if (!all || typeof all !== 'object') return { image: [] as string[], video: [] as string[] }
const imageAll: string[] = [] return collectObjectInfoLoras(all as Record<string, unknown>)
const videoAll: string[] = []
for (const [classType, info] of Object.entries(all as Record<string, unknown>)) {
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 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 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 image = uniqueNames([...fromInfo.image, ...models])
const video = uniqueNames([...fromInfo.video, ...models]) const video = uniqueNames([...fromInfo.video, ...models])
if (!image.length && video.length) return { image: video, video } if (!image.length && video.length) return { image: video, video }
@@ -147,17 +177,19 @@ async function loadLoraCache(options: { fresh?: boolean } = {}): Promise<LoraCac
const fresh = options.fresh === true const fresh = options.fresh === true
const now = Date.now() const now = Date.now()
if (!fresh && cache && now - cache.at < CACHE_MS) return cache if (!fresh && cache && now - cache.at < CACHE_MS) return cache
// Fresh must not reuse a non-fresh inflight (stale list / frozen CACHE_MS window).
if (inflight && (!fresh || inflightFresh)) return inflight if (inflight && (!fresh || inflightFresh)) return inflight
const seq = ++loadSeq
inflightFresh = fresh inflightFresh = fresh
inflight = (async () => { inflight = (async () => {
const videoHost = comfyConfigured() ? getComfyHost() : '' const videoHost = comfyConfigured() ? getComfyHost() : ''
const imageHost = getBeastImageHost() const imageHost = getBeastImageHost()
const same = Boolean(videoHost && imageHost && sameImageHost(videoHost, imageHost)) const same = Boolean(videoHost && imageHost && sameImageHost(videoHost, imageHost))
const video = videoHost 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[] } : { image: [] as string[], video: [] as string[] }
const image = imageHost && !same 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 : video
const next: LoraCache = { const next: LoraCache = {
at: Date.now(), at: Date.now(),
@@ -166,15 +198,19 @@ async function loadLoraCache(options: { fresh?: boolean } = {}): Promise<LoraCac
} }
// Comfy asleep / unreachable returns empty. Keep the last good list so the picker // Comfy asleep / unreachable returns empty. Keep the last good list so the picker
// does not vanish — but do NOT refresh `at`, or a blip freezes new folder drops out. // does not vanish — but do NOT refresh `at`, or a blip freezes new folder drops out.
// Never merge a shorter fresh list back into the old one: deletes must disappear. // Never merge a shorter live list into the old one: deletes must disappear; adds must appear.
if (!next.image.length && !next.video.length && cache && (cache.image.length || cache.video.length)) { if (!next.image.length && !next.video.length && cache && (cache.image.length || cache.video.length)) {
return cache return cache
} }
// Superseded non-fresh must not overwrite a newer fresh result.
if (seq !== loadSeq) return cache || next
cache = next cache = next
return next return next
})().finally(() => { })().finally(() => {
if (seq === loadSeq) {
inflight = null inflight = null
inflightFresh = false inflightFresh = false
}
}) })
return inflight return inflight
} }
+6 -2
View File
@@ -3,8 +3,12 @@ export async function cachedLoras({read,write,live,fresh=false}) {
if (!fresh && (cached.image?.length || cached.video?.length)) return cached; if (!fresh && (cached.image?.length || cached.video?.length)) return cached;
try { try {
const listed=await live(); const listed=await live();
const next={image:listed.image?.length?listed.image:cached.image||[],video:listed.video?.length?listed.video:cached.video||[]}; const image=Array.isArray(listed?.image)?listed.image:[];
if(next.image.length || next.video.length) write(next); 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; return next;
} catch { return cached; } } catch { return cached; }
} }
+19
View File
@@ -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')}} 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) 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',()=>{ 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')