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. */ 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; } }