diff --git a/README.md b/README.md index ecc1a10..9a5d628 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/__init__.py b/__init__.py index 340146c..17f3920 100644 --- a/__init__.py +++ b/__init__.py @@ -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.", diff --git a/loop_nodes.py b/loop_nodes.py index 6571b84..4328b1e 100644 --- a/loop_nodes.py +++ b/loop_nodes.py @@ -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, diff --git a/web/accumulator_preview.js b/web/accumulator_preview.js index b615a70..404b07f 100644 --- a/web/accumulator_preview.js +++ b/web/accumulator_preview.js @@ -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();