Compare commits

61 Commits
v1.0.1 ... main

Author SHA1 Message Date
7782bda677 Fix potential timeline hangs from cycles and refresh cascades
Add cycle detection and O(n) path building to getAllBranches, and a
concurrency guard on timeline refresh to drop overlapping calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:29:58 +01:00
53b377d2d1 Shrink timeline bar to 50% canvas width
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:46:00 +01:00
048171cb81 Move snapshot data to ComfyUI user directory
Some checks failed
Publish to ComfyUI Registry / Publish Custom Node to Registry (push) Has been cancelled
Store snapshots and profiles under <user_dir>/snapshot_manager/ instead
of <extension>/data/ so user data survives extension updates and reinstalls.
Automatically migrates existing data on first load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:48:40 +01:00
4a8128b5ce Add per-workflow branching toggle with global default setting
Replace the single global branchingEnabled boolean with a two-tier
system: a ComfyUI settings default and per-workflow overrides via the
sidebar Branch button, persisted independently in localStorage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:55:15 +01:00
01e09949fb Fix orphaned branches after clearing all snapshots
The footer "Clear All Snapshots" button deleted records from the server
but left stale in-memory state (lastCapturedIdMap, activeSnapshotId,
etc.) intact. New captures then referenced deleted parents, creating
isolated branches. Reset all branching state on both clear-all paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 13:25:27 +01:00
f9bdc75a2a Add image thumbnail previews for node-triggered snapshots
When SaveSnapshot receives an image tensor, extract a 200x150 JPEG
thumbnail (base64) and include it in the snapshot record. Sidebar shows
a small preview, tooltip displays the generated image instead of SVG,
and the preview modal shows the image above the graph.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 12:42:51 +01:00
e29e7dfad1 Add capture-in-progress guard to prevent duplicate snapshots
Concurrent captureSnapshot calls (e.g. manual Ctrl+S overlapping a
debounced auto-capture) could both pass the hash check before either
sets the hash, creating duplicates. A simple boolean flag now ensures
only one capture runs at a time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:48:45 +01:00
a1c6716ef6 Persist branching and timeline expanded toggles across reload
Store branchingEnabled and timelineExpanded in localStorage. Restore
on init with matching UI state (button class, expand arrow, bar class).
Also persist the forced collapse when branching is disabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 01:06:48 +01:00
320ab8647d Protect ancestors of locked snapshots during pruning
Pruning could delete unlocked intermediate nodes between locked
snapshots and the root, creating orphan branches. Now ancestors of
all locked snapshots are added to the protected set in both regular
and node capture paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 00:39:23 +01:00
fd14d4f1a6 Auto-select branch containing active snapshot on reload
When activeBranchSelections is empty (after reload or tab switch),
selectBranchContaining is called with the effective active ID so the
highlighted branch matches the active snapshot ring.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 00:23:58 +01:00
0c707b43f3 Add delete button to workflow picker to remove unwanted workflows
Shows × on hover for each workflow row. Confirms before deleting all
snapshots. Clears stale in-memory state for the deleted workflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 00:18:28 +01:00
463e234cd1 Persist active ring and change detection across reload and tab switch
- Seed lastCapturedIdMap, lastCapturedHashMap, and lastGraphDataMap
  from most recent snapshot on startup so the active ring appears
  immediately after page reload
- Seed lastCapturedIdMap on workflow tab switch so the ring appears
  when switching to a tab with existing snapshots

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 00:11:27 +01:00
4bfc1912f2 Fix timeline losing active ring, add latest marker, cache repeated calls
- Active ring now falls back to lastCapturedIdMap when activeSnapshotId
  is null (cleared on auto-capture), so the ring persists
- Latest snapshot gets a yellow glow ring for quick identification
- Cache getWorkflowKey() in restore/swap/refresh/populatePicker
- Inline getEffectiveWorkflowKey() to avoid redundant getWorkflowKey()
- Replace double filter() with single loop for record counting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 00:01:55 +01:00
74e1e35e6d Revert "Add Load button to workflow picker to open workflows in new tab"
This reverts commit ea69db87b1.
2026-02-26 14:27:26 +01:00
ea69db87b1 Add Load button to workflow picker to open workflows in new tab
Fetches the latest snapshot and loads it as a named temporary workflow
tab, keeping the current workflow untouched.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:19:19 +01:00
b933c34c73 Remove workflow sidebar toggle from picker
ComfyUI only supports one sidebar at a time, so opening the
workflows panel just replaces the snapshot manager. Revert to
simple view-only behavior for cross-workflow snapshot browsing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:08:18 +01:00
f0e407616e Open workflows sidebar when selecting a different workflow in picker
Instead of trying to programmatically switch the active workflow,
open the ComfyUI workflows sidebar panel so the user can switch
from there. The snapshot picker still shows the selected workflow's
snapshots in view-only mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:02:13 +01:00
ebbdfa4249 Fix workflow picker not switching active workflow
The store's openWorkflow only updates Pinia state without switching
the canvas. Use app.loadGraphData with the workflow object instead,
matching ComfyUI's internal workflow service pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 13:53:58 +01:00
24fe846646 Skip shared ancestors in expanded timeline, switch workflow on picker click
Expanded timeline: non-active branch rows now only show their unique
markers after the fork point, indented to align with the active branch.
Removes the dimmed-ancestor approach in favor of cleaner layout.

Workflow picker: clicking a workflow that is open in ComfyUI now
switches to that tab instead of just viewing its snapshots.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 13:39:14 +01:00
eddf3c6acc Fix inverted expand/collapse arrows on timeline
The timeline grows upward, so show ▴ when collapsed and ▾ when expanded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 13:05:20 +01:00
0b0b9d4c39 Add expanded timeline mode to show all branches at once
Adds a toggle button (▾/▴) to the timeline bar that expands it
vertically, displaying every branch as its own row. The active
branch is highlighted, shared ancestors are dimmed on other rows,
and clicking any marker on a non-active row switches to that branch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:57:18 +01:00
fa76fbf97a Push arrow glyphs to edges of timeline bar
Top arrow aligns content to flex-start (top edge), bottom arrow to
flex-end (bottom edge), so both arrow tips sit at their respective
edges instead of centering in the available space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:32:22 +01:00
f93441b187 Color timeline branch arrows to match their marker
Arrows inherit the marker's --snap-marker-color so they match the
node color (blue for regular, purple for node snapshots, green for
current, etc).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:31:30 +01:00
e1a48a2456 Make top arrow reach top edge of timeline bar
Fork group fills full timeline height with space-between layout.
Arrow buttons flex to fill remaining space so both arrow tips reach
their respective edges of the timeline bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:26:51 +01:00
54387c2df4 Fix top arrow position with negative margin
Use margin-bottom:-3px on the top arrow to compensate for the Unicode
glyph's built-in whitespace and pull it tight against the marker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:25:29 +01:00
e4d1c496f2 Fix top arrow to mirror bottom arrow style
Restore 8px size for both arrows. Top arrow uses align-items:flex-end
to push glyph down against marker, bottom arrow uses flex-start to
push glyph up against marker, making them symmetrical.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:21:43 +01:00
f3fab45bf0 Fix timeline top arrow not mirroring bottom arrow position
Reduce arrow button font-size and height from 8px to 6px and set
line-height to 0 so both arrows sit symmetrically tight against the
marker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:20:40 +01:00
d7bd9c4991 Add toggle to disable snapshot branching
New "Branch" button next to "Hide Auto" in the search row. When
toggled off: captures have no parentId, sidebar/timeline show a flat
timestamp-sorted list, branch navigators are hidden, and pruning
skips tree-aware protection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:18:06 +01:00
284f4e9538 Simplify timeline fork display: remove badge, tighten arrows
Remove the fork count badge from timeline markers and the wrapper div.
Reduce arrow button height and remove extra spacing so the up arrow
sits flush against the marker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:12:41 +01:00
d0057db397 Rework timeline fork display to vertical arrows with badge
Replace horizontal arrow layout with vertical stack: up/down arrows
above and below the marker, with a small blue badge showing the fork
count on the marker node.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:07:14 +01:00
d142df5332 Bump version to 3.0.0 and update documentation
Some checks failed
Publish to ComfyUI Registry / Publish Custom Node to Registry (push) Has been cancelled
Add documentation for snapshot branching, session profiles,
hide auto-saves toggle, and timeline branch navigation.
Update features list, usage sections, architecture, and FAQ.
Align pyproject.toml and README badge to 3.0.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:59:58 +01:00
3809af2662 Add hide auto-save toggle to sidebar filter
Toggle button next to the search bar hides Auto and Initial
snapshots to reduce clutter. Filter combines with text search
and persists across refreshes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:53:18 +01:00
bca7e7cf8f Add snapshot branching and profile/session manager
Branching: snapshots now track parentId to form a tree structure.
Swapping to an old snapshot and editing forks into a new branch.
Sidebar and timeline show < 1/3 > navigators at fork points to
switch between branches. Pruning protects ancestors and fork points.
Deleting a fork point re-parents its children.

Profiles: save/load named sets of workflows as session profiles.
Backend stores profiles as JSON in data/profiles/. Sidebar has a
collapsible Profiles section with save, load, and delete.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:51:00 +01:00
7518821447 Add in-memory metadata cache to avoid redundant disk I/O
List, prune, and delete operations now use a lightweight in-memory cache
of snapshot metadata (everything except graphData). Only get_full_record()
and update_meta() hit disk after warm-up, keeping sidebar loads and
auto-capture prune cycles fast.

Key changes:
- snapshot_storage.py: cache layer (_cache, _cache_warmed, _extract_meta,
  _ensure_cached), new get_full_record() and update_meta() functions,
  all existing functions updated to use cache
- snapshot_routes.py: new /get and /update-meta endpoints
- snapshot_manager.js: db_getFullRecord() and db_updateMeta() helpers,
  lazy graphData fetch in restore/swap/diff/preview/tooltip, label/notes/
  lock use update_meta instead of full put to preserve graphData on disk

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 10:57:41 +01:00
ab3bbc7f71 Refresh timeline after clearing or deleting snapshots
The sidebar refresh didn't trigger a timeline update, leaving stale
markers visible until the next auto-capture or manual action.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:31:03 +01:00
0b7fb5be0e Highlight active and current snapshots in sidebar
Add white left border for the swapped-to snapshot and green left border
for the auto-saved "you were here" snapshot, matching timeline markers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:28:06 +01:00
e5d9e6ca99 Replace emoji buttons with inline SVG icons
Swap note (pencil), lock/unlock (padlock), and preview (eye) buttons
from emoji characters to crisp inline SVGs using currentColor. Fixes
inconsistent emoji rendering across platforms and enables proper color
transitions (e.g. amber highlight for has-note state via CSS color
instead of filter hacks).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:39:54 +01:00
31b846cd5f Add SVG snapshot previews and bump to v2.5.0
Render workflow graphs as SVG previews so users can visually inspect
snapshots without restoring them. Adds hover tooltips, a full-size
preview modal (eye button), and side-by-side SVG comparison with
color-coded highlights in the diff view.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:38:02 +01:00
4b392a89cf Add snapshot diff view and bump to v2.4.0
Some checks failed
Publish to ComfyUI Registry / Publish Custom Node to Registry (push) Has been cancelled
Compare any snapshot vs the current workflow (one click) or two snapshots
against each other (Shift+click to set base). Modal shows added/removed/modified
nodes, widget value changes, property diffs, and rewired connections with
collapsible sections and colored indicators.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 08:14:29 +01:00
f9a821f8b4 Bump version to 2.3.0
Some checks failed
Publish to ComfyUI Registry / Publish Custom Node to Registry (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 00:58:21 +01:00
efc3791a57 Update README for centered timeline bar and smarter swap auto-save
Document the floating centered layout, sidebar layering behavior,
and the skip-when-browsing optimization for swap auto-saves.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 00:53:35 +01:00
dc2e408026 Lower timeline z-index below sidebar so panel covers it when open
Sidebar uses z-index 10; set timeline to 9 so it stays visible on the
canvas but sits underneath the sidebar panel when expanded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 00:52:37 +01:00
73cde5ade1 Skip redundant auto-save when browsing between snapshots
When activeSnapshotId is set, the current graph state is already a
saved snapshot. Swapping to another one no longer creates a duplicate
"Current" entry — the auto-save only fires on the first swap away
from an unsaved graph state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 00:46:38 +01:00
e554dc9973 Center timeline bar at 80% width with rounded corners
Replace full-width bar with centered floating pill that clears both the
left sidebar and right controls. Uses left/right: 10% with border-radius
and a small bottom offset. Icons remain left-aligned inside the track.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 00:35:32 +01:00
95cbcd4f6c Offset timeline padding to avoid floating sidebar overlap
Detect whether the sidebar icon strip is in floating mode and on which
side, then add matching padding so timeline markers aren't hidden
behind it. Connected mode (sidebar pushes canvas) needs no offset.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 00:27:38 +01:00
0219d86301 Revert canvas parent z-index to avoid masking sidebar
Keep only the timeline z-index: 1000 bump. Setting z-index on the
canvas parent would create a stacking context that paints over the
sidebar panel, blocking interaction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 00:25:04 +01:00
862045ecf1 Fix timeline bar hidden behind sidebar by raising z-index
ComfyUI's sidebar container uses z-index: 10. The timeline was at the
same level and lost in the stacking order. Bump timeline to z-index
1000 and set z-index 20 on the canvas parent so it forms a stacking
context above the sidebar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 00:24:32 +01:00
0b8304f458 Optimize detectChangeType to avoid unnecessary JSON.stringify
Use node count shortcut, element-wise widget comparison, link length
and boundary checks, and skip-when-flagged guards to eliminate most
serialization work in the common case.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 00:13:09 +01:00
8a8a01adff Add Fusion 360-style change-type icons to timeline markers
Each snapshot now detects what kind of change it represents (node add,
node remove, connection, parameter, move, mixed) and displays a distinct
colored icon on the timeline. Sidebar meta line shows the change type.
Existing snapshots without change data gracefully fall back to a faded
"unknown" dot.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 00:10:36 +01:00
51cb2f6855 Update README for v2.2.0 — document timeline, auto-save, Ctrl+S shortcut
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:41:23 +01:00
ee4051ad72 Add timeline active marker, auto-save before swap, snapshot button & Ctrl+S
- Track active (white ring) and current (green dot) snapshots on timeline
- Auto-capture "Current" state before swapping so user can navigate back
- Add "Snapshot" button to timeline bar for quick manual captures
- Register Ctrl+S / Cmd+S shortcut for manual snapshots
- Clear active/current markers on new captures and workflow switches
- Return record.id from captureSnapshot (backward-compatible truthy value)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:40:03 +01:00
25b909f99f Update README and architecture diagram for v2.1.0
Add SaveSnapshot node to features list and architecture diagram,
update storage label from IndexedDB to Server Storage, add
maxNodeSnapshots setting to the table, and document the
node-triggered capture flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:10:48 +01:00
11c6b7237b Bump version to 2.1.0
Some checks failed
Publish to ComfyUI Registry / Publish Custom Node to Registry (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:24:00 +01:00
3877c5838c Add SaveSnapshot node with visual separation and rolling limit
Introduce a SaveSnapshot custom node that triggers snapshot captures
via WebSocket. Node-triggered snapshots are visually distinct in the
sidebar (purple left border + "Node" badge) and managed with their
own independent rolling limit (maxNodeSnapshots setting), separate
from auto/manual snapshot pruning. Node snapshots skip hash-dedup
so repeated queue runs always capture.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:22:53 +01:00
838b3d0b00 Fix swapSnapshot using stale app.workflowManager reference
Missed one occurrence of the old API in swapSnapshot() — the workflow
passed to loadGraphData was always undefined, making Swap behave
like Restore (new tab instead of in-place replacement).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:57:29 +01:00
4cbd8fd0a9 Fix workflow key detection — use extensionManager.workflow store
app.workflowManager doesn't exist in the modern Vue-based frontend.
The active workflow is accessed via the Pinia store at
app.extensionManager.workflow.activeWorkflow. Also replaces the
non-existent addEventListener("changeWorkflow") with Pinia's
$onAction watching for "openWorkflow" actions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:55:22 +01:00
d32349bfdf Migrate snapshot storage from IndexedDB to server-side JSON files (v2.0.0)
Some checks failed
Publish to ComfyUI Registry / Publish Custom Node to Registry (push) Has been cancelled
Snapshots are now stored as individual JSON files on the server under
data/snapshots/, making them persistent across browsers and resilient
to browser data loss. Existing IndexedDB data is auto-migrated on
first load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:13:23 +01:00
81118f4610 Bump version to 1.1.1 — fix workflow key detection
Some checks failed
Publish to ComfyUI Registry / Publish Custom Node to Registry (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:39:44 +01:00
4eec4cf135 Fix workflow key detection — use .key/.filename instead of .name
The ComfyUI activeWorkflow object doesn't have a .name property. It uses
.key (path minus workflows/ prefix) and .filename (no extension). The old
code always fell through to "default", making all snapshots share one key.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:39:08 +01:00
b90ebba068 Add workflow browser — browse snapshots from any workflow (v1.1.0)
Some checks failed
Publish to ComfyUI Registry / Publish Custom Node to Registry (push) Has been cancelled
Adds a workflow selector to the sidebar so users can browse and recover
snapshots from any workflow in the database, including renamed or deleted
ones. Includes amber viewing banner, take-snapshot guard, and lazy-loaded
workflow picker with counts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:29:44 +01:00
e5ba68c356 Update README for v1.0.1: document lock/pin snapshots feature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:51:01 +01:00
11 changed files with 4461 additions and 227 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
__pycache__/
*.pyc
data/

229
README.md
View File

@@ -5,13 +5,13 @@
<p align="center">
<a href="https://registry.comfy.org/publishers/ethanfel/nodes/comfyui-snapshot-manager"><img src="https://img.shields.io/badge/ComfyUI-Registry-blue?logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJMMyA3djEwbDkgNSA5LTVWN2wtOS01eiIgZmlsbD0id2hpdGUiLz48L3N2Zz4=" alt="ComfyUI Registry"/></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License"/></a>
<img src="https://img.shields.io/badge/version-1.0.0-blue" alt="Version"/>
<img src="https://img.shields.io/badge/version-3.0.0-blue" alt="Version"/>
<img src="https://img.shields.io/badge/ComfyUI-Extension-purple" alt="ComfyUI Extension"/>
</p>
---
**Workflow Snapshot Manager** automatically captures your ComfyUI workflow as you edit. Browse, name, search, and restore any previous version from a sidebar panel — all stored locally in your browser's IndexedDB.
**Workflow Snapshot Manager** automatically captures your ComfyUI workflow as you edit. Browse, name, search, and restore any previous version from a sidebar panel — stored as JSON files on the server, accessible from any browser.
<p align="center">
<img src="assets/sidebar-preview.png" alt="Sidebar Preview" width="300"/>
@@ -23,11 +23,25 @@
- **Custom naming** — Name your snapshots when taking them manually ("Before merge", "Working v2", etc.)
- **Search & filter** — Quickly find snapshots by name with the filter bar
- **Restore or Swap** — Open a snapshot as a new workflow, or replace the current one in-place
- **Workflow browser** — Browse and recover snapshots from any workflow, including renamed or deleted ones
- **Per-workflow storage** — Each workflow has its own independent snapshot history
- **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
- **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 centered floating bar on the canvas showing all snapshots as iconic markers, with a Snapshot button for quick captures; tucks behind the sidebar when open
- **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; browsing between saved snapshots skips redundant saves
- **Ctrl+S shortcut** — Press Ctrl+S (or Cmd+S on Mac) to take a manual snapshot alongside ComfyUI's own save
- **SVG graph previews** — Hover any snapshot for a tooltip preview of the workflow graph; click the eye button for a full-size modal; diff view now shows side-by-side SVG comparison with color-coded highlights (green = added, red = removed, amber = modified)
- **Diff view** — Compare any snapshot against the current workflow (one click) or two snapshots against each other (Shift+click to set base); see added/removed/modified nodes, widget value changes, and rewired connections in a single modal
- **Snapshot branching** — Swap to an old snapshot and edit to fork into a new branch; navigate between branches with `< 1/3 >` arrows at fork points in the sidebar and timeline, like ChatGPT conversation branching
- **Profile manager** — Save and load named sets of workflows as session profiles (like browser tab groups); profiles track which workflows you visited and restore the latest snapshot for each
- **Hide auto-saves** — Toggle button next to the search bar hides auto-save snapshots to reduce clutter while keeping manual, locked, and node-triggered snapshots visible
- **Lock/pin snapshots** — Protect important snapshots from auto-pruning and "Clear All" with a single click
- **Concurrency-safe** — Lock guard prevents double-click issues during restore
- **Zero backend** — Pure frontend extension, no server dependencies
- **Server-side storage** — Snapshots persist on the ComfyUI server's filesystem, accessible from any browser
- **Automatic migration** — Existing IndexedDB snapshots are imported to the server on first load
## Installation
@@ -64,28 +78,166 @@ Use the filter bar at the top of the panel to search snapshots by name. The clea
### 5. Restore or Swap
Each snapshot has two action buttons:
Each snapshot has action buttons:
| Button | Action |
|--------|--------|
| **Preview** (eye icon) | Opens a full-size SVG preview of the workflow graph |
| **Lock** | Toggles lock protection (padlock icon) |
| **Swap** | Replaces the current workflow in-place (same tab) |
| **Restore** | Opens the snapshot as a new workflow |
### 6. Delete & Clear
### 6. Lock / Pin Snapshots
- Click **&times;** on any snapshot to delete it individually
- Click **Clear All Snapshots** in the footer to remove all snapshots for the current workflow (with confirmation dialog)
Click the **padlock icon** on any snapshot to lock it. Locked snapshots are protected from:
- **Auto-pruning** — When the snapshot count exceeds the max, only unlocked snapshots are pruned
- **Clear All** — Locked snapshots survive bulk deletion (the toast reports how many were kept)
To unlock, click the padlock again. Deleting a locked snapshot individually is still possible but requires confirmation.
### 7. Browse Other Workflows
Click the **workflow name** below the header to expand the workflow picker. It lists every workflow that has snapshots in the database, with counts. Click any workflow to view its snapshots — an amber banner confirms you're viewing a different workflow, and "Take Snapshot" is disabled to avoid confusion. Click **Back to current** to return.
This is especially useful for recovering snapshots from workflows that were renamed or deleted.
### 8. Timeline Bar
Enable the timeline in **Settings > Snapshot Manager > Timeline > Show snapshot timeline on canvas**. A centered floating 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:
<p align="center">
<img src="assets/timeline-icons.svg" alt="Timeline change-type icons" width="720"/>
</p>
| 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 background** | Current — your auto-saved state before the swap |
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 bar is centered at 80% width to clear both the sidebar icon strip and bottom-right controls, and tucks behind the sidebar panel when it's open.
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
When you swap to an older snapshot (via the sidebar or timeline), the extension automatically captures a "Current" snapshot of your work-in-progress first. This green-marked snapshot appears on the timeline so you can click it to get back. The marker disappears once you edit the graph (since auto-capture creates a proper snapshot at that point). Browsing between existing snapshots does not create additional "Current" entries — the auto-save only triggers on the first swap away from unsaved work.
### 10. Keyboard Shortcut
Press **Ctrl+S** (or **Cmd+S** on Mac) to take a manual snapshot. This works alongside ComfyUI's own workflow save — both fire simultaneously.
### 11. Delete & Clear
- Click **&times;** on any snapshot to delete it individually (locked snapshots prompt for confirmation)
- Click **Clear All Snapshots** in the footer to remove all unlocked snapshots for the current workflow (locked snapshots are preserved)
### 12. Diff View
Compare two snapshots — or a snapshot against the current workflow — to see exactly what changed without touching the graph.
**One-click (vs current workflow):** Click **Diff** on any snapshot to see what changed between that snapshot and your current live workflow.
**Two-snapshot compare:** **Shift+click** **Diff** on snapshot A to set it as the base (purple outline + toast confirmation), then click **Diff** on snapshot B to compare A → B. The base clears after comparison.
The diff modal shows:
| Section | Details |
|---------|---------|
| **SVG comparison** | Side-by-side graph previews at the top — base on the left, target on the right, with highlighted nodes (green = added, red = removed, amber = modified) |
| **Summary pills** | Colored counts — green (added), red (removed), amber (modified), blue (links) |
| **Added Nodes** | Nodes present in the target but not the base |
| **Removed Nodes** | Nodes present in the base but not the target |
| **Modified Nodes** | Nodes with changed position, size, title, mode, widget values, or properties — each change shown as old (red strikethrough) → new (green) |
| **Link Changes** | Added/removed connections with node names and slot indices |
Sections are collapsible (click the header to toggle). If the two snapshots are identical, a "No differences found." message is shown. Dismiss the modal with **Escape**, the **X** button, or by clicking outside.
### 13. SVG Graph Previews
Visually inspect any snapshot without restoring or swapping it.
**Hover tooltip:** Hover over any snapshot in the sidebar list. After 200ms, a small SVG preview appears next to the item showing the graph layout with nodes, links, and groups. Move the mouse away to dismiss.
**Preview modal:** Click the **eye button** on any snapshot to open a full-size preview modal showing the complete graph with node titles, colored link beziers, input/output slot dots, and group overlays. Dismiss with **Escape**, the **X** button, or by clicking outside.
The SVG renderer draws nodes with their stored position, size, and colors. Links are rendered as bezier curves colored by type (blue for IMAGE, orange for CLIP, purple for MODEL, etc.). Collapsed nodes appear as thin title-only strips. Thumbnails (hover tooltips) auto-simplify by hiding labels and slot dots for clarity at small sizes.
### 14. Snapshot Branching
Branching lets you explore multiple variations of a workflow without losing any history — similar to conversation branching in ChatGPT.
**How it works:**
1. Work normally — snapshots chain linearly as you edit
2. **Swap** to an older snapshot and start editing — the next auto-capture forks into a new branch from that point
3. A **`< 1/2 >`** branch navigator appears at every fork point in the sidebar and the timeline
4. Click the arrows to switch between branches — the sidebar and timeline update together
**Details:**
- Each snapshot stores a `parentId` linking it to its predecessor, forming a tree
- Legacy snapshots (from before branching) are automatically chained by timestamp for backwards compatibility
- **Pruning is branch-safe** — ancestors of the current branch tip and fork-point snapshots are never auto-pruned
- **Deleting a fork point** re-parents its children to the deleted snapshot's parent (with a confirmation dialog)
- Switching workflows clears branch navigation state
### 15. Hide Auto-saves
Click the **Hide Auto** button next to the search bar to hide all auto-save snapshots ("Auto" and "Initial"). The button turns blue and switches to **Show Auto** when active.
The filter works together with text search — both are applied simultaneously. Branch navigators remain visible regardless of the filter. Manual, locked, node-triggered, and "Current" snapshots are always shown.
### 16. Session Profiles
Save and load named sets of workflows — like browser tab groups for ComfyUI.
**Save a profile:**
1. Click the **`>`** Profiles toggle between the workflow picker and search bar to expand
2. Click **Save** — enter a profile name
3. The profile captures all workflows you've visited in this session
**Load a profile:**
1. Expand the Profiles section
2. Click **Load** on any profile — the extension fetches the latest snapshot for each workflow and loads them via `loadGraphData`
3. The profile's active workflow is loaded last so it ends up visible
4. A toast reports how many workflows were loaded and how many were skipped (missing snapshots)
**Delete a profile:** Click **X** on any profile (with confirmation).
Profiles are stored as JSON files on the server at `<extension_dir>/data/profiles/`.
> **Note:** ComfyUI's `loadGraphData` replaces the current workflow — there is no API to open new tabs. Each loaded workflow overwrites the previous one. The user ends up seeing the last loaded workflow (the active one). Previously loaded workflows may appear in ComfyUI's workflow history/tabs depending on the frontend version.
## Settings
All settings are available in **ComfyUI Settings > Snapshot Manager > Capture Settings**:
All settings are available in **ComfyUI Settings > Snapshot Manager**:
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| **Auto-capture on edit** | Toggle | `On` | Automatically save snapshots when the workflow changes |
| **Capture delay** | Slider | `3s` | Seconds to wait after the last edit before auto-capturing (130s) |
| **Max snapshots per workflow** | Slider | `50` | Maximum number of snapshots kept per workflow (5200). Oldest are pruned automatically |
| **Max snapshots per workflow** | Slider | `50` | Maximum number of unlocked snapshots kept per workflow (5200). Oldest unlocked are pruned automatically; locked snapshots are never pruned |
| **Capture on workflow load** | Toggle | `On` | Save an "Initial" snapshot when a workflow is first loaded |
| **Max node-triggered snapshots** | Slider | `5` | Rolling limit for SaveSnapshot node captures per workflow (150). Node snapshots are pruned independently from auto/manual snapshots |
| **Show snapshot timeline** | Toggle | `Off` | Display a timeline bar at the bottom of the canvas with snapshot markers, active/current indicators, and a quick Snapshot button |
## Architecture
@@ -93,27 +245,72 @@ All settings are available in **ComfyUI Settings > Snapshot Manager > Capture Se
<img src="assets/architecture.png" alt="Architecture Diagram" width="100%"/>
</p>
**Data flow:**
**Auto/manual capture flow:**
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 written to **IndexedDB** (browser-local, persistent)
5. The **sidebar panel** reads from IndexedDB 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
**Storage:** All data stays in your browser's IndexedDB — nothing is sent to any server. Snapshots persist across browser sessions and ComfyUI restarts.
**Node-triggered capture flow:**
1. **SaveSnapshot node** executes during a queue prompt run
2. A **WebSocket event** is sent to the frontend, **skipping hash dedup** (the workflow doesn't change between runs)
3. The snapshot is saved with `source: "node"` and pruned against its own rolling limit (`maxNodeSnapshots`)
4. Node snapshots appear in the sidebar with a **purple left border** and **"Node" badge**
**Swap with auto-save:**
1. User clicks **Swap** (sidebar or timeline marker)
2. If the current graph is unsaved work (not already a swapped snapshot), `captureSnapshot("Current")` saves it **before** the swap — browsing between existing snapshots skips this step
3. The target snapshot is loaded into the graph
4. The **timeline** updates: the swapped-to snapshot gets a white ring (active), the auto-saved snapshot gets a green dot (current)
5. Clicking the green dot swaps back; editing the graph clears both markers (the next auto-capture supersedes them)
**Branching:**
1. Each snapshot stores a `parentId` pointing to its predecessor
2. `buildSnapshotTree()` constructs parent/child maps from all records — legacy snapshots (no `parentId`) are chained by timestamp automatically
3. `getDisplayPath()` walks the tree from root to tip, following `activeBranchSelections` at each fork point, producing the linear branch view
4. The sidebar and timeline render only the current branch; `< 1/3 >` navigators at fork points switch between branches
5. **Pruning protection**: before pruning, `getAncestorIds()` collects all ancestors of the branch tip; these IDs plus fork-point IDs are sent as `protectedIds` to the server
**Profiles:**
1. Session tracking records each visited workflow key with timestamps
2. **Save** creates a JSON file at `<extension_dir>/data/profiles/<id>.json` with the workflow list and active workflow
3. **Load** fetches the latest snapshot for each workflow in the profile and calls `loadGraphData`
**Storage:** Snapshots are stored as JSON files on the server at `<extension_dir>/data/snapshots/<workflow_key>/<id>.json`. Profiles are stored at `<extension_dir>/data/profiles/<id>.json`. Both persist across browser sessions, ComfyUI restarts, and are accessible from any browser connecting to the same server.
## FAQ
**Where are snapshots stored?**
In your browser's IndexedDB under the database `ComfySnapshotManager`. They persist across sessions but are browser-local (not synced between devices).
On the server's filesystem under `<extension_dir>/data/snapshots/`. Each workflow gets its own directory, and each snapshot is an individual JSON file. They persist across browser sessions and are accessible from any browser connecting to the same ComfyUI server.
**I'm upgrading from v1.x — what happens to my existing snapshots?**
On first load after upgrading, the extension automatically migrates all snapshots from your browser's IndexedDB to the server. Once migration succeeds, the old IndexedDB database is deleted. If migration fails (e.g., server unreachable), your old data is preserved and migration will retry on the next load.
**Will this slow down ComfyUI?**
No. Snapshots are captured asynchronously after a debounce delay. The hash check prevents redundant writes.
**What happens if I switch workflows?**
Each workflow has its own snapshot history. Switching workflows cancels any pending captures and shows the correct snapshot list.
Each workflow has its own snapshot history. Switching workflows cancels any pending captures and shows the correct snapshot list. You can also browse snapshots from other workflows using the workflow picker.
**I renamed/deleted a workflow — are my snapshots gone?**
No. Snapshots are keyed by the workflow name at capture time. Use the workflow picker to find and restore them under the old name.
**How does branching work?**
When you swap to an old snapshot and then edit, the next capture forks into a new branch. A `< 1/2 >` navigator appears at the fork point — click the arrows to switch branches. The tree structure is computed from `parentId` links on each snapshot. Old snapshots without `parentId` (from before v3.0) are automatically chained by timestamp.
**Can I delete a fork-point snapshot?**
Yes. The extension re-parents its children to the deleted snapshot's parent, preserving the branch structure. A confirmation dialog warns you first.
**What are profiles?**
Profiles save a list of workflows you've visited in a session. Loading a profile restores the latest snapshot for each workflow. They're useful for switching between project contexts — like browser tab groups.
**Can I use this with ComfyUI Manager?**
Yes — install via ComfyUI Manager or clone the repo into `custom_nodes/`.

View File

@@ -2,9 +2,12 @@
ComfyUI Snapshot Manager
Automatically snapshots workflow state as you edit, with a sidebar panel
to browse and restore any previous version. Stored in IndexedDB.
to browse and restore any previous version. Stored in server-side JSON files.
"""
from . import snapshot_routes
from .snapshot_node import SaveSnapshot
WEB_DIRECTORY = "./js"
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
NODE_CLASS_MAPPINGS = {"SaveSnapshot": SaveSnapshot}
NODE_DISPLAY_NAME_MAPPINGS = {"SaveSnapshot": "Save Snapshot"}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 161 KiB

View File

@@ -1,93 +1,100 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 420">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 450">
<defs>
<linearGradient id="abg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0f172a"/>
<stop offset="100%" style="stop-color:#1e293b"/>
</linearGradient>
</defs>
<rect width="800" height="420" rx="12" fill="url(#abg)"/>
<!-- Title -->
<text x="400" y="35" text-anchor="middle" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#94a3b8">How It Works</text>
<!-- Graph Edit box -->
<rect x="40" y="60" width="160" height="70" rx="8" fill="#1e293b" stroke="#3b82f6" stroke-width="2"/>
<text x="120" y="92" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">Graph Edit</text>
<text x="120" y="112" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">graphChanged event</text>
<!-- Arrow 1 -->
<line x1="200" y1="95" x2="250" y2="95" stroke="#475569" stroke-width="2" marker-end="url(#arrowhead)"/>
<!-- Debounce box -->
<rect x="250" y="60" width="160" height="70" rx="8" fill="#1e293b" stroke="#f59e0b" stroke-width="2"/>
<text x="330" y="92" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">Debounce Timer</text>
<text x="330" y="112" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">configurable delay</text>
<!-- Arrow 2 -->
<line x1="410" y1="95" x2="460" y2="95" stroke="#475569" stroke-width="2" marker-end="url(#arrowhead)"/>
<!-- Hash Check box -->
<rect x="460" y="60" width="160" height="70" rx="8" fill="#1e293b" stroke="#8b5cf6" stroke-width="2"/>
<text x="540" y="92" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">Hash Check</text>
<text x="540" y="112" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">per-workflow map</text>
<!-- Arrow 3 -->
<line x1="620" y1="95" x2="640" y2="95" stroke="#475569" stroke-width="2"/>
<line x1="640" y1="95" x2="640" y2="180" stroke="#475569" stroke-width="2"/>
<line x1="640" y1="180" x2="620" y2="180" stroke="#475569" stroke-width="2" marker-end="url(#arrowhead-left)"/>
<!-- IndexedDB box -->
<rect x="460" y="150" width="160" height="70" rx="8" fill="#1e293b" stroke="#22c55e" stroke-width="2"/>
<text x="540" y="182" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">IndexedDB</text>
<text x="540" y="202" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">persistent storage</text>
<!-- Arrow 4 down to sidebar -->
<line x1="540" y1="220" x2="540" y2="265" stroke="#475569" stroke-width="2" marker-end="url(#arrowhead)"/>
<!-- Sidebar Panel box (wide) -->
<rect x="250" y="265" width="370" height="130" rx="8" fill="#1e293b" stroke="#3b82f6" stroke-width="2"/>
<text x="435" y="295" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#e2e8f0">Sidebar Panel</text>
<!-- Sidebar sub-items -->
<rect x="270" y="310" width="100" height="32" rx="5" fill="#3b82f6" opacity="0.15" stroke="#3b82f6" stroke-width="1"/>
<text x="320" y="330" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#93c5fd">Take Snapshot</text>
<rect x="380" y="310" width="70" height="32" rx="5" fill="#22c55e" opacity="0.15" stroke="#22c55e" stroke-width="1"/>
<text x="415" y="330" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#86efac">Restore</text>
<rect x="460" y="310" width="55" height="32" rx="5" fill="#f59e0b" opacity="0.15" stroke="#f59e0b" stroke-width="1"/>
<text x="488" y="330" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#fcd34d">Swap</text>
<rect x="525" y="310" width="70" height="32" rx="5" fill="#8b5cf6" opacity="0.15" stroke="#8b5cf6" stroke-width="1"/>
<text x="560" y="330" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#c4b5fd">Search</text>
<text x="435" y="375" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">toast notifications &#183; confirm dialogs &#183; loading states</text>
<!-- Restore arrow back up -->
<rect x="40" y="180" width="160" height="70" rx="8" fill="#1e293b" stroke="#22c55e" stroke-width="2"/>
<text x="120" y="207" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">Restore / Swap</text>
<text x="120" y="227" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">with lock guard</text>
<line x1="250" y1="330" x2="200" y2="265" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrowhead-green)"/>
<line x1="120" y1="180" x2="120" y2="130" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrowhead-green)"/>
<text x="120" y="155" text-anchor="middle" font-family="system-ui, sans-serif" font-size="10" fill="#22c55e">loadGraphData</text>
<!-- Manual capture arrow -->
<line x1="320" y1="310" x2="460" y2="185" stroke="#3b82f6" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrowhead-blue)"/>
<!-- Arrowhead markers -->
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#475569"/>
</marker>
<marker id="arrowhead-left" markerWidth="10" markerHeight="7" refX="0" refY="3.5" orient="auto-start-reverse">
<polygon points="10 0, 0 3.5, 10 7" fill="#475569"/>
</marker>
<marker id="arrowhead-green" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#22c55e"/>
</marker>
<marker id="arrowhead-blue" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#3b82f6"/>
</marker>
<marker id="arrowhead-purple" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#6d28d9"/>
</marker>
</defs>
<rect width="800" height="450" rx="12" fill="url(#abg)"/>
<!-- Title -->
<text x="400" y="35" text-anchor="middle" font-family="system-ui, sans-serif" font-size="16" font-weight="600" fill="#94a3b8">How It Works</text>
<!-- ═══ Row 1: Auto-capture path ═══ -->
<rect x="40" y="55" width="160" height="70" rx="8" fill="#1e293b" stroke="#3b82f6" stroke-width="2"/>
<text x="120" y="87" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">Graph Edit</text>
<text x="120" y="107" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">graphChanged event</text>
<line x1="200" y1="90" x2="250" y2="90" stroke="#475569" stroke-width="2" marker-end="url(#arrowhead)"/>
<rect x="250" y="55" width="160" height="70" rx="8" fill="#1e293b" stroke="#f59e0b" stroke-width="2"/>
<text x="330" y="87" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">Debounce Timer</text>
<text x="330" y="107" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">configurable delay</text>
<line x1="410" y1="90" x2="460" y2="90" stroke="#475569" stroke-width="2" marker-end="url(#arrowhead)"/>
<rect x="460" y="55" width="160" height="70" rx="8" fill="#1e293b" stroke="#8b5cf6" stroke-width="2"/>
<text x="540" y="87" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">Hash Check</text>
<text x="540" y="107" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">per-workflow dedup</text>
<!-- Arrow: Hash Check → Server Storage (bend down) -->
<line x1="620" y1="90" x2="655" y2="90" stroke="#475569" stroke-width="2"/>
<line x1="655" y1="90" x2="655" y2="160" stroke="#475569" stroke-width="2" marker-end="url(#arrowhead)"/>
<!-- ═══ Row 2: Node path + Server Storage ═══ -->
<rect x="40" y="165" width="170" height="70" rx="8" fill="#1e293b" stroke="#6d28d9" stroke-width="2"/>
<text x="125" y="197" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">SaveSnapshot Node</text>
<text x="125" y="217" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">queue prompt trigger</text>
<!-- Arrow: SaveSnapshot → Server Storage with label -->
<line x1="210" y1="200" x2="545" y2="200" stroke="#6d28d9" stroke-width="2" marker-end="url(#arrowhead-purple)"/>
<text x="378" y="192" text-anchor="middle" font-family="system-ui, sans-serif" font-size="10" fill="#a78bfa">WS event &#183; skips hash dedup</text>
<rect x="545" y="160" width="220" height="80" rx="8" fill="#1e293b" stroke="#22c55e" stroke-width="2"/>
<text x="655" y="195" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">Server Storage</text>
<text x="655" y="215" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">JSON files &#183; per-workflow dirs</text>
<!-- Arrow: Server Storage → Sidebar -->
<line x1="655" y1="240" x2="655" y2="285" stroke="#475569" stroke-width="2" marker-end="url(#arrowhead)"/>
<!-- ═══ Row 3: Sidebar + Restore ═══ -->
<rect x="250" y="285" width="515" height="130" rx="8" fill="#1e293b" stroke="#3b82f6" stroke-width="2"/>
<text x="507" y="313" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" font-weight="600" fill="#e2e8f0">Sidebar Panel</text>
<rect x="270" y="327" width="100" height="32" rx="5" fill="#3b82f6" opacity="0.15" stroke="#3b82f6" stroke-width="1"/>
<text x="320" y="347" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#93c5fd">Take Snapshot</text>
<rect x="380" y="327" width="70" height="32" rx="5" fill="#22c55e" opacity="0.15" stroke="#22c55e" stroke-width="1"/>
<text x="415" y="347" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#86efac">Restore</text>
<rect x="460" y="327" width="55" height="32" rx="5" fill="#f59e0b" opacity="0.15" stroke="#f59e0b" stroke-width="1"/>
<text x="488" y="347" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#fcd34d">Swap</text>
<rect x="525" y="327" width="70" height="32" rx="5" fill="#8b5cf6" opacity="0.15" stroke="#8b5cf6" stroke-width="1"/>
<text x="560" y="347" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#c4b5fd">Search</text>
<rect x="605" y="327" width="55" height="32" rx="5" fill="#6d28d9" opacity="0.15" stroke="#6d28d9" stroke-width="1"/>
<text x="633" y="347" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#a78bfa">Lock</text>
<text x="507" y="395" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">toast notifications &#183; confirm dialogs &#183; loading states</text>
<!-- Restore/Swap box -->
<rect x="40" y="305" width="160" height="70" rx="8" fill="#1e293b" stroke="#22c55e" stroke-width="2"/>
<text x="120" y="337" text-anchor="middle" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#e2e8f0">Restore / Swap</text>
<text x="120" y="357" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" fill="#64748b">with lock guard</text>
<!-- Dashed: Sidebar → Restore -->
<line x1="250" y1="350" x2="200" y2="340" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrowhead-green)"/>
<!-- Dashed: Restore → Graph Edit (routed around left side) -->
<path d="M 40 335 H 20 V 90 H 40" fill="none" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrowhead-green)"/>
<text x="11" y="250" text-anchor="middle" font-family="system-ui, sans-serif" font-size="10" fill="#22c55e" transform="rotate(-90, 11, 250)">loadGraphData</text>
<!-- Dashed: Take Snapshot → Server Storage -->
<line x1="320" y1="327" x2="558" y2="240" stroke="#3b82f6" stroke-width="1.5" stroke-dasharray="6,3" marker-end="url(#arrowhead-blue)"/>
</svg>

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

106
assets/timeline-icons.svg Normal file
View File

@@ -0,0 +1,106 @@
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="80" viewBox="0 0 720 80">
<!-- Background -->
<rect width="720" height="80" rx="8" fill="#1e293b"/>
<!-- Icon definitions -->
<defs>
<!-- Initial (blue) -->
<symbol id="icon-initial" viewBox="0 0 12 12">
<circle cx="6" cy="6" r="5" fill="currentColor"/>
</symbol>
<!-- Node Add (green) -->
<symbol id="icon-node-add" viewBox="0 0 12 12">
<path d="M6 2v8M2 6h8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</symbol>
<!-- Node Remove (red) -->
<symbol id="icon-node-remove" viewBox="0 0 12 12">
<path d="M2 6h8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</symbol>
<!-- Connection (amber) -->
<symbol id="icon-connection" viewBox="0 0 12 12">
<path d="M1 9L4 3L8 9L11 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</symbol>
<!-- Param (purple) -->
<symbol id="icon-param" viewBox="0 0 12 12">
<path d="M0 6Q3 2 6 6Q9 10 12 6" stroke="currentColor" stroke-width="1.5" fill="none"/>
</symbol>
<!-- Move (gray) -->
<symbol id="icon-move" viewBox="0 0 12 12">
<path d="M6 1L3 4h6L6 1ZM6 11L3 8h6L6 11Z" fill="currentColor"/>
</symbol>
<!-- Mixed (orange) -->
<symbol id="icon-mixed" viewBox="0 0 12 12">
<path d="M6 1L7.5 4.5H11L8.25 6.75L9.5 10.5L6 8L2.5 10.5L3.75 6.75L1 4.5H4.5Z" fill="currentColor"/>
</symbol>
<!-- Unknown (gray) -->
<symbol id="icon-unknown" viewBox="0 0 12 12">
<circle cx="6" cy="6" r="3" fill="currentColor" opacity="0.5"/>
</symbol>
</defs>
<!-- 8 icons evenly spaced: center positions at 45, 141.43, 237.86, 334.29, 430.71, 527.14, 623.57, 720-45=675 -->
<!-- Spacing: (720 - 2*45) / 7 = 90 -->
<!-- 1. Initial -->
<g transform="translate(45, 0)">
<circle cx="0" cy="28" r="16" fill="#3b82f6"/>
<use href="#icon-initial" x="-8" y="20" width="16" height="16" color="white"/>
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Initial</text>
</g>
<!-- 2. Node Add -->
<g transform="translate(135, 0)">
<circle cx="0" cy="28" r="16" fill="#22c55e"/>
<use href="#icon-node-add" x="-8" y="20" width="16" height="16" color="white"/>
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Node Add</text>
</g>
<!-- 3. Node Remove -->
<g transform="translate(225, 0)">
<circle cx="0" cy="28" r="16" fill="#ef4444"/>
<use href="#icon-node-remove" x="-8" y="20" width="16" height="16" color="white"/>
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Node Remove</text>
</g>
<!-- 4. Connection -->
<g transform="translate(315, 0)">
<circle cx="0" cy="28" r="16" fill="#f59e0b"/>
<use href="#icon-connection" x="-8" y="20" width="16" height="16" color="white"/>
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Connection</text>
</g>
<!-- 5. Param -->
<g transform="translate(405, 0)">
<circle cx="0" cy="28" r="16" fill="#a78bfa"/>
<use href="#icon-param" x="-8" y="20" width="16" height="16" color="white"/>
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Param</text>
</g>
<!-- 6. Move -->
<g transform="translate(495, 0)">
<circle cx="0" cy="28" r="16" fill="#64748b"/>
<use href="#icon-move" x="-8" y="20" width="16" height="16" color="white"/>
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Move</text>
</g>
<!-- 7. Mixed -->
<g transform="translate(585, 0)">
<circle cx="0" cy="28" r="16" fill="#f97316"/>
<use href="#icon-mixed" x="-8" y="20" width="16" height="16" color="white"/>
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Mixed</text>
</g>
<!-- 8. Unknown -->
<g transform="translate(675, 0)">
<circle cx="0" cy="28" r="16" fill="#6b7280"/>
<use href="#icon-unknown" x="-8" y="20" width="16" height="16" color="white"/>
<text x="0" y="60" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="#94a3b8">Unknown</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
[project]
name = "comfyui-snapshot-manager"
description = "Automatically snapshots workflow state with a sidebar to browse and restore previous versions."
version = "1.0.0"
version = "3.1.0"
license = {text = "MIT"}
[project.urls]

70
snapshot_node.py Normal file
View File

@@ -0,0 +1,70 @@
import base64
import io
from server import PromptServer
class _AnyType(str):
def __ne__(self, other):
return False
ANY_TYPE = _AnyType("*")
def _make_thumbnail(value):
"""Convert an image tensor to a base64 JPEG thumbnail, or return None."""
try:
import torch
if not isinstance(value, torch.Tensor):
return None
if value.ndim != 4 or value.shape[3] not in (3, 4):
return None
from PIL import Image
frame = value[0] # first frame only
if frame.shape[2] == 4:
frame = frame[:, :, :3] # drop alpha
arr = frame.clamp(0, 1).mul(255).byte().cpu().numpy()
img = Image.fromarray(arr, mode="RGB")
img.thumbnail((200, 150), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=75)
return base64.b64encode(buf.getvalue()).decode("ascii")
except Exception:
return None
class SaveSnapshot:
CATEGORY = "Snapshot Manager"
FUNCTION = "execute"
RETURN_TYPES = (ANY_TYPE,)
OUTPUT_NODE = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"value": (ANY_TYPE, {}),
"label": ("STRING", {"default": "Node Trigger"}),
}
}
@classmethod
def VALIDATE_INPUTS(cls, input_types):
return True
@classmethod
def IS_CHANGED(cls, *args, **kwargs):
return float("NaN")
def execute(self, value, label):
payload = {"label": label}
thumbnail = _make_thumbnail(value)
if thumbnail is not None:
payload["thumbnail"] = thumbnail
PromptServer.instance.send_sync(
"snapshot-manager-capture", payload
)
return (value,)

208
snapshot_routes.py Normal file
View File

@@ -0,0 +1,208 @@
"""
HTTP route handlers for snapshot storage.
Registers endpoints with PromptServer.instance.routes at import time.
"""
from aiohttp import web
from server import PromptServer
from . import snapshot_storage as storage
routes = PromptServer.instance.routes
@routes.post("/snapshot-manager/save")
async def save_snapshot(request):
try:
data = await request.json()
record = data.get("record")
if not record or "id" not in record or "workflowKey" not in record:
return web.json_response({"error": "Missing record with id and workflowKey"}, status=400)
storage.put(record)
return web.json_response({"ok": True})
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@routes.post("/snapshot-manager/list")
async def list_snapshots(request):
try:
data = await request.json()
workflow_key = data.get("workflowKey")
if not workflow_key:
return web.json_response({"error": "Missing workflowKey"}, status=400)
records = storage.get_all_for_workflow(workflow_key)
return web.json_response(records)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@routes.post("/snapshot-manager/get")
async def get_snapshot(request):
try:
data = await request.json()
workflow_key = data.get("workflowKey")
snapshot_id = data.get("id")
if not workflow_key or not snapshot_id:
return web.json_response({"error": "Missing workflowKey or id"}, status=400)
record = storage.get_full_record(workflow_key, snapshot_id)
if record is None:
return web.json_response({"error": "Not found"}, status=404)
return web.json_response(record)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@routes.post("/snapshot-manager/update-meta")
async def update_snapshot_meta(request):
try:
data = await request.json()
workflow_key = data.get("workflowKey")
snapshot_id = data.get("id")
fields = data.get("fields")
if not workflow_key or not snapshot_id or not isinstance(fields, dict):
return web.json_response({"error": "Missing workflowKey, id, or fields"}, status=400)
ok = storage.update_meta(workflow_key, snapshot_id, fields)
if not ok:
return web.json_response({"error": "Not found"}, status=404)
return web.json_response({"ok": True})
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@routes.post("/snapshot-manager/delete")
async def delete_snapshot(request):
try:
data = await request.json()
workflow_key = data.get("workflowKey")
snapshot_id = data.get("id")
if not workflow_key or not snapshot_id:
return web.json_response({"error": "Missing workflowKey or id"}, status=400)
storage.delete(workflow_key, snapshot_id)
return web.json_response({"ok": True})
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@routes.post("/snapshot-manager/delete-all")
async def delete_all_snapshots(request):
try:
data = await request.json()
workflow_key = data.get("workflowKey")
if not workflow_key:
return web.json_response({"error": "Missing workflowKey"}, status=400)
result = storage.delete_all_for_workflow(workflow_key)
return web.json_response(result)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@routes.get("/snapshot-manager/workflows")
async def list_workflows(request):
try:
keys = storage.get_all_workflow_keys()
return web.json_response(keys)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@routes.post("/snapshot-manager/prune")
async def prune_snapshots(request):
try:
data = await request.json()
workflow_key = data.get("workflowKey")
max_snapshots = data.get("maxSnapshots")
source = data.get("source")
protected_ids = data.get("protectedIds")
if not workflow_key or max_snapshots is None:
return web.json_response({"error": "Missing workflowKey or maxSnapshots"}, status=400)
deleted = storage.prune(workflow_key, int(max_snapshots), source=source, protected_ids=protected_ids)
return web.json_response({"deleted": deleted})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@routes.post("/snapshot-manager/migrate")
async def migrate_snapshots(request):
try:
data = await request.json()
records = data.get("records")
if not isinstance(records, list):
return web.json_response({"error": "Missing records array"}, status=400)
imported = 0
for record in records:
if "id" in record and "workflowKey" in record:
storage.put(record)
imported += 1
return web.json_response({"imported": imported})
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# ─── Profile Endpoints ───────────────────────────────────────────────
@routes.post("/snapshot-manager/profile/save")
async def save_profile(request):
try:
data = await request.json()
profile = data.get("profile")
if not profile or "id" not in profile:
return web.json_response({"error": "Missing profile with id"}, status=400)
storage.profile_put(profile)
return web.json_response({"ok": True})
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@routes.get("/snapshot-manager/profile/list")
async def list_profiles(request):
try:
profiles = storage.profile_get_all()
return web.json_response(profiles)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@routes.post("/snapshot-manager/profile/get")
async def get_profile(request):
try:
data = await request.json()
profile_id = data.get("id")
if not profile_id:
return web.json_response({"error": "Missing id"}, status=400)
profile = storage.profile_get(profile_id)
if profile is None:
return web.json_response({"error": "Not found"}, status=404)
return web.json_response(profile)
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@routes.post("/snapshot-manager/profile/delete")
async def delete_profile(request):
try:
data = await request.json()
profile_id = data.get("id")
if not profile_id:
return web.json_response({"error": "Missing id"}, status=400)
storage.profile_delete(profile_id)
return web.json_response({"ok": True})
except ValueError as e:
return web.json_response({"error": str(e)}, status=400)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)

429
snapshot_storage.py Normal file
View File

@@ -0,0 +1,429 @@
"""
Filesystem storage layer for workflow snapshots.
Stores each snapshot as an individual JSON file under:
<user_dir>/snapshot_manager/snapshots/<encoded_workflow_key>/<id>.json
Workflow keys are percent-encoded for filesystem safety.
An in-memory metadata cache avoids redundant disk reads for list/prune/delete
operations. Only get_full_record() reads a file from disk after warm-up.
"""
import json
import os
import shutil
import urllib.parse
# ─── Data directory resolution ───────────────────────────────────────
# Prefer ComfyUI's persistent user directory; fall back to extension-local
# paths when running outside ComfyUI (e.g. tests).
_OLD_DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
try:
import folder_paths
_USER_SM_DIR = os.path.join(folder_paths.get_user_directory(), "snapshot_manager")
except Exception:
_USER_SM_DIR = os.path.join(os.path.dirname(__file__), "data")
_DATA_DIR = os.path.join(_USER_SM_DIR, "snapshots")
# ─── In-memory metadata cache ────────────────────────────────────────
# Maps workflow_key -> list of metadata dicts (sorted by timestamp asc).
# Metadata is everything *except* graphData.
_cache = {}
_cache_warmed = set() # workflow keys already loaded from disk
def _extract_meta(record):
"""Return a lightweight copy of *record* without graphData."""
return {k: v for k, v in record.items() if k != "graphData"}
def _ensure_cached(workflow_key):
"""Warm the cache for *workflow_key* if not already loaded. Return cached list."""
if workflow_key not in _cache_warmed:
d = _workflow_dir(workflow_key)
entries = []
if os.path.isdir(d):
for fname in os.listdir(d):
if not fname.endswith(".json"):
continue
path = os.path.join(d, fname)
try:
with open(path, "r", encoding="utf-8") as f:
entries.append(_extract_meta(json.load(f)))
except (json.JSONDecodeError, OSError):
continue
entries.sort(key=lambda r: r.get("timestamp", 0))
_cache[workflow_key] = entries
_cache_warmed.add(workflow_key)
return _cache.get(workflow_key, [])
# ─── Helpers ─────────────────────────────────────────────────────────
def _workflow_dir(workflow_key):
encoded = urllib.parse.quote(workflow_key, safe="")
return os.path.join(_DATA_DIR, encoded)
def _validate_id(snapshot_id):
if not snapshot_id or "/" in snapshot_id or "\\" in snapshot_id or ".." in snapshot_id:
raise ValueError(f"Invalid snapshot id: {snapshot_id!r}")
# ─── Public API ──────────────────────────────────────────────────────
def put(record):
"""Write one snapshot record to disk and update the cache."""
snapshot_id = record["id"]
workflow_key = record["workflowKey"]
_validate_id(snapshot_id)
d = _workflow_dir(workflow_key)
os.makedirs(d, exist_ok=True)
path = os.path.join(d, f"{snapshot_id}.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(record, f, separators=(",", ":"))
# Update cache only if already warmed; otherwise _ensure_cached will
# pick up the new file from disk on next read.
if workflow_key in _cache_warmed:
meta = _extract_meta(record)
cached = _cache[workflow_key]
cached[:] = [e for e in cached if e.get("id") != snapshot_id]
cached.append(meta)
cached.sort(key=lambda r: r.get("timestamp", 0))
def get_all_for_workflow(workflow_key):
"""Return all snapshot metadata for a workflow (no graphData), sorted ascending by timestamp."""
return [dict(e) for e in _ensure_cached(workflow_key)]
def get_full_record(workflow_key, snapshot_id):
"""Read a single snapshot file from disk (with graphData). Returns dict or None."""
_validate_id(snapshot_id)
path = os.path.join(_workflow_dir(workflow_key), f"{snapshot_id}.json")
if not os.path.isfile(path):
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return None
def update_meta(workflow_key, snapshot_id, fields):
"""Merge *fields* into an existing snapshot on disk without touching graphData.
Returns True on success, False if the file does not exist.
"""
_validate_id(snapshot_id)
path = os.path.join(_workflow_dir(workflow_key), f"{snapshot_id}.json")
if not os.path.isfile(path):
return False
with open(path, "r", encoding="utf-8") as f:
record = json.load(f)
# Merge fields; None values remove the key
for k, v in fields.items():
if v is None:
record.pop(k, None)
else:
record[k] = v
with open(path, "w", encoding="utf-8") as f:
json.dump(record, f, separators=(",", ":"))
# Update cache entry
for entry in _cache.get(workflow_key, []):
if entry.get("id") == snapshot_id:
for k, v in fields.items():
if k == "graphData":
continue
if v is None:
entry.pop(k, None)
else:
entry[k] = v
break
return True
def delete(workflow_key, snapshot_id):
"""Remove one snapshot file and its cache entry. Cleans up empty workflow dir."""
_validate_id(snapshot_id)
d = _workflow_dir(workflow_key)
path = os.path.join(d, f"{snapshot_id}.json")
if os.path.isfile(path):
os.remove(path)
# Update cache
if workflow_key in _cache:
_cache[workflow_key] = [e for e in _cache[workflow_key] if e.get("id") != snapshot_id]
if not _cache[workflow_key]:
del _cache[workflow_key]
_cache_warmed.discard(workflow_key)
# Clean up empty directory
if os.path.isdir(d) and not os.listdir(d):
os.rmdir(d)
def delete_all_for_workflow(workflow_key):
"""Delete all unlocked snapshots for a workflow. Returns {lockedCount}."""
entries = _ensure_cached(workflow_key)
locked = []
locked_count = 0
d = _workflow_dir(workflow_key)
for rec in entries:
if rec.get("locked"):
locked_count += 1
locked.append(rec)
else:
_validate_id(rec["id"])
path = os.path.join(d, f"{rec['id']}.json")
if os.path.isfile(path):
os.remove(path)
# Update cache to locked-only
if locked:
_cache[workflow_key] = locked
else:
_cache.pop(workflow_key, None)
_cache_warmed.discard(workflow_key)
# Clean up empty directory
if os.path.isdir(d) and not os.listdir(d):
os.rmdir(d)
return {"lockedCount": locked_count}
def get_all_workflow_keys():
"""Scan subdirs and return [{workflowKey, count}]."""
if not os.path.isdir(_DATA_DIR):
return []
results = []
for encoded_name in os.listdir(_DATA_DIR):
subdir = os.path.join(_DATA_DIR, encoded_name)
if not os.path.isdir(subdir):
continue
workflow_key = urllib.parse.unquote(encoded_name)
entries = _ensure_cached(workflow_key)
if not entries:
continue
results.append({"workflowKey": workflow_key, "count": len(entries)})
results.sort(key=lambda r: r["workflowKey"])
return results
def prune(workflow_key, max_snapshots, source=None, protected_ids=None):
"""Delete oldest unlocked snapshots beyond limit. Returns count deleted.
source filtering:
- "node": only prune records where source == "node"
- "regular": only prune records where source is absent or not "node"
- None: prune all unlocked (existing behavior)
protected_ids: set/list of snapshot IDs that must not be pruned
(e.g. ancestors of active branch tip, fork-point snapshots).
"""
_protected = set(protected_ids) if protected_ids else set()
entries = _ensure_cached(workflow_key)
if source == "node":
candidates = [r for r in entries if not r.get("locked") and r.get("source") == "node" and r.get("id") not in _protected]
elif source == "regular":
candidates = [r for r in entries if not r.get("locked") and r.get("source") != "node" and r.get("id") not in _protected]
else:
candidates = [r for r in entries if not r.get("locked") and r.get("id") not in _protected]
if len(candidates) <= max_snapshots:
return 0
to_delete = candidates[: len(candidates) - max_snapshots]
d = _workflow_dir(workflow_key)
deleted = 0
delete_ids = set()
for rec in to_delete:
_validate_id(rec["id"])
path = os.path.join(d, f"{rec['id']}.json")
if os.path.isfile(path):
os.remove(path)
deleted += 1
delete_ids.add(rec["id"])
# Update cache
if delete_ids and workflow_key in _cache:
_cache[workflow_key] = [e for e in _cache[workflow_key] if e.get("id") not in delete_ids]
if not _cache[workflow_key]:
del _cache[workflow_key]
_cache_warmed.discard(workflow_key)
# Clean up empty directory
if os.path.isdir(d) and not os.listdir(d):
os.rmdir(d)
return deleted
# ─── Profile Storage ─────────────────────────────────────────────────
# Profiles are stored as individual JSON files under snapshot_manager/profiles/<id>.json
_PROFILES_DIR = os.path.join(_USER_SM_DIR, "profiles")
_profile_cache = None # list of profile dicts, or None if not loaded
def _ensure_profiles_dir():
os.makedirs(_PROFILES_DIR, exist_ok=True)
def _load_profile_cache():
global _profile_cache
if _profile_cache is not None:
return _profile_cache
_ensure_profiles_dir()
profiles = []
for fname in os.listdir(_PROFILES_DIR):
if not fname.endswith(".json"):
continue
path = os.path.join(_PROFILES_DIR, fname)
try:
with open(path, "r", encoding="utf-8") as f:
profiles.append(json.load(f))
except (json.JSONDecodeError, OSError):
continue
profiles.sort(key=lambda p: p.get("timestamp", 0))
_profile_cache = profiles
return _profile_cache
def _invalidate_profile_cache():
global _profile_cache
_profile_cache = None
def profile_put(profile):
"""Create or update a profile. profile must have 'id'."""
pid = profile["id"]
_validate_id(pid)
_ensure_profiles_dir()
path = os.path.join(_PROFILES_DIR, f"{pid}.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(profile, f, separators=(",", ":"))
_invalidate_profile_cache()
def profile_get_all():
"""Return all profiles sorted by timestamp."""
return [dict(p) for p in _load_profile_cache()]
def profile_get(profile_id):
"""Return a single profile by ID, or None."""
_validate_id(profile_id)
path = os.path.join(_PROFILES_DIR, f"{profile_id}.json")
if not os.path.isfile(path):
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return None
def profile_delete(profile_id):
"""Delete a profile by ID."""
_validate_id(profile_id)
path = os.path.join(_PROFILES_DIR, f"{profile_id}.json")
if os.path.isfile(path):
os.remove(path)
_invalidate_profile_cache()
def profile_update(profile_id, fields):
"""Merge fields into an existing profile. Returns True on success."""
_validate_id(profile_id)
path = os.path.join(_PROFILES_DIR, f"{profile_id}.json")
if not os.path.isfile(path):
return False
with open(path, "r", encoding="utf-8") as f:
profile = json.load(f)
for k, v in fields.items():
if v is None:
profile.pop(k, None)
else:
profile[k] = v
with open(path, "w", encoding="utf-8") as f:
json.dump(profile, f, separators=(",", ":"))
_invalidate_profile_cache()
return True
# ─── Migration from old extension-local data ─────────────────────────
def _migrate_old_data():
"""Move data from the old <extension>/data/ location to the new user directory.
Only runs when the old directory exists, has content, and the new location
differs from the old one (i.e. we're actually inside ComfyUI).
"""
old_snapshots = os.path.join(_OLD_DATA_DIR, "snapshots")
old_profiles = os.path.join(_OLD_DATA_DIR, "profiles")
# Nothing to migrate if old data dir doesn't exist or paths are the same
if os.path.normpath(_OLD_DATA_DIR) == os.path.normpath(_USER_SM_DIR):
return
if not os.path.isdir(_OLD_DATA_DIR):
return
migrated_anything = False
# Migrate snapshot workflow directories
if os.path.isdir(old_snapshots):
os.makedirs(_DATA_DIR, exist_ok=True)
for name in os.listdir(old_snapshots):
src = os.path.join(old_snapshots, name)
dst = os.path.join(_DATA_DIR, name)
if not os.path.isdir(src):
continue
if os.path.exists(dst):
# Merge: move individual files that don't already exist
for fname in os.listdir(src):
s = os.path.join(src, fname)
d = os.path.join(dst, fname)
if not os.path.exists(d):
shutil.move(s, d)
# Remove source dir if now empty
try:
os.rmdir(src)
except OSError:
pass
else:
shutil.move(src, dst)
migrated_anything = True
# Migrate profile files
if os.path.isdir(old_profiles):
os.makedirs(_PROFILES_DIR, exist_ok=True)
for fname in os.listdir(old_profiles):
if not fname.endswith(".json"):
continue
src = os.path.join(old_profiles, fname)
dst = os.path.join(_PROFILES_DIR, fname)
if not os.path.exists(dst):
shutil.move(src, dst)
else:
os.remove(src)
migrated_anything = True
if migrated_anything:
# Clean up old directories if empty
for d in (old_snapshots, old_profiles, _OLD_DATA_DIR):
try:
if os.path.isdir(d) and not os.listdir(d):
os.rmdir(d)
except OSError:
pass
print("[Snapshot Manager] Migrated data to", _USER_SM_DIR)
try:
_migrate_old_data()
except Exception as e:
print(f"[Snapshot Manager] Migration failed, old data preserved: {e}")