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:
@@ -1376,13 +1376,20 @@ async function showPreviewModal(record) {
|
|||||||
const body = document.createElement("div");
|
const body = document.createElement("div");
|
||||||
body.className = "snap-preview-body";
|
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, {
|
const svg = renderGraphSVG(record.graphData, {
|
||||||
width: 860, height: 600,
|
width: 860, height: 600,
|
||||||
showLabels: true, showLinks: true, showSlots: true, showGroups: true,
|
showLabels: true, showLinks: true, showSlots: true, showGroups: true,
|
||||||
});
|
});
|
||||||
if (svg) {
|
if (svg) {
|
||||||
body.appendChild(svg);
|
body.appendChild(svg);
|
||||||
} else {
|
} else if (!record.thumbnail) {
|
||||||
const fallback = document.createElement("div");
|
const fallback = document.createElement("div");
|
||||||
fallback.style.cssText = "color: #666; font-size: 13px; padding: 32px;";
|
fallback.style.cssText = "color: #666; font-size: 13px; padding: 32px;";
|
||||||
fallback.textContent = "Unable to render preview";
|
fallback.textContent = "Unable to render preview";
|
||||||
@@ -1502,7 +1509,7 @@ async function _captureSnapshotInner(label) {
|
|||||||
return record.id;
|
return record.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function captureNodeSnapshot(label = "Node Trigger") {
|
async function captureNodeSnapshot(label = "Node Trigger", thumbnail = null) {
|
||||||
if (restoreLock) return false;
|
if (restoreLock) return false;
|
||||||
|
|
||||||
const graphData = getGraphData();
|
const graphData = getGraphData();
|
||||||
@@ -1536,6 +1543,7 @@ async function captureNodeSnapshot(label = "Node Trigger") {
|
|||||||
source: "node",
|
source: "node",
|
||||||
changeType,
|
changeType,
|
||||||
parentId,
|
parentId,
|
||||||
|
...(thumbnail ? { thumbnail } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1776,6 +1784,13 @@ const CSS = `
|
|||||||
.snap-item:hover {
|
.snap-item:hover {
|
||||||
background: var(--comfy-menu-bg, #2a2a2a);
|
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 {
|
.snap-item-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -3530,18 +3545,25 @@ async function buildSidebar(el) {
|
|||||||
// Hover tooltip
|
// Hover tooltip
|
||||||
item.addEventListener("mouseenter", () => {
|
item.addEventListener("mouseenter", () => {
|
||||||
tooltipTimer = setTimeout(async () => {
|
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.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();
|
const rect = item.getBoundingClientRect();
|
||||||
let left = rect.right + 8;
|
let left = rect.right + 8;
|
||||||
let top = rect.top;
|
let top = rect.top;
|
||||||
@@ -3559,6 +3581,12 @@ async function buildSidebar(el) {
|
|||||||
tooltip.classList.remove("visible");
|
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(info);
|
||||||
item.appendChild(actions);
|
item.appendChild(actions);
|
||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
@@ -3924,7 +3952,8 @@ if (window.__COMFYUI_FRONTEND_VERSION__) {
|
|||||||
// Listen for node-triggered snapshot captures via WebSocket
|
// Listen for node-triggered snapshot captures via WebSocket
|
||||||
api.addEventListener("snapshot-manager-capture", (event) => {
|
api.addEventListener("snapshot-manager-capture", (event) => {
|
||||||
const label = event.detail?.label || "Node Trigger";
|
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);
|
console.warn(`[${EXTENSION_NAME}] Node-triggered capture failed:`, err);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import base64
|
||||||
|
import io
|
||||||
|
|
||||||
from server import PromptServer
|
from server import PromptServer
|
||||||
|
|
||||||
|
|
||||||
@@ -9,6 +12,30 @@ class _AnyType(str):
|
|||||||
ANY_TYPE = _AnyType("*")
|
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:
|
class SaveSnapshot:
|
||||||
CATEGORY = "Snapshot Manager"
|
CATEGORY = "Snapshot Manager"
|
||||||
FUNCTION = "execute"
|
FUNCTION = "execute"
|
||||||
@@ -33,7 +60,11 @@ class SaveSnapshot:
|
|||||||
return float("NaN")
|
return float("NaN")
|
||||||
|
|
||||||
def execute(self, value, label):
|
def execute(self, value, label):
|
||||||
|
payload = {"label": label}
|
||||||
|
thumbnail = _make_thumbnail(value)
|
||||||
|
if thumbnail is not None:
|
||||||
|
payload["thumbnail"] = thumbnail
|
||||||
PromptServer.instance.send_sync(
|
PromptServer.instance.send_sync(
|
||||||
"snapshot-manager-capture", {"label": label}
|
"snapshot-manager-capture", payload
|
||||||
)
|
)
|
||||||
return (value,)
|
return (value,)
|
||||||
|
|||||||
Reference in New Issue
Block a user