Improve snapshot reliability and usability
Publish to ComfyUI Registry / Publish Custom Node to Registry (push) Canceled after 0s
Tests / test (push) Canceled after 0s

This commit is contained in:
2026-08-24 23:50:31 +02:00
parent 6648d4b9d6
commit ff59e58b33
11 changed files with 1487 additions and 579 deletions
+341
View File
@@ -0,0 +1,341 @@
/**
* Pure graph comparison and snapshot-diff helpers.
*
* This module intentionally has no ComfyUI or DOM dependencies so the capture
* policy and the UI use the same comparison rules, and the rules can be tested
* without booting the frontend.
*/
export function quickHash(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
return hash;
}
export function deepEqual(a, b) {
if (Object.is(a, b)) return true;
if (typeof a !== typeof b || a == null || b == null) return false;
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) return false;
}
return true;
}
if (typeof a === "object") {
const aKeys = Object.keys(a).sort();
const bKeys = Object.keys(b).sort();
if (!deepEqual(aKeys, bKeys)) return false;
for (const key of aKeys) {
if (!deepEqual(a[key], b[key])) return false;
}
return true;
}
return false;
}
export function validateSnapshotData(graphData) {
return graphData != null && typeof graphData === "object" && Array.isArray(graphData.nodes);
}
const COSMETIC_NODE_KEYS = ["pos", "size", "flags", "order", "color", "bgcolor", "boxcolor", "shape"];
const PARAM_NODE_KEYS = ["type", "title", "mode", "widgets_values", "properties"];
const CONNECTION_GRAPH_KEYS = ["links", "floatingLinks", "reroutes"];
const OTHER_MEANINGFUL_GRAPH_KEYS = ["definitions", "subgraphs", "config"];
function changedOnAnyKey(a, b, keys) {
return keys.some((key) => !deepEqual(a?.[key], b?.[key]));
}
function meaningfulGroupState(groups) {
return (groups || []).filter(Boolean).map((group, index) => ({
id: group.id ?? index,
title: group.title ?? group.name ?? "",
})).sort((a, b) => String(a.id).localeCompare(String(b.id)));
}
/**
* Classify the user-visible change between two serialized ComfyUI graphs.
* Composite widget values are compared structurally, never by object identity.
*/
export function detectChangeType(prevGraph, currGraph) {
if (!prevGraph) return "initial";
const prevNodes = prevGraph.nodes || [];
const currNodes = currGraph.nodes || [];
const prevMap = new Map(prevNodes.filter(Boolean).map((node) => [node.id, node]));
const currMap = new Map(currNodes.filter(Boolean).map((node) => [node.id, node]));
let added = false;
let removed = false;
for (const id of currMap.keys()) if (!prevMap.has(id)) { added = true; break; }
for (const id of prevMap.keys()) if (!currMap.has(id)) { removed = true; break; }
// Node creation/removal is the most useful headline, even though ComfyUI
// may also add/remove associated links in the same transaction.
if (added || removed) {
if (added && removed) return "mixed";
return added ? "node_add" : "node_remove";
}
let connectionChanged = changedOnAnyKey(prevGraph, currGraph, CONNECTION_GRAPH_KEYS);
let paramChanged = changedOnAnyKey(prevGraph, currGraph, OTHER_MEANINGFUL_GRAPH_KEYS)
|| !deepEqual(
meaningfulGroupState(prevGraph.groups),
meaningfulGroupState(currGraph.groups),
);
let cosmeticChanged = !deepEqual(prevGraph.groups || [], currGraph.groups || []);
for (const [id, currNode] of currMap) {
const prevNode = prevMap.get(id);
if (!prevNode) continue;
if (!paramChanged && changedOnAnyKey(prevNode, currNode, PARAM_NODE_KEYS)) paramChanged = true;
if (!cosmeticChanged && changedOnAnyKey(prevNode, currNode, COSMETIC_NODE_KEYS)) cosmeticChanged = true;
if (connectionChanged && paramChanged && cosmeticChanged) break;
}
if (connectionChanged && paramChanged) return "mixed";
if (connectionChanged) return "connection";
if (paramChanged) return "param";
if (cosmeticChanged) return "cosmetic";
// Preserve unfamiliar/extension-defined graph changes instead of dropping
// them as layout noise. Exact duplicates are caught by content hash first.
return deepEqual(prevGraph, currGraph) ? "unchanged" : "unknown";
}
export function isMeaningfulChangeType(changeType) {
return changeType !== "unchanged" && changeType !== "cosmetic";
}
export function buildNodeLookup(...graphs) {
const map = new Map();
for (const graph of graphs) {
if (!graph || !Array.isArray(graph.nodes)) continue;
for (const node of graph.nodes) {
if (!node || map.has(node.id)) continue;
map.set(node.id, {
type: node.type || "?",
title: node.title || node.type || `#${node.id}`,
});
}
}
return map;
}
function widgetNameFor(widgetNames, node, index) {
if (!widgetNames || !node) return null;
const names = widgetNames.get(node.id);
return (names && names[index]) || null;
}
function displayValue(value) {
if (value == null) return "";
if (typeof value === "object") {
try { return JSON.stringify(value); } catch { return String(value); }
}
return String(value);
}
export function computeDetailedDiff(baseGraph, targetGraph, widgetMaps = null) {
const empty = {
addedNodes: [], removedNodes: [], modifiedNodes: [],
addedLinks: [], removedLinks: [], groupChanges: [],
summary: {
nodesAdded: 0, nodesRemoved: 0, nodesModified: 0,
linksAdded: 0, linksRemoved: 0, groupsChanged: 0,
},
};
if (!baseGraph && !targetGraph) return empty;
const baseNodes = baseGraph?.nodes || [];
const targetNodes = targetGraph?.nodes || [];
const baseMap = new Map(baseNodes.filter(Boolean).map((node) => [node.id, node]));
const targetMap = new Map(targetNodes.filter(Boolean).map((node) => [node.id, node]));
const addedNodes = [];
const removedNodes = [];
const modifiedNodes = [];
for (const [id, node] of baseMap) {
if (!targetMap.has(id)) {
removedNodes.push({ id, type: node.type || "?", title: node.title || node.type || `#${id}` });
}
}
for (const [id, targetNode] of targetMap) {
const baseNode = baseMap.get(id);
if (!baseNode) {
addedNodes.push({ id, type: targetNode.type || "?", title: targetNode.title || targetNode.type || `#${id}` });
continue;
}
const changes = {};
if (!deepEqual(baseNode.pos, targetNode.pos)) changes.position = { from: baseNode.pos, to: targetNode.pos };
if (!deepEqual(baseNode.size, targetNode.size)) changes.size = { from: baseNode.size, to: targetNode.size };
if ((baseNode.title || "") !== (targetNode.title || "")) {
changes.title = { from: baseNode.title || "", to: targetNode.title || "" };
}
if ((baseNode.mode || 0) !== (targetNode.mode || 0)) {
changes.mode = { from: baseNode.mode, to: targetNode.mode };
}
const baseWidgets = Array.isArray(baseNode.widgets_values) ? baseNode.widgets_values : [];
const targetWidgets = Array.isArray(targetNode.widgets_values) ? targetNode.widgets_values : [];
if (!deepEqual(baseNode.widgets_values, targetNode.widgets_values)) {
const widgetDiffs = [];
const length = Math.max(baseWidgets.length, targetWidgets.length);
for (let index = 0; index < length; index++) {
const from = baseWidgets[index];
const to = targetWidgets[index];
if (!deepEqual(from, to)) {
widgetDiffs.push({
index,
name: widgetNameFor(widgetMaps, targetNode, index),
from: displayValue(from),
to: displayValue(to),
});
}
}
if (widgetDiffs.length) changes.widgetValues = widgetDiffs;
}
const baseProps = baseNode.properties || {};
const targetProps = targetNode.properties || {};
const propDiffs = [];
for (const key of new Set([...Object.keys(baseProps), ...Object.keys(targetProps)])) {
if (!deepEqual(baseProps[key], targetProps[key])) {
propDiffs.push({ key, from: displayValue(baseProps[key]), to: displayValue(targetProps[key]) });
}
}
if (propDiffs.length) changes.properties = propDiffs;
if (Object.keys(changes).length) {
modifiedNodes.push({
id,
type: targetNode.type || "?",
title: targetNode.title || targetNode.type || `#${id}`,
changes,
});
}
}
const baseLinks = (baseGraph?.links || []).filter(Boolean);
const targetLinks = (targetGraph?.links || []).filter(Boolean);
const baseLinkMap = new Map(baseLinks.map((link) => [link[0], link]));
const targetLinkMap = new Map(targetLinks.map((link) => [link[0], link]));
const addedLinks = [];
const removedLinks = [];
const toLink = (link) => ({
linkId: link[0], srcNodeId: link[1], srcSlot: link[2],
destNodeId: link[3], destSlot: link[4], type: link[5],
});
for (const [linkId, link] of baseLinkMap) {
const targetLink = targetLinkMap.get(linkId);
if (!targetLink || !deepEqual(link, targetLink)) removedLinks.push(toLink(link));
}
for (const [linkId, link] of targetLinkMap) {
const baseLink = baseLinkMap.get(linkId);
if (!baseLink || !deepEqual(link, baseLink)) addedLinks.push(toLink(link));
}
const baseGroups = new Map((baseGraph?.groups || []).filter(Boolean).map(
(group, index) => [group.id ?? `index:${index}`, group],
));
const targetGroups = new Map((targetGraph?.groups || []).filter(Boolean).map(
(group, index) => [group.id ?? `index:${index}`, group],
));
const groupChanges = [];
for (const [id, group] of baseGroups) {
const targetGroup = targetGroups.get(id);
if (!targetGroup) {
groupChanges.push({ id, kind: "removed", title: group.title || group.name || `Group ${id}` });
} else if (!deepEqual(group, targetGroup)) {
const oldTitle = group.title || group.name || `Group ${id}`;
const newTitle = targetGroup.title || targetGroup.name || `Group ${id}`;
groupChanges.push({
id,
kind: "modified",
title: newTitle,
detail: oldTitle !== newTitle
? `Title: ${oldTitle}${newTitle}`
: "Layout or group settings changed",
});
}
}
for (const [id, group] of targetGroups) {
if (!baseGroups.has(id)) {
groupChanges.push({ id, kind: "added", title: group.title || group.name || `Group ${id}` });
}
}
return {
addedNodes, removedNodes, modifiedNodes, addedLinks, removedLinks, groupChanges,
summary: {
nodesAdded: addedNodes.length,
nodesRemoved: removedNodes.length,
nodesModified: modifiedNodes.length,
linksAdded: addedLinks.length,
linksRemoved: removedLinks.length,
groupsChanged: groupChanges.length,
},
};
}
export function computeCaptureMetaDiff(prevGraph, currGraph, widgetMaps = null) {
if (!prevGraph || !currGraph) return null;
const diff = computeDetailedDiff(prevGraph, currGraph, widgetMaps);
const result = {};
if (diff.addedNodes.length) result.added = diff.addedNodes.map((node) => node.title);
if (diff.removedNodes.length) result.removed = diff.removedNodes.map((node) => node.title);
const paramChanged = diff.modifiedNodes.filter((node) =>
node.changes.widgetValues || node.changes.properties || node.changes.title || node.changes.mode
);
if (paramChanged.length) {
result.params = paramChanged.map((node) => {
const names = [];
if (Array.isArray(node.changes.widgetValues)) {
for (const value of node.changes.widgetValues) if (value.name) names.push(value.name);
}
if (Array.isArray(node.changes.properties)) {
for (const value of node.changes.properties) if (value.key) names.push(value.key);
}
if (node.changes.title) names.push("title");
if (node.changes.mode) names.push("mode");
const uniqueNames = [...new Set(names)];
if (uniqueNames.length) return `${node.title} (${uniqueNames.join(", ")})`;
const widgetCount = Array.isArray(node.changes.widgetValues) ? node.changes.widgetValues.length : 0;
const count = widgetCount + (node.changes.properties?.length ?? 0);
return count ? `${node.title} (${count} value${count === 1 ? "" : "s"})` : node.title;
});
}
if (diff.addedLinks.length || diff.removedLinks.length) {
result.links = { added: diff.addedLinks.length, removed: diff.removedLinks.length };
}
if (diff.groupChanges.length) {
result.groups = diff.groupChanges.map((group) => `${group.kind}: ${group.title}`);
}
return Object.keys(result).length ? result : null;
}
export function formatCaptureDiffLines(captureDiff) {
if (!captureDiff) return [];
const lines = [];
if (captureDiff.added?.length) lines.push(`+ ${captureDiff.added.join(", ")}`);
if (captureDiff.removed?.length) lines.push(` ${captureDiff.removed.join(", ")}`);
if (captureDiff.params?.length) lines.push(`~ ${captureDiff.params.join(", ")}`);
if (captureDiff.links) {
const parts = [];
if (captureDiff.links.added) parts.push(`+${captureDiff.links.added} link${captureDiff.links.added === 1 ? "" : "s"}`);
if (captureDiff.links.removed) parts.push(`${captureDiff.links.removed} link${captureDiff.links.removed === 1 ? "" : "s"}`);
if (parts.length) lines.push(parts.join(", "));
}
if (captureDiff.groups?.length) lines.push(`${captureDiff.groups.join(", ")}`);
return lines;
}
+715 -526
View File
File diff suppressed because it is too large Load Diff