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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
66979f698a
commit
40195aa842
+19
-2
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+42
-110
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user