Let Image v2 pick a LoRA or LoRA chain.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-29 17:28:32 -05:00
co-authored by Cursor
parent 852b07bb97
commit cdc69d2bd6
8 changed files with 159 additions and 13 deletions
+102
View File
@@ -4,7 +4,9 @@ import {
XAIGEN_LORA_MESSAGE,
MINIMAX_TURBO_LORA,
filterLorasForStudio,
isKleinImageLora,
isXaigenOnlyLora,
kleinLoraOnKreaMessage,
loraIdentityKey,
normalizeLoraName,
normalizeLoraStack,
@@ -378,6 +380,106 @@ export function applyOptionalLoraLoaders(graph: WorkflowGraph, stack?: unknown,
}
}
function linkRef(value: unknown): [string, number] | null {
return Array.isArray(value) && typeof value[0] === 'string' && Number.isFinite(Number(value[1]))
? [value[0], Number(value[1])]
: null
}
function currentModelClipFeeds(graph: WorkflowGraph) {
const prompt = Object.values(graph).find(node => node.class_type === 'CLIPTextEncode' && linkRef(node.inputs.clip))
const modelNode = Object.values(graph).find(node => MODEL_FEED_CLASSES.has(node.class_type) && linkRef(node.inputs.model))
const model = linkRef(modelNode?.inputs.model)
const clip = linkRef(prompt?.inputs.clip)
return {
model: model && graph[model[0]] ? model : null,
clip: clip && graph[clip[0]] ? clip : null
}
}
export function assertImageV2LoraStack(stack: unknown, engine: 'flux' | 'krea') {
const items = normalizeLoraStack(stack)
if (engine !== 'krea') return items
const klein = items.find(item => isKleinImageLora(item.name))
if (klein) {
throw createError({ statusCode: 400, statusMessage: kleinLoraOnKreaMessage(klein.name) })
}
return items
}
/**
* Append a user LoRA chain after the current model/CLIP feed.
* Flux v2 already has Concept/Consistency loaders; this stacks after those.
* Empty stack is a no-op. Klein LoRAs on a Krea graph throw.
*/
export function applyImageV2UserLoras(
graph: WorkflowGraph,
stack?: unknown,
engine: 'flux' | 'krea' = 'flux'
) {
const xaigen = isXaigenStudio()
const items = normalizeLoraStack(stack)
.filter(item => xaigen || !isXaigenOnlyLora(item.name))
.map(item => ({
...item,
name: resolveUserLoraName(item.name, 'image')
}))
.filter(item => item.name && (xaigen || !isXaigenOnlyLora(item.name)) && !alreadyHasLora(graph, item.name))
if (!items.length) return [] as LoraStackItem[]
if (engine === 'krea') {
const klein = items.find(item => isKleinImageLora(item.name))
if (klein) {
throw createError({ statusCode: 400, statusMessage: kleinLoraOnKreaMessage(klein.name) })
}
}
const feeds = currentModelClipFeeds(graph)
const modelFrom = feeds.model || (graph['4'] ? ['4', 0] as [string, number] : null)
const clipFrom = feeds.clip || (graph['5'] ? ['5', 0] as [string, number] : null)
if (!modelFrom || !clipFrom) {
throw createError({ statusCode: 500, statusMessage: 'v2 graph has no model/CLIP feed for user LoRAs.' })
}
const firstId = 'user:lora:0'
graph[firstId] = {
class_type: LORA_LOADER,
inputs: {
lora_name: items[0].name,
strength_model: items[0].strengthModel,
strength_clip: items[0].strengthClip,
model: modelFrom,
clip: clipFrom
},
_meta: { title: items.length === 1 ? 'User LoRA' : 'User LoRA 1' }
}
for (const [id, node] of Object.entries(graph)) {
if (id === firstId) continue
for (const [key, value] of Object.entries(node.inputs)) {
if (!Array.isArray(value)) continue
if (value[0] === modelFrom[0] && value[1] === modelFrom[1]) node.inputs[key] = [firstId, 0]
else if (value[0] === clipFrom[0] && value[1] === clipFrom[1]) node.inputs[key] = [firstId, 1]
}
}
let sourceId = firstId
for (const [index, item] of items.slice(1).entries()) {
const nodeId = `user:lora:${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
}
return items
}
export function applyUserLoraToGraph(graph: WorkflowGraph, stack?: unknown) {
const xaigen = isXaigenStudio()
const items = normalizeLoraStack(stack)