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 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(() => {
|
||||||
inflight = null
|
if (seq === loadSeq) {
|
||||||
inflightFresh = false
|
inflight = null
|
||||||
|
inflightFresh = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return inflight
|
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;
|
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user