Files
ausboss-ComfyUI-AusBoss/js/load_image_pad/index.js
T
ausboss 5babcc7ed6 Keep a run's results on the workflow tab that queued it
Every workflow tab in a page shares one server connection, and ComfyUI
files a node's results by node id. A run from one tab that finished while
another tab was open landed on the open tab's node with the same id. On
Video Crop + Rotate + Pad -> Clip that was a second video under the
buttons that grew the node over the one below; Show Text and Seed also
saved the other workflow's text and seed into this one, and Save Video and
Compare replaced their own result.

- The three Crop + Rotate + Pad nodes stand down ComfyUI's image and video
  previews (they send no picture of their own), in both renderers.
- suppressCoreVideoPreview sets hideOutputImages, so Nodes 2.0 no longer
  draws Save Video's clip a second time under its player.
- shared/prompt_scope.mjs notes which graph queued each run (the queue
  call is wrapped for the prompt id). For a result from another tab, the
  AusBoss node here gets this tab's own stored result back, every pack
  onExecuted handler returns early, and the result is handed to the owner
  tab's AusBoss nodes when that tab is open again. Core nodes are left
  alone.

Tests: prompt_scope (scope rules plus a policy check that every
onExecuted handler asks isForeignRun first) and core_preview (every
transform kind suppresses both previews; video suppression sets
hideOutputImages) fail on main and pass here.
2026-09-29 15:53:03 -05:00

331 lines
12 KiB
JavaScript

import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { isForeignRun } from "../shared/prompt_scope.mjs";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
import { fillNodeHeight } from "../shared/panel_layout.mjs";
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
import { canvasHeightForWidth, parseImageReference } from "../shared/pad_canvas.mjs";
import { createPadStage } from "../shared/pad_panel.mjs";
const NODE_NAME = "AUSBOSS_NODES_LoadImagePad";
const PANEL_WIDGET = "ausboss_load_image_pad";
const PAD_MIN_WIDTH = 340;
const PANEL_CHROME = 12;
// Stage height the node opens at, plus room for the widget column above it.
const DEFAULT_NODE_HEIGHT = canvasHeightForWidth(PAD_MIN_WIDTH + 20) + PANEL_CHROME + 300;
const CSS_ID = "ausboss-loadpad-ui-v1";
const DEFAULT_SOURCE = { width: 512, height: 512 };
const CORE_IMAGE_PREVIEW_WIDGET = "$$canvas-image-preview";
// Optional IMAGE socket; when linked it replaces the file picker.
const SOURCE_INPUT = "source_image";
const WIRED_EMPTY_TEXT = "Run once to see the wired image";
function ensurePadCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-loadpad-root{position:relative;box-sizing:border-box;width:100%;height:100%;display:flex;flex-direction:column;padding:2px 6px 6px;overflow:hidden;}
.ausboss-loadpad-root canvas{flex:1 1 auto;min-height:0;width:100%;display:block;border:1px solid rgba(0,180,170,.34);border-radius:6px;background:#0c0e10;touch-action:none;}
.ausboss-loadpad-reset{position:absolute;top:8px;right:12px;height:20px;padding:0 8px;border:1px solid #3a4047;border-radius:4px;background:rgba(20,24,26,.92);color:#8ba3a1;font:600 10.5px/1 system-ui;cursor:pointer;}
.ausboss-loadpad-reset:hover{color:${BRAND};border-color:${BRAND};}
`;
document.head.appendChild(style);
}
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function isSourceWired(node) {
return node.inputs?.find((input) => input?.name === SOURCE_INPUT)?.link != null;
}
// The wired source's preview from a run's ui payload:
// { filename, subfolder, type, width, height } or null. width/height are the
// TRUE source size; the temp file itself is a thumbnail.
function findStagePreview(message) {
const ref = message?.ausboss_pad_preview?.[0] ?? null;
const size = message?.ausboss_pad_source?.[0] ?? null;
if (!ref?.filename || !Array.isArray(size) || size.length < 2) return null;
const width = Math.round(Number(size[0]) || 0);
const height = Math.round(Number(size[1]) || 0);
if (width < 1 || height < 1) return null;
return {
filename: String(ref.filename),
subfolder: String(ref.subfolder || ""),
type: String(ref.type || "temp"),
width,
height,
};
}
function numberValue(node, name, fallback = 0) {
const value = Number(findWidget(node, name)?.value);
return Number.isFinite(value) ? value : fallback;
}
// Core's image_upload helper installs its own source preview under the
// widgets; this node draws the source itself, so keep only the useful one.
function suppressCoreImagePreview(node) {
const previewIndex = node.widgets?.findIndex(
(widget) => widget.name === CORE_IMAGE_PREVIEW_WIDGET,
) ?? -1;
if (previewIndex >= 0) {
node.widgets[previewIndex].onRemove?.();
node.widgets.splice(previewIndex, 1);
}
if (node.__ausbossImgsSuppressed) return;
node.__ausbossImgsSuppressed = true;
const priorAdd = node.addCustomWidget;
if (typeof priorAdd === "function") {
node.addCustomWidget = function (customWidget) {
if (customWidget?.name === CORE_IMAGE_PREVIEW_WIDGET) hideWidget(customWidget);
return priorAdd.call(this, customWidget);
};
}
Object.defineProperty(node, "imgs", {
configurable: true,
enumerable: true,
get() { return undefined; },
set() {},
});
}
function watchWidget(node, name, onChange) {
const target = findWidget(node, name);
if (!target || target.__ausbossPadWatch) return;
target.__ausbossPadWatch = true;
const prior = target.callback;
target.callback = function (...args) {
const result = prior?.apply(this, args);
onChange();
return result;
};
}
function buildPanel(node) {
if (node.__ausbossLoadImagePad) return node.__ausbossLoadImagePad;
const imageWidget = findWidget(node, "image");
if (!imageWidget) return null;
ensurePadCss();
suppressCoreImagePreview(node);
for (const side of ["left", "top", "right", "bottom"]) {
hideWidget(findWidget(node, `pad_${side}`));
}
const root = document.createElement("div");
root.className = "ausboss-loadpad-root";
const canvas = document.createElement("canvas");
// Clears the padding on every side a link does not drive; shown only
// while there is some to clear.
const reset = document.createElement("button");
reset.type = "button";
reset.className = "ausboss-loadpad-reset";
reset.textContent = "Reset padding";
reset.title = "Set every unlinked side's padding back to 0";
root.append(canvas, reset);
const widget = node.addDOMWidget(PANEL_WIDGET, "ausboss_load_image_pad", root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => 190,
});
keepDomWidgetWidthAuto(widget);
// Not saved with the workflow either: options.serialize only keeps it out
// of the prompt, and saved values come back by position.
widget.serialize = false;
fillNodeHeight(widget, {
minWidth: PAD_MIN_WIDTH,
minHeight: 190,
minNodeSize: [PAD_MIN_WIDTH, 320],
});
const state = {
node,
canvas,
imageWidget,
bitmap: null,
known: false,
sourceWidth: DEFAULT_SOURCE.width,
sourceHeight: DEFAULT_SOURCE.height,
emptyText: "Choose or upload an image",
loadSerial: 0,
// Last run's preview of the wired source, and which mode refresh drew.
wiredPreview: null,
wired: false,
};
node.__ausbossLoadImagePad = state;
const stage = createPadStage(canvas, {
getSource: () => ({
bitmap: state.bitmap,
width: state.sourceWidth,
height: state.sourceHeight,
known: state.known,
emptyText: state.bitmap ? null : state.emptyText,
}),
getValues: () => ({
pad_left: numberValue(node, "pad_left"),
pad_top: numberValue(node, "pad_top"),
pad_right: numberValue(node, "pad_right"),
pad_bottom: numberValue(node, "pad_bottom"),
canvas_multiple: numberValue(node, "canvas_multiple", 8),
target_megapixels: numberValue(node, "target_megapixels", 0),
}),
writePad: (side, value) => {
const input = node.inputs?.find((entry) => entry.name === `pad_${side}`);
if (input?.link != null) return;
const target = findWidget(node, `pad_${side}`);
if (!target) return;
target.value = value;
target.callback?.(value);
},
onGestureEnd: () => {
notifyAusbossChange();
node.setDirtyCanvas?.(true, true);
},
});
state.stage = stage;
const sides = ["left", "top", "right", "bottom"];
const unlinked = (side) => node.inputs?.find((entry) => entry.name === `pad_${side}`)?.link == null;
const syncReset = () => {
reset.style.display = sides.some((side) => unlinked(side) && numberValue(node, `pad_${side}`) > 0) ? "block" : "none";
};
reset.addEventListener("pointerdown", (event) => event.stopPropagation());
reset.addEventListener("click", () => {
for (const side of sides) {
if (!unlinked(side)) continue;
const target = findWidget(node, `pad_${side}`);
if (!target || Number(target.value) === 0) continue;
target.value = 0;
target.callback?.(0);
}
stage.draw();
syncReset();
notifyAusbossChange();
node.setDirtyCanvas?.(true, true);
});
state.syncReset = syncReset;
const refresh = () => {
const serial = ++state.loadSerial;
state.wired = isSourceWired(node);
// A wired picture only exists after a run: draw that run's thumbnail at
// the true source size, or a wireframe until the first run.
if (state.wired) {
const preview = state.wiredPreview;
state.bitmap = null;
state.emptyText = WIRED_EMPTY_TEXT;
state.known = !!preview;
if (!preview) {
stage.draw();
return;
}
state.sourceWidth = preview.width;
state.sourceHeight = preview.height;
const { width, height, ...reference } = preview;
const image = new Image();
image.onload = () => {
if (serial !== state.loadSerial) return;
state.bitmap = image;
stage.draw();
};
// Temp previews vanish on a server restart: keep the size, show the
// wireframe until the next run.
image.onerror = () => {};
image.src = api.apiURL(`/view?${new URLSearchParams({ ...reference, t: String(Date.now()) })}`);
stage.draw();
return;
}
const reference = parseImageReference(imageWidget.value);
if (!reference) {
state.bitmap = null;
state.known = false;
state.emptyText = "Choose or upload an image";
stage.draw();
return;
}
const query = new URLSearchParams({ ...reference, t: String(Date.now()) });
const image = new Image();
image.onload = () => {
if (serial !== state.loadSerial) return;
state.bitmap = image;
state.known = true;
state.sourceWidth = image.naturalWidth || DEFAULT_SOURCE.width;
state.sourceHeight = image.naturalHeight || DEFAULT_SOURCE.height;
suppressCoreImagePreview(node);
node.imageIndex = null;
stage.draw();
};
image.onerror = () => {
if (serial !== state.loadSerial) return;
state.bitmap = null;
state.known = false;
state.emptyText = "Preview could not load this image";
stage.draw();
};
image.src = api.apiURL(`/view?${query}`);
};
state.refresh = refresh;
watchWidget(node, "image", refresh);
for (const name of ["pad_left", "pad_top", "pad_right", "pad_bottom", "canvas_multiple", "target_megapixels"]) {
watchWidget(node, name, () => { stage.draw(); syncReset(); });
}
syncReset();
// computeSize()[1] is no longer the stage's height — the panel takes the
// node's leftover space now, so the node's minimum is only the panel's
// floor. State the opening height from the same width-derived shape the
// stage used to pin itself to; a saved size still wins through onConfigure.
node.setSize?.([
Math.max(PAD_MIN_WIDTH + 20, node.size?.[0] || 0),
Math.max(node.size?.[1] || 0, DEFAULT_NODE_HEIGHT),
]);
setTimeout(refresh, 0);
return state;
}
app.registerExtension({
name: "ausboss.load_image_pad.panel",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_NAME) return;
hideInputsInDef(nodeData, ["pad_left", "pad_top", "pad_right", "pad_bottom"]);
chainCallback(nodeType.prototype, "onNodeCreated", function () {
buildPanel(this);
});
chainCallback(nodeType.prototype, "onConfigure", function () {
// Workflow restore lands widget values after creation: re-sync then.
// Never write widget values here — restored pads must survive a load.
queueMicrotask(() => {
const state = buildPanel(this);
if (state) {
suppressCoreImagePreview(this);
state.refresh?.();
state.syncReset?.();
}
});
});
chainCallback(nodeType.prototype, "onExecuted", function (message) {
if (isForeignRun()) return;
const preview = findStagePreview(message);
const state = preview ? buildPanel(this) : null;
if (!state) return;
state.wiredPreview = preview;
state.refresh?.();
});
chainCallback(nodeType.prototype, "onConnectionsChange", function () {
// Only a change of source (file <-> wire) needs a redraw from scratch.
const state = this.__ausbossLoadImagePad;
if (state && isSourceWired(this) !== state.wired) state.refresh?.();
});
chainCallback(nodeType.prototype, "onRemoved", function () {
this.__ausbossLoadImagePad?.stage?.dispose?.();
this.__ausbossLoadImagePad = null;
});
},
});