349 lines
15 KiB
JavaScript
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?.();
|
|
}
|
|
}
|