From 40195aa842f2db0857787d4b2e95c72c2966f95b Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <1027066412@qq.com> Date: Wed, 23 Sep 2026 08:07:14 +0000 Subject: [PATCH] fix(recipes,materials): keep all-in-one support without guessing or crossing roles Keeps the planned All-in-One loader and cross-node prompt features but removes the regressions they introduced: - model references: one adapter table for verified layouts (easy a1111Loader, easy fullLoader, Efficient Loader) shared by recipe_identity.js and recipe_schema.py; native loaders keep their stored labels; no more guessing categories from file names (a "...vae.safetensors" checkpoint was a VAE); "None"/"Baked VAE" are never references - recipe summary sync follows the extraction rules: only checkpoint/UNet loaders or ckpt_name/unet_name set the base model (not an upscaler's model_name), only sampler-like nodes set seed/steps/cfg, and LoRA edits update the entry of the edited loader instead of falling back to loras[0] - one LoRA entry per loader again (deduplication shifted entries) - prompt injection never writes negative text into a positive slot or the reverse; the block chooser follows the same rule - material cards only accept cross-node drops that carry prompts; parameter blocks stay same-type - drop unused imports and the unused modelSpecs alias Verified: regression cases added to tests/recipe_all_in_one_compatibility.mjs and tests/material_prompt_cross_node.mjs (both fail on the previous commit), new tests/test_recipe_model_references.py; all JS tests except the orphaned material_parameter_cross_node.mjs and 95 Python tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5 --- api/recipe_schema.py | 21 +++- web/modules/node_material_actions.js | 78 ++++++------- web/modules/recipe_identity.js | 152 +++++++------------------ web/modules/recipe_parser.js | 111 +++++++++++------- web/modules/ui_material_application.js | 14 +-- web/modules/ui_material_cards.js | 13 +-- web/modules/ui_material_detail.js | 2 +- 7 files changed, 172 insertions(+), 219 deletions(-) diff --git a/api/recipe_schema.py b/api/recipe_schema.py index e3d9d8a..e2db190 100644 --- a/api/recipe_schema.py +++ b/api/recipe_schema.py @@ -17,13 +17,28 @@ from .workflow_schema import ( _widget_values, _workflow_fingerprint, ) +# All-in-one loaders with a verified serialized widget layout. Must stay in sync +# with ALL_IN_ONE_LOADER_SPECS in web/modules/recipe_identity.js. +ALL_IN_ONE_LOADER_SPECS = { + # ComfyUI-Easy-Use: ckpt_name, vae_name, clip_skip, lora_name, ... + "easy a1111loader": ((0, "checkpoint", "ckpt_name"), (1, "vae", "vae_name"), (3, "lora", "lora_name")), + # ComfyUI-Easy-Use: ckpt_name, config_name, vae_name, clip_skip, lora_name, ... + "easy fullloader": ((0, "checkpoint", "ckpt_name"), (2, "vae", "vae_name"), (4, "lora", "lora_name")), + # efficiency-nodes-comfyui: ckpt_name, vae_name, clip_skip, lora_name, ... + "efficient loader": ((0, "checkpoint", "ckpt_name"), (1, "vae", "vae_name"), (3, "lora", "lora_name")), +} +PLACEHOLDER_MODEL_VALUES = {"none", "baked vae"} + + def _model_reference_specs(node): """Known loader adapters only; arbitrary third-party widgets stay parameters.""" node_type = _node_type(node) - lowered = node_type.lower() + lowered = node_type.lower().strip() specs = [] - if re.search(r"checkpointloader(simple)?$", lowered): + if lowered in ALL_IN_ONE_LOADER_SPECS: + specs.extend(ALL_IN_ONE_LOADER_SPECS[lowered]) + elif re.search(r"checkpointloader(simple)?$", lowered): specs.append((0, "checkpoint", "checkpoint")) elif lowered.endswith("unetloader"): specs.append((0, "unet", "unet")) @@ -175,6 +190,8 @@ def _build_model_references(recipe, verify_identities=False): saved_value = values[widget_index] if widget_index < len(values) else None if not isinstance(saved_value, str) or not saved_value.strip(): continue + if saved_value.strip().lower() in PLACEHOLDER_MODEL_VALUES: + continue identity_result = _identity_for_reference(saved_value) if isinstance(identity_result, tuple): identity, origin = (identity_result + (None,))[:2] diff --git a/web/modules/node_material_actions.js b/web/modules/node_material_actions.js index c823227..951640b 100644 --- a/web/modules/node_material_actions.js +++ b/web/modules/node_material_actions.js @@ -375,56 +375,44 @@ export function getMaterialPromptInfo(material) { }; } +/** + * Choose prompt widgets for an envelope. Text never crosses roles: a negative + * prompt is not written into a positive slot (or the reverse). Role-neutral + * slots (e.g. CLIPTextEncode `text`) accept the envelope's primary text. + */ +export function planPromptInjection(slots, envelope) { + const { positiveSlot, negativeSlot } = slots; + const generalSlot = slots.generalSlots?.[0] || null; + const positive = envelope.positive || ''; + const negative = envelope.negative || ''; + + // Strategy 1: both texts into a node that has both role slots. + if (positive && negative && positiveSlot && negativeSlot) { + return [{ index: positiveSlot.index, value: positive }, { index: negativeSlot.index, value: negative }]; + } + // Strategy 2: a single-role node receives the matching text only. + if (negativeSlot && !positiveSlot && !generalSlot) return negative ? [{ index: negativeSlot.index, value: negative }] : []; + if (positiveSlot && !negativeSlot && !generalSlot) return positive ? [{ index: positiveSlot.index, value: positive }] : []; + // Strategy 3: role-matched slot for a one-sided envelope. + const wantsNegative = envelope.primaryRole === 'negative' || (negative && !positive); + if (wantsNegative && negativeSlot) return [{ index: negativeSlot.index, value: negative }]; + if (!wantsNegative && positive && positiveSlot) return [{ index: positiveSlot.index, value: positive }]; + // Strategy 4: role-neutral slot takes the primary text. + if (generalSlot) { + const value = wantsNegative ? negative : (positive || envelope.singleText || ''); + return value ? [{ index: generalSlot.index, value }] : []; + } + return []; +} + export function dispatchPromptInjection(app, node, envelope, options = {}) { if (!node || !envelope || !envelope.hasPrompt) { throw new Error('materialNoCompatibleValues'); } - const slots = inspectNodePromptSlots(node); - if (!slots.hasSlots) { - throw new Error('materialNoCompatibleValues'); - } - - const entries = []; - - // Strategy ①: Dual-Slot Pair Injection (成对原子注入) - if (envelope.positive && envelope.negative && slots.positiveSlot && slots.negativeSlot) { - entries.push({ index: slots.positiveSlot.index, value: envelope.positive }); - entries.push({ index: slots.negativeSlot.index, value: envelope.negative }); - } - // Strategy ②: Role-Matched Injection (角色精准对齐) - else if (envelope.primaryRole === 'negative' || (envelope.negative && !envelope.positive)) { - const val = envelope.negative || envelope.singleText; - const targetSlot = slots.negativeSlot || slots.generalSlots[0] || slots.positiveSlot || slots.targets[0]; - if (!targetSlot) throw new Error('materialNoCompatibleValues'); - entries.push({ index: targetSlot.index, value: val }); - } - else if (envelope.primaryRole === 'positive' || (envelope.positive && !envelope.negative)) { - const val = envelope.positive || envelope.singleText; - const targetSlot = slots.positiveSlot || slots.generalSlots[0] || slots.negativeSlot || slots.targets[0]; - if (!targetSlot) throw new Error('materialNoCompatibleValues'); - entries.push({ index: targetSlot.index, value: val }); - } - // Strategy ④: Single-Slot / General Fallback (单槽位/通用回退) - else { - if (slots.negativeSlot && !slots.positiveSlot) { - const val = envelope.negative || envelope.singleText || envelope.positive; - entries.push({ index: slots.negativeSlot.index, value: val }); - } else if (slots.positiveSlot && !slots.negativeSlot) { - const val = envelope.positive || envelope.singleText || envelope.negative; - entries.push({ index: slots.positiveSlot.index, value: val }); - } else { - const targetSlot = slots.generalSlots[0] || slots.positiveSlot || slots.negativeSlot || slots.targets[0]; - if (!targetSlot) throw new Error('materialNoCompatibleValues'); - const val = envelope.positive || envelope.singleText || envelope.negative; - entries.push({ index: targetSlot.index, value: val }); - } - } - - if (!entries.length) { - throw new Error('materialNoCompatibleValues'); - } - + if (!slots.hasSlots) throw new Error('materialNoCompatibleValues'); + const entries = planPromptInjection(slots, envelope); + if (!entries.length) throw new Error('materialNoCompatibleValues'); return applyNodeMaterialValues(app, node, entries, options); } diff --git a/web/modules/recipe_identity.js b/web/modules/recipe_identity.js index 8f4e243..06db37b 100644 --- a/web/modules/recipe_identity.js +++ b/web/modules/recipe_identity.js @@ -1,5 +1,3 @@ -import { inferModelFolderTypes } from './model_policies.js'; - /** Pure helpers for recipe model references and provenance labels. */ const MODEL_FILE_PATTERN = /\.(?:safetensors|ckpt|pt|bin|sft)$/i; @@ -21,115 +19,50 @@ function nodeTitle(node) { return String(node?._meta?.title || node?.title || nodeType(node) || 'Unknown node').trim(); } -export function deriveNodeModelSpecs(nodeOrType) { - const node = typeof nodeOrType === 'string' ? { type: nodeOrType } : (nodeOrType || {}); - const type = nodeType(node); - const lowered = type.toLowerCase(); +/** + * All-in-one loaders with a verified serialized widget layout. Must stay in sync + * with ALL_IN_ONE_LOADER_SPECS in api/recipe_schema.py. Arbitrary third-party + * widgets stay parameters: guessing categories from file names mislabels models. + */ +export const ALL_IN_ONE_LOADER_SPECS = Object.freeze({ + // ComfyUI-Easy-Use: ckpt_name, vae_name, clip_skip, lora_name, ... + 'easy a1111loader': [[0, 'checkpoint', 'ckpt_name'], [1, 'vae', 'vae_name'], [3, 'lora', 'lora_name']], + // ComfyUI-Easy-Use: ckpt_name, config_name, vae_name, clip_skip, lora_name, ... + 'easy fullloader': [[0, 'checkpoint', 'ckpt_name'], [2, 'vae', 'vae_name'], [4, 'lora', 'lora_name']], + // efficiency-nodes-comfyui: ckpt_name, vae_name, clip_skip, lora_name, ... + 'efficient loader': [[0, 'checkpoint', 'ckpt_name'], [1, 'vae', 'vae_name'], [3, 'lora', 'lora_name']], +}); - // Tier 2: Static known mapping table for classic native nodes (100% backward compatible) - if (/checkpointloader(simple)?$/.test(lowered)) return [[0, 'checkpoint', 'ckpt_name']]; - if (lowered.endsWith('unetloader')) return [[0, 'unet', 'unet_name']]; - if (/loraloader/.test(lowered)) return [[0, 'lora', 'lora_name']]; - if (lowered.endsWith('vaeloader')) return [[0, 'vae', 'vae_name']]; - if (lowered.endsWith('clipvisionloader')) return [[0, 'clip_vision', 'clip_name']]; - if (lowered.endsWith('controlnetloader')) return [[0, 'controlnet', 'control_net_name']]; - if (/(^|[^a-z])(?:dual|triple)?cliploader$/.test(lowered)) { - const specs = [[0, 'text_encoder', 'clip_name1']]; - if (lowered.includes('dualclip') || lowered.includes('tripleclip')) specs.push([1, 'text_encoder', 'clip_name2']); - if (lowered.includes('tripleclip')) specs.push([2, 'text_encoder', 'clip_name3']); - return specs; - } +/** Loader values that mean "no file selected". */ +const PLACEHOLDER_MODEL_VALUES = new Set(['none', 'baked vae']); - // Tier 1: Live node with widgets array (inspect widget names and folder types dynamically) - if (Array.isArray(node.widgets) && node.widgets.length > 0) { - const specs = []; - for (const [index, widget] of node.widgets.entries()) { - if (!widget) continue; - const widgetName = String(widget.name || widget.label || '').trim(); - const folderTypes = inferModelFolderTypes(node, widget); - let category = null; - if (folderTypes.includes('checkpoints')) category = 'checkpoint'; - else if (folderTypes.includes('diffusion_models') || folderTypes.includes('unet')) category = 'unet'; - else if (folderTypes.includes('loras')) category = 'lora'; - else if (folderTypes.includes('vae')) category = 'vae'; - else if (folderTypes.includes('controlnet')) category = 'controlnet'; - else if (folderTypes.includes('clip_vision')) category = 'clip_vision'; - else if (folderTypes.includes('text_encoders') || folderTypes.includes('clip')) category = 'text_encoder'; - - if (!category) { - const lowerName = widgetName.toLowerCase(); - if (/^(ckpt_name|checkpoint|base_model)$/.test(lowerName)) category = 'checkpoint'; - else if (/^(unet_name|unet)$/.test(lowerName)) category = 'unet'; - else if (/^(vae_name|vae)$/.test(lowerName)) category = 'vae'; - else if (/^(lora_name|lora|lora_\d+_name)$/.test(lowerName)) category = 'lora'; - else if (/^(control_net_name|controlnet_name|controlnet)$/.test(lowerName)) category = 'controlnet'; - else if (/^(clip_name|clip|text_encoder)$/.test(lowerName)) category = 'text_encoder'; - } - - if (category) { - specs.push([index, category, widgetName || category]); - } - } - if (specs.length > 0) return specs; - } - - // Tier 3: Serialized JSON node without widgets array - // Known All-in-One loader layout patterns - if (/easy.*a1111loader/i.test(lowered)) { - return [[0, 'checkpoint', 'ckpt_name'], [1, 'vae', 'vae_name'], [3, 'lora', 'lora_name']]; - } - if (/easy.*fullloader/i.test(lowered)) { - return [[0, 'checkpoint', 'ckpt_name'], [2, 'vae', 'vae_name'], [4, 'lora', 'lora_name']]; - } - if (/efficient.*loader/i.test(lowered)) { - return [[0, 'checkpoint', 'ckpt_name'], [1, 'vae', 'vae_name'], [3, 'lora', 'lora_name']]; - } - - // General serialized JSON node inference via widgets_values scan - if (Array.isArray(node.widgets_values) && node.widgets_values.length > 0) { - const specs = []; - let hasCheckpoint = false; - for (const [index, val] of node.widgets_values.entries()) { - if (typeof val !== 'string' || !val.trim() || val.trim().toLowerCase() === 'none') continue; - if (!MODEL_FILE_PATTERN.test(val)) continue; - - const lowerVal = val.toLowerCase(); - let category = null; - let widgetName = `model_${index}`; - - if (/(?:^|[\\/_-])vae(?:[\\/_-]|\.|$)/i.test(lowerVal) || lowerVal.includes('vae') || lowered.includes('vae')) { - category = 'vae'; - widgetName = 'vae_name'; - } else if (/(?:^|[\\/_-])lora(?:[\\/_-]|\.|$)/i.test(lowerVal) || lowerVal.includes('lora') || lowered.includes('lora')) { - category = 'lora'; - widgetName = `lora_name_${index}`; - } else if (/(?:^|[\\/_-])controlnet(?:[\\/_-]|\.|$)/i.test(lowerVal) || lowerVal.includes('controlnet') || lowered.includes('controlnet')) { - category = 'controlnet'; - widgetName = `controlnet_name_${index}`; - } else if (/(?:^|[\\/_-])clip(?:[\\/_-]|\.|$)/i.test(lowerVal) || lowerVal.includes('clip') || lowered.includes('clip')) { - category = 'text_encoder'; - widgetName = `clip_name_${index}`; - } else if (lowered.includes('unet')) { - category = 'unet'; - widgetName = 'unet_name'; - } else if (!hasCheckpoint) { - category = 'checkpoint'; - widgetName = 'ckpt_name'; - hasCheckpoint = true; - } else { - category = 'checkpoint'; - widgetName = `ckpt_name_${index}`; - } - - specs.push([index, category, widgetName]); - } - if (specs.length > 0) return specs; - } - - return []; +export function isAllInOneLoaderType(type) { + return Object.prototype.hasOwnProperty.call(ALL_IN_ONE_LOADER_SPECS, String(type || '').trim().toLowerCase()); } -export const modelSpecs = deriveNodeModelSpecs; +export function isPlaceholderModelValue(value) { + return PLACEHOLDER_MODEL_VALUES.has(String(value ?? '').trim().toLowerCase()); +} + +/** [widgetIndex, category, widgetName] triples for known loader nodes (by type). */ +export function deriveNodeModelSpecs(nodeOrType) { + const type = typeof nodeOrType === 'string' ? nodeOrType : nodeType(nodeOrType); + const lowered = type.trim().toLowerCase(); + if (isAllInOneLoaderType(lowered)) return ALL_IN_ONE_LOADER_SPECS[lowered].map((spec) => [...spec]); + if (/checkpointloader(simple)?$/.test(lowered)) return [[0, 'checkpoint', 'checkpoint']]; + if (lowered.endsWith('unetloader')) return [[0, 'unet', 'unet']]; + if (/loraloader/.test(lowered)) return [[0, 'lora', 'lora']]; + if (lowered.endsWith('vaeloader')) return [[0, 'vae', 'vae']]; + if (lowered.endsWith('clipvisionloader')) return [[0, 'clip_vision', 'clip_vision']]; + if (lowered.endsWith('controlnetloader')) return [[0, 'controlnet', 'controlnet']]; + if (/(^|[^a-z])(?:dual|triple)?cliploader$/.test(lowered)) { + const specs = [[0, 'text_encoder', 'clip']]; + if (lowered.includes('dualclip') || lowered.includes('tripleclip')) specs.push([1, 'text_encoder', 'clip']); + if (lowered.includes('tripleclip')) specs.push([2, 'text_encoder', 'clip']); + return specs; + } + return []; +} function statusFor(identity) { @@ -188,7 +121,7 @@ export function deriveRecipeModelReferences(recipe) { const values = Array.isArray(node?.widgets_values) ? node.widgets_values : []; for (const [widgetIndex, category, widgetName] of deriveNodeModelSpecs(node)) { const savedValue = values[widgetIndex]; - if (typeof savedValue !== 'string' || !savedValue.trim() || savedValue.trim().toLowerCase() === 'none') continue; + if (typeof savedValue !== 'string' || !savedValue.trim() || isPlaceholderModelValue(savedValue)) continue; const identity = workflowIdentity(recipe?.workflow, node?.id, savedValue) || { status: 'unverified' }; references.push({ @@ -232,6 +165,5 @@ export function formatIdentitySize(value) { export function isModelReference(reference) { const val = String(reference?.saved_value || '').trim(); - return Boolean(val && val.toLowerCase() !== 'none' && MODEL_FILE_PATTERN.test(val)); + return Boolean(val && !isPlaceholderModelValue(val) && MODEL_FILE_PATTERN.test(val)); } - diff --git a/web/modules/recipe_parser.js b/web/modules/recipe_parser.js index e912fa2..c3f633b 100644 --- a/web/modules/recipe_parser.js +++ b/web/modules/recipe_parser.js @@ -4,6 +4,7 @@ import { isNegativePromptWidget, classifyPromptWidgetRole, } from './node_material_actions.js'; +import { isAllInOneLoaderType, isPlaceholderModelValue } from './recipe_identity.js'; /** * Read-only helpers for turning the live LiteGraph canvas into a compact @@ -17,7 +18,13 @@ const MAX_WIDGETS_PER_NODE = 64; const MAX_WIDGET_TEXT = 320; const MAX_PINNED_VALUE_JSON = 2400; const MAX_PARAMETER_CHOICES = 5000; -const MODEL_FILE_PATTERN = /\.(?:safetensors|ckpt|pt|bin|sft)$/i; +const MODEL_FILE_PATTERN = /\.(?:safetensors|ckpt|pt|bin|sft|gguf)$/i; +const NATIVE_MODEL_LOADER = /^(checkpointloader(simple)?|unetloader)$/i; +const NATIVE_MODEL_WIDGETS = ['ckpt_name', 'checkpoint', 'unet_name', 'unet', 'model_name']; +// Unambiguous names only: `model_name` also belongs to upscalers, detectors, etc. +const MODEL_WIDGETS = ['ckpt_name', 'unet_name']; +const LORA_LOADER = /lora.*loader|loader.*lora/i; +const SAMPLING_NODE = /sampl|scheduler|noise|guider/i; const SENSITIVE_WIDGET_NAME = /(?:api.?key|access.?token|auth|password|passwd|secret|credential)/i; const SUPPORTED_PROMPT_NODE_TYPES = new Set([ @@ -200,35 +207,58 @@ function findSummaryWidget(params, change) { return widget ? { node, widget } : null; } +function isSelectedModelFile(value) { + const text = textValue(value); + return Boolean(text && !isPlaceholderModelValue(text) && MODEL_FILE_PATTERN.test(text)); +} + +/** Current LoRA file shown by the edited node (single-LoRA loaders or a `lora_N_*` stack slot). */ +function loraNameForChange(params, change, widget) { + const summary = (params.nodes || []).find((item) => String(item?.id) === String(change.nodeId)); + const slot = /^lora_(\d+)_/.exec(widget)?.[1]; + const names = slot ? [`lora_${slot}_name`] : ['lora_name', 'lora', 'model_name']; + const nameWidget = (summary?.widgets || []).find((item) => names.includes(normaliseName(item?.name))); + return textValue(nameWidget?.value); +} + +function syncLoraMetadata(params, change, type, widget) { + const isLoraNode = LORA_LOADER.test(type) || isAllInOneLoaderType(type); + const isStackSlot = /^lora_\d+_(name|strength|model_strength|clip_strength)$|^lora_name_\d+$/.test(widget); + if (!isLoraNode && !isStackSlot) return; + if (!Array.isArray(params.loras)) return; + + if (/^(lora_name|lora|lora_\d+_name|lora_name_\d+)$/.test(widget) || (LORA_LOADER.test(type) && widget === 'model_name')) { + const previous = textValue(change.previousValue); + const lora = params.loras.find((item) => item?.name === previous); + if (lora && isSelectedModelFile(change.value)) lora.name = textValue(change.value); + return; + } + const lora = params.loras.find((item) => item?.name === loraNameForChange(params, change, widget)); + if (!lora) return; + if (/^(strength_model|model_strength|lora_model_strength|lora_\d+_strength|lora_\d+_model_strength)$/.test(widget)) { + lora.strength_model = numberValue(change.value); + } + if (/^(strength_clip|clip_strength|lora_clip_strength|lora_\d+_clip_strength)$/.test(widget)) { + lora.strength_clip = numberValue(change.value); + } +} + +/** Keep the presentation summary in step with an edited widget, using the same node rules as extraction. */ function syncCommonRecipeMetadata(params, change) { const type = normaliseName(change.nodeType); const widget = normaliseName(change.widgetName); const value = change.value; - if (/^(ckpt_name|checkpoint|unet_name|unet|model_name|base_model)$/.test(widget)) { + const isModelWidget = NATIVE_MODEL_LOADER.test(type) ? NATIVE_MODEL_WIDGETS.includes(widget) : MODEL_WIDGETS.includes(widget); + if (isModelWidget && (NATIVE_MODEL_LOADER.test(type) || isSelectedModelFile(value))) { params.baseModel = textValue(value) || null; if (params.baseModel) params.baseModels = [params.baseModel, ...(params.baseModels || []).filter((item) => item !== params.baseModel)]; } - if (/lora.*loader|loader.*lora/.test(type) || /lora/.test(widget)) { - if (!Array.isArray(params.loras)) params.loras = []; - let lora = params.loras.find((item) => item.nodeId === change.nodeId); - if (!lora) { - const loraIndex = (params.nodes || []).filter((item) => /lora.*loader|loader.*lora/i.test(item?.type || '')).findIndex((item) => item.id === change.nodeId); - if (loraIndex >= 0 && params.loras[loraIndex]) lora = params.loras[loraIndex]; - } - if (!lora && params.loras.length > 0) { - lora = params.loras[0]; - } - if (lora) { - if (/^(lora_name|lora|model_name|lora_\d+_name)$/.test(widget)) lora.name = textValue(value); - if (/^(strength_model|model_strength|lora_\d+_strength)$/.test(widget)) lora.strength_model = numberValue(value); - if (/^(strength_clip|clip_strength)$/.test(widget)) lora.strength_clip = numberValue(value); - } - } + syncLoraMetadata(params, change, type, widget); const samplingField = { seed: 'seed', noise_seed: 'seed', steps: 'steps', cfg: 'cfg', cfg_scale: 'cfg', sampler_name: 'sampler_name', sampler: 'sampler_name', scheduler: 'scheduler', denoise: 'denoise' }[widget]; - if (samplingField) { + if (samplingField && (SAMPLING_NODE.test(type) || isAllInOneLoaderType(type))) { params[samplingField] = ['steps', 'cfg', 'denoise'].includes(samplingField) ? numberValue(value) : value; } @@ -251,8 +281,7 @@ function syncCommonRecipeMetadata(params, change) { const override = params.promptRoleOverrides?.[String(change.nodeId)]?.role; let role = ['positive', 'negative', 'both'].includes(override) ? override : summary?.role; if (!role) { - if (isPos && isNeg) role = 'both'; - else if (isPos) role = 'positive'; + if (isPos) role = 'positive'; else if (isNeg) role = 'negative'; } const prompt = textValue(value); @@ -414,27 +443,23 @@ export function extractRecipeMetadata(graph) { const type = nodeType(node); if (metadata.nodes.length < MAX_SUMMARY_NODES) metadata.nodes.push(extractGenericNodeSummary(node)); - // Sniff base model / checkpoint - const model = textValue(widgetValue( - node, - ['ckpt_name', 'checkpoint', 'unet_name', 'unet', 'base_model', 'model_name'], - /^(checkpointloader(simple)?|unetloader)$/i.test(type) ? 0 : -1, - )); - if (model && model.toLowerCase() !== 'none' && MODEL_FILE_PATTERN.test(model)) { + // Base model: native loaders by position; other nodes only through unambiguous widget names. + const isNativeModelLoader = NATIVE_MODEL_LOADER.test(type); + const model = textValue(widgetValue(node, isNativeModelLoader ? NATIVE_MODEL_WIDGETS : MODEL_WIDGETS, isNativeModelLoader ? 0 : -1)); + if (model && (isNativeModelLoader ? !isPlaceholderModelValue(model) : isSelectedModelFile(model))) { if (!metadata.baseModels.includes(model)) metadata.baseModels.push(model); if (!metadata.baseModel) metadata.baseModel = model; } - // Sniff LoRAs (single widget or multi-slot stack) - const loraName = textValue(widgetValue(node, ['lora_name', 'lora'], /lora.*loader|loader.*lora/i.test(type) ? 0 : -1)); - if (loraName && loraName.toLowerCase() !== 'none' && MODEL_FILE_PATTERN.test(loraName)) { - const strengthModel = numberValue(widgetValue(node, ['lora_model_strength', 'strength_model', 'model_strength'], 1)); - const strengthClip = numberValue(widgetValue(node, ['lora_clip_strength', 'strength_clip', 'clip_strength'], 2)); - if (!metadata.loras.some((l) => l.name === loraName)) { + // LoRA loaders and all-in-one loaders: one entry per node, like the loaders themselves. + const isLoraLoader = LORA_LOADER.test(type); + if (isLoraLoader || isAllInOneLoaderType(type)) { + const loraName = textValue(widgetValue(node, ['lora_name', 'lora', 'model_name'], isLoraLoader ? 0 : -1)); + if (isLoraLoader ? loraName && !isPlaceholderModelValue(loraName) : isSelectedModelFile(loraName)) { metadata.loras.push({ name: loraName, - strength_model: strengthModel, - strength_clip: strengthClip, + strength_model: numberValue(widgetValue(node, ['strength_model', 'model_strength', 'lora_model_strength'], isLoraLoader ? 1 : -1)), + strength_clip: numberValue(widgetValue(node, ['strength_clip', 'clip_strength', 'lora_clip_strength'], isLoraLoader ? 2 : -1)), }); } } @@ -446,16 +471,14 @@ export function extractRecipeMetadata(graph) { if (match && match[1]) { const slotNum = match[1]; const slotVal = textValue(widget?.value); - if (slotVal && slotVal.toLowerCase() !== 'none' && MODEL_FILE_PATTERN.test(slotVal)) { + if (isSelectedModelFile(slotVal)) { const slotStrengthModel = numberValue(widgetValue(node, [`lora_${slotNum}_strength`, `lora_${slotNum}_model_strength`, `lora_model_strength_${slotNum}`], -1)); const slotStrengthClip = numberValue(widgetValue(node, [`lora_${slotNum}_clip_strength`, `lora_clip_strength_${slotNum}`], -1)); - if (!metadata.loras.some((l) => l.name === slotVal)) { - metadata.loras.push({ - name: slotVal, - strength_model: slotStrengthModel, - strength_clip: slotStrengthClip, - }); - } + metadata.loras.push({ + name: slotVal, + strength_model: slotStrengthModel, + strength_clip: slotStrengthClip, + }); } } } diff --git a/web/modules/ui_material_application.js b/web/modules/ui_material_application.js index 4584be9..17c31f2 100644 --- a/web/modules/ui_material_application.js +++ b/web/modules/ui_material_application.js @@ -7,7 +7,6 @@ import { applyPromptRolesToBlocks, materialNodeHeading } from './material_inspec import { applyMaterialBlock, applyNodeMaterialValues, - promptWidgetTargets, selectedMaterialNode, inspectNodePromptSlots, extractMaterialPromptEnvelope, @@ -15,7 +14,6 @@ import { isModelFilePath, isPromptNodeType, } from './node_material_actions.js'; -import { getMaterialPromptInfo } from './ui_material_detail.js'; export function showMaterialApplication(parent, result, node) { parent.querySelector('.anomalous-material-application-result')?.remove(); @@ -189,14 +187,12 @@ function openMaterialChoiceDialog(owner, node, blocks, payload, droppedNode, gra } } if (!textVal) throw new Error('materialNoCompatibleValues'); + // Never write a block into the opposite role's slot. + const general = slots.generalSlots[0] || null; let targetWidget = null; - if (targetBlock.promptRole === 'negative' && slots.negativeSlot) { - targetWidget = slots.negativeSlot; - } else if (targetBlock.promptRole === 'positive' && slots.positiveSlot) { - targetWidget = slots.positiveSlot; - } else { - targetWidget = slots.generalSlots[0] || slots.positiveSlot || slots.negativeSlot || slots.targets[0]; - } + if (targetBlock.promptRole === 'negative') targetWidget = slots.negativeSlot || general; + else if (targetBlock.promptRole === 'positive') targetWidget = slots.positiveSlot || general; + else targetWidget = general || slots.positiveSlot || slots.negativeSlot; if (!targetWidget) throw new Error('materialNoCompatibleValues'); const result = applyNodeMaterialValues(app, node, [{ index: targetWidget.index, value: textVal }], { sourceNodeId: targetBlock.node_id, diff --git a/web/modules/ui_material_cards.js b/web/modules/ui_material_cards.js index 4414900..1dd4a62 100644 --- a/web/modules/ui_material_cards.js +++ b/web/modules/ui_material_cards.js @@ -6,7 +6,7 @@ import { translate } from './locales.js'; import { anomalousAlert } from './ui_dialog.js'; import { text, jsonResponse } from './ui_dom.js'; import { materialNodeHeading } from './material_inspector.js'; -import { promptWidgetTargets, inspectNodePromptSlots } from './node_material_actions.js'; +import { inspectNodePromptSlots, isPromptNodeType } from './node_material_actions.js'; import { applyLibraryMaterial } from './ui_material_application.js'; import { deleteMaterial, @@ -119,13 +119,10 @@ function bindPolymorphicMaterialCardDrag(card, owner, material) { accepts: (node, source) => { if (!node) return false; if ((source.node_types || []).includes(node.type)) return true; - const slots = inspectNodePromptSlots(node); - if (!slots.hasSlots) return false; - if (isPromptMaterial(source) || source.kind === 'prompt_plan') return true; - if (Array.isArray(source.node_types) && source.node_types.some(t => /cliptextencode|prompt|easy positive|easy negative/i.test(t))) return true; - if (source.has_prompts || source.prompt_roles || source.prompt_groups) return true; - if (source.capabilities?.includes('open_workflow') || source.capabilities?.includes('apply_node_parameters') || source.capabilities?.includes('copy_prompt')) return true; - return false; + if (!inspectNodePromptSlots(node).hasSlots) return false; + // Cross-node drops only carry prompt text; parameter blocks stay same-type. + if (isPromptMaterial(source) || source.kind === 'prompt_plan' || source.capabilities?.includes('copy_prompt')) return true; + return (source.node_types || []).some(type => isPromptNodeType(type)); }, drop: (node, source, graph) => applyLibraryMaterial(owner, source, node, graph), dropOnCanvas: async (event, source, graph, position) => { diff --git a/web/modules/ui_material_detail.js b/web/modules/ui_material_detail.js index 5b188dd..bf45b78 100644 --- a/web/modules/ui_material_detail.js +++ b/web/modules/ui_material_detail.js @@ -12,7 +12,7 @@ import { renderMaterialPromptGroups, materialNodeHeading, } from './material_inspector.js'; -import { selectedMaterialNode, extractMaterialPromptEnvelope, getMaterialPromptInfo } from './node_material_actions.js'; +import { selectedMaterialNode, getMaterialPromptInfo } from './node_material_actions.js'; import { fetchMaterial, applyLibraryMaterial } from './ui_material_application.js'; const t = (key, params) => translate(key, params);