Load only the LoRAs the user picked on Klein; do not require snofs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-28 16:21:32 -05:00
co-authored by Cursor
parent a2f728fe2e
commit f51c3772e5
9 changed files with 192 additions and 25 deletions
+2
View File
@@ -3,6 +3,7 @@ import { ensureComfyReady } from '~/server/utils/comfyLifecycle'
import { getComfyHost, uploadImage, queuePrompt, purgeComfyArtifacts } from '~/server/utils/comfy'
import { withImageComfyHost, waitForImageEdit, downloadEditedImage } from '~/server/utils/imageComfy'
import { buildEditWorkflow } from '~/server/utils/imageWorkflow'
import { ensureComfyLoraNames } from '~/server/utils/loras'
import { imageDimensions } from '~/server/utils/resolution'
import { emitChainJob } from '~/server/utils/watch'
import { saveStill, stillChainName } from '~/server/utils/library'
@@ -91,6 +92,7 @@ export async function runEdit(job: Job, params: EditRunParams) {
: 'Queueing Flux.2 Klein edit on Beast...',
progress: 12
})
await ensureComfyLoraNames('image')
const graph = buildEditWorkflow({
prompt,
negative: params.negative,
+3 -2
View File
@@ -1,5 +1,5 @@
import template from '../assets/workflow_flux2_klein_edit.json'
import { applyUserLoraToGraph } from '~/server/utils/loras'
import { applyOptionalLoraLoaders } from '~/server/utils/loras'
import type { LoraStackItem } from '~/utils/loras'
const IMAGE1 = '76'
@@ -65,7 +65,8 @@ export function buildEditWorkflow(params: EditParams) {
if (dual ? singleNode : dualNode) delete graph[id]
}
applyUserLoraToGraph(graph, params.loraStack || params.loraName)
// User LoRAs only. Empty stack bypasses the optional LoraLoader (no snofs default).
applyOptionalLoraLoaders(graph, params.loraStack || params.loraName, 'image')
return graph
}
+133 -11
View File
@@ -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)
+2 -1
View File
@@ -21,7 +21,7 @@ import {
updateShotQueue
} from '~/server/utils/shotQueue'
import { composeShotPrompt, allowIdentityRefs, type PermanenceRef } from '~/utils/globalLocks'
import { persistLoraFields } from '~/server/utils/loras'
import { persistLoraFields, ensureComfyLoraNames } from '~/server/utils/loras'
import { readLoraStack, resolveLoraStack } from '~/utils/loras'
import type { LoraStackItem } from '~/utils/loras'
@@ -180,6 +180,7 @@ export async function queueMiniMax(
}
emitChainJob(job, { type: 'status', message: queueing, progress: 6 })
await waitForComfySocket(job, 4000)
await ensureComfyLoraNames('video')
const shotIndex = job.library?.chainIndex || 0
const composedPrompt = composeShotPrompt({
+3 -1
View File
@@ -3,7 +3,7 @@ import workflowV1 from '../assets/workflow_minimax_video.json'
import workflowV2 from '../assets/workflow_minimax_video_v2.json'
import workflowLtx from '../assets/workflow_ltx_video.json'
import { buildIdentityPrompt } from '~/utils/identityPrompt'
import { applyUserLoraToGraph } from '~/server/utils/loras'
import { applyUserLoraToGraph, resolveGraphLoraNames } from '~/server/utils/loras'
import type { LoraStackItem } from '~/utils/loras'
import {
isLtxWorkflow,
@@ -225,6 +225,7 @@ export function buildWorkflow(params: GenerateParams) {
if (graph['105:104']?.inputs) delete graph['105:104'].inputs.audio_vae
}
resolveGraphLoraNames(graph, 'video')
applyUserLoraToGraph(graph, params.loraStack || params.loraName)
return graph
}
@@ -298,6 +299,7 @@ function buildLtxWorkflow(params: GenerateParams, version: VideoWorkflowId) {
if (graph['12']?.inputs) graph['12'].inputs.latent = ['8', 0]
if (graph['14']?.inputs) graph['14'].inputs.latent_image = ['8', 0]
}
resolveGraphLoraNames(graph, 'video')
applyUserLoraToGraph(graph, params.loraStack || params.loraName)
return graph
}