Initial release: Workflow Snapshot Manager v1.0.0
Some checks failed
Publish to ComfyUI Registry / Publish Custom Node to Registry (push) Has been cancelled

Auto-capture workflow snapshots with per-workflow hash map, promise-based
restore lock, custom naming, search/filter, theme-aware CSS, toast
notifications, and native confirm/prompt dialogs. Includes README with
SVG/PNG assets, MIT license, and ComfyUI registry publish action.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 17:31:32 +01:00
commit e1d63e58d6
13 changed files with 1256 additions and 0 deletions

21
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: Publish to ComfyUI Registry
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "pyproject.toml"
jobs:
publish-node:
name: Publish Custom Node to Registry
runs-on: ubuntu-latest
if: github.repository == 'ethanfel/Comfyui-Workflow-Snapshot-Manager'
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
__pycache__/
*.pyc

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ethanfel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

123
README.md Normal file
View File

@@ -0,0 +1,123 @@
<p align="center">
<img src="assets/banner.png" alt="Workflow Snapshot Manager" width="100%"/>
</p>
<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/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.
<p align="center">
<img src="assets/sidebar-preview.png" alt="Sidebar Preview" width="300"/>
</p>
## Features
- **Auto-capture** — Snapshots are saved automatically as you edit, with configurable debounce
- **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
- **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
- **Concurrency-safe** — Lock guard prevents double-click issues during restore
- **Zero backend** — Pure frontend extension, no server dependencies
## Installation
### ComfyUI Manager (Recommended)
Search for **Workflow Snapshot Manager** in [ComfyUI Manager](https://github.com/ltdrdata/ComfyUI-Manager) and click Install.
### Git Clone
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/ethanfel/Comfyui-Workflow-Snapshot-Manager.git
```
Restart ComfyUI after installing.
## Usage
### 1. Open the Sidebar
Click the **clock icon** (<img src="https://img.shields.io/badge/-pi pi--history-333?style=flat" alt="history icon"/>) in the ComfyUI sidebar to open the Snapshots panel.
### 2. Snapshots are Captured Automatically
As you edit your workflow, snapshots are saved automatically after a configurable delay (default: 3 seconds). An initial snapshot is also captured when the workflow loads.
### 3. Take a Named Snapshot
Click **Take Snapshot** to manually save the current state. A prompt lets you enter a custom name — great for checkpoints like "Before refactor" or "Working config".
### 4. Search & Filter
Use the filter bar at the top of the panel to search snapshots by name. The clear button (**&times;**) resets the filter.
### 5. Restore or Swap
Each snapshot has two action buttons:
| Button | Action |
|--------|--------|
| **Swap** | Replaces the current workflow in-place (same tab) |
| **Restore** | Opens the snapshot as a new workflow |
### 6. Delete & Clear
- 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)
## Settings
All settings are available in **ComfyUI Settings > Snapshot Manager > Capture Settings**:
| 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 |
| **Capture on workflow load** | Toggle | `On` | Save an "Initial" snapshot when a workflow is first loaded |
## Architecture
<p align="center">
<img src="assets/architecture.png" alt="Architecture Diagram" width="100%"/>
</p>
**Data 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
**Storage:** All data stays in your browser's IndexedDB — nothing is sent to any server. Snapshots persist across browser sessions and ComfyUI restarts.
## 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).
**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.
**Can I use this with ComfyUI Manager?**
Yes — install via ComfyUI Manager or clone the repo into `custom_nodes/`.
## License
[MIT](LICENSE)

10
__init__.py Normal file
View File

@@ -0,0 +1,10 @@
"""
ComfyUI Snapshot Manager
Automatically snapshots workflow state as you edit, with a sidebar panel
to browse and restore any previous version. Stored in IndexedDB.
"""
WEB_DIRECTORY = "./js"
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}

BIN
assets/architecture.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

93
assets/architecture.svg Normal file
View File

@@ -0,0 +1,93 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 420">
<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>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
assets/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

32
assets/banner.svg Normal file
View File

@@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 840 200">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a1a2e"/>
<stop offset="100%" style="stop-color:#16213e"/>
</linearGradient>
<linearGradient id="accent" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#3b82f6"/>
<stop offset="100%" style="stop-color:#8b5cf6"/>
</linearGradient>
</defs>
<rect width="840" height="200" rx="12" fill="url(#bg)"/>
<rect x="0" y="192" width="840" height="8" rx="0 0 12 12" fill="url(#accent)"/>
<!-- Clock/History icon -->
<circle cx="100" cy="100" r="40" fill="none" stroke="#3b82f6" stroke-width="3"/>
<line x1="100" y1="70" x2="100" y2="100" stroke="#3b82f6" stroke-width="3" stroke-linecap="round"/>
<line x1="100" y1="100" x2="120" y2="110" stroke="#3b82f6" stroke-width="3" stroke-linecap="round"/>
<path d="M 65 72 A 40 40 0 0 0 60 100" fill="none" stroke="#8b5cf6" stroke-width="3" stroke-linecap="round"/>
<polygon points="58,68 68,76 64,64" fill="#8b5cf6"/>
<!-- Title -->
<text x="170" y="85" font-family="system-ui, -apple-system, sans-serif" font-size="32" font-weight="700" fill="#e2e8f0">Workflow Snapshot Manager</text>
<text x="170" y="120" font-family="system-ui, -apple-system, sans-serif" font-size="16" fill="#94a3b8">Auto-capture, browse, name, and restore workflow snapshots</text>
<text x="170" y="150" font-family="system-ui, -apple-system, sans-serif" font-size="13" fill="#64748b">ComfyUI Extension</text>
<!-- Decorative dots -->
<circle cx="750" cy="50" r="3" fill="#3b82f6" opacity="0.4"/>
<circle cx="770" cy="70" r="2" fill="#8b5cf6" opacity="0.3"/>
<circle cx="790" cy="45" r="2.5" fill="#22c55e" opacity="0.3"/>
<circle cx="760" cy="90" r="2" fill="#f59e0b" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
assets/sidebar-preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View File

@@ -0,0 +1,94 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 520">
<defs>
<linearGradient id="sbg" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#1e1e1e"/>
<stop offset="100%" style="stop-color:#252525"/>
</linearGradient>
</defs>
<!-- Panel background -->
<rect width="300" height="520" rx="10" fill="url(#sbg)" stroke="#333" stroke-width="1"/>
<!-- Header -->
<rect x="0" y="0" width="300" height="50" rx="10 10 0 0" fill="#1a1a1a"/>
<rect x="12" y="12" width="120" height="28" rx="5" fill="#3b82f6"/>
<text x="72" y="31" text-anchor="middle" font-family="system-ui, sans-serif" font-size="12" font-weight="600" fill="#fff">Take Snapshot</text>
<text x="265" y="31" text-anchor="end" font-family="system-ui, sans-serif" font-size="11" fill="#888">5 / 50</text>
<!-- Separator -->
<line x1="0" y1="50" x2="300" y2="50" stroke="#444"/>
<!-- Search bar -->
<rect x="12" y="58" width="252" height="30" rx="5" fill="#2a2a2a" stroke="#444" stroke-width="1"/>
<text x="24" y="78" font-family="system-ui, sans-serif" font-size="12" fill="#666">Filter snapshots...</text>
<text x="252" y="78" font-family="system-ui, sans-serif" font-size="13" fill="#666"></text>
<!-- Separator -->
<line x1="0" y1="96" x2="300" y2="96" stroke="#444"/>
<!-- Snapshot item 1 -->
<rect x="0" y="97" width="300" height="80" fill="#1e1e1e"/>
<text x="12" y="118" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#ddd">Checkpoint before merge</text>
<text x="12" y="136" font-family="system-ui, sans-serif" font-size="12" fill="#ddd">2:34:15 PM</text>
<text x="12" y="150" font-family="system-ui, sans-serif" font-size="10" fill="#777">Jan 5, 2026</text>
<text x="12" y="164" font-family="system-ui, sans-serif" font-size="10" fill="#666">42 nodes</text>
<!-- Action buttons row 1 -->
<rect x="180" y="112" width="42" height="22" rx="3" fill="#f59e0b"/>
<text x="201" y="127" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="500" fill="#fff">Swap</text>
<rect x="226" y="112" width="52" height="22" rx="3" fill="#22c55e"/>
<text x="252" y="127" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="500" fill="#fff">Restore</text>
<rect x="282" y="112" width="5" height="22" rx="2" fill="#444"/>
<line x1="0" y1="177" x2="300" y2="177" stroke="#333"/>
<!-- Snapshot item 2 -->
<rect x="0" y="178" width="300" height="80" fill="#1e1e1e"/>
<text x="12" y="199" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#ddd">Auto</text>
<text x="12" y="217" font-family="system-ui, sans-serif" font-size="12" fill="#ddd">2:31:02 PM</text>
<text x="12" y="231" font-family="system-ui, sans-serif" font-size="10" fill="#777">Jan 5, 2026</text>
<text x="12" y="245" font-family="system-ui, sans-serif" font-size="10" fill="#666">40 nodes</text>
<rect x="180" y="193" width="42" height="22" rx="3" fill="#f59e0b"/>
<text x="201" y="208" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="500" fill="#fff">Swap</text>
<rect x="226" y="193" width="52" height="22" rx="3" fill="#22c55e"/>
<text x="252" y="208" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="500" fill="#fff">Restore</text>
<line x1="0" y1="258" x2="300" y2="258" stroke="#333"/>
<!-- Snapshot item 3 -->
<rect x="0" y="259" width="300" height="80" fill="#1e1e1e"/>
<text x="12" y="280" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#ddd">Before KSampler change</text>
<text x="12" y="298" font-family="system-ui, sans-serif" font-size="12" fill="#ddd">2:28:44 PM</text>
<text x="12" y="312" font-family="system-ui, sans-serif" font-size="10" fill="#777">Jan 5, 2026</text>
<text x="12" y="326" font-family="system-ui, sans-serif" font-size="10" fill="#666">38 nodes</text>
<rect x="180" y="274" width="42" height="22" rx="3" fill="#f59e0b"/>
<text x="201" y="289" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="500" fill="#fff">Swap</text>
<rect x="226" y="274" width="52" height="22" rx="3" fill="#22c55e"/>
<text x="252" y="289" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="500" fill="#fff">Restore</text>
<line x1="0" y1="339" x2="300" y2="339" stroke="#333"/>
<!-- Snapshot item 4 -->
<rect x="0" y="340" width="300" height="80" fill="#1e1e1e"/>
<text x="12" y="361" font-family="system-ui, sans-serif" font-size="13" font-weight="600" fill="#ddd">Initial</text>
<text x="12" y="379" font-family="system-ui, sans-serif" font-size="12" fill="#ddd">2:15:30 PM</text>
<text x="12" y="393" font-family="system-ui, sans-serif" font-size="10" fill="#777">Jan 5, 2026</text>
<text x="12" y="407" font-family="system-ui, sans-serif" font-size="10" fill="#666">35 nodes</text>
<rect x="180" y="355" width="42" height="22" rx="3" fill="#f59e0b"/>
<text x="201" y="370" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="500" fill="#fff">Swap</text>
<rect x="226" y="355" width="52" height="22" rx="3" fill="#22c55e"/>
<text x="252" y="370" text-anchor="middle" font-family="system-ui, sans-serif" font-size="11" font-weight="500" fill="#fff">Restore</text>
<line x1="0" y1="420" x2="300" y2="420" stroke="#333"/>
<!-- Empty space -->
<rect x="0" y="421" width="300" height="57" fill="transparent"/>
<!-- Footer -->
<line x1="0" y1="478" x2="300" y2="478" stroke="#444"/>
<rect x="12" y="486" width="276" height="26" rx="5" fill="#555"/>
<text x="150" y="504" text-anchor="middle" font-family="system-ui, sans-serif" font-size="12" font-weight="600" fill="#ccc">Clear All Snapshots</text>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

847
js/snapshot_manager.js Normal file
View File

@@ -0,0 +1,847 @@
/**
* ComfyUI Snapshot Manager
*
* Automatically captures workflow snapshots as you edit, stores them in
* IndexedDB, and provides a sidebar panel to browse and restore any
* previous version.
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const EXTENSION_NAME = "ComfyUI.SnapshotManager";
const DB_NAME = "ComfySnapshotManager";
const STORE_NAME = "snapshots";
const RESTORE_GUARD_MS = 500;
const INITIAL_CAPTURE_DELAY_MS = 1500;
// ─── Configurable Settings (updated via ComfyUI settings UI) ────────
let maxSnapshots = 50;
let debounceMs = 3000;
let autoCaptureEnabled = true;
let captureOnLoad = true;
// ─── State ───────────────────────────────────────────────────────────
const lastCapturedHashMap = new Map();
let restoreLock = null;
let captureTimer = null;
let sidebarRefresh = null; // callback set by sidebar render
// ─── IndexedDB Layer ─────────────────────────────────────────────────
let dbPromise = null;
function openDB() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: "id" });
store.createIndex("workflowKey", "workflowKey", { unique: false });
store.createIndex("timestamp", "timestamp", { unique: false });
store.createIndex("workflowKey_timestamp", ["workflowKey", "timestamp"], { unique: false });
}
};
req.onsuccess = () => {
const db = req.result;
db.onclose = () => { dbPromise = null; };
db.onversionchange = () => { db.close(); dbPromise = null; };
resolve(db);
};
req.onerror = () => {
dbPromise = null;
reject(req.error);
};
});
return dbPromise;
}
async function db_put(record) {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).put(record);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch (err) {
console.warn(`[${EXTENSION_NAME}] IndexedDB write failed:`, err);
showToast("Failed to save snapshot", "error");
throw err;
}
}
async function db_getAllForWorkflow(workflowKey) {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const idx = tx.objectStore(STORE_NAME).index("workflowKey_timestamp");
const range = IDBKeyRange.bound([workflowKey, 0], [workflowKey, Infinity]);
const req = idx.getAll(range);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
} catch (err) {
console.warn(`[${EXTENSION_NAME}] IndexedDB read failed:`, err);
showToast("Failed to read snapshots", "error");
return [];
}
}
async function db_delete(id) {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch (err) {
console.warn(`[${EXTENSION_NAME}] IndexedDB delete failed:`, err);
showToast("Failed to delete snapshot", "error");
}
}
async function db_deleteAllForWorkflow(workflowKey) {
try {
const records = await db_getAllForWorkflow(workflowKey);
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
for (const r of records) {
store.delete(r.id);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch (err) {
console.warn(`[${EXTENSION_NAME}] IndexedDB bulk delete failed:`, err);
showToast("Failed to clear snapshots", "error");
throw err;
}
}
async function pruneSnapshots(workflowKey) {
try {
const all = await db_getAllForWorkflow(workflowKey);
if (all.length <= maxSnapshots) return;
// sorted ascending by timestamp (index order), oldest first
const toDelete = all.slice(0, all.length - maxSnapshots);
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
for (const r of toDelete) {
store.delete(r.id);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch (err) {
console.warn(`[${EXTENSION_NAME}] IndexedDB prune failed:`, err);
}
}
// ─── Helpers ─────────────────────────────────────────────────────────
function quickHash(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
return hash;
}
function getWorkflowKey() {
try {
const wf = app.workflowManager?.activeWorkflow;
return wf?.name || wf?.path || "default";
} catch {
return "default";
}
}
function getGraphData() {
try {
return app.graph.serialize();
} catch {
return null;
}
}
function generateId() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function validateSnapshotData(graphData) {
return graphData != null && typeof graphData === "object" && Array.isArray(graphData.nodes);
}
// ─── Restore Lock ───────────────────────────────────────────────────
async function withRestoreLock(fn) {
if (restoreLock) return;
let resolve;
restoreLock = new Promise((r) => { resolve = r; });
try {
await fn();
} finally {
setTimeout(() => {
restoreLock = null;
resolve();
if (sidebarRefresh) {
sidebarRefresh().catch(() => {});
}
}, RESTORE_GUARD_MS);
}
}
// ─── UI Utilities ───────────────────────────────────────────────────
function showToast(message, severity = "info") {
try {
app.extensionManager.toast.add({
severity,
summary: "Snapshot Manager",
detail: message,
life: 2500,
});
} catch { /* silent fallback */ }
}
async function showConfirmDialog(message) {
try {
return await app.extensionManager.dialog.confirm({
title: "Snapshot Manager",
message,
});
} catch {
return window.confirm(message);
}
}
async function showPromptDialog(message, defaultValue = "Manual") {
try {
const result = await app.extensionManager.dialog.prompt({
title: "Snapshot Name",
message,
});
return result;
} catch {
return window.prompt(message, defaultValue);
}
}
// ─── Snapshot Capture ────────────────────────────────────────────────
async function captureSnapshot(label = "Auto") {
if (restoreLock) return false;
const graphData = getGraphData();
if (!graphData) return false;
const nodes = graphData.nodes || [];
if (nodes.length === 0) return false;
const workflowKey = getWorkflowKey();
const serialized = JSON.stringify(graphData);
const hash = quickHash(serialized);
if (hash === lastCapturedHashMap.get(workflowKey)) return false;
const record = {
id: generateId(),
workflowKey,
timestamp: Date.now(),
label,
nodeCount: nodes.length,
graphData,
};
try {
await db_put(record);
await pruneSnapshots(workflowKey);
} catch {
return false;
}
lastCapturedHashMap.set(workflowKey, hash);
if (sidebarRefresh) {
sidebarRefresh().catch(() => {});
}
return true;
}
function scheduleCaptureSnapshot() {
if (!autoCaptureEnabled) return;
if (restoreLock) return;
if (captureTimer) clearTimeout(captureTimer);
captureTimer = setTimeout(() => {
captureTimer = null;
captureSnapshot("Auto").catch((err) => {
console.warn(`[${EXTENSION_NAME}] Auto-capture failed:`, err);
});
}, debounceMs);
}
// ─── Restore ─────────────────────────────────────────────────────────
async function restoreSnapshot(record) {
await withRestoreLock(async () => {
if (!validateSnapshotData(record.graphData)) {
showToast("Invalid snapshot data", "error");
return;
}
try {
await app.loadGraphData(record.graphData, true, true);
lastCapturedHashMap.set(getWorkflowKey(), quickHash(JSON.stringify(record.graphData)));
showToast("Snapshot restored", "success");
} catch (err) {
console.warn(`[${EXTENSION_NAME}] Restore failed:`, err);
showToast("Failed to restore snapshot", "error");
}
});
}
async function swapSnapshot(record) {
await withRestoreLock(async () => {
if (!validateSnapshotData(record.graphData)) {
showToast("Invalid snapshot data", "error");
return;
}
try {
const workflow = app.workflowManager?.activeWorkflow;
await app.loadGraphData(record.graphData, true, true, workflow);
lastCapturedHashMap.set(getWorkflowKey(), quickHash(JSON.stringify(record.graphData)));
showToast("Snapshot swapped", "success");
} catch (err) {
console.warn(`[${EXTENSION_NAME}] Swap failed:`, err);
showToast("Failed to swap snapshot", "error");
}
});
}
// ─── Sidebar UI ──────────────────────────────────────────────────────
const CSS = `
.snap-sidebar {
display: flex;
flex-direction: column;
height: 100%;
color: var(--input-text, #ccc);
font-family: system-ui, -apple-system, sans-serif;
font-size: 13px;
}
.snap-header {
padding: 8px 10px;
border-bottom: 1px solid var(--border-color, #444);
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.snap-header button {
padding: 5px 10px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
background: #3b82f6;
color: #fff;
white-space: nowrap;
}
.snap-header button:hover {
background: #2563eb;
}
.snap-header button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.snap-header .snap-count {
margin-left: auto;
font-size: 11px;
color: var(--descrip-text, #888);
white-space: nowrap;
}
.snap-search {
padding: 6px 10px;
border-bottom: 1px solid var(--border-color, #444);
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.snap-search input {
flex: 1;
padding: 4px 8px;
border: 1px solid var(--border-color, #444);
border-radius: 4px;
background: var(--comfy-menu-bg, #2a2a2a);
color: var(--input-text, #ccc);
font-size: 12px;
outline: none;
}
.snap-search input::placeholder {
color: var(--descrip-text, #888);
}
.snap-search-clear {
background: none;
border: none;
color: var(--descrip-text, #888);
cursor: pointer;
font-size: 14px;
padding: 2px 4px;
line-height: 1;
visibility: hidden;
}
.snap-search-clear.visible {
visibility: visible;
}
.snap-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.snap-item {
display: flex;
align-items: center;
padding: 6px 10px;
border-bottom: 1px solid var(--border-color, #333);
gap: 8px;
}
.snap-item:hover {
background: var(--comfy-menu-bg, #2a2a2a);
}
.snap-item-info {
flex: 1;
min-width: 0;
}
.snap-item-label {
font-size: 13px;
font-weight: 600;
color: var(--input-text, #ddd);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.snap-item-time {
font-size: 12px;
color: var(--input-text, #ddd);
}
.snap-item-date {
font-size: 10px;
color: var(--descrip-text, #777);
}
.snap-item-meta {
font-size: 10px;
color: var(--descrip-text, #666);
}
.snap-item-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.snap-item-actions button {
padding: 3px 8px;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
font-weight: 500;
}
.snap-item-actions button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.snap-btn-swap {
background: #f59e0b;
color: #fff;
}
.snap-btn-swap:hover:not(:disabled) {
background: #d97706;
}
.snap-btn-restore {
background: #22c55e;
color: #fff;
}
.snap-btn-restore:hover:not(:disabled) {
background: #16a34a;
}
.snap-btn-delete {
background: var(--comfy-menu-bg, #444);
color: var(--descrip-text, #aaa);
}
.snap-btn-delete:hover:not(:disabled) {
background: #dc2626;
color: #fff;
}
.snap-footer {
padding: 8px 10px;
border-top: 1px solid var(--border-color, #444);
flex-shrink: 0;
}
.snap-footer button {
width: 100%;
padding: 5px 10px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
background: var(--comfy-menu-bg, #555);
color: var(--input-text, #ccc);
}
.snap-footer button:hover {
background: #dc2626;
color: #fff;
}
.snap-empty {
padding: 20px;
text-align: center;
color: var(--descrip-text, #666);
font-size: 12px;
}
`;
function injectStyles() {
if (document.getElementById("snapshot-manager-styles")) return;
const style = document.createElement("style");
style.id = "snapshot-manager-styles";
style.textContent = CSS;
document.head.appendChild(style);
}
function formatTime(ts) {
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function formatDate(ts) {
const d = new Date(ts);
return d.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" });
}
async function buildSidebar(el) {
injectStyles();
el.innerHTML = "";
const container = document.createElement("div");
container.className = "snap-sidebar";
// Header
const header = document.createElement("div");
header.className = "snap-header";
const takeBtn = document.createElement("button");
takeBtn.textContent = "Take Snapshot";
takeBtn.addEventListener("click", async () => {
let name = await showPromptDialog("Enter a name for this snapshot:", "Manual");
if (name == null) return; // cancelled (null or undefined)
name = name.trim() || "Manual";
takeBtn.disabled = true;
takeBtn.textContent = "Saving...";
try {
const saved = await captureSnapshot(name);
if (saved) showToast("Snapshot saved", "success");
} finally {
takeBtn.disabled = false;
takeBtn.textContent = "Take Snapshot";
}
});
const countSpan = document.createElement("span");
countSpan.className = "snap-count";
header.appendChild(takeBtn);
header.appendChild(countSpan);
// Search
const searchRow = document.createElement("div");
searchRow.className = "snap-search";
const searchInput = document.createElement("input");
searchInput.type = "text";
searchInput.placeholder = "Filter snapshots...";
const searchClear = document.createElement("button");
searchClear.className = "snap-search-clear";
searchClear.textContent = "\u2715";
searchClear.addEventListener("click", () => {
searchInput.value = "";
searchClear.classList.remove("visible");
filterItems("");
});
searchInput.addEventListener("input", () => {
const term = searchInput.value;
searchClear.classList.toggle("visible", term.length > 0);
filterItems(term.toLowerCase());
});
searchRow.appendChild(searchInput);
searchRow.appendChild(searchClear);
// List
const list = document.createElement("div");
list.className = "snap-list";
// Footer
const footer = document.createElement("div");
footer.className = "snap-footer";
const clearBtn = document.createElement("button");
clearBtn.textContent = "Clear All Snapshots";
clearBtn.addEventListener("click", async () => {
const confirmed = await showConfirmDialog("Delete all snapshots for this workflow?");
if (!confirmed) return;
try {
await db_deleteAllForWorkflow(getWorkflowKey());
showToast("All snapshots cleared", "info");
} catch {
// db_deleteAllForWorkflow already toasts on error
}
await refresh(true);
});
footer.appendChild(clearBtn);
container.appendChild(header);
container.appendChild(searchRow);
container.appendChild(list);
container.appendChild(footer);
el.appendChild(container);
// Track items for filtering
let itemEntries = [];
function filterItems(term) {
for (const entry of itemEntries) {
const match = !term || entry.label.toLowerCase().includes(term);
entry.element.style.display = match ? "" : "none";
}
}
function setActionButtonsDisabled(disabled) {
const buttons = list.querySelectorAll(".snap-btn-swap, .snap-btn-restore, .snap-btn-delete");
for (const btn of buttons) {
btn.disabled = disabled;
}
}
async function refresh(resetSearch = false) {
const workflowKey = getWorkflowKey();
const records = await db_getAllForWorkflow(workflowKey);
// newest first
records.sort((a, b) => b.timestamp - a.timestamp);
countSpan.textContent = `${records.length} / ${maxSnapshots}`;
list.innerHTML = "";
itemEntries = [];
if (resetSearch) {
searchInput.value = "";
searchClear.classList.remove("visible");
}
if (records.length === 0) {
const empty = document.createElement("div");
empty.className = "snap-empty";
empty.textContent = "No snapshots yet. Edit the workflow or click 'Take Snapshot'.";
list.appendChild(empty);
return;
}
for (const rec of records) {
const item = document.createElement("div");
item.className = "snap-item";
const info = document.createElement("div");
info.className = "snap-item-info";
const labelDiv = document.createElement("div");
labelDiv.className = "snap-item-label";
labelDiv.textContent = rec.label;
const time = document.createElement("div");
time.className = "snap-item-time";
time.textContent = formatTime(rec.timestamp);
const date = document.createElement("div");
date.className = "snap-item-date";
date.textContent = formatDate(rec.timestamp);
const meta = document.createElement("div");
meta.className = "snap-item-meta";
meta.textContent = `${rec.nodeCount} nodes`;
info.appendChild(labelDiv);
info.appendChild(time);
info.appendChild(date);
info.appendChild(meta);
const actions = document.createElement("div");
actions.className = "snap-item-actions";
const swapBtn = document.createElement("button");
swapBtn.className = "snap-btn-swap";
swapBtn.textContent = "Swap";
swapBtn.title = "Replace current workflow in-place";
swapBtn.addEventListener("click", async () => {
setActionButtonsDisabled(true);
await swapSnapshot(rec);
});
const restoreBtn = document.createElement("button");
restoreBtn.className = "snap-btn-restore";
restoreBtn.textContent = "Restore";
restoreBtn.title = "Open as new workflow";
restoreBtn.addEventListener("click", async () => {
setActionButtonsDisabled(true);
await restoreSnapshot(rec);
});
const deleteBtn = document.createElement("button");
deleteBtn.className = "snap-btn-delete";
deleteBtn.textContent = "\u2715";
deleteBtn.title = "Delete this snapshot";
deleteBtn.addEventListener("click", async () => {
await db_delete(rec.id);
await refresh();
});
actions.appendChild(swapBtn);
actions.appendChild(restoreBtn);
actions.appendChild(deleteBtn);
item.appendChild(info);
item.appendChild(actions);
list.appendChild(item);
itemEntries.push({ element: item, label: rec.label });
}
// Re-apply current search filter to newly built items
const currentTerm = searchInput.value.toLowerCase();
if (currentTerm) {
filterItems(currentTerm);
}
}
sidebarRefresh = refresh;
await refresh(true);
}
// ─── Extension Registration ──────────────────────────────────────────
if (window.__COMFYUI_FRONTEND_VERSION__) {
app.registerExtension({
name: EXTENSION_NAME,
settings: [
{
id: "SnapshotManager.autoCapture",
name: "Auto-capture on edit",
type: "boolean",
defaultValue: true,
category: ["Snapshot Manager", "Capture Settings", "Auto-capture on edit"],
onChange(value) {
autoCaptureEnabled = value;
},
},
{
id: "SnapshotManager.debounceSeconds",
name: "Capture delay (seconds)",
type: "slider",
defaultValue: 3,
attrs: { min: 1, max: 30, step: 1 },
category: ["Snapshot Manager", "Capture Settings", "Capture delay (seconds)"],
onChange(value) {
debounceMs = value * 1000;
},
},
{
id: "SnapshotManager.maxSnapshots",
name: "Max snapshots per workflow",
type: "slider",
defaultValue: 50,
attrs: { min: 5, max: 200, step: 5 },
category: ["Snapshot Manager", "Capture Settings", "Max snapshots per workflow"],
onChange(value) {
maxSnapshots = value;
},
},
{
id: "SnapshotManager.captureOnLoad",
name: "Capture on workflow load",
type: "boolean",
defaultValue: true,
category: ["Snapshot Manager", "Capture Settings", "Capture on workflow load"],
onChange(value) {
captureOnLoad = value;
},
},
],
init() {
app.extensionManager.registerSidebarTab({
id: "snapshot-manager",
icon: "pi pi-history",
title: "Snapshots",
tooltip: "Browse and restore workflow snapshots",
type: "custom",
render: async (el) => {
await buildSidebar(el);
},
destroy: () => {
sidebarRefresh = null;
},
});
},
async setup() {
// Listen for graph changes (dispatched by ChangeTracker via api)
api.addEventListener("graphChanged", () => {
scheduleCaptureSnapshot();
});
// Listen for workflow switches
if (app.workflowManager) {
app.workflowManager.addEventListener("changeWorkflow", () => {
// Cancel any pending capture from the previous workflow
if (captureTimer) {
clearTimeout(captureTimer);
captureTimer = null;
}
if (sidebarRefresh) {
sidebarRefresh(true).catch(() => {});
}
});
}
// Capture initial state after a short delay (decoupled from debounceMs)
setTimeout(() => {
if (!captureOnLoad) return;
captureSnapshot("Initial").catch((err) => {
console.warn(`[${EXTENSION_NAME}] Initial capture failed:`, err);
});
}, INITIAL_CAPTURE_DELAY_MS);
},
});
} else {
// Legacy frontend: register without sidebar
app.registerExtension({
name: EXTENSION_NAME,
async setup() {
console.log(`[${EXTENSION_NAME}] Sidebar requires modern ComfyUI frontend, skipping.`);
},
});
}

13
pyproject.toml Normal file
View File

@@ -0,0 +1,13 @@
[project]
name = "comfyui-snapshot-manager"
description = "Automatically snapshots workflow state with a sidebar to browse and restore previous versions."
version = "1.0.0"
license = {text = "MIT"}
[project.urls]
Repository = "https://github.com/ethanfel/Comfyui-Workflow-Snapshot-Manager"
[tool.comfy]
PublisherId = "ethanfel"
DisplayName = "Workflow Snapshot Manager"
Icon = ""