Files
DemonGatanjieuandClaude Opus 5.5 845d2b6178 Let saves and workflows reach each other
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>
2026-11-04 16:39:22 +08:00

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