From 1340c3273237953ef0632704972b2159227e9e06 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 25 Jun 2026 10:01:09 +0200 Subject: [PATCH] Improve accumulator preview interactions --- __init__.py | 8 +- loop_nodes.py | 51 +++--- web/accumulator_preview.js | 317 ++++++++++++++++++++++++++++++++++--- 3 files changed, 331 insertions(+), 45 deletions(-) diff --git a/__init__.py b/__init__.py index 5a6ef66..d0463cc 100644 --- a/__init__.py +++ b/__init__.py @@ -508,7 +508,10 @@ if PromptServer is not None and web is not None: async def sxcp_accumulator_list(request): try: payload = await request.json() - result = accumulator_list_entries(str(payload.get("store_key") or "")) + result = accumulator_list_entries( + str(payload.get("store_key") or ""), + preview_limit=int(payload.get("preview_limit") or 0), + ) return web.json_response(result) except Exception as exc: return web.json_response({"error": str(exc)}, status=400) @@ -522,6 +525,7 @@ if PromptServer is not None and web is not None: entry_id=str(payload.get("entry_id") or ""), index=int(payload.get("index") or 0), clear=bool(payload.get("clear")), + preview_limit=int(payload.get("preview_limit") or 0), ) return web.json_response(result) except Exception as exc: @@ -536,6 +540,8 @@ if PromptServer is not None and web is not None: entry_id=str(payload.get("entry_id") or ""), index=int(payload.get("index") or 0), direction=str(payload.get("direction") or "up"), + target_index=int(payload.get("target_index") or 0), + preview_limit=int(payload.get("preview_limit") or 0), ) return web.json_response(result) except Exception as exc: diff --git a/loop_nodes.py b/loop_nodes.py index a17d32e..29844c4 100644 --- a/loop_nodes.py +++ b/loop_nodes.py @@ -238,17 +238,20 @@ def _accumulator_status(key: str, store: list[dict[str, Any]]) -> str: return f"key={key}; entries={len(store)}; image_entries={len(images)}; formats={shape_text}" -def accumulator_list_entries(store_key: str) -> dict[str, Any]: +def accumulator_list_entries(store_key: str, preview_limit: int = 0) -> dict[str, Any]: key = str(store_key or "").strip() if not key: raise ValueError("store_key is required for accumulator preview actions") store = _ACCUMULATOR_STORES.setdefault(key, []) - return { + result = { "store_key": key, "entries": _entry_infos(store), "count": len(store), "status": _accumulator_status(key, store), } + if int(preview_limit) > 0: + result["images"] = _preview_image_results(store, preview_limit, None, None) + return result def accumulator_delete_entries( @@ -256,6 +259,7 @@ def accumulator_delete_entries( entry_id: str = "", index: int = 0, clear: bool = False, + preview_limit: int = 0, ) -> dict[str, Any]: key = str(store_key or "").strip() if not key: @@ -278,7 +282,7 @@ def accumulator_delete_entries( removed = 1 else: raise ValueError("entry_id or 1-based index is required") - result = accumulator_list_entries(key) + result = accumulator_list_entries(key, preview_limit=preview_limit) result["removed"] = removed return result @@ -288,13 +292,15 @@ def accumulator_move_entry( entry_id: str = "", index: int = 0, direction: str = "up", + target_index: int = 0, + preview_limit: int = 0, ) -> dict[str, Any]: key = str(store_key or "").strip() if not key: raise ValueError("store_key is required for accumulator preview actions") store = _ACCUMULATOR_STORES.setdefault(key, []) if not store: - result = accumulator_list_entries(key) + result = accumulator_list_entries(key, preview_limit=preview_limit) result["moved"] = False return result zero_index = -1 @@ -311,26 +317,33 @@ def accumulator_move_entry( else: raise ValueError("entry_id or 1-based index is required") if zero_index < 0: - result = accumulator_list_entries(key) + result = accumulator_list_entries(key, preview_limit=preview_limit) result["moved"] = False return result - direction = str(direction or "up").strip().lower() - if direction == "top": - target_index = 0 - elif direction == "bottom": - target_index = len(store) - 1 - elif direction == "down": - target_index = min(len(store) - 1, zero_index + 1) - else: - target_index = max(0, zero_index - 1) - moved = target_index != zero_index - if moved: + requested_target = int(target_index) + if requested_target > 0: entry = store.pop(zero_index) - store.insert(target_index, entry) - result = accumulator_list_entries(key) + target_zero_index = max(0, min(len(store), requested_target - 1)) + store.insert(target_zero_index, entry) + moved = target_zero_index != zero_index + else: + direction = str(direction or "up").strip().lower() + if direction == "top": + target_zero_index = 0 + elif direction == "bottom": + target_zero_index = len(store) - 1 + elif direction == "down": + target_zero_index = min(len(store) - 1, zero_index + 1) + else: + target_zero_index = max(0, zero_index - 1) + moved = target_zero_index != zero_index + if moved: + entry = store.pop(zero_index) + store.insert(target_zero_index, entry) + result = accumulator_list_entries(key, preview_limit=preview_limit) result["moved"] = moved result["from_index"] = zero_index + 1 - result["to_index"] = target_index + 1 + result["to_index"] = target_zero_index + 1 return result diff --git a/web/accumulator_preview.js b/web/accumulator_preview.js index ca0afed..438a0e3 100644 --- a/web/accumulator_preview.js +++ b/web/accumulator_preview.js @@ -3,6 +3,8 @@ import { api } from "../../scripts/api.js"; const EXTENSION = "ethanfel.prompt_builder.accumulator_preview"; const NODE_NAME = "SxCPAccumulatorPreview"; +const ANIM_PREVIEW_WIDGET = "$$comfy_animation_preview"; +const ENTRY_ACTIONS = ["move up", "move down", "move top", "move bottom", "delete selected"]; const entryCache = new Map(); function widget(node, name) { @@ -59,6 +61,13 @@ function outputEntries(output) { return asArray(entries); } +function outputImages(output) { + const images = output?.images; + if (!images) return []; + if (Array.isArray(images) && images.length === 1 && Array.isArray(images[0])) return images[0]; + return asArray(images).filter(Boolean); +} + function entryLabel(entry) { const index = entry?.index ?? "?"; const id = entry?.id ? ` ${entry.id}` : ""; @@ -86,6 +95,7 @@ function setEntries(node, entries, status = "") { } } setStatus(node, status || `${entries.length} entries`); + renderEntryStrip(node); resizeNode(node); } @@ -101,6 +111,219 @@ function storeKey(node) { return String(widget(node, "store_key")?.value || node._sxcpResolvedStoreKey || "").trim(); } +function previewLimit(node) { + const value = Number(widget(node, "preview_limit")?.value ?? 64); + return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 64; +} + +function actionPayload(node, values = {}) { + return {preview_limit: previewLimit(node), ...values}; +} + +function removeAnimationPreviewWidget(node) { + const widgetIndex = node.widgets?.findIndex((w) => w.name === ANIM_PREVIEW_WIDGET) ?? -1; + if (widgetIndex < 0) return; + node.widgets[widgetIndex].onRemove?.(); + node.widgets.splice(widgetIndex, 1); +} + +function imageUrl(params) { + const query = new URLSearchParams(params).toString(); + const preview = app.getPreviewFormatParam?.() || ""; + const rand = app.getRandParam?.() || `&rand=${Math.random()}`; + return api.apiURL(`/view?${query}${preview}${rand}`); +} + +function loadImage(src) { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => resolve(null); + img.src = src; + }); +} + +function selectEntry(node, entry) { + if (!node._sxcpEntrySelectWidget || !entry) return; + const label = entryLabel(entry); + node._sxcpEntrySelectWidget.value = label; + node.setDirtyCanvas?.(true, true); +} + +function entryStripRows(node) { + const entries = entryCache.get(nodeKey(node)) || node._sxcpAccumulatorEntries || []; + return entries.filter((entry) => entry?.has_image).slice(0, previewLimit(node)); +} + +function clearDragHighlights(root) { + root?.querySelectorAll?.("[data-sxcp-drop]").forEach((row) => { + row.style.borderColor = "rgba(255,255,255,0.18)"; + row.style.background = "rgba(255,255,255,0.04)"; + delete row.dataset.sxcpDrop; + }); +} + +function dropTargetIndex(sourceEntry, targetEntry, event, targetElement) { + const sourceIndex = Number(sourceEntry?.index || 0); + const targetIndex = Number(targetEntry?.index || 0); + if (!sourceIndex || !targetIndex) return 0; + const rect = targetElement.getBoundingClientRect(); + const insertAfter = event.clientY > rect.top + rect.height / 2; + let finalIndex = targetIndex + (insertAfter ? 1 : 0); + if (sourceIndex < finalIndex) finalIndex -= 1; + return Math.max(1, finalIndex); +} + +function makeStripRow(node, entry, imageParams, rowNumber) { + const row = document.createElement("div"); + row.draggable = true; + row.dataset.index = String(entry.index || ""); + row.style.cssText = [ + "display:flex", + "align-items:center", + "gap:8px", + "min-height:52px", + "padding:4px", + "border:1px solid rgba(255,255,255,0.18)", + "border-radius:4px", + "background:rgba(255,255,255,0.04)", + "cursor:grab", + "box-sizing:border-box", + "user-select:none", + ].join(";"); + row.title = "Drag to reorder accumulator images"; + + const thumb = document.createElement("img"); + thumb.style.cssText = [ + "width:44px", + "height:44px", + "object-fit:cover", + "border-radius:3px", + "background:#111", + "flex:0 0 auto", + ].join(";"); + if (imageParams) thumb.src = imageUrl(imageParams); + + const label = document.createElement("div"); + label.textContent = `${rowNumber}. ${entryLabel(entry)}`; + label.style.cssText = [ + "overflow:hidden", + "text-overflow:ellipsis", + "white-space:nowrap", + "font:12px sans-serif", + "color:#ddd", + "flex:1 1 auto", + ].join(";"); + + row.append(thumb, label); + row.addEventListener("click", () => selectEntry(node, entry)); + row.addEventListener("dragstart", (event) => { + node._sxcpDraggedEntry = entry; + row.style.opacity = "0.55"; + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", String(entry.id || entry.index || "")); + } + }); + row.addEventListener("dragend", () => { + row.style.opacity = "1"; + clearDragHighlights(node._sxcpEntryStripRoot); + node._sxcpDraggedEntry = null; + }); + row.addEventListener("dragover", (event) => { + event.preventDefault(); + if (!node._sxcpDraggedEntry || node._sxcpDraggedEntry === entry) return; + clearDragHighlights(node._sxcpEntryStripRoot); + row.dataset.sxcpDrop = "1"; + row.style.borderColor = "#7aa2ff"; + row.style.background = "rgba(122,162,255,0.18)"; + }); + row.addEventListener("drop", async (event) => { + event.preventDefault(); + const sourceEntry = node._sxcpDraggedEntry; + clearDragHighlights(node._sxcpEntryStripRoot); + if (!sourceEntry || sourceEntry === entry) return; + const targetIndex = dropTargetIndex(sourceEntry, entry, event, row); + if (!targetIndex || targetIndex === Number(sourceEntry.index || 0)) return; + await moveEntryToIndex(node, sourceEntry, targetIndex); + }); + return row; +} + +function renderEntryStrip(node) { + const root = node._sxcpEntryStripRoot; + if (!root) return; + const entries = entryStripRows(node); + const images = node._sxcpPreviewImageParams || []; + root.replaceChildren(); + root.style.cssText = [ + "display:flex", + "flex-direction:column", + "gap:4px", + "max-height:240px", + "overflow:auto", + "padding:4px", + "border:1px solid rgba(255,255,255,0.12)", + "border-radius:4px", + "background:rgba(0,0,0,0.18)", + "box-sizing:border-box", + ].join(";"); + + if (!entries.length) { + const empty = document.createElement("div"); + empty.textContent = "no image entries"; + empty.style.cssText = "font:12px sans-serif;color:#aaa;padding:6px;"; + root.append(empty); + node._sxcpEntryStripHeight = 36; + return; + } + + for (const [index, entry] of entries.entries()) { + root.append(makeStripRow(node, entry, images[index], index + 1)); + } + node._sxcpEntryStripHeight = Math.min(250, 12 + entries.length * 58); +} + +function applyPreviewImages(node, images) { + const normalized = asArray(images).filter(Boolean); + node._sxcpPreviewImageParams = normalized; + const key = nodeKey(node); + const output = app.nodeOutputs?.[key] || {}; + if (app.nodeOutputs) { + app.nodeOutputs[key] = {...output, images: normalized}; + } + + removeAnimationPreviewWidget(node); + node.images = null; + node.imgs = null; + node.imageIndex = null; + node.overIndex = null; + node.pointerDown = null; + + if (!normalized.length) { + renderEntryStrip(node); + resizeNode(node); + return; + } + + Promise.all(normalized.map((params) => loadImage(imageUrl(params)))).then((loadedImages) => { + if (app.nodeOutputs?.[key]?.images !== normalized) return; + node.images = normalized; + node.imgs = loadedImages.filter(Boolean); + node.setSizeForImage?.(); + resizeNode(node); + }); + renderEntryStrip(node); + resizeNode(node); +} + +function applyActionResult(node, data, status) { + if (Object.prototype.hasOwnProperty.call(data || {}, "images")) { + applyPreviewImages(node, data.images || []); + } + setEntries(node, data.entries || [], status || data.status || ""); +} + async function postJson(path, payload) { const response = await api.fetchApi(path, { method: "POST", @@ -119,8 +342,8 @@ async function refreshEntries(node) { return; } try { - const data = await postJson("/sxcp/accumulator/list", {store_key: key}); - setEntries(node, data.entries || [], data.status || ""); + const data = await postJson("/sxcp/accumulator/list", actionPayload(node, {store_key: key})); + applyActionResult(node, data, data.status || ""); } catch (err) { console.error(`[${EXTENSION}] refresh failed`, err); alert(`Refresh failed: ${err}`); @@ -141,13 +364,13 @@ async function deleteSelected(node) { const label = entryLabel(entry); if (!confirm(`Delete accumulator entry ${label}?`)) return; try { - const data = await postJson("/sxcp/accumulator/delete", { + const data = await postJson("/sxcp/accumulator/delete", actionPayload(node, { store_key: key, entry_id: entry.id || "", index: entry.id ? 0 : entry.index, clear: false, - }); - setEntries(node, data.entries || [], `${data.status || ""}; deleted=${data.removed || 0}; rerun preview to refresh images`); + })); + applyActionResult(node, data, `${data.status || ""}; deleted=${data.removed || 0}`); } catch (err) { console.error(`[${EXTENSION}] delete failed`, err); alert(`Delete failed: ${err}`); @@ -166,19 +389,50 @@ async function moveSelected(node, direction) { return; } try { - const data = await postJson("/sxcp/accumulator/move", { + const data = await postJson("/sxcp/accumulator/move", actionPayload(node, { store_key: key, entry_id: entry.id || "", index: entry.id ? 0 : entry.index, direction, - }); - setEntries(node, data.entries || [], `${data.status || ""}; moved=${data.moved ? "yes" : "no"}; rerun preview to refresh images`); + })); + applyActionResult(node, data, `${data.status || ""}; moved=${data.moved ? "yes" : "no"}`); } catch (err) { console.error(`[${EXTENSION}] move failed`, err); alert(`Move failed: ${err}`); } } +async function moveEntryToIndex(node, entry, targetIndex) { + const key = storeKey(node); + if (!key) { + alert("Set the same explicit store_key on the Accumulator and Accumulator Preview first."); + return; + } + if (!entry) return; + try { + const data = await postJson("/sxcp/accumulator/move", actionPayload(node, { + store_key: key, + entry_id: entry.id || "", + index: entry.id ? 0 : entry.index, + target_index: targetIndex, + })); + applyActionResult(node, data, `${data.status || ""}; moved=${data.moved ? "yes" : "no"}`); + } catch (err) { + console.error(`[${EXTENSION}] drag move failed`, err); + alert(`Move failed: ${err}`); + } +} + +async function applyEntryAction(node) { + const action = widget(node, "entry_action")?.value || node._sxcpEntryActionWidget?.value || "move up"; + if (action === "delete selected") { + await deleteSelected(node); + return; + } + const direction = action.replace(/^move\s+/, ""); + await moveSelected(node, direction); +} + async function clearStore(node) { const key = storeKey(node); if (!key) { @@ -187,8 +441,8 @@ async function clearStore(node) { } if (!confirm(`Clear all entries from accumulator "${key}"?`)) return; try { - const data = await postJson("/sxcp/accumulator/delete", {store_key: key, clear: true}); - setEntries(node, data.entries || [], `${data.status || ""}; cleared=${data.removed || 0}; rerun preview to refresh images`); + const data = await postJson("/sxcp/accumulator/delete", actionPayload(node, {store_key: key, clear: true})); + applyActionResult(node, data, `${data.status || ""}; cleared=${data.removed || 0}`); } catch (err) { console.error(`[${EXTENSION}] clear failed`, err); alert(`Clear failed: ${err}`); @@ -199,29 +453,39 @@ function setupNode(node) { hideWidget(widget(node, "delete_action")); hideWidget(widget(node, "delete_entry_id")); hideWidget(widget(node, "delete_index")); + for (const legacyButton of [ + "Move Selected Top", + "Move Selected Up", + "Move Selected Down", + "Move Selected Bottom", + "Delete Selected Entry", + ]) { + hideWidget(widget(node, legacyButton)); + } if (!node._sxcpEntrySelectWidget) { node._sxcpEntrySelectWidget = node.addWidget("combo", "selected_entry", "no entries", () => {}, {values: ["no entries"]}); node._sxcpEntrySelectWidget.serialize = false; } + if (!node._sxcpEntryStripWidget && typeof node.addDOMWidget === "function") { + node._sxcpEntryStripRoot = document.createElement("div"); + node._sxcpEntryStripWidget = node.addDOMWidget("entry_strip", "div", node._sxcpEntryStripRoot, { + serialize: false, + hideOnZoom: false, + getMinHeight: () => node._sxcpEntryStripHeight || 36, + }); + renderEntryStrip(node); + } + if (!node._sxcpEntryActionWidget) { + node._sxcpEntryActionWidget = node.addWidget("combo", "entry_action", ENTRY_ACTIONS[0], () => {}, {values: ENTRY_ACTIONS}); + node._sxcpEntryActionWidget.serialize = false; + } if (!node._sxcpAccumulatorStatusWidget) { node._sxcpAccumulatorStatusWidget = node.addWidget("text", "accumulator_status", "no accumulator data", () => {}); node._sxcpAccumulatorStatusWidget.serialize = false; } - if (!node._sxcpMoveTopButton) { - node._sxcpMoveTopButton = node.addWidget("button", "Move Selected Top", null, () => moveSelected(node, "top")); - } - if (!node._sxcpMoveUpButton) { - node._sxcpMoveUpButton = node.addWidget("button", "Move Selected Up", null, () => moveSelected(node, "up")); - } - if (!node._sxcpMoveDownButton) { - node._sxcpMoveDownButton = node.addWidget("button", "Move Selected Down", null, () => moveSelected(node, "down")); - } - if (!node._sxcpMoveBottomButton) { - node._sxcpMoveBottomButton = node.addWidget("button", "Move Selected Bottom", null, () => moveSelected(node, "bottom")); - } - if (!node._sxcpDeleteSelectedButton) { - node._sxcpDeleteSelectedButton = node.addWidget("button", "Delete Selected Entry", null, () => deleteSelected(node)); + if (!node._sxcpApplyEntryActionButton) { + node._sxcpApplyEntryActionButton = node.addWidget("button", "Apply Entry Action", null, () => applyEntryAction(node)); } if (!node._sxcpClearButton) { node._sxcpClearButton = node.addWidget("button", "Clear Accumulator", null, () => clearStore(node)); @@ -237,10 +501,13 @@ app.registerExtension({ async setup() { api.addEventListener("executed", ({detail}) => { - const node = getNodeById(detail?.node); + const node = getNodeById(detail?.display_node ?? detail?.node); if (!isAccumulatorPreviewNode(node)) return; const output = detail?.output || {}; node._sxcpResolvedStoreKey = outputStoreKey(output); + if (Object.prototype.hasOwnProperty.call(output, "images")) { + applyPreviewImages(node, outputImages(output)); + } setEntries(node, outputEntries(output), outputStatus(output)); }); },