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
692 lines
30 KiB
JavaScript
692 lines
30 KiB
JavaScript
import {
|
|
promptWidgetTargets,
|
|
isPositivePromptWidget,
|
|
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
|
|
* Workflow Recipe summary. The saved workflow itself remains authoritative:
|
|
* an unfamiliar custom node must never prevent a recipe from being saved.
|
|
*/
|
|
|
|
const MAX_UPSTREAM_NODES = 96;
|
|
const MAX_SUMMARY_NODES = 120;
|
|
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|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([
|
|
'cliptextencode',
|
|
]);
|
|
const SUPPORTED_PROMPT_CONSUMERS = new Map([
|
|
['ksampler', { positive: 'positive', negative: 'negative' }],
|
|
['ksampleradvanced', { positive: 'positive', negative: 'negative' }],
|
|
['cfgguider', { positive: 'positive', negative: 'negative' }],
|
|
['basicguider', { conditioning: 'positive' }],
|
|
['dualcfgguider', { cond1: 'positive', cond2: 'positive', negative: 'negative' }],
|
|
]);
|
|
const SUPPORTED_CONDITIONING_PASSTHROUGH = new Set([
|
|
'conditioningaverage',
|
|
'conditioningcombine',
|
|
'conditioningconcat',
|
|
'conditioningmultiply',
|
|
'conditioningsetarea',
|
|
'conditioningsetareapercentage',
|
|
'conditioningsetareastrength',
|
|
'conditioningsetmask',
|
|
'conditioningsettimesteprange',
|
|
'conditioningzeroout',
|
|
]);
|
|
|
|
function nodeType(node) {
|
|
return String(node?.type || node?.comfyClass || '');
|
|
}
|
|
|
|
function normaliseName(value) {
|
|
return String(value || '').trim().toLowerCase();
|
|
}
|
|
|
|
export function isSupportedPromptNodeType(value) {
|
|
return SUPPORTED_PROMPT_NODE_TYPES.has(normaliseName(value));
|
|
}
|
|
|
|
function widgetValue(node, names, fallbackIndex = -1) {
|
|
const wanted = new Set(names.map(normaliseName));
|
|
const widgets = Array.isArray(node?.widgets) ? node.widgets : [];
|
|
const named = widgets.find((widget) => wanted.has(normaliseName(widget?.name)));
|
|
if (named && named.value !== undefined && named.value !== null) return named.value;
|
|
|
|
if (fallbackIndex >= 0) {
|
|
const fallback = widgets[fallbackIndex]?.value;
|
|
if (fallback !== undefined && fallback !== null) return fallback;
|
|
const serialised = node?.widgets_values?.[fallbackIndex];
|
|
if (serialised !== undefined && serialised !== null) return serialised;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function textValue(value) {
|
|
return typeof value === 'string' ? value.trim() : '';
|
|
}
|
|
|
|
function numberValue(value) {
|
|
if (value === null || value === undefined || value === '') return null;
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function summariseWidgetValue(value) {
|
|
if (typeof value === 'string') {
|
|
const trimmed = value.trim();
|
|
return trimmed.length > MAX_WIDGET_TEXT ? `${trimmed.slice(0, MAX_WIDGET_TEXT - 1)}…` : trimmed;
|
|
}
|
|
if (typeof value === 'number' || typeof value === 'boolean') return value;
|
|
if (Array.isArray(value) && value.length <= 12 && value.every((item) => ['string', 'number', 'boolean'].includes(typeof item))) {
|
|
return value.map((item) => typeof item === 'string' && item.length > 80 ? `${item.slice(0, 79)}…` : item);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function clonePinnableValue(value) {
|
|
if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) {
|
|
if (typeof value === 'string' && value.length > MAX_PINNED_VALUE_JSON) return null;
|
|
return value;
|
|
}
|
|
try {
|
|
const encoded = JSON.stringify(value);
|
|
if (!encoded || encoded.length > MAX_PINNED_VALUE_JSON) return null;
|
|
return JSON.parse(encoded);
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function extractGenericNodeSummary(node) {
|
|
const widgets = [];
|
|
for (const [index, widget] of (node?.widgets || []).entries()) {
|
|
const name = textValue(widget?.name) || `widget_${index + 1}`;
|
|
if (SENSITIVE_WIDGET_NAME.test(name) || normaliseName(widget?.type) === 'button') continue;
|
|
const value = summariseWidgetValue(widget?.value);
|
|
if (value === null || value === '') continue;
|
|
widgets.push({ name, value, index });
|
|
if (widgets.length >= MAX_WIDGETS_PER_NODE) break;
|
|
}
|
|
|
|
const nodeData = node?.constructor?.nodeData || {};
|
|
return {
|
|
id: node?.id ?? null,
|
|
type: nodeType(node) || 'Unknown',
|
|
title: textValue(node?.title) || null,
|
|
category: textValue(nodeData.category || node?.category) || null,
|
|
module: textValue(nodeData.python_module || nodeData.module) || null,
|
|
widgets,
|
|
widgetCount: Array.isArray(node?.widgets) ? node.widgets.length : 0,
|
|
};
|
|
}
|
|
|
|
/** Return safe, user-selectable widget values for manual key-parameter pins. */
|
|
export function extractRecipeParameterChoices(graph) {
|
|
const choices = [];
|
|
if (!graph || !Array.isArray(graph._nodes)) return choices;
|
|
for (const node of graph._nodes) {
|
|
for (const [widgetIndex, widget] of (node?.widgets || []).entries()) {
|
|
const widgetName = textValue(widget?.name) || `widget_${widgetIndex + 1}`;
|
|
if (SENSITIVE_WIDGET_NAME.test(widgetName) || normaliseName(widget?.type) === 'button') continue;
|
|
const value = clonePinnableValue(widget?.value);
|
|
if (value === null || value === '') continue;
|
|
choices.push({
|
|
key: `${node?.id ?? 'unknown'}:${widgetIndex}:${widgetName}`,
|
|
nodeId: node?.id ?? null,
|
|
nodeType: nodeType(node) || 'Unknown',
|
|
nodeTitle: textValue(node?.title) || null,
|
|
widgetName,
|
|
value,
|
|
});
|
|
if (choices.length >= MAX_PARAMETER_CHOICES) return choices;
|
|
}
|
|
}
|
|
return choices;
|
|
}
|
|
|
|
/**
|
|
* Return editable/pinnable choices from an already saved recipe summary.
|
|
* New summaries retain their original widget index; older recipes gracefully
|
|
* fall back to the visible widget order.
|
|
*/
|
|
export function extractRecipeParameterChoicesFromMetadata(params, workflow = null) {
|
|
const choices = [];
|
|
const workflowNodes = new Map((workflow?.nodes || []).filter(Boolean).map((node) => [node.id, node]));
|
|
for (const node of params?.nodes || []) {
|
|
for (const [visibleIndex, widget] of (node?.widgets || []).entries()) {
|
|
const widgetName = textValue(widget?.name) || `widget_${visibleIndex + 1}`;
|
|
if (SENSITIVE_WIDGET_NAME.test(widgetName)) continue;
|
|
const widgetIndex = Number.isInteger(widget?.index) ? widget.index : visibleIndex;
|
|
const workflowValues = workflowNodes.get(node?.id)?.widgets_values;
|
|
const fullValue = Array.isArray(workflowValues) && widgetIndex >= 0 && widgetIndex < workflowValues.length
|
|
? workflowValues[widgetIndex]
|
|
: widget?.value;
|
|
const value = clonePinnableValue(fullValue);
|
|
if (value === null || value === '') continue;
|
|
choices.push({
|
|
key: `${node?.id ?? 'unknown'}:${widgetIndex}:${widgetName}`,
|
|
nodeId: node?.id ?? null,
|
|
nodeType: node?.type || 'Unknown',
|
|
nodeTitle: node?.title || null,
|
|
widgetName,
|
|
widgetIndex,
|
|
value,
|
|
});
|
|
if (choices.length >= MAX_PARAMETER_CHOICES) return choices;
|
|
}
|
|
}
|
|
return choices;
|
|
}
|
|
|
|
function valuesMatch(left, right) {
|
|
if (left === right) return true;
|
|
try { return JSON.stringify(left) === JSON.stringify(right); } catch (error) { return false; }
|
|
}
|
|
|
|
function findSummaryWidget(params, change) {
|
|
const node = (params?.nodes || []).find((item) => item?.id === change.nodeId);
|
|
if (!node) return null;
|
|
const widget = (node.widgets || []).find((item) => Number.isInteger(change.widgetIndex) && item.index === change.widgetIndex)
|
|
|| (node.widgets || []).find((item) => item.name === change.widgetName && valuesMatch(item.value, change.previousValue));
|
|
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;
|
|
|
|
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)];
|
|
}
|
|
|
|
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 && (SAMPLING_NODE.test(type) || isAllInOneLoaderType(type))) {
|
|
params[samplingField] = ['steps', 'cfg', 'denoise'].includes(samplingField) ? numberValue(value) : value;
|
|
}
|
|
|
|
const isPos = isPositivePromptWidget(widget);
|
|
const isNeg = isNegativePromptWidget(widget);
|
|
if (isPos || isNeg || (isSupportedPromptNodeType(type) && /^(text|prompt)$/.test(widget))) {
|
|
const positive = Array.isArray(params.promptPositive) ? params.promptPositive : (params.promptPositive = []);
|
|
const negative = Array.isArray(params.promptNegative) ? params.promptNegative : (params.promptNegative = []);
|
|
const position = positive.findIndex((item) => item === change.previousValue);
|
|
if (position >= 0) {
|
|
positive[position] = textValue(value);
|
|
return;
|
|
}
|
|
const negativePosition = negative.findIndex((item) => item === change.previousValue);
|
|
if (negativePosition >= 0) {
|
|
negative[negativePosition] = textValue(value);
|
|
return;
|
|
}
|
|
const summary = (params.nodes || []).find((item) => String(item?.id) === String(change.nodeId));
|
|
const override = params.promptRoleOverrides?.[String(change.nodeId)]?.role;
|
|
let role = ['positive', 'negative', 'both'].includes(override) ? override : summary?.role;
|
|
if (!role) {
|
|
if (isPos) role = 'positive';
|
|
else if (isNeg) role = 'negative';
|
|
}
|
|
const prompt = textValue(value);
|
|
if (!prompt) return;
|
|
if ((role === 'positive' || role === 'both') && !positive.includes(prompt)) positive.push(prompt);
|
|
if ((role === 'negative' || role === 'both') && !negative.includes(prompt)) negative.push(prompt);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Apply direct safe-widget edits to a serialized recipe without instantiating
|
|
* its graph. Unknown nodes stay opaque; only known widget slots are touched.
|
|
*/
|
|
export function applyRecipeWidgetChanges(params, workflow, changes) {
|
|
for (const change of changes || []) {
|
|
const found = findSummaryWidget(params, change);
|
|
if (!found) continue;
|
|
const { widget } = found;
|
|
const workflowNode = (workflow?.nodes || []).find((node) => node?.id === change.nodeId);
|
|
let widgetIndex = Number.isInteger(change.widgetIndex) ? change.widgetIndex : widget.index;
|
|
if (!Number.isInteger(widgetIndex) && Array.isArray(workflowNode?.widgets_values)) {
|
|
widgetIndex = workflowNode.widgets_values.findIndex((value) => valuesMatch(value, change.previousValue));
|
|
}
|
|
if (!Number.isInteger(widgetIndex) || widgetIndex < 0 || !Array.isArray(workflowNode?.widgets_values)) continue;
|
|
widget.value = change.value;
|
|
widget.index = widgetIndex;
|
|
workflowNode.widgets_values[widgetIndex] = change.value;
|
|
syncCommonRecipeMetadata(params, change);
|
|
}
|
|
return { params, workflow };
|
|
}
|
|
|
|
function buildNodeIndex(graph) {
|
|
return new Map((graph?._nodes || []).filter(Boolean).map((node) => [String(node.id), node]));
|
|
}
|
|
|
|
function linkOriginId(link) {
|
|
if (Array.isArray(link)) return link[1];
|
|
return link?.origin_id;
|
|
}
|
|
|
|
function findLink(graph, linkId) {
|
|
if (linkId === null || linkId === undefined) return null;
|
|
const links = graph?.links;
|
|
if (Array.isArray(links)) return links.find((link) => Array.isArray(link) && link[0] === linkId) || null;
|
|
if (links instanceof Map) return links.get(linkId) || links.get(String(linkId)) || null;
|
|
return links?.[linkId] || null;
|
|
}
|
|
|
|
function inputLinkOrigins(graph, node, inputNames) {
|
|
const wanted = new Set(inputNames.map(normaliseName));
|
|
const origins = [];
|
|
for (const input of node?.inputs || []) {
|
|
if (!wanted.has(normaliseName(input?.name)) || input?.link === null || input?.link === undefined) continue;
|
|
const origin = linkOriginId(findLink(graph, input.link));
|
|
if (origin !== null && origin !== undefined && !origins.includes(origin)) origins.push(origin);
|
|
}
|
|
return origins;
|
|
}
|
|
|
|
function collectConditioningNodes(graph, startNodeId, nodeIndex) {
|
|
const queue = [startNodeId];
|
|
const visited = new Set();
|
|
const clipNodes = [];
|
|
|
|
while (queue.length && visited.size < MAX_UPSTREAM_NODES) {
|
|
const nodeId = queue.shift();
|
|
if (nodeId === null || nodeId === undefined || visited.has(nodeId)) continue;
|
|
visited.add(nodeId);
|
|
const node = nodeIndex.get(String(nodeId));
|
|
if (!node) continue;
|
|
|
|
if (isSupportedPromptNodeType(nodeType(node))) {
|
|
if (!clipNodes.includes(node)) clipNodes.push(node);
|
|
continue;
|
|
}
|
|
|
|
// Unknown third-party conditioning nodes are deliberately opaque.
|
|
// Users can label their text nodes manually in the recipe detail view.
|
|
if (!SUPPORTED_CONDITIONING_PASSTHROUGH.has(normaliseName(nodeType(node)))) continue;
|
|
|
|
for (const input of node.inputs || []) {
|
|
const inputType = normaliseName(input?.type);
|
|
const inputName = normaliseName(input?.name);
|
|
if (inputType !== 'conditioning' && !inputName.includes('conditioning')) continue;
|
|
const originId = linkOriginId(findLink(graph, input?.link));
|
|
if (originId !== null && originId !== undefined && !visited.has(originId)) {
|
|
queue.push(originId);
|
|
}
|
|
}
|
|
}
|
|
return clipNodes;
|
|
}
|
|
|
|
function appendUnique(target, values) {
|
|
for (const value of values) {
|
|
if (value && !target.includes(value)) target.push(value);
|
|
}
|
|
}
|
|
|
|
function recipeSampler(node) {
|
|
const type = nodeType(node);
|
|
const isStandardSampler = /^ksampler$/i.test(type);
|
|
const isAdvancedSampler = /^ksampleradvanced$/i.test(type);
|
|
return {
|
|
type,
|
|
seed: widgetValue(node, ['seed', 'noise_seed'], (isStandardSampler || isAdvancedSampler) ? 0 : -1),
|
|
steps: numberValue(widgetValue(node, ['steps'], (isStandardSampler || isAdvancedSampler) ? 2 : -1)),
|
|
cfg: numberValue(widgetValue(node, ['cfg', 'cfg_scale'], (isStandardSampler || isAdvancedSampler) ? 3 : -1)),
|
|
sampler_name: textValue(widgetValue(node, ['sampler_name', 'sampler'], (isStandardSampler || isAdvancedSampler) ? 4 : -1)) || null,
|
|
scheduler: textValue(widgetValue(node, ['scheduler'], (isStandardSampler || isAdvancedSampler) ? 5 : -1)) || null,
|
|
denoise: numberValue(widgetValue(node, ['denoise'], isStandardSampler ? 6 : -1)),
|
|
};
|
|
}
|
|
|
|
function mergeSampler(metadata, sampler) {
|
|
const isDedicated = /^ksampler(advanced)?$/i.test(sampler.type) || /samplercustom/i.test(sampler.type);
|
|
metadata.samplers.push(sampler);
|
|
for (const field of ['seed', 'steps', 'cfg', 'sampler_name', 'scheduler', 'denoise']) {
|
|
if (sampler[field] !== null && sampler[field] !== '') {
|
|
if (metadata[field] === null || (isDedicated && !metadata._hasDedicatedSampler)) {
|
|
metadata[field] = sampler[field];
|
|
}
|
|
}
|
|
}
|
|
if (isDedicated) metadata._hasDedicatedSampler = true;
|
|
}
|
|
|
|
/**
|
|
* Extract a best-effort summary from the live LiteGraph graph without
|
|
* modifying nodes, links, widgets, or graph state.
|
|
*/
|
|
export function extractRecipeMetadata(graph) {
|
|
const metadata = {
|
|
baseModel: null,
|
|
baseModels: [],
|
|
loras: [],
|
|
promptPositive: [],
|
|
promptNegative: [],
|
|
nodes: [],
|
|
nodeCount: graph?._nodes?.length || 0,
|
|
samplers: [],
|
|
seed: null,
|
|
steps: null,
|
|
cfg: null,
|
|
sampler_name: null,
|
|
scheduler: null,
|
|
denoise: null,
|
|
resolution: null,
|
|
};
|
|
if (!graph || !Array.isArray(graph._nodes)) return metadata;
|
|
|
|
const nodeIndex = buildNodeIndex(graph);
|
|
const positiveNodes = [];
|
|
const negativeNodes = [];
|
|
|
|
for (const node of graph._nodes) {
|
|
const type = nodeType(node);
|
|
if (metadata.nodes.length < MAX_SUMMARY_NODES) metadata.nodes.push(extractGenericNodeSummary(node));
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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: 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)),
|
|
});
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(node.widgets)) {
|
|
for (const widget of node.widgets) {
|
|
const wName = String(widget?.name || '').toLowerCase();
|
|
const match = wName.match(/^lora(?:_(\d+))?_name$/) || wName.match(/^lora_name_(\d+)$/);
|
|
if (match && match[1]) {
|
|
const slotNum = match[1];
|
|
const slotVal = textValue(widget?.value);
|
|
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));
|
|
metadata.loras.push({
|
|
name: slotVal,
|
|
strength_model: slotStrengthModel,
|
|
strength_clip: slotStrengthClip,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sniff samplers
|
|
if (/^ksampler(advanced)?$/i.test(type) || /samplercustom/i.test(type) || /sampler/i.test(type) || widgetValue(node, ['sampler_name', 'sampler']) !== null || widgetValue(node, ['steps']) !== null) {
|
|
const sampler = recipeSampler(node);
|
|
if (sampler.steps !== null || sampler.sampler_name !== null || sampler.cfg !== null) {
|
|
mergeSampler(metadata, sampler);
|
|
}
|
|
}
|
|
|
|
// Sniff prompt consumers
|
|
let roleInputs = SUPPORTED_PROMPT_CONSUMERS.get(normaliseName(type));
|
|
if (!roleInputs && Array.isArray(node.inputs)) {
|
|
const inputNames = node.inputs.map((i) => normaliseName(i?.name));
|
|
const hasPos = inputNames.includes('positive');
|
|
const hasNeg = inputNames.includes('negative');
|
|
if (hasPos || hasNeg) {
|
|
roleInputs = {};
|
|
if (hasPos) roleInputs.positive = 'positive';
|
|
if (hasNeg) roleInputs.negative = 'negative';
|
|
}
|
|
}
|
|
if (roleInputs) {
|
|
for (const [inputName, role] of Object.entries(roleInputs)) {
|
|
for (const origin of inputLinkOrigins(graph, node, [inputName])) {
|
|
const clips = collectConditioningNodes(graph, origin, nodeIndex);
|
|
const target = role === 'negative' ? negativeNodes : positiveNodes;
|
|
for (const clip of clips) if (!target.includes(clip)) target.push(clip);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sniff latent resolution
|
|
if (!metadata.resolution) {
|
|
const width = numberValue(widgetValue(node, ['empty_latent_width', 'width'], /empty.*latent/i.test(type) ? 0 : -1));
|
|
const height = numberValue(widgetValue(node, ['empty_latent_height', 'height'], /empty.*latent/i.test(type) ? 1 : -1));
|
|
if (width && height) metadata.resolution = { width, height };
|
|
}
|
|
}
|
|
|
|
for (const summary of metadata.nodes) {
|
|
if (!isSupportedPromptNodeType(summary.type)) continue;
|
|
const node = nodeIndex.get(String(summary.id));
|
|
const prompt = textValue(widgetValue(node, ['text', 'prompt'], 0));
|
|
const isPositive = positiveNodes.includes(node);
|
|
const isNegative = negativeNodes.includes(node);
|
|
|
|
if (isPositive && isNegative) {
|
|
summary.role = 'both';
|
|
summary.roleSource = 'topology';
|
|
if (prompt) {
|
|
appendUnique(metadata.promptPositive, [prompt]);
|
|
appendUnique(metadata.promptNegative, [prompt]);
|
|
}
|
|
} else if (isPositive) {
|
|
summary.role = 'positive';
|
|
summary.roleSource = 'topology';
|
|
if (prompt) appendUnique(metadata.promptPositive, [prompt]);
|
|
} else if (isNegative) {
|
|
summary.role = 'negative';
|
|
summary.roleSource = 'topology';
|
|
if (prompt) appendUnique(metadata.promptNegative, [prompt]);
|
|
} else {
|
|
summary.role = 'unknown';
|
|
summary.roleSource = 'unresolved';
|
|
}
|
|
}
|
|
|
|
// Fallback for embedded prompts on Loader / All-in-One nodes
|
|
// Precedence: External Link > Embedded fallback (only if promptPositive/promptNegative are empty)
|
|
for (const node of graph._nodes) {
|
|
if (isSupportedPromptNodeType(nodeType(node))) continue;
|
|
const targets = promptWidgetTargets(node);
|
|
if (!targets || targets.length === 0) continue;
|
|
|
|
const summary = metadata.nodes.find((item) => String(item?.id) === String(node.id));
|
|
|
|
for (const target of targets) {
|
|
const widget = node.widgets?.[target.index];
|
|
const val = textValue(widget?.value);
|
|
if (!val) continue;
|
|
|
|
const role = classifyPromptWidgetRole(target.name);
|
|
if (role === 'positive' && metadata.promptPositive.length === 0) {
|
|
appendUnique(metadata.promptPositive, [val]);
|
|
if (summary) {
|
|
summary.role = summary.role === 'negative' ? 'both' : 'positive';
|
|
summary.roleSource = 'embedded';
|
|
}
|
|
} else if (role === 'negative' && metadata.promptNegative.length === 0) {
|
|
appendUnique(metadata.promptNegative, [val]);
|
|
if (summary) {
|
|
summary.role = summary.role === 'positive' ? 'both' : 'negative';
|
|
summary.roleSource = 'embedded';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
delete metadata._hasDedicatedSampler;
|
|
return metadata;
|
|
}
|
|
|
|
|
|
/**
|
|
* Capture the graph once at save time. The serialized workflow is the source
|
|
* of truth; metadata is only a bounded presentation layer.
|
|
*/
|
|
export function captureRecipeDraft(graph) {
|
|
const workflow = graph?.serialize?.();
|
|
const nodes = Array.isArray(workflow?.nodes) ? workflow.nodes : [];
|
|
const links = Array.isArray(workflow?.links)
|
|
? workflow.links
|
|
: (workflow?.links && typeof workflow.links === 'object' ? Object.values(workflow.links) : []);
|
|
const scope = analyseRecipeScope(graph);
|
|
return {
|
|
workflow,
|
|
metadata: extractRecipeMetadata(graph),
|
|
workflowScope: scope.scope,
|
|
stats: {
|
|
nodeCount: nodes.length,
|
|
linkCount: links.length,
|
|
groupCount: Array.isArray(workflow?.groups) ? workflow.groups.length : 0,
|
|
boundaryInputCount: scope.boundaryInputCount,
|
|
outputNodeCount: scope.outputNodeCount,
|
|
},
|
|
};
|
|
}
|
|
|
|
function nodeIsActive(node) {
|
|
return node?.mode !== 2 && node?.mode !== 4;
|
|
}
|
|
|
|
function nodeIsOutput(node) {
|
|
return node?.constructor?.nodeData?.output_node === true;
|
|
}
|
|
|
|
function inputIsOptional(node, input) {
|
|
const nodeData = node?.constructor?.nodeData;
|
|
const name = String(input?.name || '');
|
|
const modern = Array.isArray(nodeData?.inputs)
|
|
? nodeData.inputs.find((item) => String(item?.name || '') === name)
|
|
: null;
|
|
if (modern) return modern.isOptional === true || modern.optional === true;
|
|
if (nodeData?.input?.optional && Object.prototype.hasOwnProperty.call(nodeData.input.optional, name)) return true;
|
|
if (nodeData?.input?.required && Object.prototype.hasOwnProperty.call(nodeData.input.required, name)) return false;
|
|
const hollow = globalThis.LiteGraph?.HOLLOW_CIRCLE_SHAPE;
|
|
return hollow !== undefined && input?.shape === hollow;
|
|
}
|
|
|
|
function inputHasWidget(node, input) {
|
|
if (input?.widget || input?.widgetId) return true;
|
|
const name = String(input?.name || '');
|
|
return Boolean(name && (node?.widgets || []).some((widget) => String(widget?.name || '') === name));
|
|
}
|
|
|
|
function recipeGraphNodes(graph) {
|
|
const nodes = [];
|
|
const seen = new Set();
|
|
for (const node of graph?._nodes || []) {
|
|
for (const item of [node, ...(node?.getInnerNodes ? node.getInnerNodes(new Map()) : [])]) {
|
|
if (!item || seen.has(item)) continue;
|
|
seen.add(item);
|
|
nodes.push(item);
|
|
}
|
|
}
|
|
return nodes;
|
|
}
|
|
|
|
/** Classify a live graph as an independently runnable workflow or a reusable fragment. */
|
|
export function analyseRecipeScope(graph) {
|
|
const nodes = recipeGraphNodes(graph).filter(nodeIsActive);
|
|
const outputNodeCount = nodes.filter(nodeIsOutput).length;
|
|
let boundaryInputCount = 0;
|
|
for (const node of nodes) {
|
|
for (const input of node.inputs || []) {
|
|
if (input?.link !== null && input?.link !== undefined) continue;
|
|
if (inputHasWidget(node, input) || inputIsOptional(node, input)) continue;
|
|
boundaryInputCount += 1;
|
|
}
|
|
}
|
|
return {
|
|
scope: outputNodeCount > 0 && boundaryInputCount === 0 ? 'complete' : 'partial',
|
|
outputNodeCount,
|
|
boundaryInputCount,
|
|
};
|
|
}
|
|
|
|
/** Capture a bounded canvas preview; the optional element makes this testable. */
|
|
export function captureCanvasThumbnail(canvasEl = null) {
|
|
const source = canvasEl || document.getElementById('comfy-canvas-element') || document.querySelector('canvas');
|
|
if (!source?.width || !source?.height) return null;
|
|
try {
|
|
const maxEdge = 720;
|
|
const scale = Math.min(1, maxEdge / Math.max(source.width, source.height));
|
|
const thumbnail = document.createElement('canvas');
|
|
thumbnail.width = Math.max(1, Math.round(source.width * scale));
|
|
thumbnail.height = Math.max(1, Math.round(source.height * scale));
|
|
const context = thumbnail.getContext('2d');
|
|
if (!context) return null;
|
|
context.drawImage(source, 0, 0, thumbnail.width, thumbnail.height);
|
|
return thumbnail.toDataURL('image/jpeg', 0.65);
|
|
} catch (error) {
|
|
console.warn('Failed to capture recipe thumbnail:', error);
|
|
return null;
|
|
}
|
|
}
|