Files
DemonGatanjieu-Anomalous_Mo…/web/modules/recipe_parser.js
T
DemonGatanjieuandClaude Opus 5.5 40195aa842 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
2026-09-23 08:07:14 +00:00

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;
}
}