Files
DemonGatanjieu-Anomalous_Mo…/web/modules/recipe_actions.js
T

349 lines
15 KiB
JavaScript

import { app } from '../../../scripts/app.js';
import { mergeRecipeHashRecords } from './recipe_provenance.js';
function cloneJson(value) {
if (value === undefined) return undefined;
return JSON.parse(JSON.stringify(value));
}
function nodeType(node) {
return String(node?.type || node?.class_type || '').trim();
}
function savedNodeRecords(workflow) {
return Array.isArray(workflow?.nodes) ? workflow.nodes.filter((node) => node && node.id !== undefined) : [];
}
function nodeShape(node) {
const inputs = Array.isArray(node?.inputs) ? node.inputs.length : -1;
const outputs = Array.isArray(node?.outputs) ? node.outputs.length : -1;
return `${nodeType(node)}|${inputs}|${outputs}`;
}
function nodeTitle(node) {
return String(node?.title || node?.properties?.['Node name for S&R'] || '').trim().toLowerCase();
}
function liveNodeById(graph, id) {
return graph?.getNodeById?.(id)
|| graph?.getNodeById?.(Number(id))
|| graph?._nodes?.find((node) => String(node?.id) === String(id))
|| null;
}
function volatileWidget(node, widget, index) {
const name = String(widget?.name || '').toLowerCase();
if (/(^|[_\s-])(seed|noise_seed|random_seed|variation_seed|last_seed)([_\s-]|$)/i.test(name)) return true;
const type = nodeType(node).toLowerCase();
if (type === 'ksampler') return index === 0;
if (type === 'ksampleradvanced') return index === 1;
return false;
}
function matchSkeleton(sourceWorkflow, candidateWorkflow) {
const sourceNodes = savedNodeRecords(sourceWorkflow);
const candidateNodes = savedNodeRecords(candidateWorkflow);
const available = new Set(candidateNodes);
const matches = new Map();
const missing = [];
for (const source of sourceNodes) {
const exact = [...available].find((candidate) => (
String(candidate.id) === String(source.id)
&& nodeType(candidate) === nodeType(source)
&& nodeShape(candidate) === nodeShape(source)
));
const sameTitle = [...available].find((candidate) => (
nodeShape(candidate) === nodeShape(source)
&& nodeTitle(candidate) === nodeTitle(source)
&& nodeTitle(source)
));
const sameShape = [...available].find((candidate) => nodeShape(candidate) === nodeShape(source));
const sameType = [...available].find((candidate) => nodeType(candidate) === nodeType(source));
const target = exact || sameTitle || sameShape || sameType;
if (!target) {
missing.push(nodeType(source) || String(source.id));
continue;
}
available.delete(target);
matches.set(String(source.id), target);
}
return { matches, missing, sourceCount: sourceNodes.length, candidateCount: candidateNodes.length };
}
export function assertRecipeSkeleton(sourceWorkflow, candidateWorkflow) {
const result = matchSkeleton(sourceWorkflow, candidateWorkflow);
if (result.missing.length) {
const error = new Error(`Missing recipe skeleton nodes: ${result.missing.join(', ')}`);
error.code = 'recipe_parameter_skeleton_mismatch';
error.missing = result.missing;
throw error;
}
return result;
}
/** Apply only saved widget values to a matching local graph, with rollback. */
export function applyRecipeParametersToCanvas(source) {
const graph = app.graph;
const sourceWorkflow = source?.workflow;
const candidateWorkflow = graph?.serialize?.();
if (!graph || !sourceWorkflow || !candidateWorkflow) throw new Error('recipe_parameter_apply_unavailable');
const skeleton = assertRecipeSkeleton(sourceWorkflow, candidateWorkflow);
const sourceNodes = savedNodeRecords(sourceWorkflow);
const changes = [];
for (const sourceNode of sourceNodes) {
const targetRecord = skeleton.matches.get(String(sourceNode.id));
if (!targetRecord || !Array.isArray(sourceNode.widgets_values)) continue;
// `candidateWorkflow` is a serialized graph and intentionally has no
// live widget objects. Resolve the matched record back to ComfyUI's
// runtime node before reading callbacks or writing widget values.
const target = liveNodeById(graph, targetRecord.id);
if (!target) {
const error = new Error(`Could not resolve live node ${targetRecord.id} on ${nodeType(targetRecord)}`);
error.code = 'recipe_parameter_node_unavailable';
throw error;
}
const targetWidgets = Array.isArray(target.widgets) ? target.widgets : [];
for (let index = 0; index < sourceNode.widgets_values.length; index += 1) {
if (volatileWidget(sourceNode, targetWidgets[index], index)) continue;
if (!targetWidgets[index]) {
const error = new Error(`Missing widget ${index} on ${nodeType(target)}`);
error.code = 'recipe_parameter_widget_mismatch';
throw error;
}
changes.push({
sourceNode,
target,
targetWidget: targetWidgets[index],
index,
value: cloneJson(sourceNode.widgets_values[index]),
previousValue: cloneJson(targetWidgets[index].value),
});
}
}
const previous = changes.map((change) => ({ ...change, previousValue: cloneJson(change.previousValue) }));
graph.beforeChange?.();
try {
for (const change of changes) {
change.targetWidget.value = cloneJson(change.value);
if (Array.isArray(change.target.widgets_values)) change.target.widgets_values[change.index] = cloneJson(change.value);
if (typeof change.targetWidget.callback === 'function') {
change.targetWidget.callback.call(change.targetWidget, change.targetWidget.value, app.canvas, change.target);
}
if (typeof change.target.onWidgetChanged === 'function') {
// Current ComfyUI uses the fourth argument as the live widget
// for its error-clearing hooks. Omitting it makes those hooks
// evaluate `sourceNodeId in undefined`.
change.target.onWidgetChanged(
change.index,
change.targetWidget.value,
change.previousValue,
change.targetWidget,
);
}
}
graph.change?.();
graph.setDirtyCanvas?.(true, true);
app.canvas?.setDirty?.(true, true);
return { nodes: sourceNodes.length, widgets: changes.length };
} catch (error) {
for (const change of previous) {
change.targetWidget.value = cloneJson(change.previousValue);
if (Array.isArray(change.target.widgets_values)) change.target.widgets_values[change.index] = cloneJson(change.previousValue);
}
graph.change?.();
graph.setDirtyCanvas?.(true, true);
throw error;
} finally {
graph.afterChange?.();
}
}
function linkRecords(workflow) {
if (Array.isArray(workflow?.links)) return workflow.links.filter((link) => Array.isArray(link) || link && typeof link === 'object');
if (workflow?.links instanceof Map) return [...workflow.links.values()].filter(Boolean);
if (workflow?.links && typeof workflow.links === 'object') return Object.values(workflow.links).filter(Boolean);
return [];
}
function savedGroupRecords(workflow) {
return Array.isArray(workflow?.groups) ? workflow.groups.filter((group) => group && typeof group === 'object') : [];
}
function graphNode(graph, id) {
return graph?.getNodeById?.(id)
|| graph?._nodes?.find((node) => String(node?.id) === String(id))
|| null;
}
function nextNodeId(graph, usedIds) {
let max = 0;
for (const node of graph?._nodes || []) max = Math.max(max, Number(node?.id) || 0);
for (const id of usedIds) max = Math.max(max, Number(id) || 0);
return max + 1;
}
function liveBounds(graph) {
const nodes = (graph?._nodes || []).filter(Boolean);
if (!nodes.length) return null;
return nodes.reduce((bounds, node) => {
const x = Number(node.pos?.[0]) || 0;
const y = Number(node.pos?.[1]) || 0;
const width = Number(node.size?.[0]) || 220;
const height = Number(node.size?.[1]) || 120;
bounds.minX = Math.min(bounds.minX, x);
bounds.minY = Math.min(bounds.minY, y);
bounds.maxX = Math.max(bounds.maxX, x + width);
bounds.maxY = Math.max(bounds.maxY, y + height);
return bounds;
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
}
function savedBounds(nodes) {
if (!nodes.length) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
return nodes.reduce((bounds, node) => {
const x = Number(node.pos?.[0]) || 0;
const y = Number(node.pos?.[1]) || 0;
const width = Number(node.size?.[0]) || 220;
const height = Number(node.size?.[1]) || 120;
bounds.minX = Math.min(bounds.minX, x);
bounds.minY = Math.min(bounds.minY, y);
bounds.maxX = Math.max(bounds.maxX, x + width);
bounds.maxY = Math.max(bounds.maxY, y + height);
return bounds;
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
}
function instantiateNode(nodeData) {
const type = nodeType(nodeData);
const creator = globalThis.LiteGraph?.createNode;
if (typeof creator !== 'function' || !type) return null;
const node = creator.call(globalThis.LiteGraph, type);
if (!node) return null;
node.configure?.(cloneJson(nodeData));
return node;
}
function instantiateGroup(groupData) {
const GroupClass = globalThis.LiteGraph?.LGraphGroup;
if (typeof GroupClass !== 'function') return null;
const group = new GroupClass(String(groupData?.title || ''));
group.configure?.(cloneJson(groupData));
return group;
}
function linkField(link, arrayIndex, objectKey) {
return Array.isArray(link) ? link[arrayIndex] : link?.[objectKey];
}
function connectLink(link, idMap, graph) {
const originId = linkField(link, 1, 'origin_id');
const targetId = linkField(link, 3, 'target_id');
const originSlot = linkField(link, 2, 'origin_slot');
const targetSlot = linkField(link, 4, 'target_slot');
if (originId === undefined || targetId === undefined || originSlot === undefined || targetSlot === undefined) return false;
const origin = graphNode(graph, idMap.get(String(originId)));
const target = graphNode(graph, idMap.get(String(targetId)));
if (!origin || !target) return false;
const result = origin.connect?.(Number(originSlot), target, Number(targetSlot));
return Boolean(result);
}
/** Merge one serialized workflow into the live graph as one rollback-able action. */
export function appendRecipeToCanvas(recipe) {
const graph = app.graph;
const savedNodes = savedNodeRecords(recipe?.workflow);
const savedLinks = linkRecords(recipe?.workflow);
const savedGroups = savedGroupRecords(recipe?.workflow);
if (!graph || !savedNodes.length) throw new Error('recipe_append_empty');
if (typeof globalThis.LiteGraph?.createNode !== 'function') throw new Error('recipe_append_unsupported');
if (recipe?.workflow?.definitions?.subgraphs?.length) {
throw new Error('recipe_append_subgraphs_unsupported');
}
if (savedGroups.length && typeof globalThis.LiteGraph?.LGraphGroup !== 'function') {
throw new Error('recipe_append_groups_unsupported');
}
const idMap = new Map();
const inserted = [];
const existingIds = new Set((graph._nodes || []).map((node) => String(node?.id)));
let id = nextNodeId(graph, savedNodes.map((node) => node.id));
const sourceBounds = savedBounds(savedNodes);
const targetBounds = liveBounds(graph);
const offsetX = targetBounds ? targetBounds.maxX + 140 - sourceBounds.minX : 40 - sourceBounds.minX;
const offsetY = targetBounds ? targetBounds.minY - sourceBounds.minY : 40 - sourceBounds.minY;
const insertedGroups = [];
const hadHashRecords = Boolean(graph.extra && Object.prototype.hasOwnProperty.call(graph.extra, 'anomalous_hashes'));
const previousHashRecords = cloneJson(graph.extra?.anomalous_hashes);
graph.beforeChange?.();
try {
for (const nodeData of savedNodes) {
const node = instantiateNode(nodeData);
if (!node) {
const error = new Error(`Missing node type: ${nodeType(nodeData)}`);
error.code = 'recipe_append_missing_node';
throw error;
}
while (existingIds.has(String(id))) id += 1;
const oldId = String(nodeData.id);
idMap.set(oldId, id);
node.id = id;
existingIds.add(String(id));
const x = Number(nodeData.pos?.[0]) || 0;
const y = Number(nodeData.pos?.[1]) || 0;
node.pos = [x + offsetX, y + offsetY];
graph.add(node);
inserted.push(node);
id += 1;
}
for (const link of savedLinks) {
if (!connectLink(link, idMap, graph)) {
const error = new Error('Invalid link in recipe');
error.code = 'recipe_append_invalid_link';
throw error;
}
}
for (const groupData of savedGroups) {
const group = instantiateGroup(groupData);
if (!group) {
const error = new Error('Could not restore a recipe group.');
error.code = 'recipe_append_groups_unsupported';
throw error;
}
if (Array.isArray(group.bounding) && group.bounding.length >= 4) {
group.bounding = [
Number(group.bounding[0]) + offsetX,
Number(group.bounding[1]) + offsetY,
group.bounding[2],
group.bounding[3],
];
}
graph.add(group);
insertedGroups.push(group);
}
const hashRecords = mergeRecipeHashRecords(graph, recipe.workflow, idMap);
graph.change?.();
graph.setDirtyCanvas?.(true, true);
app.canvas?.setDirty?.(true, true);
app.canvas?.selectNodes?.(inserted);
app.canvas?.selectItems?.(inserted);
return { nodes: inserted.length, links: savedLinks.length, groups: insertedGroups.length, hashRecords };
} catch (error) {
for (const group of insertedGroups) graph.remove?.(group);
for (const node of inserted) graph.remove?.(node);
if (hadHashRecords) {
if (!graph.extra || typeof graph.extra !== 'object') graph.extra = {};
graph.extra.anomalous_hashes = previousHashRecords;
} else if (graph.extra && typeof graph.extra === 'object') {
delete graph.extra.anomalous_hashes;
}
graph.change?.();
graph.setDirtyCanvas?.(true, true);
throw error;
} finally {
graph.afterChange?.();
}
}