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>
This commit is contained in:
2026-02-27 12:42:51 +01:00
parent e29e7dfad1
commit f9bdc75a2a
2 changed files with 75 additions and 15 deletions

View File

@@ -1376,13 +1376,20 @@ async function showPreviewModal(record) {
const body = document.createElement("div");
body.className = "snap-preview-body";
if (record.thumbnail) {
const thumbImg = document.createElement("img");
thumbImg.src = `data:image/jpeg;base64,${record.thumbnail}`;
thumbImg.style.cssText = "max-width:100%;max-height:300px;border-radius:6px;margin-bottom:12px;display:block;";
body.appendChild(thumbImg);
}
const svg = renderGraphSVG(record.graphData, {
width: 860, height: 600,
showLabels: true, showLinks: true, showSlots: true, showGroups: true,
});
if (svg) {
body.appendChild(svg);
} else {
} else if (!record.thumbnail) {
const fallback = document.createElement("div");
fallback.style.cssText = "color: #666; font-size: 13px; padding: 32px;";
fallback.textContent = "Unable to render preview";
@@ -1502,7 +1509,7 @@ async function _captureSnapshotInner(label) {
return record.id;
}
async function captureNodeSnapshot(label = "Node Trigger") {
async function captureNodeSnapshot(label = "Node Trigger", thumbnail = null) {
if (restoreLock) return false;
const graphData = getGraphData();
@@ -1536,6 +1543,7 @@ async function captureNodeSnapshot(label = "Node Trigger") {
source: "node",
changeType,
parentId,
...(thumbnail ? { thumbnail } : {}),
};
try {
@@ -1776,6 +1784,13 @@ const CSS = `
.snap-item:hover {
background: var(--comfy-menu-bg, #2a2a2a);
}
.snap-item-thumb {
width: 40px;
height: 30px;
object-fit: cover;
border-radius: 3px;
flex-shrink: 0;
}
.snap-item-info {
flex: 1;
min-width: 0;
@@ -3530,18 +3545,25 @@ async function buildSidebar(el) {
// Hover tooltip
item.addEventListener("mouseenter", () => {
tooltipTimer = setTimeout(async () => {
const svgCacheKey = `${rec.id}:240x180`;
let graphData = rec.graphData;
if (!graphData && !svgCache.has(svgCacheKey)) {
const full = await db_getFullRecord(rec.workflowKey, rec.id);
if (!full || !tooltipTimer) return; // abort if mouse already left
graphData = full.graphData;
}
if (!tooltipTimer) return; // abort if mouse left during fetch
const svg = getCachedSVG(rec.id, graphData, { width: 240, height: 180 });
if (!svg) return;
tooltip.innerHTML = "";
tooltip.appendChild(svg);
if (rec.thumbnail) {
const img = document.createElement("img");
img.src = `data:image/jpeg;base64,${rec.thumbnail}`;
img.style.cssText = "max-width:240px;max-height:180px;border-radius:4px;display:block;";
tooltip.appendChild(img);
} else {
const svgCacheKey = `${rec.id}:240x180`;
let graphData = rec.graphData;
if (!graphData && !svgCache.has(svgCacheKey)) {
const full = await db_getFullRecord(rec.workflowKey, rec.id);
if (!full || !tooltipTimer) return;
graphData = full.graphData;
}
if (!tooltipTimer) return;
const svg = getCachedSVG(rec.id, graphData, { width: 240, height: 180 });
if (!svg) return;
tooltip.appendChild(svg);
}
const rect = item.getBoundingClientRect();
let left = rect.right + 8;
let top = rect.top;
@@ -3559,6 +3581,12 @@ async function buildSidebar(el) {
tooltip.classList.remove("visible");
});
if (rec.thumbnail) {
const thumb = document.createElement("img");
thumb.className = "snap-item-thumb";
thumb.src = `data:image/jpeg;base64,${rec.thumbnail}`;
item.appendChild(thumb);
}
item.appendChild(info);
item.appendChild(actions);
list.appendChild(item);
@@ -3924,7 +3952,8 @@ if (window.__COMFYUI_FRONTEND_VERSION__) {
// 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) => {
const thumbnail = event.detail?.thumbnail || null;
captureNodeSnapshot(label, thumbnail).catch((err) => {
console.warn(`[${EXTENSION_NAME}] Node-triggered capture failed:`, err);
});
});

View File

@@ -1,3 +1,6 @@
import base64
import io
from server import PromptServer
@@ -9,6 +12,30 @@ class _AnyType(str):
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"
@@ -33,7 +60,11 @@ class SaveSnapshot:
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", {"label": label}
"snapshot-manager-capture", payload
)
return (value,)