Use scalable grid for accumulator preview
This commit is contained in:
+400
-330
@@ -3,9 +3,17 @@ import { api } from "../../scripts/api.js";
|
|||||||
|
|
||||||
const EXTENSION = "ethanfel.prompt_builder.accumulator_preview";
|
const EXTENSION = "ethanfel.prompt_builder.accumulator_preview";
|
||||||
const NODE_NAME = "SxCPAccumulatorPreview";
|
const NODE_NAME = "SxCPAccumulatorPreview";
|
||||||
const ANIM_PREVIEW_WIDGET = "$$comfy_animation_preview";
|
const STYLE_ID = "sxcp-accumulator-preview-styles";
|
||||||
const ENTRY_ACTIONS = ["move up", "move down", "move top", "move bottom", "delete selected"];
|
|
||||||
const entryCache = new Map();
|
const MIN_CELL_W = 180;
|
||||||
|
const GAP = 6;
|
||||||
|
const PAD = 4;
|
||||||
|
const TOOLBAR_H = 28;
|
||||||
|
const CAPTION_H = 20;
|
||||||
|
const EMPTY_GRID_H = 84;
|
||||||
|
const MAX_GRID_H = 640;
|
||||||
|
const MIN_W = 560;
|
||||||
|
const MARGIN = 10;
|
||||||
|
|
||||||
function widget(node, name) {
|
function widget(node, name) {
|
||||||
return node.widgets?.find((w) => w.name === name);
|
return node.widgets?.find((w) => w.name === name);
|
||||||
@@ -13,22 +21,10 @@ function widget(node, name) {
|
|||||||
|
|
||||||
function hideWidget(w) {
|
function hideWidget(w) {
|
||||||
if (!w) return;
|
if (!w) return;
|
||||||
if (w.origType === undefined) w.origType = w.type;
|
|
||||||
w.type = "hidden";
|
|
||||||
w.hidden = true;
|
w.hidden = true;
|
||||||
w.computeSize = () => [0, -4];
|
w.computeSize = () => [0, -4];
|
||||||
}
|
}
|
||||||
|
|
||||||
function resizeNode(node) {
|
|
||||||
const size = node.computeSize?.();
|
|
||||||
if (size) node.setSize?.(size);
|
|
||||||
app.graph?.setDirtyCanvas(true, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeKey(nodeOrId) {
|
|
||||||
return String(typeof nodeOrId === "object" ? nodeOrId?.id : nodeOrId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAccumulatorPreviewNode(node) {
|
function isAccumulatorPreviewNode(node) {
|
||||||
return node?.comfyClass === NODE_NAME || node?.type === NODE_NAME;
|
return node?.comfyClass === NODE_NAME || node?.type === NODE_NAME;
|
||||||
}
|
}
|
||||||
@@ -68,47 +64,8 @@ function outputImages(output) {
|
|||||||
return asArray(images).filter(Boolean);
|
return asArray(images).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function entryLabel(entry) {
|
|
||||||
const index = entry?.index ?? "?";
|
|
||||||
const id = entry?.id ? ` ${entry.id}` : "";
|
|
||||||
const image = entry?.has_image ? " image" : " value";
|
|
||||||
const shape = Array.isArray(entry?.shape) && entry.shape.length >= 2 ? ` ${entry.shape[1]}x${entry.shape[0]}` : "";
|
|
||||||
const metadata = entry?.has_metadata ? " metadata" : "";
|
|
||||||
return `#${index}${id}${image}${shape}${metadata}`.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus(node, status) {
|
|
||||||
if (!node._sxcpAccumulatorStatusWidget) return;
|
|
||||||
node._sxcpAccumulatorStatusWidget.value = status || "no accumulator data";
|
|
||||||
node.setDirtyCanvas?.(true, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setEntries(node, entries, status = "") {
|
|
||||||
entries = asArray(entries);
|
|
||||||
entryCache.set(nodeKey(node), entries);
|
|
||||||
node._sxcpAccumulatorEntries = entries;
|
|
||||||
if (node._sxcpEntrySelectWidget) {
|
|
||||||
const labels = entries.map(entryLabel);
|
|
||||||
node._sxcpEntrySelectWidget.options.values = labels.length ? labels : ["no entries"];
|
|
||||||
if (!labels.includes(node._sxcpEntrySelectWidget.value)) {
|
|
||||||
node._sxcpEntrySelectWidget.value = labels[0] || "no entries";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setStatus(node, status || `${entries.length} entries`);
|
|
||||||
renderEntryStrip(node);
|
|
||||||
resizeNode(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectedEntry(node) {
|
|
||||||
const entries = entryCache.get(nodeKey(node)) || node._sxcpAccumulatorEntries || [];
|
|
||||||
const selected = widget(node, "selected_entry")?.value || node._sxcpEntrySelectWidget?.value || "";
|
|
||||||
const labels = entries.map(entryLabel);
|
|
||||||
const index = labels.indexOf(selected);
|
|
||||||
return index >= 0 ? entries[index] : entries[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function storeKey(node) {
|
function storeKey(node) {
|
||||||
return String(widget(node, "store_key")?.value || node._sxcpResolvedStoreKey || "").trim();
|
return String(widget(node, "store_key")?.value || node._sxapResolvedStoreKey || "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewLimit(node) {
|
function previewLimit(node) {
|
||||||
@@ -120,11 +77,9 @@ function actionPayload(node, values = {}) {
|
|||||||
return {preview_limit: previewLimit(node), ...values};
|
return {preview_limit: previewLimit(node), ...values};
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAnimationPreviewWidget(node) {
|
function imageEntries(node) {
|
||||||
const widgetIndex = node.widgets?.findIndex((w) => w.name === ANIM_PREVIEW_WIDGET) ?? -1;
|
const entries = node._sxapEntries || [];
|
||||||
if (widgetIndex < 0) return;
|
return entries.filter((entry) => entry?.has_image).slice(0, previewLimit(node));
|
||||||
node.widgets[widgetIndex].onRemove?.();
|
|
||||||
node.widgets.splice(widgetIndex, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageUrl(params) {
|
function imageUrl(params) {
|
||||||
@@ -134,194 +89,17 @@ function imageUrl(params) {
|
|||||||
return api.apiURL(`/view?${query}${preview}${rand}`);
|
return api.apiURL(`/view?${query}${preview}${rand}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadImage(src) {
|
function entryLabel(entry) {
|
||||||
return new Promise((resolve) => {
|
const parts = [`#${entry?.index ?? "?"}`];
|
||||||
const img = new Image();
|
if (entry?.id) parts.push(entry.id);
|
||||||
img.onload = () => resolve(img);
|
if (Array.isArray(entry?.shape) && entry.shape.length >= 2) parts.push(`${entry.shape[1]}x${entry.shape[0]}`);
|
||||||
img.onerror = () => resolve(null);
|
if (entry?.has_metadata) parts.push("metadata");
|
||||||
img.src = src;
|
return parts.join(" ");
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectEntry(node, entry) {
|
function entryTitle(entry) {
|
||||||
if (!node._sxcpEntrySelectWidget || !entry) return;
|
const value = entry?.value ? `\n${entry.value}` : "";
|
||||||
const label = entryLabel(entry);
|
return `${entryLabel(entry)}${value}`;
|
||||||
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) {
|
async function postJson(path, payload) {
|
||||||
@@ -335,34 +113,284 @@ async function postJson(path, payload) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function injectStyles() {
|
||||||
|
if (document.getElementById(STYLE_ID)) return;
|
||||||
|
const css = `
|
||||||
|
.sxap-wrap { display:flex; flex-direction:column; gap:4px; box-sizing:border-box; height:100%; min-height:0; }
|
||||||
|
.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-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, 240px); border:2px solid transparent;
|
||||||
|
border-radius:4px; overflow:hidden; background:#222; box-sizing:border-box;
|
||||||
|
transition:border-color .1s, background .1s; }
|
||||||
|
.sxap-cell:hover { border-color:#555; }
|
||||||
|
.sxap-cell.sxap-selected { border-color:#6cf; }
|
||||||
|
.sxap-cell.sxap-drop { border-color:#fc6; border-style:dashed; }
|
||||||
|
.sxap-thumb { width:100%; object-fit:contain; display:block; cursor:grab; background:#111; }
|
||||||
|
.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; }
|
||||||
|
.sxap-meta { position:absolute; top:3px; left:50%; transform:translateX(-50%); font-size:9px;
|
||||||
|
background:rgba(40,160,100,0.9); color:#fff; padding:0 3px; border-radius:3px;
|
||||||
|
pointer-events:none; }
|
||||||
|
.sxap-del { position:absolute; top:2px; right:2px; width:18px; height:18px; border:none; border-radius:3px;
|
||||||
|
background:rgba(180,30,30,0.88); color:#fff; font-size:11px; line-height:1;
|
||||||
|
cursor:pointer; padding:0; }
|
||||||
|
.sxap-caption { width:100%; height:${CAPTION_H}px; box-sizing:border-box;
|
||||||
|
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding:2px 4px;
|
||||||
|
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-status { min-width:0; margin-left:auto; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
|
||||||
|
font-size:11px; opacity:0.75; }
|
||||||
|
`;
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = STYLE_ID;
|
||||||
|
style.textContent = css;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryAspect(entry) {
|
||||||
|
const shape = Array.isArray(entry?.shape) ? entry.shape : [];
|
||||||
|
const height = Number(shape[0] || 0);
|
||||||
|
const width = Number(shape[1] || 0);
|
||||||
|
if (height > 0 && width > 0) return height / width;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 entries = imageEntries(node);
|
||||||
|
const count = entries.length;
|
||||||
|
const perRow = count <= 1 ? 1 : Math.max(1, Math.floor((inner + GAP) / (MIN_CELL_W + GAP)));
|
||||||
|
const cellW = Math.max(MIN_CELL_W, Math.floor((inner - GAP * (perRow - 1)) / perRow));
|
||||||
|
let gridH = EMPTY_GRID_H;
|
||||||
|
if (count > 0) {
|
||||||
|
gridH = 2 * PAD;
|
||||||
|
for (let start = 0; start < count; start += perRow) {
|
||||||
|
const row = entries.slice(start, start + perRow);
|
||||||
|
const rowH = Math.max(...row.map((entry) => Math.round(cellW * entryAspect(entry)) + CAPTION_H));
|
||||||
|
gridH += rowH;
|
||||||
|
if (start + perRow < count) gridH += GAP;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {cellW, gridH};
|
||||||
|
}
|
||||||
|
|
||||||
|
function recomputeSize(node) {
|
||||||
|
const {cellW, gridH} = layoutMetrics(node);
|
||||||
|
node._sxapCellW = cellW;
|
||||||
|
node._sxapWidgetH = 2 * MARGIN + TOOLBAR_H + 6 + Math.min(gridH, MAX_GRID_H);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncWidgetWidth(node) {
|
||||||
|
if (node._sxapWidget) node._sxapWidget.width = node.size?.[0] || MIN_W;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeToContent(node) {
|
||||||
|
recomputeSize(node);
|
||||||
|
const targetH = node.computeSize?.()[1] || node._sxapWidgetH || 140;
|
||||||
|
const width = node.size?.[0] || MIN_W;
|
||||||
|
const currentH = node.size?.[1] || 0;
|
||||||
|
const previousAutoH = node._sxapAutoHeight || 0;
|
||||||
|
const userSizedTaller = previousAutoH > 0 && currentH > previousAutoH + 6 && currentH > targetH;
|
||||||
|
const nextH = userSizedTaller ? currentH : targetH;
|
||||||
|
node._sxapAutoHeight = nextH;
|
||||||
|
node.setSize?.([width, nextH]);
|
||||||
|
syncWidgetWidth(node);
|
||||||
|
node.setDirtyCanvas?.(true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function suppressBuiltinPreview(node) {
|
||||||
|
try {
|
||||||
|
Object.defineProperty(node, "imgs", {
|
||||||
|
configurable: true,
|
||||||
|
get() { return undefined; },
|
||||||
|
set() {},
|
||||||
|
});
|
||||||
|
} catch (_err) {
|
||||||
|
// Best effort: if another extension made it non-configurable, the grid still works.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWidgetValue(node, name, value) {
|
||||||
|
const w = widget(node, name);
|
||||||
|
if (!w) return;
|
||||||
|
w.value = value;
|
||||||
|
w.callback?.(value, app.canvas, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(node, status) {
|
||||||
|
node._sxapStatus = status || "";
|
||||||
|
if (node._sxapStatusEl) node._sxapStatusEl.textContent = status || "";
|
||||||
|
node.setDirtyCanvas?.(true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDropState(node) {
|
||||||
|
node._sxapGridEl?.querySelectorAll?.(".sxap-drop").forEach((cell) => cell.classList.remove("sxap-drop"));
|
||||||
|
node._sxapGridEl?.classList.remove("sxap-dragover");
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSelected(node, selectedIndex) {
|
||||||
|
node._sxapSelectedIndex = selectedIndex;
|
||||||
|
node._sxapGridEl?.querySelectorAll?.(".sxap-cell").forEach((cell) => {
|
||||||
|
cell.classList.toggle("sxap-selected", Number(cell.dataset.index || 0) === selectedIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCell(node, entry, imageParams, displayIndex) {
|
||||||
|
const cell = document.createElement("div");
|
||||||
|
cell.className = "sxap-cell" + (entry.index === node._sxapSelectedIndex ? " sxap-selected" : "");
|
||||||
|
cell.dataset.index = String(entry.index || "");
|
||||||
|
cell.title = entryTitle(entry);
|
||||||
|
const cellW = node._sxapCellW || MIN_CELL_W;
|
||||||
|
cell.style.setProperty("--sxap-cell-w", `${cellW}px`);
|
||||||
|
|
||||||
|
cell.ondragover = (event) => {
|
||||||
|
if (!node._sxapDragEntry) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
||||||
|
cell.classList.add("sxap-drop");
|
||||||
|
};
|
||||||
|
cell.ondragleave = () => cell.classList.remove("sxap-drop");
|
||||||
|
cell.ondrop = async (event) => {
|
||||||
|
if (!node._sxapDragEntry) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
cell.classList.remove("sxap-drop");
|
||||||
|
const source = node._sxapDragEntry;
|
||||||
|
node._sxapDragEntry = null;
|
||||||
|
if (source.index === entry.index) return;
|
||||||
|
await moveEntryToIndex(node, source, entry.index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const thumb = document.createElement("img");
|
||||||
|
thumb.className = "sxap-thumb";
|
||||||
|
thumb.style.height = `${Math.max(48, Math.round(cellW * entryAspect(entry)))}px`;
|
||||||
|
if (imageParams) thumb.src = imageUrl(imageParams);
|
||||||
|
thumb.draggable = true;
|
||||||
|
thumb.onclick = () => markSelected(node, entry.index);
|
||||||
|
thumb.ondragstart = (event) => {
|
||||||
|
node._sxapDragEntry = entry;
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData("text/plain", String(entry.id || entry.index || ""));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
thumb.ondragend = () => {
|
||||||
|
node._sxapDragEntry = null;
|
||||||
|
clearDropState(node);
|
||||||
|
};
|
||||||
|
cell.appendChild(thumb);
|
||||||
|
|
||||||
|
const badge = document.createElement("div");
|
||||||
|
badge.className = "sxap-badge";
|
||||||
|
badge.textContent = String(entry.index ?? displayIndex + 1);
|
||||||
|
cell.appendChild(badge);
|
||||||
|
|
||||||
|
if (entry.has_metadata) {
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "sxap-meta";
|
||||||
|
meta.textContent = "M";
|
||||||
|
meta.title = "has metadata";
|
||||||
|
cell.appendChild(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
const del = document.createElement("button");
|
||||||
|
del.className = "sxap-del";
|
||||||
|
del.textContent = "x";
|
||||||
|
del.title = "Delete";
|
||||||
|
del.onclick = async (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
await deleteEntry(node, entry);
|
||||||
|
};
|
||||||
|
cell.appendChild(del);
|
||||||
|
|
||||||
|
const caption = document.createElement("div");
|
||||||
|
caption.className = "sxap-caption";
|
||||||
|
caption.textContent = entry.id || entry.value || entryLabel(entry);
|
||||||
|
cell.appendChild(caption);
|
||||||
|
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid(node) {
|
||||||
|
const grid = node._sxapGridEl;
|
||||||
|
if (!grid) return;
|
||||||
|
const entries = imageEntries(node);
|
||||||
|
const images = node._sxapImages || [];
|
||||||
|
grid.replaceChildren();
|
||||||
|
|
||||||
|
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 {
|
||||||
|
entries.forEach((entry, index) => {
|
||||||
|
grid.appendChild(renderCell(node, entry, images[index], index));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = (node._sxapEntries || []).filter((entry) => entry?.has_image).length;
|
||||||
|
const count = entries.length;
|
||||||
|
if (node._sxapStatusEl) {
|
||||||
|
const prefix = 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count !== node._sxapLastCount) {
|
||||||
|
node._sxapLastCount = count;
|
||||||
|
requestAnimationFrame(() => resizeToContent(node));
|
||||||
|
} else {
|
||||||
|
recomputeSize(node);
|
||||||
|
syncWidgetWidth(node);
|
||||||
|
node.setDirtyCanvas?.(true, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyData(node, data, status = "") {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(data || {}, "store_key")) {
|
||||||
|
node._sxapResolvedStoreKey = data.store_key || "";
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(data || {}, "entries")) {
|
||||||
|
node._sxapEntries = asArray(data.entries);
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(data || {}, "images")) {
|
||||||
|
node._sxapImages = asArray(data.images).filter(Boolean);
|
||||||
|
}
|
||||||
|
setStatus(node, status || data?.status || "");
|
||||||
|
renderGrid(node);
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshEntries(node) {
|
async function refreshEntries(node) {
|
||||||
const key = storeKey(node);
|
const key = storeKey(node);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
alert("Set the same explicit store_key on the Accumulator and Accumulator Preview first.");
|
setStatus(node, "no store_key yet");
|
||||||
|
renderGrid(node);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await postJson("/sxcp/accumulator/list", actionPayload(node, {store_key: key}));
|
const data = await postJson("/sxcp/accumulator/list", actionPayload(node, {store_key: key}));
|
||||||
applyActionResult(node, data, data.status || "");
|
applyData(node, data, data.status || "");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[${EXTENSION}] refresh failed`, err);
|
console.error(`[${EXTENSION}] refresh failed`, err);
|
||||||
alert(`Refresh failed: ${err}`);
|
alert(`Refresh failed: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSelected(node) {
|
async function deleteEntry(node, entry) {
|
||||||
const key = storeKey(node);
|
const key = storeKey(node);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
alert("Set the same explicit store_key on the Accumulator and Accumulator Preview first.");
|
alert("Set the same explicit store_key on the Accumulator and Accumulator Preview first.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const entry = selectedEntry(node);
|
if (!entry) return;
|
||||||
if (!entry) {
|
|
||||||
alert("No accumulator entry selected.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const label = entryLabel(entry);
|
|
||||||
if (!confirm(`Delete accumulator entry ${label}?`)) return;
|
|
||||||
try {
|
try {
|
||||||
const data = await postJson("/sxcp/accumulator/delete", actionPayload(node, {
|
const data = await postJson("/sxcp/accumulator/delete", actionPayload(node, {
|
||||||
store_key: key,
|
store_key: key,
|
||||||
@@ -370,38 +398,13 @@ async function deleteSelected(node) {
|
|||||||
index: entry.id ? 0 : entry.index,
|
index: entry.id ? 0 : entry.index,
|
||||||
clear: false,
|
clear: false,
|
||||||
}));
|
}));
|
||||||
applyActionResult(node, data, `${data.status || ""}; deleted=${data.removed || 0}`);
|
applyData(node, data, `${data.status || ""}; deleted=${data.removed || 0}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[${EXTENSION}] delete failed`, err);
|
console.error(`[${EXTENSION}] delete failed`, err);
|
||||||
alert(`Delete failed: ${err}`);
|
alert(`Delete failed: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function moveSelected(node, direction) {
|
|
||||||
const key = storeKey(node);
|
|
||||||
if (!key) {
|
|
||||||
alert("Set the same explicit store_key on the Accumulator and Accumulator Preview first.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const entry = selectedEntry(node);
|
|
||||||
if (!entry) {
|
|
||||||
alert("No accumulator entry selected.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await postJson("/sxcp/accumulator/move", actionPayload(node, {
|
|
||||||
store_key: key,
|
|
||||||
entry_id: entry.id || "",
|
|
||||||
index: entry.id ? 0 : entry.index,
|
|
||||||
direction,
|
|
||||||
}));
|
|
||||||
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) {
|
async function moveEntryToIndex(node, entry, targetIndex) {
|
||||||
const key = storeKey(node);
|
const key = storeKey(node);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
@@ -416,23 +419,13 @@ async function moveEntryToIndex(node, entry, targetIndex) {
|
|||||||
index: entry.id ? 0 : entry.index,
|
index: entry.id ? 0 : entry.index,
|
||||||
target_index: targetIndex,
|
target_index: targetIndex,
|
||||||
}));
|
}));
|
||||||
applyActionResult(node, data, `${data.status || ""}; moved=${data.moved ? "yes" : "no"}`);
|
applyData(node, data, `${data.status || ""}; moved=${data.moved ? "yes" : "no"}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[${EXTENSION}] drag move failed`, err);
|
console.error(`[${EXTENSION}] drag move failed`, err);
|
||||||
alert(`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) {
|
async function clearStore(node) {
|
||||||
const key = storeKey(node);
|
const key = storeKey(node);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
@@ -442,58 +435,129 @@ async function clearStore(node) {
|
|||||||
if (!confirm(`Clear all entries from accumulator "${key}"?`)) return;
|
if (!confirm(`Clear all entries from accumulator "${key}"?`)) return;
|
||||||
try {
|
try {
|
||||||
const data = await postJson("/sxcp/accumulator/delete", actionPayload(node, {store_key: key, clear: true}));
|
const data = await postJson("/sxcp/accumulator/delete", actionPayload(node, {store_key: key, clear: true}));
|
||||||
applyActionResult(node, data, `${data.status || ""}; cleared=${data.removed || 0}`);
|
applyData(node, data, `${data.status || ""}; cleared=${data.removed || 0}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[${EXTENSION}] clear failed`, err);
|
console.error(`[${EXTENSION}] clear failed`, err);
|
||||||
alert(`Clear failed: ${err}`);
|
alert(`Clear failed: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupNode(node) {
|
async function saveBatch(node) {
|
||||||
hideWidget(widget(node, "delete_action"));
|
const saveWidget = widget(node, "save_batch");
|
||||||
hideWidget(widget(node, "delete_entry_id"));
|
if (!saveWidget) {
|
||||||
hideWidget(widget(node, "delete_index"));
|
alert("Missing save_batch widget.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWidgetValue(node, "finished", true);
|
||||||
|
setWidgetValue(node, "save_batch", true);
|
||||||
|
node.setDirtyCanvas?.(true, true);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await app.queuePrompt(0, 1);
|
||||||
|
} catch (_err) {
|
||||||
|
await app.queuePrompt(0);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[${EXTENSION}] save queue failed`, err);
|
||||||
|
alert(`Save failed: ${err}`);
|
||||||
|
} finally {
|
||||||
|
setWidgetValue(node, "save_batch", false);
|
||||||
|
node.setDirtyCanvas?.(true, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideInternalWidgets(node) {
|
||||||
|
for (const name of [
|
||||||
|
"delete_action",
|
||||||
|
"delete_entry_id",
|
||||||
|
"delete_index",
|
||||||
|
"save_batch",
|
||||||
|
"finished",
|
||||||
|
"selected_entry",
|
||||||
|
"entry_action",
|
||||||
|
"accumulator_status",
|
||||||
|
]) {
|
||||||
|
hideWidget(widget(node, name));
|
||||||
|
}
|
||||||
for (const legacyButton of [
|
for (const legacyButton of [
|
||||||
"Move Selected Top",
|
"Move Selected Top",
|
||||||
"Move Selected Up",
|
"Move Selected Up",
|
||||||
"Move Selected Down",
|
"Move Selected Down",
|
||||||
"Move Selected Bottom",
|
"Move Selected Bottom",
|
||||||
"Delete Selected Entry",
|
"Delete Selected Entry",
|
||||||
|
"Apply Entry Action",
|
||||||
|
"Clear Accumulator",
|
||||||
|
"Refresh Entry List",
|
||||||
]) {
|
]) {
|
||||||
hideWidget(widget(node, legacyButton));
|
hideWidget(widget(node, legacyButton));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!node._sxcpEntrySelectWidget) {
|
function setupNode(node) {
|
||||||
node._sxcpEntrySelectWidget = node.addWidget("combo", "selected_entry", "no entries", () => {}, {values: ["no entries"]});
|
injectStyles();
|
||||||
node._sxcpEntrySelectWidget.serialize = false;
|
suppressBuiltinPreview(node);
|
||||||
}
|
hideInternalWidgets(node);
|
||||||
if (!node._sxcpEntryStripWidget && typeof node.addDOMWidget === "function") {
|
|
||||||
node._sxcpEntryStripRoot = document.createElement("div");
|
if (!node._sxapGridEl && typeof node.addDOMWidget === "function") {
|
||||||
node._sxcpEntryStripWidget = node.addDOMWidget("entry_strip", "div", node._sxcpEntryStripRoot, {
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "sxap-wrap";
|
||||||
|
|
||||||
|
const grid = document.createElement("div");
|
||||||
|
grid.className = "sxap-grid";
|
||||||
|
wrap.appendChild(grid);
|
||||||
|
|
||||||
|
const toolbar = document.createElement("div");
|
||||||
|
toolbar.className = "sxap-toolbar";
|
||||||
|
|
||||||
|
const save = document.createElement("button");
|
||||||
|
save.textContent = "Save";
|
||||||
|
save.title = "Save current accumulator batch once";
|
||||||
|
save.onclick = () => saveBatch(node);
|
||||||
|
|
||||||
|
const refresh = document.createElement("button");
|
||||||
|
refresh.textContent = "Refresh";
|
||||||
|
refresh.onclick = () => refreshEntries(node);
|
||||||
|
|
||||||
|
const clear = document.createElement("button");
|
||||||
|
clear.textContent = "Clear";
|
||||||
|
clear.onclick = () => clearStore(node);
|
||||||
|
|
||||||
|
const status = document.createElement("span");
|
||||||
|
status.className = "sxap-status";
|
||||||
|
|
||||||
|
toolbar.append(save, refresh, clear, status);
|
||||||
|
wrap.appendChild(toolbar);
|
||||||
|
|
||||||
|
node._sxapGridEl = grid;
|
||||||
|
node._sxapStatusEl = status;
|
||||||
|
node._sxapEntries = node._sxapEntries || [];
|
||||||
|
node._sxapImages = node._sxapImages || [];
|
||||||
|
node._sxapLastCount = -1;
|
||||||
|
recomputeSize(node);
|
||||||
|
node._sxapWidget = node.addDOMWidget("accumulator_grid", "div", wrap, {
|
||||||
serialize: false,
|
serialize: false,
|
||||||
hideOnZoom: false,
|
getMinHeight: () => node._sxapWidgetH || 140,
|
||||||
getMinHeight: () => node._sxcpEntryStripHeight || 36,
|
|
||||||
});
|
});
|
||||||
renderEntryStrip(node);
|
renderGrid(node);
|
||||||
}
|
}
|
||||||
if (!node._sxcpEntryActionWidget) {
|
|
||||||
node._sxcpEntryActionWidget = node.addWidget("combo", "entry_action", ENTRY_ACTIONS[0], () => {}, {values: ENTRY_ACTIONS});
|
const onResize = node.onResize;
|
||||||
node._sxcpEntryActionWidget.serialize = false;
|
if (!node._sxapResizeWrapped) {
|
||||||
|
node.onResize = function () {
|
||||||
|
const result = onResize?.apply(this, arguments);
|
||||||
|
recomputeSize(node);
|
||||||
|
syncWidgetWidth(node);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
node._sxapResizeWrapped = true;
|
||||||
}
|
}
|
||||||
if (!node._sxcpAccumulatorStatusWidget) {
|
|
||||||
node._sxcpAccumulatorStatusWidget = node.addWidget("text", "accumulator_status", "no accumulator data", () => {});
|
syncWidgetWidth(node);
|
||||||
node._sxcpAccumulatorStatusWidget.serialize = false;
|
if (!node._sxapInitialSizeSet) {
|
||||||
|
node._sxapInitialSizeSet = true;
|
||||||
|
node.setSize?.([Math.max(node.size?.[0] || 0, MIN_W), node.computeSize?.()[1] || node.size?.[1] || 180]);
|
||||||
}
|
}
|
||||||
if (!node._sxcpApplyEntryActionButton) {
|
renderGrid(node);
|
||||||
node._sxcpApplyEntryActionButton = node.addWidget("button", "Apply Entry Action", null, () => applyEntryAction(node));
|
|
||||||
}
|
|
||||||
if (!node._sxcpClearButton) {
|
|
||||||
node._sxcpClearButton = node.addWidget("button", "Clear Accumulator", null, () => clearStore(node));
|
|
||||||
}
|
|
||||||
if (!node._sxcpRefreshButton) {
|
|
||||||
node._sxcpRefreshButton = node.addWidget("button", "Refresh Entry List", null, () => refreshEntries(node));
|
|
||||||
}
|
|
||||||
resizeNode(node);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
@@ -504,11 +568,14 @@ app.registerExtension({
|
|||||||
const node = getNodeById(detail?.display_node ?? detail?.node);
|
const node = getNodeById(detail?.display_node ?? detail?.node);
|
||||||
if (!isAccumulatorPreviewNode(node)) return;
|
if (!isAccumulatorPreviewNode(node)) return;
|
||||||
const output = detail?.output || {};
|
const output = detail?.output || {};
|
||||||
node._sxcpResolvedStoreKey = outputStoreKey(output);
|
const key = outputStoreKey(output);
|
||||||
if (Object.prototype.hasOwnProperty.call(output, "images")) {
|
if (key) node._sxapResolvedStoreKey = key;
|
||||||
applyPreviewImages(node, outputImages(output));
|
applyData(node, {
|
||||||
}
|
store_key: key,
|
||||||
setEntries(node, outputEntries(output), outputStatus(output));
|
entries: outputEntries(output),
|
||||||
|
images: outputImages(output),
|
||||||
|
status: outputStatus(output),
|
||||||
|
}, outputStatus(output));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -525,7 +592,10 @@ app.registerExtension({
|
|||||||
const onConfigure = nodeType.prototype.onConfigure;
|
const onConfigure = nodeType.prototype.onConfigure;
|
||||||
nodeType.prototype.onConfigure = function () {
|
nodeType.prototype.onConfigure = function () {
|
||||||
const result = onConfigure?.apply(this, arguments);
|
const result = onConfigure?.apply(this, arguments);
|
||||||
queueMicrotask(() => setupNode(this));
|
queueMicrotask(() => {
|
||||||
|
setupNode(this);
|
||||||
|
refreshEntries(this);
|
||||||
|
});
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user