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>
This commit is contained in:
@@ -6,7 +6,8 @@ to browse and restore any previous version. Stored in server-side JSON files.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from . import snapshot_routes
|
from . import snapshot_routes
|
||||||
|
from .snapshot_node import SaveSnapshot
|
||||||
|
|
||||||
WEB_DIRECTORY = "./js"
|
WEB_DIRECTORY = "./js"
|
||||||
NODE_CLASS_MAPPINGS = {}
|
NODE_CLASS_MAPPINGS = {"SaveSnapshot": SaveSnapshot}
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
NODE_DISPLAY_NAME_MAPPINGS = {"SaveSnapshot": "Save Snapshot"}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ let maxSnapshots = 50;
|
|||||||
let debounceMs = 3000;
|
let debounceMs = 3000;
|
||||||
let autoCaptureEnabled = true;
|
let autoCaptureEnabled = true;
|
||||||
let captureOnLoad = true;
|
let captureOnLoad = true;
|
||||||
|
let maxNodeSnapshots = 5;
|
||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@ async function pruneSnapshots(workflowKey) {
|
|||||||
const resp = await api.fetchApi("/snapshot-manager/prune", {
|
const resp = await api.fetchApi("/snapshot-manager/prune", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ workflowKey, maxSnapshots }),
|
body: JSON.stringify({ workflowKey, maxSnapshots, source: "regular" }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const err = await resp.json();
|
const err = await resp.json();
|
||||||
@@ -137,6 +138,22 @@ async function pruneSnapshots(workflowKey) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pruneNodeSnapshots(workflowKey) {
|
||||||
|
try {
|
||||||
|
const resp = await api.fetchApi("/snapshot-manager/prune", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ workflowKey, maxSnapshots: maxNodeSnapshots, source: "node" }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json();
|
||||||
|
throw new Error(err.error || resp.statusText);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[${EXTENSION_NAME}] Node prune failed:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── IndexedDB Migration ────────────────────────────────────────────
|
// ─── IndexedDB Migration ────────────────────────────────────────────
|
||||||
|
|
||||||
async function migrateFromIndexedDB() {
|
async function migrateFromIndexedDB() {
|
||||||
@@ -331,6 +348,43 @@ async function captureSnapshot(label = "Auto") {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function captureNodeSnapshot(label = "Node Trigger") {
|
||||||
|
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 record = {
|
||||||
|
id: generateId(),
|
||||||
|
workflowKey,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
label,
|
||||||
|
nodeCount: nodes.length,
|
||||||
|
graphData,
|
||||||
|
locked: false,
|
||||||
|
source: "node",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db_put(record);
|
||||||
|
await pruneNodeSnapshots(workflowKey);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pickerDirty = true;
|
||||||
|
|
||||||
|
if (sidebarRefresh) {
|
||||||
|
sidebarRefresh().catch(() => {});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleCaptureSnapshot() {
|
function scheduleCaptureSnapshot() {
|
||||||
if (!autoCaptureEnabled) return;
|
if (!autoCaptureEnabled) return;
|
||||||
if (restoreLock) return;
|
if (restoreLock) return;
|
||||||
@@ -566,6 +620,20 @@ const CSS = `
|
|||||||
background: #dc2626;
|
background: #dc2626;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
.snap-item-node {
|
||||||
|
border-left: 3px solid #6d28d9;
|
||||||
|
}
|
||||||
|
.snap-node-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #6d28d9;
|
||||||
|
color: #fff;
|
||||||
|
margin-left: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
.snap-empty {
|
.snap-empty {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -922,7 +990,11 @@ async function buildSidebar(el) {
|
|||||||
// newest first
|
// newest first
|
||||||
records.sort((a, b) => b.timestamp - a.timestamp);
|
records.sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
|
||||||
countSpan.textContent = `${records.length} / ${maxSnapshots}`;
|
const regularCount = records.filter(r => r.source !== "node").length;
|
||||||
|
const nodeCount = records.filter(r => r.source === "node").length;
|
||||||
|
countSpan.textContent = nodeCount > 0
|
||||||
|
? `${regularCount}/${maxSnapshots} + ${nodeCount}/${maxNodeSnapshots} node`
|
||||||
|
: `${regularCount} / ${maxSnapshots}`;
|
||||||
|
|
||||||
// Update selector label and styling
|
// Update selector label and styling
|
||||||
selectorLabel.textContent = effKey;
|
selectorLabel.textContent = effKey;
|
||||||
@@ -959,7 +1031,7 @@ async function buildSidebar(el) {
|
|||||||
|
|
||||||
for (const rec of records) {
|
for (const rec of records) {
|
||||||
const item = document.createElement("div");
|
const item = document.createElement("div");
|
||||||
item.className = "snap-item";
|
item.className = rec.source === "node" ? "snap-item snap-item-node" : "snap-item";
|
||||||
|
|
||||||
const info = document.createElement("div");
|
const info = document.createElement("div");
|
||||||
info.className = "snap-item-info";
|
info.className = "snap-item-info";
|
||||||
@@ -967,6 +1039,12 @@ async function buildSidebar(el) {
|
|||||||
const labelDiv = document.createElement("div");
|
const labelDiv = document.createElement("div");
|
||||||
labelDiv.className = "snap-item-label";
|
labelDiv.className = "snap-item-label";
|
||||||
labelDiv.textContent = rec.label;
|
labelDiv.textContent = rec.label;
|
||||||
|
if (rec.source === "node") {
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "snap-node-badge";
|
||||||
|
badge.textContent = "Node";
|
||||||
|
labelDiv.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
const time = document.createElement("div");
|
const time = document.createElement("div");
|
||||||
time.className = "snap-item-time";
|
time.className = "snap-item-time";
|
||||||
@@ -1102,6 +1180,17 @@ if (window.__COMFYUI_FRONTEND_VERSION__) {
|
|||||||
captureOnLoad = value;
|
captureOnLoad = value;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "SnapshotManager.maxNodeSnapshots",
|
||||||
|
name: "Max node-triggered snapshots per workflow",
|
||||||
|
type: "slider",
|
||||||
|
defaultValue: 5,
|
||||||
|
attrs: { min: 1, max: 50, step: 1 },
|
||||||
|
category: ["Snapshot Manager", "Capture Settings", "Max node-triggered snapshots"],
|
||||||
|
onChange(value) {
|
||||||
|
maxNodeSnapshots = value;
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -1130,6 +1219,14 @@ if (window.__COMFYUI_FRONTEND_VERSION__) {
|
|||||||
scheduleCaptureSnapshot();
|
scheduleCaptureSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for node-triggered snapshot captures via WebSocket
|
||||||
|
api.addEventListener("snapshot-manager-capture", (event) => {
|
||||||
|
const label = event.detail?.label || "Node Trigger";
|
||||||
|
captureNodeSnapshot(label).catch((err) => {
|
||||||
|
console.warn(`[${EXTENSION_NAME}] Node-triggered capture failed:`, err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Listen for workflow switches via Pinia store action
|
// Listen for workflow switches via Pinia store action
|
||||||
const workflowStore = app.extensionManager?.workflow;
|
const workflowStore = app.extensionManager?.workflow;
|
||||||
if (workflowStore?.$onAction) {
|
if (workflowStore?.$onAction) {
|
||||||
|
|||||||
39
snapshot_node.py
Normal file
39
snapshot_node.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from server import PromptServer
|
||||||
|
|
||||||
|
|
||||||
|
class _AnyType(str):
|
||||||
|
def __ne__(self, other):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
ANY_TYPE = _AnyType("*")
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
PromptServer.instance.send_sync(
|
||||||
|
"snapshot-manager-capture", {"label": label}
|
||||||
|
)
|
||||||
|
return (value,)
|
||||||
@@ -84,9 +84,10 @@ async def prune_snapshots(request):
|
|||||||
data = await request.json()
|
data = await request.json()
|
||||||
workflow_key = data.get("workflowKey")
|
workflow_key = data.get("workflowKey")
|
||||||
max_snapshots = data.get("maxSnapshots")
|
max_snapshots = data.get("maxSnapshots")
|
||||||
|
source = data.get("source")
|
||||||
if not workflow_key or max_snapshots is None:
|
if not workflow_key or max_snapshots is None:
|
||||||
return web.json_response({"error": "Missing workflowKey or maxSnapshots"}, status=400)
|
return web.json_response({"error": "Missing workflowKey or maxSnapshots"}, status=400)
|
||||||
deleted = storage.prune(workflow_key, int(max_snapshots))
|
deleted = storage.prune(workflow_key, int(max_snapshots), source=source)
|
||||||
return web.json_response({"deleted": deleted})
|
return web.json_response({"deleted": deleted})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|||||||
@@ -104,13 +104,24 @@ def get_all_workflow_keys():
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def prune(workflow_key, max_snapshots):
|
def prune(workflow_key, max_snapshots, source=None):
|
||||||
"""Delete oldest unlocked snapshots beyond limit. Returns count deleted."""
|
"""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)
|
||||||
|
"""
|
||||||
records = get_all_for_workflow(workflow_key)
|
records = get_all_for_workflow(workflow_key)
|
||||||
unlocked = [r for r in records if not r.get("locked")]
|
if source == "node":
|
||||||
if len(unlocked) <= max_snapshots:
|
candidates = [r for r in records if not r.get("locked") and r.get("source") == "node"]
|
||||||
|
elif source == "regular":
|
||||||
|
candidates = [r for r in records if not r.get("locked") and r.get("source") != "node"]
|
||||||
|
else:
|
||||||
|
candidates = [r for r in records if not r.get("locked")]
|
||||||
|
if len(candidates) <= max_snapshots:
|
||||||
return 0
|
return 0
|
||||||
to_delete = unlocked[: len(unlocked) - max_snapshots]
|
to_delete = candidates[: len(candidates) - max_snapshots]
|
||||||
d = _workflow_dir(workflow_key)
|
d = _workflow_dir(workflow_key)
|
||||||
deleted = 0
|
deleted = 0
|
||||||
for rec in to_delete:
|
for rec in to_delete:
|
||||||
|
|||||||
Reference in New Issue
Block a user