Refresh Studio 2 LoRAs from live Comfy object_info on fresh=1.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+57
-21
@@ -44,6 +44,7 @@ type LoraCache = {
|
||||
let cache: LoraCache | null = null
|
||||
let inflight: Promise<LoraCache> | null = null
|
||||
let inflightFresh = false
|
||||
let loadSeq = 0
|
||||
|
||||
function uniqueNames(values: unknown[]) {
|
||||
const seen = new Set<string>()
|
||||
@@ -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<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') {
|
||||
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<string, unknown>)
|
||||
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<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) }
|
||||
return collectObjectInfoLoras(all as Record<string, unknown>)
|
||||
}
|
||||
|
||||
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<LoraCac
|
||||
const fresh = options.fresh === true
|
||||
const now = Date.now()
|
||||
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
|
||||
const seq = ++loadSeq
|
||||
inflightFresh = fresh
|
||||
inflight = (async () => {
|
||||
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<LoraCac
|
||||
}
|
||||
// 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.
|
||||
// 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)) {
|
||||
return cache
|
||||
}
|
||||
// Superseded non-fresh must not overwrite a newer fresh result.
|
||||
if (seq !== loadSeq) return cache || next
|
||||
cache = next
|
||||
return next
|
||||
})().finally(() => {
|
||||
inflight = null
|
||||
inflightFresh = false
|
||||
if (seq === loadSeq) {
|
||||
inflight = null
|
||||
inflightFresh = false
|
||||
}
|
||||
})
|
||||
return inflight
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user