@@ -28,7 +28,8 @@
- **Theme-aware UI** — Adapts to light and dark ComfyUI themes
- **Toast notifications** — Visual feedback for save, restore, and error operations
- **SaveSnapshot node** — Trigger snapshot captures from your workflow with a custom node; node snapshots are visually distinct (purple border + "Node" badge) and have their own rolling limit
-- **Timeline bar** — Optional visual timeline on the canvas showing all snapshots as dots, with a Snapshot button for quick captures
+- **Change-type icons** — Timeline markers show what kind of change each snapshot represents (node add, remove, connection, parameter, move, mixed) with distinct colored icons — like Fusion 360's operation timeline
+- **Timeline bar** — Optional visual timeline on the canvas showing all snapshots as iconic markers, with a Snapshot button for quick captures
- **Active & current markers** — When you swap to a snapshot, the timeline highlights where you came from (green dot) and where you are (white ring)
- **Auto-save before swap** — Swapping to an older snapshot automatically saves your current state first, so you can always get back
- **Ctrl+S shortcut** — Press Ctrl+S (or Cmd+S on Mac) to take a manual snapshot alongside ComfyUI's own save
@@ -97,17 +98,35 @@ This is especially useful for recovering snapshots from workflows that were rena
### 8. Timeline Bar
-Enable the timeline in **Settings > Snapshot Manager > Timeline > Show snapshot timeline on canvas**. A thin bar appears at the bottom of the canvas with a dot for each snapshot:
+Enable the timeline in **Settings > Snapshot Manager > Timeline > Show snapshot timeline on canvas**. A thin bar appears at the bottom of the canvas with an iconic marker for each snapshot — each icon shows what kind of change the snapshot represents:
-| Marker | Meaning |
-|--------|---------|
-| **Blue dot** | Regular snapshot |
-| **Purple dot** | Node-triggered snapshot |
+
+
+
+
+| Icon | Color | Change Type |
+|------|-------|-------------|
+| Filled circle | Blue | **Initial** — first snapshot after load |
+| Plus **+** | Green | **Node Add** — nodes were added |
+| Minus **−** | Red | **Node Remove** — nodes were removed |
+| Zigzag | Amber | **Connection** — links/wires changed |
+| Wave | Purple | **Param** — widget values changed |
+| Arrows ↕ | Gray | **Move** — nodes repositioned |
+| Star ✱ | Orange | **Mixed** — multiple change types |
+| Faded dot | Gray | **Unknown** — legacy snapshot or no detected change |
+
+Additional marker styles are layered on top of the change-type icon:
+
+| Overlay | Meaning |
+|---------|---------|
+| **Purple background** | Node-triggered snapshot (overrides change-type color) |
| **Yellow border** | Locked snapshot |
| **White ring (larger)** | Active — the snapshot you swapped TO |
-| **Green dot** | Current — your auto-saved state before the swap |
+| **Green background** | Current — your auto-saved state before the swap |
-Click any dot to swap to that snapshot. The **Snapshot** button on the right side of the bar takes a quick manual snapshot.
+Click any marker to swap to that snapshot. Hover to see a tooltip with the snapshot name, time, and change description. The **Snapshot** button on the right takes a quick manual snapshot.
+
+The sidebar list also shows the change type in the meta line below each snapshot (e.g., "5 nodes · Parameters changed").
### 9. Auto-save Before Swap
@@ -146,9 +165,10 @@ All settings are available in **ComfyUI Settings > Snapshot Manager**:
1. **Graph edits** trigger a `graphChanged` event
2. A **debounce timer** prevents excessive writes
3. The workflow is serialized and **hash-checked** against the last capture (per-workflow) to avoid duplicates
-4. New snapshots are sent to the **server** and stored as individual JSON files under `data/snapshots/`
-5. The **sidebar panel** fetches snapshots from the server and renders the snapshot list
-6. **Restore/Swap** loads graph data back into ComfyUI with a lock guard to prevent concurrent operations
+4. The previous graph state is diffed against the current to **detect the change type** (node add/remove, connection, parameter, move, or mixed) — stored as a `changeType` field on the record
+5. New snapshots are sent to the **server** and stored as individual JSON files under `data/snapshots/`
+6. The **sidebar panel** and **timeline bar** fetch snapshots from the server and render them with change-type icons
+7. **Restore/Swap** loads graph data back into ComfyUI with a lock guard to prevent concurrent operations, and updates the graph cache so the next diff is accurate
**Node-triggered capture flow:**
diff --git a/assets/timeline-icons.svg b/assets/timeline-icons.svg
new file mode 100644
index 0000000..91446c1
--- /dev/null
+++ b/assets/timeline-icons.svg
@@ -0,0 +1,106 @@
+
diff --git a/js/snapshot_manager.js b/js/snapshot_manager.js
index bd52be7..dfeb33a 100644
--- a/js/snapshot_manager.js
+++ b/js/snapshot_manager.js
@@ -28,6 +28,7 @@ let showTimeline = false;
// ─── State ───────────────────────────────────────────────────────────
const lastCapturedHashMap = new Map();
+const lastGraphDataMap = new Map(); // workflowKey -> previous graphData for change-type detection
let restoreLock = null;
let captureTimer = null;
let sidebarRefresh = null; // callback set by sidebar render
@@ -256,6 +257,73 @@ function validateSnapshotData(graphData) {
return graphData != null && typeof graphData === "object" && Array.isArray(graphData.nodes);
}
+// ─── Change-Type Detection ──────────────────────────────────────────
+
+function detectChangeType(prevGraph, currGraph) {
+ if (!prevGraph) return "initial";
+
+ const prevNodes = prevGraph.nodes || [];
+ const currNodes = currGraph.nodes || [];
+ const prevIds = new Set(prevNodes.map(n => n.id));
+ const currIds = new Set(currNodes.map(n => n.id));
+
+ const added = currNodes.filter(n => !prevIds.has(n.id));
+ const removed = prevNodes.filter(n => !currIds.has(n.id));
+ const nodesChanged = added.length > 0 || removed.length > 0;
+
+ // When nodes are added/removed, link changes are expected — don't flag separately
+ if (nodesChanged) {
+ if (added.length > 0 && removed.length > 0) return "mixed";
+ return added.length > 0 ? "node_add" : "node_remove";
+ }
+
+ // Node sets identical — check links, params, positions
+ let flags = 0;
+ const FLAG_CONNECTION = 1;
+ const FLAG_PARAM = 2;
+ const FLAG_MOVE = 4;
+
+ // Compare links
+ const prevLinks = JSON.stringify(prevGraph.links || []);
+ const currLinks = JSON.stringify(currGraph.links || []);
+ if (prevLinks !== currLinks) flags |= FLAG_CONNECTION;
+
+ // Build lookup for prev nodes by id
+ const prevNodeMap = new Map(prevNodes.map(n => [n.id, n]));
+
+ for (const cn of currNodes) {
+ const pn = prevNodeMap.get(cn.id);
+ if (!pn) continue;
+
+ // Compare widget values
+ const cw = JSON.stringify(cn.widgets_values ?? null);
+ const pw = JSON.stringify(pn.widgets_values ?? null);
+ if (cw !== pw) flags |= FLAG_PARAM;
+
+ // Compare positions
+ const cPos = Array.isArray(cn.pos) ? cn.pos : [0, 0];
+ const pPos = Array.isArray(pn.pos) ? pn.pos : [0, 0];
+ if (cPos[0] !== pPos[0] || cPos[1] !== pPos[1]) flags |= FLAG_MOVE;
+
+ // Early exit if all flags set
+ if (flags === (FLAG_CONNECTION | FLAG_PARAM | FLAG_MOVE)) break;
+ }
+
+ if (flags === 0) return "unknown";
+
+ // Count set flags
+ const count = ((flags & FLAG_CONNECTION) ? 1 : 0)
+ + ((flags & FLAG_PARAM) ? 1 : 0)
+ + ((flags & FLAG_MOVE) ? 1 : 0);
+ if (count > 1) return "mixed";
+
+ if (flags & FLAG_CONNECTION) return "connection";
+ if (flags & FLAG_PARAM) return "param";
+ if (flags & FLAG_MOVE) return "move";
+
+ return "unknown";
+}
+
// ─── Restore Lock ───────────────────────────────────────────────────
async function withRestoreLock(fn) {
@@ -330,6 +398,9 @@ async function captureSnapshot(label = "Auto") {
const hash = quickHash(serialized);
if (hash === lastCapturedHashMap.get(workflowKey)) return false;
+ const prevGraph = lastGraphDataMap.get(workflowKey);
+ const changeType = detectChangeType(prevGraph, graphData);
+
const record = {
id: generateId(),
workflowKey,
@@ -338,6 +409,7 @@ async function captureSnapshot(label = "Auto") {
nodeCount: nodes.length,
graphData,
locked: false,
+ changeType,
};
try {
@@ -348,6 +420,7 @@ async function captureSnapshot(label = "Auto") {
}
lastCapturedHashMap.set(workflowKey, hash);
+ lastGraphDataMap.set(workflowKey, graphData);
pickerDirty = true;
currentSnapshotId = null; // new capture supersedes "current" bookmark
activeSnapshotId = null; // graph has changed, no snapshot is "active"
@@ -371,6 +444,8 @@ async function captureNodeSnapshot(label = "Node Trigger") {
if (nodes.length === 0) return false;
const workflowKey = getWorkflowKey();
+ const prevGraph = lastGraphDataMap.get(workflowKey);
+ const changeType = detectChangeType(prevGraph, graphData);
const record = {
id: generateId(),
@@ -381,6 +456,7 @@ async function captureNodeSnapshot(label = "Node Trigger") {
graphData,
locked: false,
source: "node",
+ changeType,
};
try {
@@ -390,6 +466,7 @@ async function captureNodeSnapshot(label = "Node Trigger") {
return false;
}
+ lastGraphDataMap.set(workflowKey, graphData);
pickerDirty = true;
currentSnapshotId = null;
activeSnapshotId = null;
@@ -426,6 +503,7 @@ async function restoreSnapshot(record) {
try {
await app.loadGraphData(record.graphData, true, true);
lastCapturedHashMap.set(getWorkflowKey(), quickHash(JSON.stringify(record.graphData)));
+ lastGraphDataMap.set(getWorkflowKey(), record.graphData);
showToast("Snapshot restored", "success");
} catch (err) {
console.warn(`[${EXTENSION_NAME}] Restore failed:`, err);
@@ -450,6 +528,7 @@ async function swapSnapshot(record) {
const workflow = app.extensionManager?.workflow?.activeWorkflow;
await app.loadGraphData(record.graphData, true, true, workflow);
lastCapturedHashMap.set(getWorkflowKey(), quickHash(JSON.stringify(record.graphData)));
+ lastGraphDataMap.set(getWorkflowKey(), record.graphData);
activeSnapshotId = record.id;
showToast("Snapshot swapped", "success");
} catch (err) {
@@ -787,23 +866,32 @@ const CSS = `
.snap-timeline-track {
flex: 1;
height: 100%;
- position: relative;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ overflow-x: auto;
}
.snap-timeline-marker {
- position: absolute;
- top: 50%;
- width: 10px;
- height: 10px;
+ width: 18px;
+ height: 18px;
border-radius: 50%;
- background: #3b82f6;
- transform: translate(-50%, -50%);
cursor: pointer;
transition: transform 0.1s, box-shadow 0.1s;
border: 2px solid transparent;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--snap-marker-color, #3b82f6);
+}
+.snap-timeline-marker svg {
+ display: block;
+ color: #fff;
+ pointer-events: none;
}
.snap-timeline-marker:hover {
- transform: translate(-50%, -50%) scale(1.5);
- box-shadow: 0 0 6px rgba(59, 130, 246, 0.6);
+ transform: scale(1.4);
+ box-shadow: 0 0 8px var(--snap-marker-color, rgba(59,130,246,0.6));
}
.snap-timeline-marker-node {
background: #6d28d9;
@@ -816,10 +904,10 @@ const CSS = `
}
.snap-timeline-marker-active {
border-color: #fff;
- transform: translate(-50%, -50%) scale(1.3);
+ transform: scale(1.3);
}
.snap-timeline-marker-active:hover {
- transform: translate(-50%, -50%) scale(1.5);
+ transform: scale(1.5);
}
.snap-timeline-marker-current {
background: #10b981;
@@ -852,6 +940,49 @@ const CSS = `
}
`;
+const CHANGE_TYPE_ICONS = {
+ initial: {
+ svg: '',
+ color: "#3b82f6",
+ label: "Initial snapshot",
+ },
+ node_add: {
+ svg: '',
+ color: "#22c55e",
+ label: "Nodes added",
+ },
+ node_remove: {
+ svg: '',
+ color: "#ef4444",
+ label: "Nodes removed",
+ },
+ connection: {
+ svg: '',
+ color: "#f59e0b",
+ label: "Connections changed",
+ },
+ param: {
+ svg: '',
+ color: "#a78bfa",
+ label: "Parameters changed",
+ },
+ move: {
+ svg: '',
+ color: "#64748b",
+ label: "Nodes repositioned",
+ },
+ mixed: {
+ svg: '',
+ color: "#f97316",
+ label: "Multiple changes",
+ },
+ unknown: {
+ svg: '',
+ color: "#6b7280",
+ label: "Unknown change",
+ },
+};
+
function injectStyles() {
if (document.getElementById("snapshot-manager-styles")) return;
const style = document.createElement("style");
@@ -1161,7 +1292,8 @@ async function buildSidebar(el) {
const meta = document.createElement("div");
meta.className = "snap-item-meta";
- meta.textContent = `${rec.nodeCount} nodes`;
+ const changeLabel = (CHANGE_TYPE_ICONS[rec.changeType] || CHANGE_TYPE_ICONS.unknown).label;
+ meta.textContent = `${rec.nodeCount} nodes \u00b7 ${changeLabel}`;
info.appendChild(labelDiv);
info.appendChild(time);
@@ -1297,23 +1429,19 @@ function buildTimeline() {
return;
}
- const minTs = records[0].timestamp;
- const maxTs = records[records.length - 1].timestamp;
- const range = maxTs - minTs;
-
for (const rec of records) {
const marker = document.createElement("div");
marker.className = "snap-timeline-marker";
- // Position: spread evenly if all same timestamp, otherwise by time
- const pct = range > 0
- ? ((rec.timestamp - minTs) / range) * 100
- : 50;
- marker.style.left = `${pct}%`;
+ // Change-type icon and color
+ const iconInfo = CHANGE_TYPE_ICONS[rec.changeType] || CHANGE_TYPE_ICONS.unknown;
+ marker.style.setProperty("--snap-marker-color", iconInfo.color);
+ marker.innerHTML = iconInfo.svg;
- // Node snapshot styling
+ // Node snapshot styling — override color to purple but keep the SVG icon
if (rec.source === "node") {
marker.classList.add("snap-timeline-marker-node");
+ marker.style.setProperty("--snap-marker-color", "#6d28d9");
}
// Locked snapshot styling
@@ -1329,10 +1457,11 @@ function buildTimeline() {
// Current snapshot styling (auto-saved "you were here" bookmark)
if (rec.id === currentSnapshotId) {
marker.classList.add("snap-timeline-marker-current");
+ marker.style.setProperty("--snap-marker-color", "#10b981");
}
- // Native tooltip
- marker.title = `${rec.label} — ${formatTime(rec.timestamp)}`;
+ // Native tooltip with change-type description
+ marker.title = `${rec.label} — ${formatTime(rec.timestamp)}\n${iconInfo.label}`;
// Click to swap
marker.addEventListener("click", () => {