diff --git a/README.md b/README.md index 2f41d47..5f99d26 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

ComfyUI Registry MIT License - Version + Version ComfyUI Extension

@@ -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 | +

+ Timeline change-type icons +

+ +| 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Initial + + + + + + + Node Add + + + + + + + Node Remove + + + + + + + Connection + + + + + + + Param + + + + + + + Move + + + + + + + Mixed + + + + + + + Unknown + + 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", () => {