A save copies to a workflow card, a preset of a card with the same structure, or a combo; a card's preset can join the open canvas's saves. The Workflows page lists every group, so saves of a closed tab or a deleted file can be opened again, on a new tab where the group goes on, or removed. A card keeps no key of a canvas's saves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
595 lines
26 KiB
JavaScript
595 lines
26 KiB
JavaScript
import { promptBoxes, promptNameRole } from './prompt_boxes.js';
|
|
import { isAllInOneLoaderType, isPlaceholderModelValue } from './recipe_identity.js';
|
|
import { isSelectedModelFile, nodeType, normaliseName, numberValue, textValue, widgetValue } from './recipe_widgets.js';
|
|
import { nodeLoras, syncLoraMetadata } from './recipe_loras.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 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 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',
|
|
]);
|
|
|
|
export function isSupportedPromptNodeType(value) {
|
|
return SUPPORTED_PROMPT_NODE_TYPES.has(normaliseName(value));
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/** 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 = promptNameRole(widget) === 'positive';
|
|
const isNeg = promptNameRole(widget) === 'negative';
|
|
if (isPos || isNeg || (isSupportedPromptNodeType(type) && /^(text|text_g|text_l|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;
|
|
}
|
|
|
|
// LoRAs: loaders, all-in-one loaders, stack rows, Power Lora Loader rows (recipe_loras.js).
|
|
metadata.loras.push(...nodeLoras(node, type));
|
|
|
|
// 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 = promptBoxes(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 = promptNameRole(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.
|
|
*/
|
|
/** `workflow` without the key of the saves it belongs to (ui_snapshot_bar.js): a card opened later is a canvas of its own. */
|
|
function withoutSaves(workflow) {
|
|
if (!workflow?.extra?.anomalous_saves) return workflow;
|
|
const { anomalous_saves: key, ...extra } = workflow.extra;
|
|
return { ...workflow, extra };
|
|
}
|
|
|
|
export function captureRecipeDraft(graph) {
|
|
const workflow = withoutSaves(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;
|
|
}
|
|
}
|