Add accumulator preview zoom and carousel

This commit is contained in:
2026-06-26 11:26:20 +02:00
parent 482c62b397
commit 20483ca019
4 changed files with 147 additions and 13 deletions
+5
View File
@@ -171,6 +171,11 @@ Its outputs are:
ComfyUI image batches require matching dimensions. For mixed image formats, use
`image_list` or the grouped `image_batch_1..4` outputs instead of `image_batch`.
`SxCP Accumulator Preview` can show images as a wrapped grid or as a carousel.
Set `view_mode=carousel` to inspect one image at a time with the `Prev` and
`Next` buttons. `zoom_level` controls thumbnail size in grid mode and the image
area in carousel mode; `carousel_index` stores the selected carousel position.
`SxCP Preview Any As Text` is a persistent text preview for arbitrary values.
Connect any output to `value`; the node renders strings directly and formats
dict/list/tensor-like values as readable text. After execution, its
+3
View File
@@ -145,6 +145,9 @@ COMMON_INPUT_TOOLTIPS = {
"entry_id": "Stable ID used for replace_by_entry_id or grouping variants.",
"entry_tag": "Optional suffix added to entry_id.",
"preview_limit": "Maximum number of accumulator images to show in the preview panel.",
"view_mode": "Accumulator Preview layout: grid shows many images, carousel shows one large image at a time.",
"zoom_level": "Accumulator Preview image scale. Higher values make grid thumbnails or carousel image area larger.",
"carousel_index": "1-based image position shown in carousel mode. The previous/next buttons update this value.",
"delete_action": "Optional execution-time delete operation. JS buttons can delete interactively without setting this.",
"delete_entry_id": "Entry id to delete when delete_action is delete_entry_id.",
"delete_index": "1-based entry index to delete when delete_action is delete_index. 0 disables it.",
+7
View File
@@ -46,6 +46,7 @@ COLLECTION_MODES = ["auto_batch", "list", "image_batch", "latent_batch", "string
ACCUMULATOR_ACTIONS = ["append_variant", "replace_by_entry_id", "append", "clear_then_append", "clear", "read"]
ACCUMULATOR_IMAGE_BATCH_MODES = ["same_size_only", "resize_to_first"]
ACCUMULATOR_IMAGE_GROUPS = 4
ACCUMULATOR_PREVIEW_VIEW_MODES = ["grid", "carousel"]
ACCUMULATOR_PREVIEW_DELETE_ACTIONS = ["none", "delete_entry_id", "delete_index", "clear"]
INDEX_SWITCH_MODES = ["pick_input", "route_output"]
INDEX_SWITCH_BASES = ["one_based", "zero_based"]
@@ -1156,6 +1157,9 @@ class SxCPAccumulatorPreview:
"required": {
"store_key": ("STRING", {"default": "", "multiline": False}),
"preview_limit": ("INT", {"default": 64, "min": 1, "max": 512, "step": 1}),
"view_mode": (ACCUMULATOR_PREVIEW_VIEW_MODES, {"default": "grid"}),
"zoom_level": ("FLOAT", {"default": 1.0, "min": 0.5, "max": 3.0, "step": 0.05}),
"carousel_index": ("INT", {"default": 1, "min": 1, "max": 100000, "step": 1}),
"delete_action": (ACCUMULATOR_PREVIEW_DELETE_ACTIONS, {"default": "none"}),
"delete_entry_id": ("STRING", {"default": "", "multiline": False}),
"delete_index": ("INT", {"default": 0, "min": 0, "max": 100000, "step": 1}),
@@ -1206,6 +1210,9 @@ class SxCPAccumulatorPreview:
self,
store_key,
preview_limit,
view_mode,
zoom_level,
carousel_index,
delete_action,
delete_entry_id,
delete_index,
+132 -13
View File
@@ -6,9 +6,12 @@ const NODE_NAME = "SxCPAccumulatorPreview";
const STYLE_ID = "sxcp-accumulator-preview-styles";
const DEBUG_STORAGE_KEY = "sxcpAccumulatorPreviewDebug";
const MIN_CELL_W = 150;
const MAX_CELL_W = 190;
const DEFAULT_GRID_H = 360;
const BASE_CELL_W = 170;
const MIN_CELL_W = 90;
const MAX_CELL_W = 520;
const BASE_GRID_H = 360;
const MIN_GRID_H = 240;
const MAX_GRID_H = 900;
const GAP = 6;
const PAD = 4;
const TOOLBAR_H = 28;
@@ -137,6 +140,33 @@ function previewLimit(node) {
return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 64;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function viewMode(node) {
return widget(node, "view_mode")?.value === "carousel" ? "carousel" : "grid";
}
function zoomLevel(node) {
const value = Number(widget(node, "zoom_level")?.value ?? 1);
return Number.isFinite(value) ? clamp(value, 0.5, 3.0) : 1;
}
function carouselPosition(node, count) {
if (count <= 0) return 1;
const value = Number(widget(node, "carousel_index")?.value ?? node._sxapCarouselIndex ?? 1);
return clamp(Number.isFinite(value) ? Math.round(value) : 1, 1, count);
}
function setCarouselPosition(node, position) {
const count = imageEntries(node).length;
const next = count > 0 ? clamp(Math.round(Number(position) || 1), 1, count) : 1;
node._sxapCarouselIndex = next;
setWidgetValue(node, "carousel_index", next);
renderGrid(node);
}
function actionPayload(node, values = {}) {
return {preview_limit: previewLimit(node), ...values};
}
@@ -224,6 +254,7 @@ function injectStyles() {
.sxap-grid { display:flex; flex-wrap:wrap; gap:${GAP}px; align-content:flex-start; overflow-y:auto;
padding:${PAD}px; background:rgba(0,0,0,0.15); border-radius:4px;
flex:1 1 auto; min-height:0; box-sizing:border-box; }
.sxap-grid.sxap-carousel { flex-wrap:nowrap; align-items:center; justify-content:center; overflow:hidden; }
.sxap-grid.sxap-dragover { outline:2px dashed #6cf; outline-offset:-2px; }
.sxap-empty { width:100%; padding:12px; text-align:center; font-size:12px; opacity:0.65; box-sizing:border-box; }
.sxap-cell { position:relative; width:var(--sxap-cell-w, 170px); border:2px solid transparent;
@@ -232,8 +263,10 @@ function injectStyles() {
.sxap-cell:hover { border-color:#555; }
.sxap-cell.sxap-selected { border-color:#6cf; }
.sxap-cell.sxap-drop { border-color:#fc6; border-style:dashed; }
.sxap-cell.sxap-carousel-cell { width:100%; height:100%; display:flex; flex-direction:column; }
.sxap-thumb { width:100%; height:var(--sxap-thumb-h, 170px); object-fit:contain; display:block;
cursor:grab; background:#111; }
.sxap-carousel-cell .sxap-thumb { flex:1 1 auto; min-height:0; }
.sxap-thumb:active { cursor:grabbing; }
.sxap-badge { position:absolute; top:2px; left:2px; font-size:10px; background:rgba(0,0,0,0.65);
color:#fff; padding:0 4px; border-radius:3px; pointer-events:none; }
@@ -248,6 +281,8 @@ function injectStyles() {
background:rgba(0,0,0,0.62); color:#fff; font-size:10px; line-height:16px; }
.sxap-toolbar { display:flex; align-items:center; gap:6px; flex:0 0 auto; min-height:${TOOLBAR_H - 2}px; }
.sxap-toolbar button { font-size:11px; padding:2px 8px; cursor:pointer; }
.sxap-toolbar button:disabled { opacity:0.45; cursor:default; }
.sxap-carousel-label { font-size:11px; opacity:0.75; min-width:34px; text-align:center; }
.sxap-status { min-width:0; margin-left:auto; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
font-size:11px; opacity:0.75; }
`;
@@ -260,11 +295,16 @@ function injectStyles() {
function layoutMetrics(node) {
const width = Math.max(node.size?.[0] || MIN_W, MIN_W);
const inner = Math.max(MIN_CELL_W, width - 2 * MARGIN - 2 * PAD);
const perRow = Math.max(1, Math.floor((inner + GAP) / (MIN_CELL_W + GAP)));
const rawCellW = Math.floor((inner - GAP * (perRow - 1)) / perRow);
const cellW = Math.min(MAX_CELL_W, Math.max(MIN_CELL_W, rawCellW));
const zoom = zoomLevel(node);
if (viewMode(node) === "carousel") {
const gridH = clamp(Math.round(BASE_GRID_H * zoom), MIN_GRID_H, MAX_GRID_H);
return {cellW: inner, thumbH: Math.max(120, gridH - CAPTION_H - 2 * PAD), gridH};
}
const desiredCellW = clamp(Math.round(BASE_CELL_W * zoom), MIN_CELL_W, MAX_CELL_W);
const cellW = Math.min(desiredCellW, inner);
const thumbH = Math.round(cellW * 1.1);
return {cellW, thumbH, gridH: DEFAULT_GRID_H};
const gridH = clamp(Math.round(BASE_GRID_H * Math.max(0.75, Math.min(zoom, 1.75))), MIN_GRID_H, MAX_GRID_H);
return {cellW, thumbH, gridH};
}
function recomputeSize(node) {
@@ -278,12 +318,12 @@ function syncWidgetWidth(node) {
if (node._sxapWidget) node._sxapWidget.width = node.size?.[0] || MIN_W;
}
function resizeToContent(node) {
function resizeToContent(node, force = false) {
recomputeSize(node);
const targetH = node.computeSize?.()[1] || node._sxapWidgetH || 140;
const width = node.size?.[0] || MIN_W;
const currentH = node.size?.[1] || 0;
const nextH = currentH > 0 ? Math.max(currentH, targetH) : targetH;
const nextH = force || currentH <= 0 ? targetH : Math.max(currentH, targetH);
node._sxapAutoHeight = nextH;
if (Math.abs(currentH - nextH) > 1) node.setSize?.([width, nextH]);
syncWidgetWidth(node);
@@ -327,9 +367,11 @@ function markSelected(node, selectedIndex) {
});
}
function renderCell(node, entry, imageParams, displayIndex) {
function renderCell(node, entry, imageParams, displayIndex, options = {}) {
const cell = document.createElement("div");
cell.className = "sxap-cell" + (entry.index === node._sxapSelectedIndex ? " sxap-selected" : "");
cell.className = "sxap-cell"
+ (options.carousel ? " sxap-carousel-cell" : "")
+ (entry.index === node._sxapSelectedIndex ? " sxap-selected" : "");
cell.dataset.index = String(entry.index || "");
cell.title = entryTitle(entry);
const cellW = node._sxapCellW || MIN_CELL_W;
@@ -424,17 +466,44 @@ function renderCell(node, entry, imageParams, displayIndex) {
return cell;
}
function updateCarouselControls(node, count) {
const carousel = viewMode(node) === "carousel";
const position = carouselPosition(node, count);
if (node._sxapPrevButton) {
node._sxapPrevButton.style.display = carousel ? "" : "none";
node._sxapPrevButton.disabled = !carousel || count <= 1;
}
if (node._sxapNextButton) {
node._sxapNextButton.style.display = carousel ? "" : "none";
node._sxapNextButton.disabled = !carousel || count <= 1;
}
if (node._sxapCarouselLabel) {
node._sxapCarouselLabel.style.display = carousel ? "" : "none";
node._sxapCarouselLabel.textContent = count ? `${position}/${count}` : "0/0";
}
}
function renderGrid(node) {
const grid = node._sxapGridEl;
if (!grid) return;
const entries = imageEntries(node);
const carousel = viewMode(node) === "carousel";
grid.replaceChildren();
grid.classList.toggle("sxap-carousel", carousel);
if (!entries.length) {
const empty = document.createElement("div");
empty.className = "sxap-empty";
empty.textContent = storeKey(node) ? "No accumulator images." : "Run once or set an explicit store_key.";
grid.appendChild(empty);
} else if (carousel) {
const position = carouselPosition(node, entries.length);
const entry = entries[position - 1];
if (Number(widget(node, "carousel_index")?.value || 1) !== position) {
setWidgetValue(node, "carousel_index", position);
}
markSelected(node, entry.index);
grid.appendChild(renderCell(node, entry, imageParamsForEntry(node, entry, position - 1), position - 1, {carousel: true}));
} else {
debugLog("renderGrid", entries.map((entry, index) => ({
index: entry.index,
@@ -449,8 +518,11 @@ function renderGrid(node) {
const total = (node._sxapEntries || []).filter((entry) => entry?.has_image).length;
const count = entries.length;
updateCarouselControls(node, count);
if (node._sxapStatusEl) {
const prefix = total > count ? `${count}/${total} shown` : `${total} image${total === 1 ? "" : "s"}`;
const prefix = carousel && count
? `image ${carouselPosition(node, count)}/${count}`
: total > count ? `${count}/${total} shown` : `${total} image${total === 1 ? "" : "s"}`;
node._sxapStatusEl.textContent = node._sxapStatus ? `${prefix}; ${node._sxapStatus}` : prefix;
node._sxapStatusEl.title = node._sxapStatusEl.textContent;
}
@@ -623,10 +695,35 @@ function hideInternalWidgets(node) {
}
}
function refreshLayout(node) {
recomputeSize(node);
renderGrid(node);
resizeToContent(node, true);
}
function wrapWidgetCallback(node, name) {
const w = widget(node, name);
if (!w || w._sxapWrapped) return;
const original = w.callback;
w.callback = function () {
const result = original?.apply(this, arguments);
requestAnimationFrame(() => refreshLayout(node));
return result;
};
w._sxapWrapped = true;
}
function installWidgetRefreshHandlers(node) {
wrapWidgetCallback(node, "view_mode");
wrapWidgetCallback(node, "zoom_level");
wrapWidgetCallback(node, "carousel_index");
}
function setupNode(node) {
injectStyles();
suppressBuiltinPreview(node);
hideInternalWidgets(node);
installWidgetRefreshHandlers(node);
if (!node._sxapGridEl && typeof node.addDOMWidget === "function") {
const wrap = document.createElement("div");
@@ -652,14 +749,36 @@ function setupNode(node) {
clear.textContent = "Clear";
clear.onclick = () => clearStore(node);
const prev = document.createElement("button");
prev.textContent = "Prev";
prev.title = "Previous carousel image";
prev.onclick = () => {
const count = imageEntries(node).length;
setCarouselPosition(node, carouselPosition(node, count) - 1);
};
const next = document.createElement("button");
next.textContent = "Next";
next.title = "Next carousel image";
next.onclick = () => {
const count = imageEntries(node).length;
setCarouselPosition(node, carouselPosition(node, count) + 1);
};
const carouselLabel = document.createElement("span");
carouselLabel.className = "sxap-carousel-label";
const status = document.createElement("span");
status.className = "sxap-status";
toolbar.append(save, refresh, clear, status);
toolbar.append(save, refresh, clear, prev, next, carouselLabel, status);
wrap.appendChild(toolbar);
node._sxapGridEl = grid;
node._sxapStatusEl = status;
node._sxapPrevButton = prev;
node._sxapNextButton = next;
node._sxapCarouselLabel = carouselLabel;
node._sxapEntries = node._sxapEntries || [];
node._sxapImages = node._sxapImages || [];
node._sxapImageByKey = node._sxapImageByKey || new Map();