|
|
|
@@ -2,15 +2,18 @@ import { comfyConfigured, comfyFetch, getComfyHost } from '~/server/utils/comfy'
|
|
|
|
|
import { getBeastImageHost, imageComfyFetch, sameImageHost } from '~/server/utils/imageComfy'
|
|
|
|
|
import {
|
|
|
|
|
XAIGEN_LORA_MESSAGE,
|
|
|
|
|
MINIMAX_TURBO_LORA,
|
|
|
|
|
filterLorasForStudio,
|
|
|
|
|
isXaigenOnlyLora,
|
|
|
|
|
loraIdentityKey,
|
|
|
|
|
normalizeLoraName,
|
|
|
|
|
normalizeLoraStack,
|
|
|
|
|
persistLoraFields,
|
|
|
|
|
resolveComfyLoraName,
|
|
|
|
|
type LoraKind,
|
|
|
|
|
type LoraStackItem
|
|
|
|
|
} from '~/utils/loras'
|
|
|
|
|
import { isXaigenStudio } from '~/utils/videoModels'
|
|
|
|
|
import { isXaigenStudio, LTX_DISTILLED_LORA } from '~/utils/videoModels'
|
|
|
|
|
|
|
|
|
|
type WorkflowNode = { class_type: string; inputs: Record<string, unknown>; _meta?: { title?: string } }
|
|
|
|
|
type WorkflowGraph = Record<string, WorkflowNode>
|
|
|
|
@@ -28,6 +31,7 @@ const OBJECT_INFO_NODES = [
|
|
|
|
|
]
|
|
|
|
|
const MODEL_FEED_CLASSES = new Set(['BasicGuider', 'CFGGuider', 'KSampler', 'KSamplerAdvanced'])
|
|
|
|
|
const CACHE_MS = 20_000
|
|
|
|
|
const SYSTEM_LORA_PREFERRED = [MINIMAX_TURBO_LORA, LTX_DISTILLED_LORA]
|
|
|
|
|
|
|
|
|
|
type LoraCache = {
|
|
|
|
|
at: number
|
|
|
|
@@ -150,11 +154,10 @@ async function loadLoraCache(): Promise<LoraCache> {
|
|
|
|
|
const image = imageHost && !same
|
|
|
|
|
? await discoverFromHost('image').catch(() => ({ image: [] as string[], video: [] as string[] }))
|
|
|
|
|
: video
|
|
|
|
|
const xaigen = isXaigenStudio()
|
|
|
|
|
const next: LoraCache = {
|
|
|
|
|
at: Date.now(),
|
|
|
|
|
image: filterLorasForStudio(uniqueNames([...image.image, ...video.image]), xaigen),
|
|
|
|
|
video: filterLorasForStudio(uniqueNames([...video.video, ...image.video]), xaigen)
|
|
|
|
|
image: uniqueNames([...image.image, ...video.image]),
|
|
|
|
|
video: uniqueNames([...video.video, ...image.video])
|
|
|
|
|
}
|
|
|
|
|
cache = next
|
|
|
|
|
return next
|
|
|
|
@@ -166,21 +169,73 @@ async function loadLoraCache(): Promise<LoraCache> {
|
|
|
|
|
|
|
|
|
|
export async function listStudioLoras() {
|
|
|
|
|
try {
|
|
|
|
|
return await loadLoraCache()
|
|
|
|
|
const listed = await loadLoraCache()
|
|
|
|
|
const xaigen = isXaigenStudio()
|
|
|
|
|
return {
|
|
|
|
|
at: listed.at,
|
|
|
|
|
image: filterLorasForStudio(listed.image, xaigen),
|
|
|
|
|
video: filterLorasForStudio(listed.video, xaigen)
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
return { at: 0, image: [] as string[], video: [] as string[] }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Unfiltered Comfy filenames for graph system LoRAs (MiniMax turbo, LTX distilled). Reuses the listing cache. */
|
|
|
|
|
export function cachedComfyLoraNames(kind?: LoraKind) {
|
|
|
|
|
if (!cache) return [] as string[]
|
|
|
|
|
if (kind === 'image') return uniqueNames([...cache.image, ...cache.video])
|
|
|
|
|
if (kind === 'video') return uniqueNames([...cache.video, ...cache.image])
|
|
|
|
|
return uniqueNames([...cache.image, ...cache.video])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function ensureComfyLoraNames(kind?: LoraKind) {
|
|
|
|
|
try {
|
|
|
|
|
await loadLoraCache()
|
|
|
|
|
} catch {
|
|
|
|
|
/* keep whatever was cached */
|
|
|
|
|
}
|
|
|
|
|
return cachedComfyLoraNames(kind)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function resolveGraphLoraNames(graph: WorkflowGraph, kind?: LoraKind) {
|
|
|
|
|
const names = cachedComfyLoraNames(kind)
|
|
|
|
|
for (const node of Object.values(graph)) {
|
|
|
|
|
if (node.class_type !== LORA_LOADER && node.class_type !== LORA_MODEL_ONLY) continue
|
|
|
|
|
const current = String(node.inputs.lora_name || '').trim()
|
|
|
|
|
if (!current) continue
|
|
|
|
|
const preferred = SYSTEM_LORA_PREFERRED.find(item => loraIdentityKey(item) === loraIdentityKey(current)) || current
|
|
|
|
|
node.inputs.lora_name = names.length ? resolveComfyLoraName(preferred, names) : preferred
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function allowedLoraNames(kind?: LoraKind) {
|
|
|
|
|
return filterLorasForStudio(cachedComfyLoraNames(kind), isXaigenStudio())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveUserLoraName(name: string, kind?: LoraKind) {
|
|
|
|
|
const allowed = allowedLoraNames(kind)
|
|
|
|
|
if (!allowed.length) return name
|
|
|
|
|
return resolveComfyLoraName(name, allowed)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function assertLoraAllowed(raw: unknown, kind: LoraKind) {
|
|
|
|
|
const name = normalizeLoraName(raw)
|
|
|
|
|
if (!name) return ''
|
|
|
|
|
if (isXaigenOnlyLora(name) && !isXaigenStudio()) {
|
|
|
|
|
throw createError({ statusCode: 400, statusMessage: XAIGEN_LORA_MESSAGE })
|
|
|
|
|
}
|
|
|
|
|
const known = cache?.[kind]
|
|
|
|
|
if (known?.length && !known.includes(name) && !known.some(item => item.replace(/\\/g, '/') === name)) {
|
|
|
|
|
throw createError({ statusCode: 400, statusMessage: `Unknown ${kind} LoRA` })
|
|
|
|
|
const allowed = allowedLoraNames(kind)
|
|
|
|
|
if (allowed.length) {
|
|
|
|
|
const identity = loraIdentityKey(name)
|
|
|
|
|
const listed = allowed.some((item) => {
|
|
|
|
|
const path = item.replace(/\\/g, '/')
|
|
|
|
|
return path === name || path.toLowerCase() === name.toLowerCase() || loraIdentityKey(item) === identity
|
|
|
|
|
})
|
|
|
|
|
if (!listed) {
|
|
|
|
|
throw createError({ statusCode: 400, statusMessage: `Unknown ${kind} LoRA` })
|
|
|
|
|
}
|
|
|
|
|
return resolveUserLoraName(name, kind)
|
|
|
|
|
}
|
|
|
|
|
return name
|
|
|
|
|
}
|
|
|
|
@@ -225,10 +280,11 @@ function linkSource(value: unknown): string | null {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function alreadyHasLora(graph: WorkflowGraph, name: string) {
|
|
|
|
|
const wanted = name.replace(/\\/g, '/').toLowerCase()
|
|
|
|
|
const wanted = loraIdentityKey(name)
|
|
|
|
|
if (!wanted) return false
|
|
|
|
|
return Object.values(graph).some((node) => {
|
|
|
|
|
if (node.class_type !== LORA_LOADER && node.class_type !== LORA_MODEL_ONLY) return false
|
|
|
|
|
return String(node.inputs.lora_name || '').replace(/\\/g, '/').toLowerCase() === wanted
|
|
|
|
|
return loraIdentityKey(String(node.inputs.lora_name || '')) === wanted
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -260,8 +316,74 @@ function findModelFeed(graph: WorkflowGraph) {
|
|
|
|
|
return ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function bypassLoraNode(graph: WorkflowGraph, id: string) {
|
|
|
|
|
const node = graph[id]
|
|
|
|
|
if (!node) return
|
|
|
|
|
const model = Array.isArray(node.inputs.model) ? node.inputs.model : null
|
|
|
|
|
const clip = Array.isArray(node.inputs.clip) ? node.inputs.clip : null
|
|
|
|
|
for (const [otherId, other] of Object.entries(graph)) {
|
|
|
|
|
if (otherId === id) continue
|
|
|
|
|
for (const [key, value] of Object.entries(other.inputs)) {
|
|
|
|
|
const src = linkSource(value)
|
|
|
|
|
if (src !== id || !Array.isArray(value)) continue
|
|
|
|
|
const slot = value[1]
|
|
|
|
|
if (slot === 0 && model) other.inputs[key] = [model[0], model[1]]
|
|
|
|
|
else if (slot === 1 && clip) other.inputs[key] = [clip[0], clip[1]]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
delete graph[id]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Optional Klein-style LoraLoader: user stack only. Empty stack bypasses model/CLIP around the loader. */
|
|
|
|
|
export function applyOptionalLoraLoaders(graph: WorkflowGraph, stack?: unknown, kind: LoraKind = 'image') {
|
|
|
|
|
const loaders = Object.entries(graph).filter(([, node]) => node.class_type === LORA_LOADER)
|
|
|
|
|
const xaigen = isXaigenStudio()
|
|
|
|
|
const items = normalizeLoraStack(stack)
|
|
|
|
|
.filter(item => xaigen || !isXaigenOnlyLora(item.name))
|
|
|
|
|
.map(item => ({
|
|
|
|
|
...item,
|
|
|
|
|
name: resolveUserLoraName(item.name, kind)
|
|
|
|
|
}))
|
|
|
|
|
.filter(item => item.name && (xaigen || !isXaigenOnlyLora(item.name)))
|
|
|
|
|
if (!loaders.length) {
|
|
|
|
|
applyUserLoraToGraph(graph, items)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (!items.length) {
|
|
|
|
|
for (const [id] of loaders) bypassLoraNode(graph, id)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
for (const [id] of loaders) {
|
|
|
|
|
const first = items[0]
|
|
|
|
|
graph[id].inputs.lora_name = first.name
|
|
|
|
|
graph[id].inputs.strength_model = first.strengthModel
|
|
|
|
|
graph[id].inputs.strength_clip = first.strengthClip
|
|
|
|
|
graph[id]._meta = { title: items.length === 1 ? 'User LoRA' : 'User LoRA 1' }
|
|
|
|
|
let sourceId = id
|
|
|
|
|
for (const [index, item] of items.slice(1).entries()) {
|
|
|
|
|
const nodeId = `user:lora:${id}:${index + 1}`
|
|
|
|
|
injectAfter(graph, sourceId, nodeId, {
|
|
|
|
|
class_type: LORA_LOADER,
|
|
|
|
|
inputs: {
|
|
|
|
|
lora_name: item.name,
|
|
|
|
|
strength_model: item.strengthModel,
|
|
|
|
|
strength_clip: item.strengthClip,
|
|
|
|
|
model: [sourceId, 0],
|
|
|
|
|
clip: [sourceId, 1]
|
|
|
|
|
},
|
|
|
|
|
_meta: { title: `User LoRA ${index + 2}` }
|
|
|
|
|
})
|
|
|
|
|
sourceId = nodeId
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function applyUserLoraToGraph(graph: WorkflowGraph, stack?: unknown) {
|
|
|
|
|
const items = normalizeLoraStack(stack).filter(item => !alreadyHasLora(graph, item.name))
|
|
|
|
|
const xaigen = isXaigenStudio()
|
|
|
|
|
const items = normalizeLoraStack(stack)
|
|
|
|
|
.filter(item => xaigen || !isXaigenOnlyLora(item.name))
|
|
|
|
|
.map(item => ({ ...item, name: resolveUserLoraName(item.name) }))
|
|
|
|
|
.filter(item => item.name && (xaigen || !isXaigenOnlyLora(item.name)) && !alreadyHasLora(graph, item.name))
|
|
|
|
|
if (!items.length) return
|
|
|
|
|
|
|
|
|
|
const clipLoaders = Object.entries(graph).filter(([, node]) => node.class_type === LORA_LOADER)
|
|
|
|
|