Files
shootthesound-ComfyUI-Angelo/web/angelo.js
T
shootthesound 0ac2e8a46d feat: in-node Queue button + full-flow bug-hunt hardening (2.5.0)
New:
- ▶ Queue button beside the Mode dropdown — the in-node twin of
  ComfyUI's Queue button (same code path, so Persistent Mask re-runs
  work identically) usable from both modes and fullscreen.
- Re-roll after ✨ Quick Photo Refine now re-runs the ✨ pass (same as
  pressing ✨ again) instead of silently corrupting the session.
- Outpaint Accept shows the approved canvas instantly (optimistic swap
  from the already-loaded review image; commit lands in the background,
  and image_w/h update immediately so edits in the gap map correctly).
- One-shot migration for v2.0.0–v2.1.0 workflow saves: the lir_seq
  widget removed in 2.2.0 shifted positional widgets_values, corrupting
  quick_prompt_mode/shrink_seq and failing Queue validation. Detected
  semantically (a number on a combo), repaired on load, self-deactivates
  on the next save.

Fixed (three-agent audit of backend flows, frontend flows, and the
JS↔Python contract):
- Reset was a silent no-op with Persistent Mask ON (queue hook cleared
  the reset flag pre-serialization; hook now skips reset-pending nodes).
- Internal action queues bumped EVERY persistent-mask node — editing
  node A re-ran node B's held mask. Initiator-scoped now.
- Re-roll / repeat-✨ popped history before sampling: a Cancel/OOM
  permanently lost the last result. Both are now sample-first,
  replace-on-success.
- Re-roll/Vary after Undo/Redo/✨ re-ran stale mask widgets (worst
  case: a bogus corner-circle edit from click_x=-1). History entries
  now carry provenance; Re-roll/Vary gate on it.
- Reset after _HISTORY_CAP eviction rebased onto a mid-session image
  and overwrote the session base. Base fallbacks prefer source_latent.
- FLUX 2 large-canvas decodes missed the anti-NaN tiled path (threshold
  assumed an 8x VAE); ratio now queried from the VAE.
- Vary chooser surviving an Undo could commit onto the wrong history
  entry (stash cleared on undo/redo; JS closes the chooser).
- No-op edits (Remove without CLIP, empty mask) pushed junk history and
  killed Redo.
- Esc was dead over the Vary chooser / Outpaint review (hover lost to
  the overlay); Esc in a fullscreen popup exited fullscreen and
  destroyed the popup (defer + rescue on exit + listener leak fixed).
- Remove's Xtra-Fine restore state now survives workflow reloads
  (node.properties).
- Pan speed under graph zoom, context menu behind fullscreen,
  Smpl Denoise 0.0 failing server validation, mode fallback order.
2026-07-04 04:22:43 +01:00

5614 lines
255 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ComfyUI-Angelo — click-to-refine UI extension.
//
// Strategy: each AngeloRefine node gets its own canvas DOM widget
// attached at the bottom of the node. We draw the refined preview into
// that canvas ourselves (instead of using ComfyUI's auto-preview), and
// the canvas has a real DOM click listener. This sidesteps the issue
// where DOM image elements swallow clicks before LiteGraph sees them.
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const NODE_NAME = "AngeloRefine";
// Flip to true if something stops working and you want click → pixel-coord
// → widget-update → queue traced in the browser console.
const Angelo_DEBUG = false;
function dbg(...args) {
if (Angelo_DEBUG) console.log("[Angelo]", ...args);
}
// --- Module-level hover tracking for the keyboard shortcuts. Set by
// the canvas mouseenter / mouseleave handlers in attachPreviewCanvas.
let _AngeloHoveredNode = null;
// --- The node currently held in before/after compare via the '\' key.
// Tracked at module level so the keyup / window-blur handlers can end
// the compare even if the cursor has left the canvas mid-hold.
let _AngeloCompareNode = null;
// --- Global queuePrompt hook: bumps click_seq on every AngeloRefine
// node where persistent_mask is on, so the standard ComfyUI Queue
// button re-runs the refine on the held mask with a fresh seed.
// Installed once per extension load; no-op for graphs without
// AngeloRefine.
function installQueueHook() {
if (app._AngeloQueueHookInstalled) return;
if (typeof app.queuePrompt !== "function") return;
const orig = app.queuePrompt.bind(app);
app.queuePrompt = function (...args) {
try {
const nodes = (app.graph && app.graph._nodes) || [];
// Angelo-initiated queues (a click/undo/re-roll/… on ONE node)
// set _AngeloQueueInitiator to that node's id — the hook must
// then leave every OTHER node alone, or editing node A re-runs
// node B's held persistent mask. null = a real Queue press
// (ComfyUI's button or the in-node ▶ Queue): bump all holders.
const initiator = app._AngeloQueueInitiator;
for (const n of nodes) {
if (n?.type !== NODE_NAME) continue;
if (initiator != null && n.id !== initiator) continue;
const persistW = findWidget(n, "persistent_mask");
if (!persistW || !persistW.value) continue;
// A genuine Reset is in flight on this node — don't hijack
// it. (Clearing `reset` here made Reset a silent no-op on
// persistent-mask nodes; the 1s revert timer in triggerReset
// already covers the stale-tick case this guarded against.)
const resetW = findWidget(n, "reset");
if (resetW && resetW.value) continue;
const seqW = findWidget(n, "click_seq");
if (!seqW) continue;
setWidget(seqW, ((seqW.value || 0) + 1) & 0x7FFFFFFF);
dbg("queueHook: bumped click_seq on persistent-mask node", n.id, "→", seqW.value);
}
} catch (e) {
dbg("queueHook error (passing through)", e);
}
return orig(...args);
};
app._AngeloQueueHookInstalled = true;
dbg("installed app.queuePrompt hook for persistent_mask");
}
// --- Global keyboard shortcuts. Active only when the cursor is hovering
// a Angelo canvas AND that node is in Edit Mode. Mirrors
// creative-tool conventions:
// [ ] → click_radius (universal brush-size pattern)
// { } → feather_radius (Photoshop brush hardness/softness)
// , . → denoise (< > ordering on the same keys)
// Captured in the capture phase so they beat ComfyUI's own
// keybindings if any happen to overlap. preventDefault on a match
// so the key event doesn't propagate further.
function installKeyboardShortcuts() {
if (app._AngeloKeysInstalled) return;
app._AngeloKeysInstalled = true;
const bindings = [
// [key, widget_name, delta, min, max, asInt, syncFn]
["[", "click_radius", -4, 8, 1024, true, "syncClickRadiusInput"],
["]", "click_radius", 4, 8, 1024, true, "syncClickRadiusInput"],
["{", "feather_radius", -4, 0, 256, true, "syncFeatherInput"],
["}", "feather_radius", 4, 0, 256, true, "syncFeatherInput"],
[",", "denoise", -0.05, 0.05, 1.0, false, "syncDenoiseInput"],
[".", "denoise", 0.05, 0.05, 1.0, false, "syncDenoiseInput"],
];
const handlers = {};
for (const b of bindings) handlers[b[0]] = b;
document.addEventListener("keydown", (event) => {
let node = _AngeloHoveredNode;
if (!node && event.key === "Escape") {
// The Vary chooser / Outpaint review / detect overlays cover
// the canvas, so the pointer's mouseleave already cleared the
// hover — Esc must still reach them (the outpaint header even
// advertises "Esc = cancel"). Fall back to whichever node owns
// an open overlay.
const nodes = (app.graph && app.graph._nodes) || [];
node = nodes.find(n => n?.type === NODE_NAME
&& (isOutpaintReviewOpen(n) || isVaryChooserOpen(n)
|| (n._AngeloDetections && n._AngeloDetections.length)));
}
if (!node) return;
// Esc closes the Outpaint review first (cancels at zero cost —
// nothing was committed).
if (event.key === "Escape" && isOutpaintReviewOpen(node)) {
hideOutpaintReview(node);
event.preventDefault();
return;
}
// Esc closes the Vary chooser first (keeps the current result),
// before falling through to detect-mode dismissal.
if (event.key === "Escape" && isVaryChooserOpen(node)) {
hideVaryChooser(node);
event.preventDefault();
return;
}
// Esc dismisses pending detection candidates (even from an input,
// so it works right after typing a concept + Detect).
if (event.key === "Escape" && node._AngeloDetections && node._AngeloDetections.length) {
clearDetections(node);
return;
}
// Don't intercept when the user is typing in an input or textarea
// (e.g., the toolbar Seed input, or any other DOM widget).
const t = event.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) {
return;
}
// Space also exits detect mode (only when not typing — handled
// above — so a space in the concept box is unaffected).
if (event.key === " " && node._AngeloDetections && node._AngeloDetections.length) {
clearDetections(node);
event.preventDefault();
return;
}
// '\' (hold) = before/after compare — flash the session's BASE image
// while held, release to return to the current state. Lightroom's
// before/after key. Works in any mode (it's read-only); sits before
// the Edit-Mode gate below. No modifiers so Shift+\ (|) stays free.
if (event.key === "\\" && !event.ctrlKey && !event.metaKey && !event.altKey) {
if (node._AngeloSourceImg && node._AngeloImg && !node._AngeloCompareHold) {
node._AngeloCompareHold = true;
_AngeloCompareNode = node;
redrawCanvasWithOverlays(node);
}
event.preventDefault();
event.stopPropagation();
return;
}
// 'F' fits the image to the panel (zoom=1, centred). Works in any
// mode — it mirrors the double-middle-click reset — so it sits before
// the Edit-Mode gate below. No modifiers, so Ctrl-F etc. stay with the
// browser.
if ((event.key === "f" || event.key === "F")
&& !event.ctrlKey && !event.metaKey && !event.altKey) {
if (node._AngeloImg) {
resetView(node);
redrawCanvasWithOverlays(node);
event.preventDefault();
event.stopPropagation();
}
return;
}
// Only active in Edit Mode. Sampler Mode has the toolbar
// greyed; the keys would feel inert.
const modeW = findWidget(node, "mode");
if (!modeW || String(modeW.value) !== "Edit Mode") return;
// NOTE: Undo/Redo are deliberately button-only (no Ctrl-Z / Ctrl-Y /
// Ctrl-Shift-Z). Binding those over the canvas clashed too much with
// ComfyUI's graph-level undo/redo, so the shortcuts were removed.
const binding = handlers[event.key];
if (!binding) return;
const [, name, delta, min, max, asInt, syncName] = binding;
const w = findWidget(node, name);
if (!w) return;
let v = Number(w.value || 0) + delta;
v = Math.max(min, Math.min(max, v));
if (asInt) v = Math.round(v);
else v = Math.round(v * 100) / 100; // avoid float drift accumulating
setWidget(w, v);
// Sync the corresponding toolbar input so the visible value
// tracks the keyboard adjustment.
const syncFn = ({
syncClickRadiusInput, syncFeatherInput,
syncDenoiseInput,
})[syncName];
if (syncFn) syncFn(node);
// For click_radius, also redraw the canvas so the hover ring
// resizes immediately on the visible image.
if (name === "click_radius" && typeof redrawCanvasWithOverlays === "function") {
redrawCanvasWithOverlays(node);
}
dbg("key", event.key, "→", name, "=", v);
event.preventDefault();
event.stopPropagation();
}, true); // capture phase
// End the '\' before/after compare on key release — or on window blur,
// which swallows the keyup (alt-tab mid-hold would otherwise leave the
// BEFORE view stuck).
const endCompare = () => {
const n = _AngeloCompareNode;
if (!n) return;
n._AngeloCompareHold = false;
_AngeloCompareNode = null;
redrawCanvasWithOverlays(n);
};
document.addEventListener("keyup", (event) => {
if (event.key === "\\") endCompare();
}, true);
window.addEventListener("blur", endCompare);
// Handle image paste (Ctrl+V / Cmd+V) from the OS clipboard.
window.addEventListener("paste", (event) => {
const node = _AngeloHoveredNode;
if (!node) return; // Only active when hovering over an Angelo canvas.
// Do not intercept if the user is pasting text into an input or textarea.
const activeTag = document.activeElement ? document.activeElement.tagName : "";
const isEditable = document.activeElement && document.activeElement.isContentEditable;
if (activeTag === "INPUT" || activeTag === "TEXTAREA" || isEditable) {
return;
}
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files.length > 0) {
const imageFiles = Array.from(event.clipboardData.files).filter(f => f.type.startsWith("image/"));
if (imageFiles.length > 0) {
// Strictly prevent ComfyUI from intercepting this and spawning a LoadImage node
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
let file = imageFiles[0];
// Browsers often name pasted files generically like "image.png".
// Append a timestamp to make it distinct in uploads and logs.
if (file.name === "image.png" || !file.name) {
const ext = file.type.split('/')[1] || "png";
const fakeName = `pasted_${Date.now()}.${ext}`;
file = new File([file], fakeName, { type: file.type });
}
// Route it through the same resolution popup as Drag & Drop / Load Image.
showLoadImagePopup(node, file);
}
}
}, true); // capture phase on window guarantees it fires before ComfyUI's document listener
dbg("installed keyboard shortcuts");
}
// --- One-shot migration for workflows saved on v2.0.0–v2.1.0. ---
// Those versions had a `lir_seq` widget between `upscale_seq` and
// `quick_prompt_mode`; v2.2.0 removed it, shifting every later positional
// widgets_values slot left by one on load. Old widget tail:
// [..., upscale_seq, lir_seq, quick_prompt_mode] (save)
// [..., upscale_seq, quick_prompt_mode, shrink_seq, …] (load target)
// so exactly TWO widgets land wrong: quick_prompt_mode receives the dead
// lir_seq INT (→ combo validation fails at Queue time), and shrink_seq
// receives the old quick_prompt_mode STRING. Everything after got no value
// (the old array was shorter) and already holds its default.
//
// Detection is SEMANTIC, not version-based: quick_prompt_mode is a combo
// whose serialized value is always a string in a healthy save — a number
// there is an unambiguous marker of the pre-2.2 layout. Healthy saves are
// a no-op, and the next save serializes the repaired values, so the shim
// self-deactivates. (Name-keyed widgets_values formats never shift and
// never trip the marker.)
function migrateLegacyWidgetValues(node) {
const qpW = findWidget(node, "quick_prompt_mode");
if (!qpW || typeof qpW.value !== "number") return; // healthy save
const ssW = findWidget(node, "shrink_seq");
const validModes = (qpW.options && qpW.options.values) || [];
if (ssW && typeof ssW.value === "string" && validModes.includes(ssW.value)) {
// The real prompt-mode string slid one slot down onto shrink_seq —
// recover it.
qpW.value = ssW.value;
} else {
// v2.0.0-era save (no quick_prompt_mode yet) — default it.
qpW.value = "Identity + Quality";
}
if (ssW && typeof ssW.value !== "number") ssW.value = 0;
console.log("[Angelo] migrated a pre-2.2 workflow save (removed lir_seq "
+ "slot) — ✨ prompt mode restored to \"" + qpW.value + "\"");
}
app.registerExtension({
name: "Angelo.ClickToRefine",
async setup() {
installQueueHook();
installKeyboardShortcuts();
},
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
if (nodeData.name !== NODE_NAME) return;
// --- Node setup: attach the preview canvas + hide mechanical widgets ---
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (origOnNodeCreated) origOnNodeCreated.apply(this, arguments);
hideMechanicalWidgets(this);
attachPreviewCanvas(this);
// Force LiteGraph to recompute layout now that hidden widgets
// claim zero height — otherwise the node keeps its initial
// tall size and the (now-hidden) widget slots show as gaps.
if (typeof this.setSize === "function" && this.computeSize) {
const min = this.computeSize();
if (this.size[1] < min[1]) this.size[1] = min[1];
}
// Reflect persisted widget state in every toolbar control.
// NOTE: on an existing-workflow load this runs BEFORE the
// serialized widget values are restored, so the toggles
// may show defaults here. The onConfigure hook below re-runs
// the same sync after the restore to correct any mismatch.
syncAllToolbarControls(this);
// Mode widget: grey toolbar in Sampler Mode, un-grey in
// Refinement; auto-force sampler_seed_control = fixed when
// flipping into Refinement (and restore sampler_seed to its
// at-run value via lockSeedToAtRun).
const modeW = findWidget(this, "mode");
if (modeW) {
const origCb = modeW.callback;
modeW.callback = (value, ...args) => {
const prevValue = modeW._AngeloPrevValue;
if (origCb) {
try { origCb.call(modeW, value, ...args); }
catch (e) { dbg("mode callback orig threw", e); }
}
modeW._AngeloPrevValue = value;
syncModeSwitchToFixed(this, prevValue);
syncModeState(this);
};
modeW._AngeloPrevValue = modeW.value;
}
syncModeState(this); // initial state reflects persisted widget
// Seed-control widgets: when value transitions TO "fixed"
// (either by user click or programmatic set), restore the
// corresponding seed widget to the seed_at_run value. This
// ensures "fixed" always means "the seed that produced the
// current canvas", not whatever value after-gen left in the
// widget. Wrap each widget's callback to detect the transition.
const samplerCtrlW = findWidget(this, "sampler_seed_control");
if (samplerCtrlW) {
const origCb = samplerCtrlW.callback;
samplerCtrlW.callback = (value, ...args) => {
const prevValue = samplerCtrlW._AngeloPrevValue;
if (origCb) {
try { origCb.call(samplerCtrlW, value, ...args); }
catch (e) { dbg("sampler_seed_control callback orig threw", e); }
}
samplerCtrlW._AngeloPrevValue = value;
if (value === "fixed" && prevValue !== "fixed") {
lockSeedToAtRun(this, "sampler_seed", "sampler_seed_control");
}
};
samplerCtrlW._AngeloPrevValue = samplerCtrlW.value;
}
const seedCtrlW = findWidget(this, "seed_control");
if (seedCtrlW) {
const origCb = seedCtrlW.callback;
seedCtrlW.callback = (value, ...args) => {
const prevValue = seedCtrlW._AngeloPrevValue;
if (origCb) {
try { origCb.call(seedCtrlW, value, ...args); }
catch (e) { dbg("seed_control callback orig threw", e); }
}
seedCtrlW._AngeloPrevValue = value;
if (value === "fixed" && prevValue !== "fixed") {
lockSeedToAtRun(this, "seed", "seed_control");
}
};
seedCtrlW._AngeloPrevValue = seedCtrlW.value;
}
const min = [340, 540];
if (this.size[0] < min[0]) this.size[0] = min[0];
if (this.size[1] < min[1]) this.size[1] = min[1];
};
// --- Right-click node menu: Open / Copy / Paste image (#7). Covers
// right-clicks on the litegraph-rendered node body / title; the
// DOM preview canvas has its own contextmenu handler too. ---
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (canvas, options) {
if (origGetExtraMenuOptions) origGetExtraMenuOptions.apply(this, arguments);
const node = this;
const hasImg = !!node._AngeloImg;
options.unshift(
{ content: "Angelo: open image in new tab", disabled: !hasImg,
callback: () => _angeloOpenImageInTab(node) },
{ content: "Angelo: copy image", disabled: !hasImg,
callback: () => _angeloCopyImageToClipboard(node) },
null,
);
return options;
};
// --- onExecuted: receive the new preview URL, draw into our canvas,
// and run after-gen seed control + record seed_at_run for the
// lock-on-fixed semantics. ---
const origOnExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
if (origOnExecuted) origOnExecuted.apply(this, arguments);
dbg("onExecuted", message);
// Preview image (Angelo_preview)
const refs = message?.Angelo_preview;
if (refs && refs.length > 0) {
const ref = refs[0];
this._AngeloPreviewRef = ref; // for the SAM 3 / YOLO detect route
const url = makeViewUrl(ref);
dbg("loading preview", url);
loadIntoCanvas(this, url);
}
// Base image for the hold-to-compare key (\). Preloaded into an
// off-canvas Image so the flash is instant; only re-fetched when
// the base actually changes (Python reuses the cached refs).
const srcRefs = message?.Angelo_source_preview;
if (srcRefs && srcRefs.length > 0) {
const srcUrl = makeViewUrl(srcRefs[0]);
if (this._AngeloSourceUrl !== srcUrl) {
this._AngeloSourceUrl = srcUrl;
const node = this;
const im = new Image();
im.crossOrigin = "anonymous";
im.onload = () => { node._AngeloSourceImg = im; };
im.onerror = () => dbg("source preview load error", srcUrl);
im.src = srcUrl;
}
}
// NOTE: seg_polygon is intentionally NOT cleared here. It must
// persist across runs like stroke_points / rect_points so a
// Persistent Mask re-roll keeps using the SAM-detected
// silhouette. It's cleared instead by the manual-mask triggers
// (a click / paint / rect resets it) — see triggerRefine etc.
// Seed_at_run capture — used by the lock-on-fixed code. ComfyUI's
// ui message values arrive as 1-element lists (their convention).
const lastMode = message?.Angelo_mode?.[0];
const samplerSeedAtRun = message?.Angelo_sampler_seed_at_run?.[0];
const refineSeedAtRun = message?.Angelo_refine_seed_at_run?.[0];
if (samplerSeedAtRun != null) {
this._AngeloSamplerSeedAtRun = Number(samplerSeedAtRun);
}
if (refineSeedAtRun != null) {
this._AngeloRefineSeedAtRun = Number(refineSeedAtRun);
}
// After-gen seed control. ComfyUI's standard "seed widgets" have
// an auto-added control_after_generate dropdown that does this
// for them; ours are explicit ENUM widgets, so we apply the
// logic ourselves. Runs AFTER seed_at_run is captured so a
// subsequent lock-on-fixed restores the pre-modification value.
if (lastMode === "Sampler Mode") {
applyAfterGenControl(this, "sampler_seed", "sampler_seed_control");
} else if (lastMode === "Edit Mode") {
applyAfterGenControl(this, "seed", "seed_control");
}
// Vary ×4: candidate previews arrived → open the chooser.
const varyRefs = message?.Angelo_vary_previews;
if (varyRefs && varyRefs.length) {
showVaryChooser(this, varyRefs);
}
// Outpaint: the extended canvas arrived → open the review.
const opRefs = message?.Angelo_outpaint_preview;
if (opRefs && opRefs.length) {
showOutpaintReview(this, opRefs[0]);
}
// Fix All: this run has landed — fire the next candidate. The
// small delay lets the fresh preview paint (and the green tick
// register) before the next confirm queues.
if (this._AngeloFixAll) {
const node = this;
setTimeout(() => _angeloFixAllStep(node), 120);
}
};
// Reset and Undo now live on the DOM toggle bar above the canvas
// (see attachPreviewCanvas). Removed the canvas-title-bar hooks
// that used to draw + hit-test them — all interactive controls
// sit on one horizontal line.
// --- onConfigure: fires when a saved workflow is loaded and the
// node's serialized widget values are restored. onNodeCreated
// runs BEFORE that restore, so the toolbar would otherwise
// reflect defaults (e.g. Paint Mode shows OFF when the saved
// value was ON, and vice-versa). Re-run the full toolbar sync
// here so the DOM controls match the restored widget state. ---
const origOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
if (origOnConfigure) origOnConfigure.apply(this, arguments);
// Defer one tick — some ComfyUI versions finish applying
// widget values immediately after onConfigure returns, so a
// microtask-delayed sync sees the final restored state.
const node = this;
queueMicrotask(() => {
try { migrateLegacyWidgetValues(node); }
catch (e) { dbg("legacy widget migration threw", e); }
try { syncAllToolbarControls(node); }
catch (e) { dbg("onConfigure sync threw", e); }
});
};
},
});
// ============================================================
// Preview canvas (the DOM widget that displays + handles clicks)
// ============================================================
function attachPreviewCanvas(node) {
if (node._AngeloWidget) return; // already attached
const container = document.createElement("div");
container.style.width = "100%";
container.style.position = "relative";
container.style.background = "#1a1a1a";
container.style.border = "1px solid #333";
container.style.borderRadius = "4px";
container.style.overflow = "hidden";
container.style.display = "flex";
container.style.flexDirection = "column";
// Toolbar above the canvas, top to bottom:
// modeRow — Mode dropdown, centred (the master Sampler/Edit switch)
// row3 — shared generation config (steps/cfg/sampler/sched), always active
// row4 — Sampler-Mode base-gen seed group, greyed in Edit Mode
// refineRows — edit actions + refine values, greyed in Sampler Mode
const toggleBarWrap = document.createElement("div");
toggleBarWrap.style.display = "flex";
toggleBarWrap.style.flexDirection = "column";
toggleBarWrap.style.background = "#222";
toggleBarWrap.style.borderBottom = "1px solid #333";
toggleBarWrap.style.transition = "opacity 0.15s ease"; // smooth grey/un-grey on mode switch
node._AngeloToolbarWrap = toggleBarWrap;
// Mode row — centred at the top of the node, visually separated from
// the rest of the toolbar by a bottom border.
const modeRow = makeToolbarRow();
modeRow.style.justifyContent = "center";
modeRow.style.borderBottom = "1px solid #333";
modeRow.style.position = "relative"; // anchor for the floating Cancel Detect button
// Generation / sampler-seed rows (set-once-ish, sit under Mode).
const row3 = makeToolbarRow();
const row4 = makeToolbarRow();
row4.style.transition = "opacity 0.15s ease";
// Edit control rows — greyed in Sampler Mode (wrapped so the grey
// target is just these rows, not the generation rows). A top border
// separates the "edit" group from the "generation" group above.
const refineRowsWrap = document.createElement("div");
refineRowsWrap.style.transition = "opacity 0.15s ease";
refineRowsWrap.style.borderTop = "1px solid #333";
const row1 = makeToolbarRow();
const row2 = makeToolbarRow();
const outpaintRow = makeToolbarRow(); // Outpaint mode only (populated below)
outpaintRow.style.display = "none";
const quickRow = makeToolbarRow(); // Quick Actions (one-press magic buttons)
const detectRow = makeToolbarRow(); // SAM 3 detect (Refine + Smart Inpaint)
detectRow.style.flexWrap = "nowrap"; // keep it one line; the text box flexes
refineRowsWrap.appendChild(row1);
refineRowsWrap.appendChild(row2);
refineRowsWrap.appendChild(outpaintRow);
refineRowsWrap.appendChild(quickRow);
refineRowsWrap.appendChild(detectRow);
node._AngeloOutpaintRow = outpaintRow;
node._AngeloQuickRow = quickRow;
node._AngeloDetectRow = detectRow;
toggleBarWrap.appendChild(modeRow);
toggleBarWrap.appendChild(row3);
toggleBarWrap.appendChild(row4);
toggleBarWrap.appendChild(refineRowsWrap);
node._AngeloModeRow = modeRow;
node._AngeloRefineRowsWrap = refineRowsWrap;
node._AngeloSamplerSeedRow = row4;
// ===== ROW 1: actions + mode toggles =====
const resetBtn = makeActionButton("Reset", () => triggerReset(node), "reset");
resetBtn.title = "Throw away the cached refined latent + history and start fresh from the upstream latent.";
row1.appendChild(resetBtn);
// Undo / Redo as a joined two-icon pair — they're conceptually one
// control, and the glyphs (with full-word tooltips) reclaim row
// width for Re-roll / Vary ×4.
const undoRedoWrap = document.createElement("div");
undoRedoWrap.style.cssText = "display:inline-flex; flex:0 0 auto;";
const undoBtn = makeActionButton("⟲", () => triggerUndo(node), "undo");
undoBtn.title = "Undo — pop the most recent refine off the history stack (up to 10 deep). Restores the cached latent from before the last edit.";
undoBtn.style.borderRadius = "3px 0 0 3px";
undoBtn.style.padding = "3px 9px";
undoBtn.style.fontSize = "13px";
undoBtn.style.lineHeight = "15px";
const redoBtn = makeActionButton("⟳", () => triggerRedo(node), "redo");
redoBtn.title = "Redo — re-apply the most recent edit that Undo removed. A new edit clears the redo history.";
redoBtn.style.borderRadius = "0 3px 3px 0";
redoBtn.style.borderLeft = "none";
redoBtn.style.padding = "3px 9px";
redoBtn.style.fontSize = "13px";
redoBtn.style.lineHeight = "15px";
undoRedoWrap.appendChild(undoBtn);
undoRedoWrap.appendChild(redoBtn);
row1.appendChild(undoRedoWrap);
const rerollBtn = makeActionButton("Re-roll", () => triggerReroll(node), "reroll");
rerollBtn.title = "Try the most recent edit again with a fresh seed — SAME mask, SAME starting "
+ "image. Each press replaces the last attempt with a new variation (it doesn't stack on "
+ "top). Make an edit first, then Re-roll to cycle seeds without re-painting or resetting. "
+ "Works for clicks, brush strokes, rectangles and detected masks.\n\n"
+ "After a ✨ Quick Photo Refine, Re-roll re-runs the ✨ pass instead (same as pressing ✨ "
+ "again) — it always re-rolls the last thing you did. After an Undo/Redo it's inert until "
+ "your next edit.";
row1.appendChild(rerollBtn);
node._AngeloRerollBtn = rerollBtn;
const varyBtn = makeActionButton("Vary ×4", () => triggerVary(node), "vary");
varyBtn.title = "Generate FOUR variations of the most recent edit at once — same mask, same "
+ "starting image, four different seeds — then pick your favourite from a chooser overlay. "
+ "Re-roll with four dice instead of one. The chosen variation replaces the last attempt "
+ "(✕ on the chooser keeps the current one); nothing commits until you pick. Make an edit "
+ "first. Not available while Restore is ON (restores are deterministic).";
row1.appendChild(varyBtn);
node._AngeloVaryBtn = varyBtn;
row1.appendChild(makeSeparator());
const persistentMaskToggle = makeToggleButton("Persistent Mask", () => {
const w = findWidget(node, "persistent_mask");
if (!w) return;
setWidget(w, !w.value);
syncPersistentMaskToggle(node);
});
persistentMaskToggle.title = "When ON, the last mask is held. Pressing the standard ComfyUI Queue button re-runs that region on the LATEST result with a fresh seed each time, so each press builds further — gradually morph an area over several presses without re-painting. (To re-roll the same edit on the ORIGINAL image instead, use the Re-roll button.)";
row1.appendChild(persistentMaskToggle);
node._AngeloPersistentMaskToggle = persistentMaskToggle;
const areaPromptToggle = makeToggleButton("Area Prompt", () => {
const w = findWidget(node, "area_prompt");
if (!w) return;
setWidget(w, !w.value);
syncAreaPromptToggle(node);
syncAreaPromptVisibility(node);
});
areaPromptToggle.title = "When ON, a text box appears between the toolbar and the canvas. Refines encode that text with the connected CLIP and use it instead of the main prompt — paint/drag a region and reshape it with a different prompt. Requires a CLIP input wired + non-empty area text. Forced ON in Smart Inpaint.";
row1.appendChild(areaPromptToggle);
node._AngeloAreaPromptToggle = areaPromptToggle;
const paintModeToggle = makeToggleButton("Paint Mode", () => {
const w = findWidget(node, "paint_mode");
if (!w) return;
setWidget(w, !w.value);
syncPaintModeToggle(node);
});
paintModeToggle.title = "When ON, hold + drag on the preview paints a freeform brush stroke (each dragged point becomes a circle of click_radius; the union is the refine mask). Release to submit. When OFF, clicks behave as single-circle refines.";
row1.appendChild(paintModeToggle);
node._AngeloPaintModeToggle = paintModeToggle;
// Restore / Remove are conflicting brushes — turning one on turns the other
// off so the toolbar can't lie about which is active.
//
// Remove REQUIRES Xtra-Fine, so toggling remove_mode also forces
// fine_upscaling on (remembering the prior state) and restores it on the
// way out — off, unless Xtra-Fine was already on before Remove was
// enabled. The remembered state lives in node.properties (NOT a plain JS
// field): LiteGraph serializes properties with the workflow, so a save
// made with Remove ON still restores Xtra-Fine correctly after a reload.
const _setRemove = (on) => {
const w = findWidget(node, "remove_mode");
if (!w) return;
const fw = findWidget(node, "fine_upscaling");
node.properties = node.properties || {};
if (on && !w.value) {
node.properties.angelo_xf_before_remove = fw ? !!fw.value : false;
if (fw) setWidget(fw, true);
} else if (!on && w.value) {
if (fw) setWidget(fw, !!node.properties.angelo_xf_before_remove);
delete node.properties.angelo_xf_before_remove;
}
setWidget(w, on);
};
const _clearOtherBrushes = (keep) => {
if (keep !== "restore_mode") {
const rw = findWidget(node, "restore_mode");
if (rw && rw.value) setWidget(rw, false);
}
if (keep !== "remove_mode") _setRemove(false); // also restores Xtra-Fine
};
const _syncBrushToggles = () => {
syncRestoreToggle(node);
syncRemoveToggle(node);
syncFineUpscaleToggle(node);
};
const restoreToggle = makeToggleButton("Restore", () => {
const w = findWidget(node, "restore_mode");
if (!w) return;
const next = !w.value;
setWidget(w, next);
if (next) _clearOtherBrushes("restore_mode");
_syncBrushToggles();
});
restoreToggle.title = "Restore brush — when ON, clicks and paint strokes RESTORE the painted "
+ "region back to the session's original base image instead of refining it. No sampling at "
+ "all, so it's instant. Use it to bring back details an edit shouldn't have touched (e.g. "
+ "refine a spacesuit, then brush the face inside the helmet back to the original). Feather "
+ "applies for a soft blend; works with clicks, Paint Mode strokes, and Detect masks. "
+ "Refine mode only — the Smart modes ignore it.";
row1.appendChild(restoreToggle);
node._AngeloRestoreToggle = restoreToggle;
// Remove brush — sits right beside Restore because it's the same gesture
// with a different fill: Restore brings the ORIGINAL back, Remove erases
// to BACKGROUND. Paint / click / Detect a region, and the edit model fills
// the hole with a continuation of the surroundings. Edit models only.
const removeToggle = makeToggleButton("🩹 Remove", () => {
const w = findWidget(node, "remove_mode");
if (!w) return;
const next = !w.value;
if (next) _clearOtherBrushes("remove_mode");
_setRemove(next); // toggles remove_mode + forces/restores Xtra-Fine
_syncBrushToggles();
});
removeToggle.title = "Remove brush — erase an object and let the edit model rebuild the "
+ "background behind it. Paint over it (or click, or Detect \"the person\" and it uses that "
+ "mask). One full-denoise pass regenerates the masked region as background: the object is "
+ "cut out of the reference the model anchors to (a real hole), so it rebuilds from the "
+ "surroundings instead of redrawing the object.\n\n"
+ "The Denoise box is ignored (the pass is always full denoise); your main/Area prompt isn't "
+ "used either — a fixed background-fill instruction drives it. Feather is forced to 0 and the mask "
+ "auto-grows ~10% to cover the object's halo.\n\n"
+ "Remove auto-enables Xtra-Fine (and shows Ctx Pad / MP / Method) and requires it — the "
+ "removal always runs on a high-res crop, which is what rebuilds fine background detail. "
+ "Turning Remove off restores Xtra-Fine to how it was before.\n\n"
+ "Edit models only (FLUX 2 Klein / Qwen-Image-Edit). Refine mode only; mutually exclusive "
+ "with Restore. Tips: shadows/reflections aren't under the brush — paint those too, or "
+ "they'll give the removal away. Left any artifacts behind? Turn Remove OFF and clean them "
+ "with the regular brush — Paint Mode on, in Refine mode, at a gentle Denoise (~0.6) — to "
+ "smooth them out without regenerating the whole area. Undo / Re-roll / Vary ×4 all work.";
row1.appendChild(removeToggle);
node._AngeloRemoveToggle = removeToggle;
const fineUpscaleToggle = makeToggleButton("Xtra-Fine", () => {
// Remove forces Xtra-Fine on and requires it — ignore clicks while
// Remove is active so it can't be switched off underneath it.
if (findWidget(node, "remove_mode")?.value) return;
const w = findWidget(node, "fine_upscaling");
if (!w) return;
setWidget(w, !w.value);
syncFineUpscaleToggle(node);
});
fineUpscaleToggle.title = "Xtra-Fine — refine the painted region at much higher effective resolution (ADetailer-style). The region is cropped, enlarged in pixel space to the MP target, re-encoded, refined, and composited back, so the model has room to render fine detail (faces, hands, eyes). Capped at Max scale.\n\nTip: pair it with Area Prompt — describe exactly what that region should be (e.g. \"detailed photorealistic face, sharp eyes\") for the strongest result.";
row1.appendChild(fineUpscaleToggle);
node._AngeloFineUpscaleToggle = fineUpscaleToggle;
// Reference: a unified toggle + strength pair. The strength box only
// exists while the toggle is lit — joined borders make them read as
// one control.
const refGroup = document.createElement("div");
refGroup.style.cssText = "display:inline-flex; align-items:stretch; flex:0 0 auto;";
const refineRefToggle = makeToggleButton("Reference", () => {
const w = findWidget(node, "refine_reference");
if (!w) return;
const next = !w.value;
setWidget(w, next);
if (next) {
// Switching ON must mean something — seed a sensible strength
// if the box is still at zero.
const sw = findWidget(node, "reference_strength");
if (sw && !(Number(sw.value) > 0)) setWidget(sw, 0.8);
}
syncReferenceControls(node);
});
refineRefToggle.title = "Reference — anchor a refine to the current image so you can run high "
+ "Denoise without losing the subject. Refine mode, edit models only (FLUX 2 Klein / Qwen).\n\n"
+ "WHY: normally high denoise destroys identity along with the noise. With Reference ON the "
+ "edit model holds the person/scene from the image while the texture fully re-renders — "
+ "that's where the quality comes from. The restoration sweet spot is the box at 0.6–1.0 "
+ "with Denoise 0.7–1.0.\n\n"
+ "STRENGTH BOX (appears when ON): how hard it anchors. 1 = locked to the image; lower = "
+ "blends in more freedom to change (0.6 = 60% anchored). With Xtra-Fine ON the anchor is "
+ "the upscaled crop.\n\n"
+ "Turn it OFF when your Area Prompt is meant to CHANGE the region — anchoring fights the "
+ "change. (Values strictly between 0 and 1 run a second pass per step, slightly slower; "
+ "0 and 1 are free.)";
refGroup.appendChild(refineRefToggle);
const refineRefInput = makeNumberInput("", { min: 0, max: 1, step: 0.05, width: 46 }, (val) => {
const w = findWidget(node, "reference_strength");
if (!w) return;
setWidget(w, val);
});
refineRefInput.title = "Reference strength: the anchored/free blend ratio (0.6 = 60% anchored). "
+ "Restoration sweet spot is usually 0.6–1.0.";
refineRefInput.style.padding = "0";
refineRefInput.style.gap = "0";
if (refineRefInput._AngeloInput) {
refineRefInput._AngeloInput.style.borderRadius = "0 3px 3px 0";
refineRefInput._AngeloInput.style.borderLeft = "none";
refineRefInput._AngeloInput.style.height = "100%";
refineRefInput._AngeloInput.style.boxSizing = "border-box";
}
refGroup.appendChild(refineRefInput);
row1.appendChild(refGroup);
node._AngeloRefGroup = refGroup;
node._AngeloRefineRefToggle = refineRefToggle;
node._AngeloRefineRefInput = refineRefInput;
row1.appendChild(makeSeparator());
// Inpainting Mode dropdown — Refine / Insert V1 / Insert V2.
// Refine = current behaviour (best for refining existing content)
// Smart Inpaint = drag a rectangle; locks denoise=1.0 / Fine Upscale=ON /
// Ctx Pad=0; adds reference_latents so an edit model
// (Klein 9B) sees the scene through its edit branch.
const inpaintModeWidget = findWidget(node, "inpainting_mode");
const inpaintModeOptions = (inpaintModeWidget && inpaintModeWidget.options && inpaintModeWidget.options.values)
? inpaintModeWidget.options.values
: ["Refine", "Smart Inpaint", "Smart Guided Inpaint", "Outpaint"];
const inpaintModeSelect = makeDropdown("Inpaint",
inpaintModeOptions,
(val) => {
const w = findWidget(node, "inpainting_mode");
if (!w) return;
setWidget(w, val);
// Switching INTO Smart Inpaint: default feather to 15 (a soft
// rectangle edge that blends the inserted content into the
// surroundings — a useful default for this mode). It stays
// user-adjustable afterwards — this only fires on the user's mode
// pick, not on workflow load, so a saved feather value is
// preserved across reloads.
if (val === "Smart Inpaint") {
const fw = findWidget(node, "feather_radius");
if (fw) {
setWidget(fw, 15);
syncFeatherInput(node);
}
}
// Mode change rewires the canvas interaction model — redraw
// so the cursor / toolbar / overlays reflect it immediately.
syncSmartInpaintLockedWidgets(node);
redrawCanvasWithOverlays(node);
}
);
inpaintModeSelect.title = "Inpainting Mode.\n\n"
+ "Refine — paint/click on the canvas to refine an existing region (faces, hands, textures). Partial-denoise from existing content.\n\n"
+ "Smart Inpaint — drag a rectangle on the canvas (click and hold one corner, release at the opposite). Adds NEW content in that region. Locks denoise=1.0 + Xtra-Fine=ON + Area Prompt=ON; injects reference_latents so an edit model's (FLUX 2 Klein 9B etc.) edit branch activates. Feather defaults to 15 (soft blend) but stays adjustable.\n\n"
+ "Smart Guided Inpaint — no painting or boxes. Pick a LOCATION from the dropdown above the Area Prompt (top left, center, bottom half, …); it's prepended to your prompt at run time (e.g. 'In the top left of the image, a red car') and the edit model places the content there across the whole image. Locks denoise=1.0 + Xtra-Fine=OFF + Area Prompt=ON; Feather and Persistent Mask disabled (no mask). Press 'Generate Guided Edit' to run. Coarse regions land most reliably.\n\n"
+ "Outpaint — extend the canvas. Use the arrow buttons, or click near an edge of the preview (a glowing band shows where the extension goes). Every result is shown in a review overlay first — Accept commits it as a NEW session base (history resets), Try again re-rolls it, Cancel costs nothing.";
row1.appendChild(inpaintModeSelect);
node._AngeloInpaintModeSelect = inpaintModeSelect;
// ===== ROW 2: numeric values =====
const clickRadiusInput = makeNumberInput("Click R", { min: 8, max: 1024, step: 4, width: 56 }, (val) => {
const w = findWidget(node, "click_radius");
if (!w) return;
setWidget(w, Math.round(val));
});
clickRadiusInput.title = "Pixel-space radius of the refinement region for a single click. Also the brush size in paint mode.\n\nKeyboard (cursor over canvas): [ to shrink, ] to grow.";
row2.appendChild(clickRadiusInput);
node._AngeloClickRadiusInput = clickRadiusInput;
const featherInput = makeNumberInput("Feather", { min: 0, max: 256, step: 4, width: 56 }, (val) => {
const w = findWidget(node, "feather_radius");
if (!w) return;
setWidget(w, Math.round(val));
});
featherInput.title = "Pixel-space gaussian blur on the mask edge. Smooths the seam between refined region and preserved surroundings. Roughly click_radius / 4 is a good default.\n\nKeyboard (cursor over canvas): { (shift+[) to shrink, } (shift+]) to grow.";
row2.appendChild(featherInput);
node._AngeloFeatherInput = featherInput;
const denoiseInput = makeNumberInput("Denoise", { min: 0.05, max: 1.0, step: 0.05, width: 56 }, (val) => {
const w = findWidget(node, "denoise");
if (!w) return;
setWidget(w, val);
});
denoiseInput.title = "How much of the sampler trajectory to run on the refine. 0.3 = subtle touch-up, 0.6 = real redo, 0.9+ = essentially regenerate that region.\n\nKeyboard (cursor over canvas): , to decrease, . to increase.";
row2.appendChild(denoiseInput);
node._AngeloDenoiseInput = denoiseInput;
const seedInput = makeNumberInput("Seed", { min: 0, max: 0xFFFFFFFFFFFFFFFF, step: 1, width: 120 }, (val) => {
const w = findWidget(node, "seed");
if (!w) return;
setWidget(w, Math.round(val));
});
seedInput.title = "[Edit Mode] Seed for the refine pass. After each click the Seed Ctrl dropdown decides what happens — fixed (leave alone), randomize (new random), increment (+1), decrement (-1).";
row2.appendChild(seedInput);
node._AngeloSeedInput = seedInput;
const seedCtrlSelect = makeDropdown("Ctrl",
["fixed", "increment", "decrement", "randomize"],
(val) => {
const w = findWidget(node, "seed_control");
if (!w) return;
setWidget(w, val);
}
);
seedCtrlSelect.title = "[Edit Mode] After-click seed behaviour. Mirrors ComfyUI's standard seed control_after_generate dropdown.";
row2.appendChild(seedCtrlSelect);
node._AngeloSeedCtrlSelect = seedCtrlSelect;
// Xtra-Fine value group (separator + MP/Max/Ctx Pad/Method). Only
// shown while Xtra-Fine is effectively ON — the values mean nothing
// otherwise, and hiding them halves row 2 for the common case. The
// show/hide lives in syncFineUpscaleToggle.
const fineSep = makeSeparator();
row2.appendChild(fineSep);
node._AngeloFineSep = fineSep;
const mpInput = makeNumberInput("MP", { min: 0.1, max: 4.0, step: 0.1, width: 50 }, (val) => {
const w = findWidget(node, "min_megapixels");
if (!w) return;
setWidget(w, val);
});
mpInput.title = "Xtra-Fine: target megapixels for the refine pass. Higher = bigger compute per click but sharper detail. Only used when Xtra-Fine is ON.";
row2.appendChild(mpInput);
node._AngeloMpInput = mpInput;
const maxInput = makeNumberInput("Max", { min: 1.0, max: 16.0, step: 0.5, width: 50 }, (val) => {
const w = findWidget(node, "max_upscale");
if (!w) return;
setWidget(w, val);
});
maxInput.title = "Xtra-Fine: hard cap on linear enlarge factor (8× = 64× area). Prevents pathological blow-up on tiny paints. Only used when Xtra-Fine is ON.";
row2.appendChild(maxInput);
node._AngeloMaxInput = maxInput;
const ctxPadInput = makeNumberInput("Ctx Pad", { min: 0, max: 512, step: 8, width: 50 }, (val) => {
const w = findWidget(node, "fine_context_pad");
if (!w) return;
setWidget(w, val);
});
ctxPadInput.title = "Xtra-Fine: pixel-space padding around the painted shape bbox before cropping. Gives the model surrounding context. Only used when Xtra-Fine is ON.";
row2.appendChild(ctxPadInput);
node._AngeloCtxPadInput = ctxPadInput;
// Read the resize-method options from the underlying widget enum.
const methodWidget = findWidget(node, "resize_method");
const methodOptions = (methodWidget && methodWidget.options && methodWidget.options.values)
? methodWidget.options.values
: ["nearest-exact", "bilinear", "area", "bicubic", "bislerp", "lanczos"];
const methodSelect = makeDropdown("Method",
methodOptions,
(val) => {
const w = findWidget(node, "resize_method");
if (!w) return;
setWidget(w, val);
}
);
methodSelect.title = "Xtra-Fine: pixel-space enlarge method. lanczos = sharpest with mild ringing; bilinear = smooth (great for skin/faces); bicubic = middle; nearest-exact = blocky preserves exact values; bislerp/area = niche. Only used when Xtra-Fine is ON.";
row2.appendChild(methodSelect);
node._AngeloMethodSelect = methodSelect;
// ===== QUICK ACTIONS BAR: one-press magic buttons (Refine mode) =====
const qaLabel = document.createElement("span");
qaLabel.textContent = "✦ Quick Actions:";
qaLabel.style.cssText = "font-size:11px; color:#bbb; padding:0 2px 0 4px; white-space:nowrap;";
quickRow.appendChild(qaLabel);
const quickFixBtn = makeActionButton("✨ Quick Photo Refine", () => triggerQuickPhotoRefine(node), "quickfix");
quickFixBtn.title = "One-click photo refine — a true magic button with its own fixed recipe: "
+ "the WHOLE image runs through the Xtra-Fine pipeline (1.3MP working target — small "
+ "images get internally supersampled, refined, composited back) with the instruction "
+ "\"Keep the identity from image 1. make the image high quality.\", reference anchor "
+ "1.0, denoise 1.0, feather 0. Identity stays, texture re-renders. NO toolbar box "
+ "affects it — only the Seed applies (leave Ctrl on randomize and mash for variations); "
+ "Undo steps back through passes. Auto-tiles on canvases over ~1.6MP.\n\n"
+ "Needs an edit model (FLUX 2 Klein / Qwen-Image-Edit) + a wired CLIP — on non-edit models "
+ "the reference is ignored, so this REGENERATES the image instead (Undo brings it back). "
+ "Refine mode only.";
quickRow.appendChild(quickFixBtn);
node._AngeloQuickFixBtn = quickFixBtn;
const qpWidget = findWidget(node, "quick_prompt_mode");
const qpOptions = (qpWidget && qpWidget.options && qpWidget.options.values)
? qpWidget.options.values
: ["Identity + Quality", "Restore Photo", "Identity + Colours", "Use Area Prompt"];
const quickPromptSelect = makeDropdown("", qpOptions, (val) => {
const w = findWidget(node, "quick_prompt_mode");
if (w) setWidget(w, val);
});
quickPromptSelect.title = "Which instruction ✨ Quick Photo Refine runs with:\n"
+ "• Identity + Quality (default) — 'Keep the identity from image 1. make the image high quality.' "
+ "(Qwen-Image-Edit gets its tuned dust-and-scratches variant automatically.)\n"
+ "• Restore Photo — 'Keep the identity from image 1. restore the photo.' For damaged/old photos.\n"
+ "• Identity + Colours — adds a colour hold for images where the palette must not move.\n"
+ "• Use Area Prompt — your own text from the Area Prompt box drives the pass "
+ "(falls back to the default if the box is empty).";
quickRow.appendChild(quickPromptSelect);
node._AngeloQuickPromptSelect = quickPromptSelect;
// Lite toggle — same Quick Photo Refine recipe at a gentler denoise.
const liteToggle = makeToggleButton("Lite", () => {
const w = findWidget(node, "quick_lite");
if (!w) return;
setWidget(w, !w.value);
_syncToggle(node._AngeloLiteToggle, findWidget(node, "quick_lite")?.value, _TOGGLE_ON_COLORS.teal);
});
liteToggle.title = "Lite mode for ✨ Quick Photo Refine — the exact same recipe at a GENTLER "
+ "denoise (0.8 instead of 1.0). It re-renders less and stays closer to the input: a "
+ "lighter restore / cleanup rather than a full rebuild. Everything else (reference anchor, "
+ "prompt, target resolution, tiling, re-roll) is identical.";
quickRow.appendChild(liteToggle);
node._AngeloLiteToggle = liteToggle;
_syncToggle(liteToggle, findWidget(node, "quick_lite")?.value, _TOGGLE_ON_COLORS.teal);
// Separator after the Lite toggle — the prompt dropdown + Lite belong to
// Quick Photo Refine, so the pipe groups them and sets them apart from
// the upscale buttons that follow.
quickRow.appendChild(makeSeparator());
const upscaleBtn = makeActionButton("⬆ 2× Pixel", () => triggerPixelUpscale(node), "quickfix");
upscaleBtn.title = "Pure pixel-space 2× upscale — lanczos, NO AI, deterministic. The image is "
+ "decoded, enlarged 2×, re-encoded, and committed immediately as the session's new base "
+ "(dimension change, so history resets — like loading a new photo). Nothing is invented "
+ "or re-rendered.\n\n"
+ "For AI enhancement afterwards, that's your next move: press ✨ Quick Photo Refine "
+ "(auto-tiles on the now-large canvas), or spot-edit with Xtra-Fine.";
quickRow.appendChild(upscaleBtn);
node._AngeloUpscaleBtn = upscaleBtn;
const shrinkBtn = makeActionButton("⬇ Shrink", () => showShrinkPopup(node), "quickfix");
shrinkBtn.title = "Downscale the image (no AI): pick a scale factor in the popup and Angelo "
+ "resamples it smaller with AREA averaging — the right method for shrinking (anti-aliased, "
+ "no ringing). The new dimensions (snapped to a multiple of 16) are shown before you "
+ "confirm. Committed as a fresh session base — dimension change, so history resets. "
+ "Refine mode only.";
quickRow.appendChild(shrinkBtn);
node._AngeloShrinkBtn = shrinkBtn;
// ===== OUTPAINT ROW: direction + amount (Outpaint mode only) =====
// Arrows extend the canvas in that direction; "All" pads every side
// (zoom-out). The same action is available by clicking near an edge
// of the preview — the row is the explicit/discoverable surface, the
// canvas is the fast one. Every result goes through a review overlay
// before anything commits.
const opModeLabel = document.createElement("span");
opModeLabel.textContent = "⛶ Outpaint:";
opModeLabel.style.cssText = "font-size:11px; color:#bbb; padding:0 2px 0 4px; white-space:nowrap;";
outpaintRow.appendChild(opModeLabel);
const mkOutpaintBtn = (txt, dir, tip) => {
const b = document.createElement("button");
b.type = "button";
b.textContent = txt;
b.title = tip;
b.style.cssText = "cursor:pointer; padding:3px 9px; font-size:13px; font-weight:bold; "
+ "border:1px solid rgba(120,190,235,0.7); border-radius:3px; "
+ "background:rgba(40,62,82,0.95); color:#d8eeff; line-height:1; "
+ "user-select:none; flex:0 0 auto;";
b.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
triggerOutpaint(node, dir);
});
b.addEventListener("pointerdown", (e) => e.stopPropagation());
return b;
};
outpaintRow.appendChild(mkOutpaintBtn("←", "left", "Extend the canvas to the LEFT by Amount px"));
outpaintRow.appendChild(mkOutpaintBtn("↑", "up", "Extend the canvas UPWARD by Amount px"));
outpaintRow.appendChild(mkOutpaintBtn("↓", "down", "Extend the canvas DOWNWARD by Amount px"));
outpaintRow.appendChild(mkOutpaintBtn("→", "right", "Extend the canvas to the RIGHT by Amount px"));
outpaintRow.appendChild(mkOutpaintBtn("⛶ All", "all", "Extend ALL four sides by Amount px (zoom-out)"));
outpaintRow.appendChild(makeSeparator());
const opAmountInput = makeNumberInput("Amount", { min: 16, max: 2048, step: 16, width: 56 }, (val) => {
const w = findWidget(node, "outpaint_amount");
if (w) setWidget(w, Math.round(val / 16) * 16);
});
opAmountInput.title = "How many pixels to extend the canvas by (snapped to /16 so any VAE lands on clean latent cells).";
outpaintRow.appendChild(opAmountInput);
node._AngeloOutpaintAmountInput = opAmountInput;
const opOverlapInput = makeNumberInput("Overlap", { min: 0, max: 512, step: 8, width: 50 }, (val) => {
const w = findWidget(node, "outpaint_overlap");
if (w) setWidget(w, Math.round(val));
});
opOverlapInput.title = "Feathered band reaching this many pixels INTO the existing image — that band is redrawn so the seam blends instead of butting. 64 is a good default.";
outpaintRow.appendChild(opOverlapInput);
node._AngeloOutpaintOverlapInput = opOverlapInput;
const opHint = document.createElement("span");
opHint.textContent = "edge-click = extend · drag = protect · Shift = protect anywhere";
opHint.style.cssText = "font-size:10px; color:#8aa; padding:0 4px; white-space:nowrap;";
opHint.title = "Canvas gestures in Outpaint mode:\n"
+ "• Click near an edge — extend the canvas that way (a glowing band previews it).\n"
+ "• Drag in the interior — paint a PROTECT region (red). Protected pixels are "
+ "excluded from the Overlap band, so something near the frame edge (a car, a face) "
+ "stays exactly as-is while the rest of the seam still blends generously. "
+ "Brush size = Click R.\n"
+ "• Hold SHIFT — the protect brush wins everywhere, including the edge zone, so "
+ "you can start a stroke on something flush against the frame edge without "
+ "triggering an extension.";
outpaintRow.appendChild(opHint);
const opClearProtectBtn = document.createElement("button");
opClearProtectBtn.type = "button";
opClearProtectBtn.textContent = "✕ Protect";
opClearProtectBtn.title = "Clear the painted protect region.";
opClearProtectBtn.style.cssText = "cursor:pointer; padding:2px 8px; font-size:10px; "
+ "border:1px solid rgba(255,120,120,0.7); border-radius:3px; "
+ "background:rgba(80,40,40,0.95); color:#fbb; line-height:1.4; "
+ "user-select:none; flex:0 0 auto; display:none;";
opClearProtectBtn.addEventListener("pointerdown", (e) => e.stopPropagation());
opClearProtectBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
node._AngeloProtect = [];
const wpr = findWidget(node, "outpaint_protect");
if (wpr) setWidget(wpr, "");
syncOutpaintControls(node);
redrawCanvasWithOverlays(node);
});
outpaintRow.appendChild(opClearProtectBtn);
node._AngeloOutpaintClearProtectBtn = opClearProtectBtn;
// ===== DETECT ROW: SAM 3 auto-segment (Refine + Smart Inpaint) =====
const detLabel = document.createElement("span");
detLabel.textContent = "🔍 Detect:";
detLabel.style.cssText = "font-size:11px; color:#bbb; padding:0 2px 0 4px;";
detectRow.appendChild(detLabel);
const detText = document.createElement("input");
detText.type = "text";
detText.placeholder = "what to segment (e.g. the face)";
detText.style.cssText = "flex:1 1 0; min-width:0; background:#1a1a1a; color:#ddd; "
+ "border:1px solid #555; border-radius:3px; padding:2px 6px; font-size:11px;";
detText.title = "SAM 3 concept prompt — a noun phrase describing what to find. "
+ "Hit Detect to highlight every match; click the one you want to refine/inpaint it.";
for (const ev of ["pointerdown", "mousedown", "keydown", "keyup"]) {
detText.addEventListener(ev, (e) => e.stopPropagation());
}
detText.addEventListener("keydown", (e) => { if (e.key === "Enter") runDetect(node); });
detectRow.appendChild(detText);
node._AngeloDetectText = detText;
const detConf = makeNumberInput("Conf", { min: 0.05, max: 0.95, step: 0.05, width: 48 }, () => {});
detConf.title = "Detection confidence threshold. Lower (≈0.2–0.3) finds more / fainter matches.";
if (detConf._AngeloInput) detConf._AngeloInput.value = "0.3";
detectRow.appendChild(detConf);
node._AngeloDetectConf = detConf;
const detBtn = makeActionButton("Detect", () => runDetect(node), "neutral");
detBtn.title = "Run SAM 3 on the current preview and highlight matches. Click a highlight to confirm; Esc / click empty space to dismiss.";
detectRow.appendChild(detBtn);
node._AngeloDetectBtn = detBtn;
// Space + separator between Detect and the quick-presets dropdown.
detectRow.appendChild(makeSeparator());
// Quick-detect presets — selecting one runs SAM 3 immediately with
// that concept (does NOT change the text box). Resets to the
// placeholder after each pick so the same item can be re-run.
const quickSel = document.createElement("select");
quickSel.style.cssText = "font-size:11px; padding:2px 4px; border:1px solid #555; "
+ "border-radius:3px; background:#1a1a1a; color:#ddd; margin-left:4px; min-width:104px;";
quickSel.title = "Quick-detect a common subject — runs SAM 3 immediately. Doesn't touch the text box.";
// Placeholder first (index 0 — reset target), then grouped presets.
const _ph = document.createElement("option");
_ph.value = "Quick Detect…"; _ph.textContent = "Quick Detect…";
quickSel.appendChild(_ph);
const _QUICK_GROUPS = {
"People": ["Person", "Face", "Eyes", "Mouth", "Teeth", "Nose", "Ears", "Hair", "Skin", "Beard"],
"Body": ["Hands", "Fingers", "Arms", "Legs", "Feet", "Torso"],
"Clothing": ["Clothing", "Dress", "Shirt", "Jacket", "Pants", "Shoes", "Hat", "Glasses", "Jewelry", "Bag"],
"Animals": ["Animal", "Dog", "Cat", "Bird", "Horse"],
"Scene": ["Background", "Sky", "Clouds", "Sun", "Moon", "Water", "Tree", "Grass", "Flowers", "Mountains", "Road"],
"Objects": ["Building", "Window", "Door", "Car", "Furniture", "Food", "Bottle", "Phone", "Text", "Logo"],
};
for (const [group, items] of Object.entries(_QUICK_GROUPS)) {
const og = document.createElement("optgroup");
og.label = group;
for (const it of items) {
const opt = document.createElement("option");
opt.value = it;
opt.textContent = it;
og.appendChild(opt);
}
quickSel.appendChild(og);
}
quickSel.addEventListener("change", () => {
const v = quickSel.value;
quickSel.selectedIndex = 0; // reset to "Quick Detect…"
if (v && v !== "Quick Detect…") runDetect(node, v.toLowerCase());
});
quickSel.addEventListener("pointerdown", (e) => e.stopPropagation());
quickSel.addEventListener("mousedown", (e) => e.stopPropagation());
detectRow.appendChild(quickSel);
node._AngeloDetectQuick = quickSel;
// Mask grow / shrink — nudges ALL detected masks in/out together, 2px
// at a time, so a tight SAM silhouette can be loosened (or a loose one
// tightened) before committing. Pure-frontend: offsets the polygons /
// bbox the JS already holds; the backend rasterises whatever it gets.
// Lives in the FLOATING DETECT PANEL (appended there below), not this
// always-visible row — it only means anything while candidates are up,
// and the detect row is nowrap-tight on narrow nodes.
const maskRow = document.createElement("div");
maskRow.style.cssText = "display:flex; align-items:center; justify-content:center; gap:4px;";
const maskLabel = document.createElement("span");
maskLabel.textContent = "Mask:";
maskLabel.style.cssText = "font-size:11px; color:#bbb; padding:0 1px; white-space:nowrap;";
maskRow.appendChild(maskLabel);
const mkGrowBtn = (txt, delta, tip) => {
const b = document.createElement("button");
b.type = "button";
b.textContent = txt;
b.title = tip;
b.style.cssText = "cursor:pointer; padding:2px 7px; font-size:13px; font-weight:bold; "
+ "border:1px solid #555; border-radius:3px; background:#2a2a2a; color:#ddd; "
+ "line-height:1; user-select:none; flex:0 0 auto;";
b.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); adjustMaskGrow(node, delta); });
b.addEventListener("pointerdown", (e) => e.stopPropagation());
return b;
};
maskRow.appendChild(mkGrowBtn("−", -2, "Shrink all detected masks by 2px"));
const growReadout = document.createElement("span");
growReadout.textContent = "0px";
growReadout.style.cssText = "font-size:11px; color:#9cf; min-width:34px; text-align:center; white-space:nowrap;";
maskRow.appendChild(growReadout);
node._AngeloMaskGrowReadout = growReadout;
maskRow.appendChild(mkGrowBtn("+", 2, "Grow all detected masks by 2px"));
// ===== MODE ROW: the master Sampler/Edit switch, centred up top =====
const modeWidget = findWidget(node, "mode");
const modeOptions = (modeWidget && modeWidget.options && modeWidget.options.values)
? modeWidget.options.values
: ["Sampler Mode", "Edit Mode"];
const modeSelect = makeDropdown("Mode",
modeOptions,
(val) => {
const w = findWidget(node, "mode");
if (!w) return;
setWidget(w, val); // fires the wrapped mode callback (lock + grey sync)
}
);
modeSelect.title = "Sampler Mode = generate a fresh base latent from the inputs (acts like a KSampler). Edit Mode = click/drag the preview to refine or inpaint the cached latent. Switching to Edit Mode auto-locks the sampler seed to the value that produced the base.";
modeRow.appendChild(modeSelect);
node._AngeloModeSelect = modeSelect;
// Fullscreen toggle — pinned top-left of the Mode row (mirrors the
// floating detect panel on the right, so it never disturbs the centred
// Mode dropdown). Pops the WHOLE editor (toolbar + canvas) into a
// viewport-filling overlay for a much bigger canvas; Esc or the button
// returns it. Works in both Sampler and Edit mode.
const fullscreenBtn = document.createElement("button");
fullscreenBtn.type = "button";
fullscreenBtn.textContent = "⛶ Fullscreen";
fullscreenBtn.style.cssText = "position:absolute; left:6px; top:4px; z-index:6; cursor:pointer; "
+ "padding:3px 9px; font-size:11px; font-weight:bold; border-radius:3px; "
+ "border:1px solid #555; background:#2a2a2a; color:#ccc; user-select:none; line-height:15px;";
for (const ev of ["pointerdown", "mousedown"]) {
fullscreenBtn.addEventListener(ev, (e) => e.stopPropagation());
}
fullscreenBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
toggleAngeloFullscreen(node);
});
modeRow.appendChild(fullscreenBtn);
node._AngeloFullscreenBtn = fullscreenBtn;
syncFullscreenButton(node);
// ▶ Queue — the in-node twin of ComfyUI's Queue button. Same code path
// (app.queuePrompt via the queuePrompt() helper), so the persistent-mask
// queue hook and everything else behave identically — but you never have
// to leave the node (or fullscreen) to press it. Works in both modes:
// Sampler Mode = generate the base; Edit Mode = re-execute the graph
// (e.g. Persistent Mask re-runs, upstream changes).
const queueBtn = document.createElement("button");
queueBtn.type = "button";
queueBtn.textContent = "▶ Queue";
queueBtn.title = "Queue the workflow — identical to ComfyUI's main Queue button, right here in "
+ "the node (works in fullscreen too).\n\n"
+ "Sampler Mode: generates a fresh base image.\n"
+ "Edit Mode: re-executes the graph — with Persistent Mask ON each press re-runs the held "
+ "mask on the latest result with a fresh seed (the gradual-morph loop), and it's also the "
+ "button to press after changing something upstream.";
queueBtn.style.cssText = "cursor:pointer; padding:3px 12px; font-size:11px; font-weight:bold; "
+ "border:1px solid rgba(140, 220, 170, 0.9); border-radius:3px; "
+ "background:rgba(30, 120, 80, 0.95); color:#fff; user-select:none; line-height:15px; "
+ "margin-left:6px;";
for (const ev of ["pointerdown", "mousedown"]) {
queueBtn.addEventListener(ev, (e) => e.stopPropagation());
}
queueBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
queuePrompt();
});
modeRow.appendChild(queueBtn);
node._AngeloQueueBtn = queueBtn;
// Floating detect-mode panel — pinned top-right of the Mode row, shown
// only while candidates are active. Holds the red Cancel button + an
// opacity slider for the highlight overlay (drag down to peek at the
// edges of a just-generated region). Styled as one neat cluster.
node._AngeloDetOpacity = 1.0;
const detectPanel = document.createElement("div");
detectPanel.style.cssText = "position:absolute; right:6px; top:4px; z-index:6; display:none; "
+ "flex-direction:column; align-items:stretch; gap:4px; padding:5px 6px; "
+ "background:#000; border:1px solid #555; border-radius:5px;";
const cancelDetectBtn = document.createElement("button");
cancelDetectBtn.type = "button";
cancelDetectBtn.textContent = "✕ Cancel Detect";
cancelDetectBtn.title = "Leave detect mode (you can also press Esc or Space). "
+ "The highlighted candidates stay up so you can edit each one until you cancel.";
cancelDetectBtn.style.cssText = "font-size:11px; font-weight:bold; padding:3px 10px; "
+ "border:1px solid #e66; border-radius:3px; background:rgba(200,40,40,0.95); "
+ "color:#fff; cursor:pointer;";
for (const ev of ["pointerdown", "mousedown"]) {
cancelDetectBtn.addEventListener(ev, (e) => e.stopPropagation());
}
cancelDetectBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
clearDetections(node);
});
const opRow = document.createElement("div");
opRow.style.cssText = "display:flex; align-items:center; gap:6px; font-size:10px; color:#ccd;";
const opLabel = document.createElement("span");
opLabel.textContent = "Highlight";
const opSlider = document.createElement("input");
opSlider.type = "range";
opSlider.min = "0"; opSlider.max = "1"; opSlider.step = "0.05"; opSlider.value = "1";
opSlider.style.cssText = "flex:1 1 auto; width:96px; cursor:pointer;";
opSlider.title = "Selection-highlight opacity — drag down to peek at the edges of what you just generated. Candidates stay clickable.";
for (const ev of ["pointerdown", "mousedown"]) {
opSlider.addEventListener(ev, (e) => e.stopPropagation());
}
opSlider.addEventListener("input", () => {
node._AngeloDetOpacity = parseFloat(opSlider.value);
redrawCanvasWithOverlays(node);
});
opRow.appendChild(opLabel);
opRow.appendChild(opSlider);
// Touch-up brush hint (the brush is modifier-driven, so flag it here).
const brushHint = document.createElement("div");
brushHint.textContent = "Refine: Shift-drag = +mask · Alt-drag = −mask";
brushHint.style.cssText = "font-size:9px; color:#9aa; white-space:nowrap; text-align:center;";
brushHint.title = "In Refine, hold Shift and drag on the preview to grow the mask you start over, "
+ "or Alt-drag to carve it back (holes allowed). Brush size = Click R. Then click the candidate to apply.";
// Fix All — automatically work through every remaining candidate.
// Confirms one, waits for its run to land (onExecuted), confirms the
// next; the green edited-tracking shows progress on the canvas while
// the button doubles as a Stop control with a live count.
const fixAllBtn = document.createElement("button");
fixAllBtn.type = "button";
fixAllBtn.textContent = "⚡ Fix All";
fixAllBtn.title = "Automatically edit every remaining (non-green) candidate in turn with the "
+ "current settings — Area Prompt, Xtra-Fine, seed control, all apply per candidate. "
+ "Each one is a separate history entry, so individual Undo still works. "
+ "Click again to stop after the in-flight candidate.";
fixAllBtn.style.cssText = "font-size:11px; font-weight:bold; padding:3px 10px; "
+ "border:1px solid #4a7; border-radius:3px; background:rgba(30,120,80,0.95); "
+ "color:#fff; cursor:pointer;";
for (const ev of ["pointerdown", "mousedown"]) {
fixAllBtn.addEventListener(ev, (e) => e.stopPropagation());
}
fixAllBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
toggleFixAll(node);
});
detectPanel.appendChild(cancelDetectBtn);
detectPanel.appendChild(fixAllBtn);
detectPanel.appendChild(opRow);
detectPanel.appendChild(maskRow);
detectPanel.appendChild(brushHint);
modeRow.appendChild(detectPanel);
node._AngeloCancelDetectBtn = cancelDetectBtn;
node._AngeloFixAllBtn = fixAllBtn;
node._AngeloDetectPanel = detectPanel;
node._AngeloDetOpacitySlider = opSlider;
// ===== ROW 3: shared generation config (always active) =====
const stepsInput = makeNumberInput("Steps", { min: 1, max: 100, step: 1, width: 48 }, (val) => {
const w = findWidget(node, "steps");
if (!w) return;
setWidget(w, Math.round(val));
});
stepsInput.title = "Sampler step count for both Sampler Mode and refines. Match the model — FLUX 2 Klein distilled = 4.";
row3.appendChild(stepsInput);
node._AngeloStepsInput = stepsInput;
const cfgInput = makeNumberInput("CFG", { min: 0.0, max: 30.0, step: 0.1, width: 48 }, (val) => {
const w = findWidget(node, "cfg");
if (!w) return;
setWidget(w, val);
});
cfgInput.title = "Classifier-free guidance scale. FLUX 2 Klein distilled uses CFG=1 (no negative branch).";
row3.appendChild(cfgInput);
node._AngeloCfgInput = cfgInput;
const samplerWidget = findWidget(node, "sampler_name");
const samplerOptions = (samplerWidget && samplerWidget.options && samplerWidget.options.values)
? samplerWidget.options.values
: ["euler"];
const samplerSelect = makeDropdown("Sampler",
samplerOptions,
(val) => {
const w = findWidget(node, "sampler_name");
if (!w) return;
setWidget(w, val);
}
);
samplerSelect.title = "Sampling algorithm (shared by Sampler Mode + refines).";
row3.appendChild(samplerSelect);
node._AngeloSamplerSelect = samplerSelect;
const schedulerWidget = findWidget(node, "scheduler");
const schedulerOptions = (schedulerWidget && schedulerWidget.options && schedulerWidget.options.values)
? schedulerWidget.options.values
: ["simple"];
const schedulerSelect = makeDropdown("Sched",
schedulerOptions,
(val) => {
const w = findWidget(node, "scheduler");
if (!w) return;
setWidget(w, val);
}
);
schedulerSelect.title = "Noise schedule (shared by Sampler Mode + refines).";
row3.appendChild(schedulerSelect);
node._AngeloSchedulerSelect = schedulerSelect;
// Load Image — bring an external photo in as the base to edit. Always
// active (both modes), which is exactly the Mode row's semantics — so
// it lives there, to the left of the Mode dropdown, keeping row 3 as
// pure sampler config and using the Mode row's otherwise-empty space.
const loadImgBtn = makeActionButton("🖼 Load Image", () => triggerLoadImage(node), "neutral");
loadImgBtn.title = "Load an external image as the base to edit / refine. "
+ "You'll be asked to keep its resolution or resize to a target "
+ "megapixel (both rounded to a /16 multiple). The image becomes the "
+ "base — Reset and Undo return to it. While loaded, the latent input "
+ "is ignored (hit Unload to go back to it). No Empty Latent needed.";
modeRow.insertBefore(loadImgBtn, modeSelect);
node._AngeloLoadImageBtn = loadImgBtn;
const unloadImgBtn = makeActionButton("✕ Unload", () => unloadImage(node), "neutral");
unloadImgBtn.title = "Clear the loaded image and return to the wired latent input "
+ "as the base. Shown only while an image is loaded.";
unloadImgBtn.style.display = "none";
modeRow.insertBefore(unloadImgBtn, modeSelect);
node._AngeloUnloadImageBtn = unloadImgBtn;
// ===== ROW 4: Sampler-Mode seed group (greyed in Edit Mode) =====
const samplerSeedInput = makeNumberInput("Smpl Seed", { min: 0, max: 0xFFFFFFFFFFFFFFFF, step: 1, width: 120 }, (val) => {
const w = findWidget(node, "sampler_seed");
if (!w) return;
setWidget(w, Math.round(val));
});
samplerSeedInput.title = "[Sampler Mode] Seed for the base generation. After each run the Sampler Ctrl dropdown decides what happens to it.";
row4.appendChild(samplerSeedInput);
node._AngeloSamplerSeedInput = samplerSeedInput;
const samplerSeedCtrlSelect = makeDropdown("Smpl Ctrl",
["fixed", "increment", "decrement", "randomize"],
(val) => {
const w = findWidget(node, "sampler_seed_control");
if (!w) return;
setWidget(w, val);
}
);
samplerSeedCtrlSelect.title = "[Sampler Mode] After-generate seed behaviour for the base. Auto-forced to 'fixed' when you switch to Edit Mode so re-queues don't regenerate the base.";
row4.appendChild(samplerSeedCtrlSelect);
node._AngeloSamplerSeedCtrlSelect = samplerSeedCtrlSelect;
// min matches the Python widget (0.05) — a 0.0 here used to pass JS
// clamping but fail ComfyUI's server-side validation on the next Queue.
const samplerDenoiseInput = makeNumberInput("Smpl Denoise", { min: 0.05, max: 1.0, step: 0.05, width: 56 }, (val) => {
const w = findWidget(node, "sampler_denoise");
if (!w) return;
setWidget(w, val);
});
samplerDenoiseInput.title = "[Sampler Mode] Denoise for the base generation. 1.0 = full generation from the incoming (usually empty) latent.";
row4.appendChild(samplerDenoiseInput);
node._AngeloSamplerDenoiseInput = samplerDenoiseInput;
container.appendChild(toggleBarWrap);
// --- Area Prompt input — sits BETWEEN the toolbar and the canvas,
// and only shows when Area Prompt is ON (or Smart Inpaint forces
// it on). One textarea with a Pos/Neg toggle deciding which
// underlying widget it edits (area_text_positive /
// area_text_negative). Hiding it never clears the text — that
// lives in the widgets, and syncAreaPromptBox reloads it. ---
attachAreaPromptBox(node, container);
// Canvas + placeholder live in their own relative-positioned wrap
// so the absolutely-positioned placeholder overlays ONLY the canvas.
// The wrap flex-grows to fill whatever vertical space is left after
// the toolbar + area-prompt box, and centres the canvas inside it.
// The canvas's DISPLAY size is computed in JS (fitCanvasDisplaySize)
// to fit this wrap while preserving aspect ratio — letterboxed by
// the empty space around it, NOT by object-fit (which would break
// the click-coordinate mapping that reads canvas.getBoundingClientRect).
const canvasWrap = document.createElement("div");
canvasWrap.style.position = "relative";
canvasWrap.style.width = "100%";
canvasWrap.style.flex = "1 1 auto";
canvasWrap.style.minHeight = "0";
canvasWrap.style.display = "flex";
canvasWrap.style.alignItems = "center";
canvasWrap.style.justifyContent = "center";
canvasWrap.style.overflow = "hidden";
// The canvas is ABSOLUTELY positioned inside the wrap — applyView()
// sets its width/height (= fit size × zoom) and left/top (centre +
// pan). This lets zoom>1 overflow the wrap (clipped by overflow:
// hidden) while click/paint/overlay mapping stays correct, because
// they all read canvas.getBoundingClientRect() which reflects the
// live size+position. No max-width/height (would block zoom>1).
const canvas = document.createElement("canvas");
canvas.style.display = "block";
canvas.style.position = "absolute";
canvas.style.cursor = "crosshair";
canvas.width = 512;
canvas.height = 512;
canvasWrap.appendChild(canvas);
// Placeholder text shown until the first image arrives
const placeholder = document.createElement("div");
placeholder.textContent = "Queue the workflow to generate a preview.\nClick a region in the preview to refine it.";
placeholder.style.position = "absolute";
placeholder.style.inset = "0";
placeholder.style.display = "flex";
placeholder.style.alignItems = "center";
placeholder.style.justifyContent = "center";
placeholder.style.textAlign = "center";
placeholder.style.color = "#888";
placeholder.style.padding = "20px";
placeholder.style.whiteSpace = "pre-line";
placeholder.style.pointerEvents = "none";
placeholder.style.fontSize = "12px";
canvasWrap.appendChild(placeholder);
// Corner minimap navigator — shown only when zoomed in (zoom > 1).
const minimap = document.createElement("canvas");
minimap.style.position = "absolute";
minimap.style.right = "6px";
minimap.style.bottom = "6px";
minimap.style.border = "1px solid rgba(255,255,255,0.35)";
minimap.style.borderRadius = "2px";
minimap.style.background = "rgba(0,0,0,0.4)";
minimap.style.pointerEvents = "none";
minimap.style.display = "none";
minimap.style.zIndex = "5";
canvasWrap.appendChild(minimap);
node._AngeloMinimap = minimap;
// Persistent in-app notice bar — overlays the top of the preview for
// actionable messages (e.g. "SAM 3 Detect isn't installed — run the
// installer"). Stays until dismissed (✕) or a detect succeeds; this is
// NOT a transient toast, because the message needs reading + acting on.
const notice = document.createElement("div");
notice.style.cssText = "position:absolute; left:0; right:0; top:0; z-index:7; display:none; "
+ "padding:8px 28px 8px 10px; background:rgba(150,40,40,0.96); color:#fff; "
+ "font:12px/1.45 Arial,sans-serif; white-space:pre-line; "
+ "border-bottom:1px solid rgba(255,255,255,0.25);";
const noticeText = document.createElement("span");
notice.appendChild(noticeText);
const noticeClose = document.createElement("button");
noticeClose.type = "button";
noticeClose.textContent = "✕";
noticeClose.title = "Dismiss";
noticeClose.style.cssText = "position:absolute; right:5px; top:5px; background:transparent; "
+ "border:none; color:#fff; font-size:14px; line-height:1; cursor:pointer;";
for (const ev of ["pointerdown", "mousedown"]) {
noticeClose.addEventListener(ev, (e) => e.stopPropagation());
}
noticeClose.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); notice.style.display = "none"; });
notice.appendChild(noticeClose);
canvasWrap.appendChild(notice);
node._AngeloNotice = notice;
node._AngeloNoticeText = noticeText;
// Loading overlay — shown while a Detect request is in flight. SAM 3's
// FIRST detect has to build + cache the model (several seconds), during
// which the canvas would otherwise look frozen before the first outline
// appears. Auto-dismisses when the detect resolves; the ✕ is a manual
// escape hatch so a hung/crashed request can never trap the overlay up.
if (!document.getElementById("angelo-spin-style")) {
const s = document.createElement("style");
s.id = "angelo-spin-style";
s.textContent = "@keyframes angelo-spin{to{transform:rotate(360deg)}}";
document.head.appendChild(s);
}
const loading = document.createElement("div");
loading.style.cssText = "position:absolute; inset:0; z-index:8; display:none; "
+ "align-items:center; justify-content:center; background:rgba(0,0,0,0.55);";
const loadingBox = document.createElement("div");
loadingBox.style.cssText = "position:relative; display:flex; align-items:center; gap:11px; "
+ "padding:15px 36px 15px 18px; background:rgba(28,28,28,0.97); color:#fff; "
+ "font:13px/1.4 Arial,sans-serif; border-radius:8px; "
+ "border:1px solid rgba(255,255,255,0.18); box-shadow:0 4px 16px rgba(0,0,0,0.55);";
const spinner = document.createElement("div");
spinner.style.cssText = "flex:0 0 auto; width:16px; height:16px; "
+ "border:2px solid rgba(255,255,255,0.25); border-top-color:#fff; "
+ "border-radius:50%; animation:angelo-spin 0.8s linear infinite;";
const loadingText = document.createElement("span");
loadingText.textContent = "Loading SAM 3…";
loadingBox.appendChild(spinner);
loadingBox.appendChild(loadingText);
const loadingClose = document.createElement("button");
loadingClose.type = "button";
loadingClose.textContent = "✕";
loadingClose.title = "Dismiss";
loadingClose.style.cssText = "position:absolute; right:6px; top:4px; background:transparent; "
+ "border:none; color:#fff; font-size:13px; line-height:1; cursor:pointer;";
for (const ev of ["pointerdown", "mousedown"]) {
loadingClose.addEventListener(ev, (e) => e.stopPropagation());
}
loadingClose.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); loading.style.display = "none"; });
loadingBox.appendChild(loadingClose);
loading.appendChild(loadingBox);
canvasWrap.appendChild(loading);
node._AngeloLoading = loading;
node._AngeloLoadingText = loadingText;
// Vary ×4 chooser — a 2×2 grid of candidate previews over the canvas.
// Click one to commit it (replaces the last attempt); ✕ or Esc keeps
// the current result. Populated by showVaryChooser when a vary run's
// Angelo_vary_previews message arrives.
const varyOverlay = document.createElement("div");
varyOverlay.style.cssText = "position:absolute; inset:0; z-index:9; display:none; "
+ "flex-direction:column; gap:6px; padding:8px; background:rgba(0,0,0,0.8);";
const varyHeader = document.createElement("div");
varyHeader.style.cssText = "display:flex; align-items:center; justify-content:space-between; "
+ "color:#ddd; font:bold 12px Arial,sans-serif;";
const varyTitle = document.createElement("span");
varyTitle.textContent = "Pick a variation — click one (✕ keeps the current result)";
const varyClose = document.createElement("button");
varyClose.type = "button";
varyClose.textContent = "✕";
varyClose.title = "Close without picking — keeps the current result";
varyClose.style.cssText = "background:transparent; border:1px solid #666; border-radius:3px; "
+ "color:#fff; font-size:13px; line-height:1; padding:3px 8px; cursor:pointer;";
for (const ev of ["pointerdown", "mousedown"]) {
varyClose.addEventListener(ev, (e) => e.stopPropagation());
}
varyClose.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
hideVaryChooser(node);
});
varyHeader.appendChild(varyTitle);
varyHeader.appendChild(varyClose);
const varyGrid = document.createElement("div");
varyGrid.style.cssText = "flex:1 1 auto; min-height:0; display:grid; "
+ "grid-template-columns:1fr 1fr; grid-template-rows:1fr 1fr; gap:6px;";
varyOverlay.appendChild(varyHeader);
varyOverlay.appendChild(varyGrid);
// Swallow canvas-bound pointer events so a chooser click can't paint.
for (const ev of ["pointerdown", "mousedown", "click", "wheel"]) {
varyOverlay.addEventListener(ev, (e) => e.stopPropagation());
}
canvasWrap.appendChild(varyOverlay);
node._AngeloVaryOverlay = varyOverlay;
node._AngeloVaryGrid = varyGrid;
// Outpaint review — the extended canvas shown full-size with three
// choices. NOTHING commits until Accept: Try again re-rolls the same
// extension with a fresh seed, Cancel/Esc walks away free. Accepting
// installs the new canvas as a fresh session base (history resets —
// stated right on the button so it's never a surprise).
const opOverlay = document.createElement("div");
opOverlay.style.cssText = "position:absolute; inset:0; z-index:10; display:none; "
+ "flex-direction:column; gap:6px; padding:8px; background:rgba(0,0,0,0.85);";
const opHeader = document.createElement("div");
opHeader.style.cssText = "display:flex; align-items:center; justify-content:space-between; "
+ "color:#ddd; font:bold 12px Arial,sans-serif;";
const opTitle = document.createElement("span");
opTitle.textContent = "Outpaint preview — keep it?";
const opEsc = document.createElement("span");
opEsc.textContent = "Esc = cancel";
opEsc.style.cssText = "font-weight:normal; font-size:10px; color:#8aa;";
opHeader.appendChild(opTitle);
opHeader.appendChild(opEsc);
node._AngeloOutpaintTitle = opTitle;
const opImgWrap = document.createElement("div");
opImgWrap.style.cssText = "flex:1 1 auto; min-height:0; display:flex; "
+ "align-items:center; justify-content:center;";
const opImg = document.createElement("img");
opImg.style.cssText = "max-width:100%; max-height:100%; object-fit:contain; display:block; "
+ "border:1px solid #444; border-radius:3px;";
opImg.draggable = false;
opImgWrap.appendChild(opImg);
const opBtnRow = document.createElement("div");
opBtnRow.style.cssText = "display:flex; justify-content:center; gap:8px;";
const mkOpReviewBtn = (txt, css) => {
const b = document.createElement("button");
b.type = "button";
b.textContent = txt;
b.style.cssText = "font-size:12px; font-weight:bold; padding:5px 14px; border-radius:4px; "
+ "cursor:pointer; " + css;
for (const ev of ["pointerdown", "mousedown"]) {
b.addEventListener(ev, (e) => e.stopPropagation());
}
return b;
};
const opAcceptBtn = mkOpReviewBtn("✓ Accept (new base — history resets)",
"border:1px solid #4a7; background:rgba(30,120,80,0.95); color:#fff;");
opAcceptBtn.title = "Commit the extended canvas as the session's new base image. "
+ "Like loading a new photo: Undo history resets, and Reset / Restore / the \\ compare "
+ "key all anchor to this new canvas.";
const opRetryBtn = mkOpReviewBtn("🎲 Try again",
"border:1px solid rgba(170,130,220,0.9); background:rgba(58,50,72,0.95); color:#ecdcff;");
opRetryBtn.title = "Re-roll the same extension with a fresh seed. Nothing has been committed.";
const opCancelBtn = mkOpReviewBtn("✕ Cancel",
"border:1px solid #555; background:#2a2a2a; color:#ccc;");
opCancelBtn.title = "Walk away — the canvas stays exactly as it was. Nothing was committed.";
opAcceptBtn.addEventListener("click", (e) => {
e.preventDefault(); e.stopPropagation();
hideOutpaintReview(node);
triggerOutpaintAccept(node);
});
opRetryBtn.addEventListener("click", (e) => {
e.preventDefault(); e.stopPropagation();
hideOutpaintReview(node);
triggerOutpaintRetry(node);
});
opCancelBtn.addEventListener("click", (e) => {
e.preventDefault(); e.stopPropagation();
hideOutpaintReview(node);
});
opBtnRow.appendChild(opAcceptBtn);
opBtnRow.appendChild(opRetryBtn);
opBtnRow.appendChild(opCancelBtn);
opOverlay.appendChild(opHeader);
opOverlay.appendChild(opImgWrap);
opOverlay.appendChild(opBtnRow);
for (const ev of ["pointerdown", "mousedown", "click", "wheel"]) {
opOverlay.addEventListener(ev, (e) => e.stopPropagation());
}
canvasWrap.appendChild(opOverlay);
node._AngeloOutpaintOverlay = opOverlay;
node._AngeloOutpaintImg = opImg;
// Per-node view state (zoom/pan). zoom 1 = fit; pan in CSS px.
node._AngeloZoom = 1;
node._AngeloPanX = 0;
node._AngeloPanY = 0;
node._AngeloBaseW = 0;
node._AngeloBaseH = 0;
container.appendChild(canvasWrap);
// --- Zoom / pan (view layer, independent of the refine pipeline) ---
// • Wheel → zoom toward the cursor (clamped 0.25–8×).
// • Middle-drag → pan.
// • Double-middle-click, or 'F' (cursor over node) → reset to fit.
// While zoomed/panned the auto-fit (fitCanvasDisplaySize) is suppressed
// so it never stomps the manual view; reset / new image restore fit.
canvasWrap.addEventListener("wheel", (event) => {
if (!node._AngeloImg || !node._AngeloBaseW) return;
event.preventDefault();
event.stopPropagation(); // don't also zoom the ComfyUI graph
const wrapW = canvasWrap.clientWidth, wrapH = canvasWrap.clientHeight;
const wrapRect = canvasWrap.getBoundingClientRect();
// Convert cursor from viewport pixels to wrap-layout pixels.
// ComfyUI applies a CSS transform: scale() on the graph container
// when the user zooms the graph; getBoundingClientRect() reflects
// that scale (visual pixels) while clientWidth/Height stay at the
// unscaled layout size. Without dividing by the ratio the cursor
// anchor drifts sideways under graph zoom and the image walks off
// on each wheel tick. Thanks to @KursatAs (#23) for the diagnosis.
const graphScaleX = wrapRect.width > 0 ? wrapRect.width / wrapW : 1;
const graphScaleY = wrapRect.height > 0 ? wrapRect.height / wrapH : 1;
const cx = (event.clientX - wrapRect.left) / graphScaleX;
const cy = (event.clientY - wrapRect.top) / graphScaleY;
const oldZoom = node._AngeloZoom || 1;
const factor = event.deltaY < 0 ? 1.15 : (1 / 1.15);
const newZoom = Math.max(0.25, Math.min(8, oldZoom * factor));
if (newZoom === oldZoom) return;
// Wheeling back through ~1× snaps to a clean fit (zoom=1, no pan).
// Without this, float drift leaves zoom at e.g. 1.0000002, which
// keeps the minimap up and the auto-fit suppressed at what looks
// like fit.
if (Math.abs(newZoom - 1) < 0.01) {
resetView(node);
redrawCanvasWithOverlays(node);
return;
}
const baseW = node._AngeloBaseW, baseH = node._AngeloBaseH;
const oldW = baseW * oldZoom, oldH = baseH * oldZoom;
const oldLeft = (wrapW - oldW) / 2 + (node._AngeloPanX || 0);
const oldTop = (wrapH - oldH) / 2 + (node._AngeloPanY || 0);
// Normalised image point currently under the cursor.
const nx = (cx - oldLeft) / oldW;
const ny = (cy - oldTop) / oldH;
const newW = baseW * newZoom, newH = baseH * newZoom;
// Solve pan so that same normalised point stays under the cursor.
node._AngeloZoom = newZoom;
node._AngeloPanX = cx - nx * newW - (wrapW - newW) / 2;
node._AngeloPanY = cy - ny * newH - (wrapH - newH) / 2;
applyView(node);
redrawCanvasWithOverlays(node);
}, { passive: false });
// Suppress the Windows middle-click autoscroll cursor.
canvasWrap.addEventListener("mousedown", (event) => {
if (event.button === 1) event.preventDefault();
});
canvasWrap.addEventListener("pointerdown", (event) => {
if (event.button !== 1) return; // middle button = pan / reset
event.preventDefault();
event.stopPropagation(); // don't let litegraph treat it as a node drag
const now = performance.now();
if (node._AngeloLastMiddleDown && (now - node._AngeloLastMiddleDown) < 350) {
// Double middle-click → reset to fit.
node._AngeloLastMiddleDown = 0;
node._AngeloPanning = null;
resetView(node);
redrawCanvasWithOverlays(node);
return;
}
node._AngeloLastMiddleDown = now;
try { canvasWrap.setPointerCapture(event.pointerId); } catch (e) { /* noop */ }
// clientX/Y deltas are VISUAL px, but _AngeloPanX/Y are LAYOUT px —
// when the ComfyUI graph is zoomed the two differ by the graph scale,
// making the pan run fast/slow (#23's wheel fix, applied to pan).
// Capture the ratio once at drag start.
const wrapRect = canvasWrap.getBoundingClientRect();
node._AngeloPanning = {
startX: event.clientX, startY: event.clientY,
startPanX: node._AngeloPanX || 0, startPanY: node._AngeloPanY || 0,
pointerId: event.pointerId,
scaleX: (canvasWrap.clientWidth > 0 && wrapRect.width > 0)
? wrapRect.width / canvasWrap.clientWidth : 1,
scaleY: (canvasWrap.clientHeight > 0 && wrapRect.height > 0)
? wrapRect.height / canvasWrap.clientHeight : 1,
};
if (node._AngeloCanvas) node._AngeloCanvas.style.cursor = "grabbing";
});
canvasWrap.addEventListener("pointermove", (event) => {
const p = node._AngeloPanning;
if (!p) return;
node._AngeloPanX = p.startPanX + (event.clientX - p.startX) / (p.scaleX || 1);
node._AngeloPanY = p.startPanY + (event.clientY - p.startY) / (p.scaleY || 1);
applyView(node);
});
function endAngeloPan() {
const p = node._AngeloPanning;
if (!p) return;
node._AngeloPanning = null;
try { canvasWrap.releasePointerCapture(p.pointerId); } catch (e) { /* noop */ }
redrawCanvasWithOverlays(node); // restores the mode-appropriate cursor
}
canvasWrap.addEventListener("pointerup", (event) => {
if (event.button === 1) endAngeloPan();
});
canvasWrap.addEventListener("pointercancel", endAngeloPan);
// --- Right-click menu + drag-drop image loading (#7) ---
// Right-click the preview → Open / Copy / Paste. The preview is a DOM
// <canvas>, so we show our own LiteGraph menu here (the node's
// getExtraMenuOptions only fires on the litegraph-rendered node body).
canvasWrap.addEventListener("contextmenu", (event) => {
event.preventDefault();
event.stopPropagation();
_angeloShowImageContextMenu(node, event);
});
// Drag-drop an OS image file onto the node → load it as the base, through
// the SAME resolution popup + upload path as the Load Image button
// (showLoadImagePopup). stopPropagation so the drop doesn't bubble to the
// graph canvas (which would otherwise spawn a LoadImage node).
const _dropHi = () => { container.style.outline = "2px dashed rgba(120,170,220,0.9)"; container.style.outlineOffset = "-2px"; };
const _dropLo = () => { container.style.outline = ""; container.style.outlineOffset = ""; };
container.addEventListener("dragover", (event) => {
const dt = event.dataTransfer;
if (dt && Array.from(dt.items || []).some((i) => i.kind === "file")) {
event.preventDefault();
event.stopPropagation();
dt.dropEffect = "copy";
_dropHi();
}
});
container.addEventListener("dragleave", () => {
// Clear unconditionally; a still-active drag re-adds it via dragover.
_dropLo();
});
container.addEventListener("drop", (event) => {
const dt = event.dataTransfer;
const file = dt && dt.files && Array.from(dt.files).find((f) => f.type && f.type.startsWith("image/"));
_dropLo();
if (!file) return;
event.preventDefault();
event.stopPropagation();
showLoadImagePopup(node, file);
});
// --- Pointer events (instead of mouse*) + pointer capture so long
// drags don't get cancelled when the cursor leaves the canvas
// boundary briefly. Pointer capture routes all subsequent move/
// up events to the canvas until the drag ends. ---
// Helper: clamp event coordinates to the canvas's CSS rect, then
// convert to canvas-intrinsic (= image-pixel) coordinates.
function eventToImagePixel(event) {
const rect = canvas.getBoundingClientRect();
let cx = event.clientX - rect.left;
let cy = event.clientY - rect.top;
// Clamp so off-canvas drag positions still produce valid points
// at the canvas edge rather than negative / oversized values.
cx = Math.max(0, Math.min(rect.width, cx));
cy = Math.max(0, Math.min(rect.height, cy));
const img = node._AngeloImg;
if (!img || !img.naturalWidth || rect.width === 0 || rect.height === 0) {
return null;
}
return {
cssX: cx,
cssY: cy,
pixelX: (cx / rect.width) * img.naturalWidth,
pixelY: (cy / rect.height) * img.naturalHeight,
};
}
canvas.addEventListener("pointermove", (event) => {
const p = eventToImagePixel(event);
if (!p) return;
node._AngeloHover = { x: p.cssX, y: p.cssY };
// Outpaint mode: edges are the direction picker, the interior is
// the protect brush — an active drag stamps protect circles.
if (isOutpaintMode(node)) {
if (node._AngeloOutpaintPainting) {
const prot = node._AngeloProtect = node._AngeloProtect || [];
const last = prot[prot.length - 1];
const r = _brushRadius(node);
if (!last || Math.hypot(last[0] - p.pixelX, last[1] - p.pixelY) > r * 0.3) {
prot.push([p.pixelX, p.pixelY, r]);
}
redrawCanvasWithOverlays(node);
return;
}
// Shift forces the protect brush everywhere — suppresses the
// edge zone so strokes can START on something flush against
// the frame edge (same convention as Detect's Shift brush).
const dir = event.shiftKey ? null : _outpaintEdgeDir(node, p);
if (dir !== node._AngeloOutpaintHoverDir) {
node._AngeloOutpaintHoverDir = dir;
}
redrawCanvasWithOverlays(node);
return;
}
// Detection select mode.
if (node._AngeloDetections && node._AngeloDetections.length) {
// Active touch-up stroke → extend the brush along the drag.
if (node._AngeloTouchup) {
const tu = node._AngeloTouchup;
_brushLine(tu.det, tu.last, [p.pixelX, p.pixelY], _brushRadius(node), tu.subtract);
tu.last = [p.pixelX, p.pixelY];
redrawCanvasWithOverlays(node);
return;
}
// Brush preview while Shift/Alt is held (Refine only).
const brushKey = (event.shiftKey || event.altKey)
&& !isSmartInpaintMode(node) && !isSmartGuidedInpaintMode(node);
if (brushKey) {
node._AngeloBrushPreview = { x: p.pixelX, y: p.pixelY, r: _brushRadius(node), subtract: event.altKey };
canvas.style.cursor = "crosshair";
node._AngeloHoverDet = -1;
redrawCanvasWithOverlays(node);
return;
}
if (node._AngeloBrushPreview) node._AngeloBrushPreview = null;
// Otherwise highlight the candidate under the cursor.
const det = _detAtPoint(node, p.pixelX, p.pixelY);
const idx = det ? node._AngeloDetections.indexOf(det) : -1;
canvas.style.cursor = idx >= 0 ? "pointer" : "default";
if (idx !== node._AngeloHoverDet) {
node._AngeloHoverDet = idx;
redrawCanvasWithOverlays(node);
}
return;
}
if (node._AngeloDraggingRect) {
// Smart Inpaint: update the live opposite-corner of the
// drag-out rectangle as the user moves the cursor.
node._AngeloDraggingRect.x2 = p.pixelX;
node._AngeloDraggingRect.y2 = p.pixelY;
node._AngeloDraggingRect.cssX2 = p.cssX;
node._AngeloDraggingRect.cssY2 = p.cssY;
} else if (node._AngeloPainting) {
const stroke = node._AngeloStroke;
const last = stroke[stroke.length - 1];
// Dedup at 2px (image-pixel space) — saves bandwidth.
if (!last || Math.hypot(last[0] - p.pixelX, last[1] - p.pixelY) > 2) {
stroke.push([p.pixelX, p.pixelY]);
}
}
redrawCanvasWithOverlays(node);
});
canvas.addEventListener("pointerleave", () => {
node._AngeloHover = null;
node._AngeloOutpaintHoverDir = null;
if (node._AngeloBrushPreview) node._AngeloBrushPreview = null;
// IMPORTANT: do NOT cancel an active paint stroke here. With
// pointer capture set on pointerdown, we keep receiving move/up
// events even when the cursor leaves the canvas — long strokes
// can briefly cross the boundary without breaking.
redrawCanvasWithOverlays(node);
if (_AngeloHoveredNode === node) _AngeloHoveredNode = null;
});
// Track keyboard-shortcut hover ownership separately from the
// paint-stroke pointer events above. pointerenter fires when the
// cursor crosses INTO the canvas — at that point this node owns the
// keyboard shortcuts.
canvas.addEventListener("pointerenter", () => {
_AngeloHoveredNode = node;
});
canvas.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
// Detection select mode owns the canvas — let the click confirm a
// candidate; don't start a paint stroke or rectangle drag. EXCEPT a
// Shift/Alt drag, which is the touch-up brush (Refine only): Shift
// adds to the first-overlapped candidate's mask, Alt subtracts.
if (node._AngeloDetections && node._AngeloDetections.length) {
const brushKey = (event.shiftKey || event.altKey)
&& !isSmartInpaintMode(node) && !isSmartGuidedInpaintMode(node);
if (brushKey) {
const pp = eventToImagePixel(event);
if (!pp) return;
const target = _pickTouchupTarget(node, pp.pixelX, pp.pixelY);
if (!target) return;
try { canvas.setPointerCapture(event.pointerId); node._AngeloPointerId = event.pointerId; } catch (e) { /* noop */ }
const subtract = event.altKey;
_ensureEditMask(node, target);
_brushStamp(target, pp.pixelX, pp.pixelY, _brushRadius(node), subtract);
node._AngeloTouchup = { det: target, subtract, last: [pp.pixelX, pp.pixelY] };
node._AngeloBrushPreview = null;
redrawCanvasWithOverlays(node);
}
return;
}
// Outpaint mode: near an edge the click handler owns the extension;
// in the interior, a drag paints the PROTECT brush (areas the
// overlap band must leave frozen).
if (isOutpaintMode(node)) {
// Shift = protect brush wins even in the edge zone.
if (node._AngeloOutpaintHoverDir && !event.shiftKey) return; // edge-click = extend
node._AngeloOutpaintHoverDir = null;
const pp = eventToImagePixel(event);
if (!pp) return;
try {
canvas.setPointerCapture(event.pointerId);
node._AngeloPointerId = event.pointerId;
} catch (e) { /* noop */ }
node._AngeloOutpaintPainting = true;
node._AngeloProtect = node._AngeloProtect || [];
node._AngeloProtect.push([pp.pixelX, pp.pixelY, _brushRadius(node)]);
redrawCanvasWithOverlays(node);
return;
}
// Smart Guided Inpaint has no canvas interaction at all — the
// location comes from the dropdown, the run from the button.
if (isSmartGuidedInpaintMode(node)) return;
const smartInpaint = isSmartInpaintMode(node);
const paintOn = isPaintModeOn(node);
dbg("pointerdown", { smartInpaint, paintModeOn: paintOn });
// Smart Inpaint always owns the canvas — drag-out rectangle.
// It takes priority over paint_mode.
if (!smartInpaint && !paintOn) return;
const p = eventToImagePixel(event);
if (!p) return;
// Pointer capture: route all subsequent pointer events to this
// element until pointerup, regardless of cursor position.
try {
canvas.setPointerCapture(event.pointerId);
node._AngeloPointerId = event.pointerId;
} catch (e) {
dbg("setPointerCapture failed", e);
}
if (smartInpaint) {
node._AngeloDraggingRect = {
x1: p.pixelX, y1: p.pixelY,
x2: p.pixelX, y2: p.pixelY,
cssX1: p.cssX, cssY1: p.cssY,
cssX2: p.cssX, cssY2: p.cssY,
};
} else {
node._AngeloPainting = true;
node._AngeloStroke = [[p.pixelX, p.pixelY]];
}
redrawCanvasWithOverlays(node);
// Don't preventDefault on pointerdown — we still want pointermove
// to fire normally. The subsequent "click" event won't fire if
// any meaningful drag occurred (browser behaviour), and our
// click handler also short-circuits when paint_mode is on.
});
function endPaintStroke(event) {
if (!node._AngeloPainting) return;
node._AngeloPainting = false;
if (node._AngeloPointerId !== undefined) {
try { canvas.releasePointerCapture(node._AngeloPointerId); }
catch (e) { /* already released */ }
node._AngeloPointerId = undefined;
}
const stroke = node._AngeloStroke || [];
node._AngeloStroke = null;
if (stroke.length === 0) {
redrawCanvasWithOverlays(node);
return;
}
dbg("paint stroke submitted", { points: stroke.length });
triggerPaintRefine(node, stroke);
redrawCanvasWithOverlays(node);
}
function endRectDrag() {
if (!node._AngeloDraggingRect) return;
const r = node._AngeloDraggingRect;
node._AngeloDraggingRect = null;
if (node._AngeloPointerId !== undefined) {
try { canvas.releasePointerCapture(node._AngeloPointerId); }
catch (e) { /* already released */ }
node._AngeloPointerId = undefined;
}
const dx = Math.abs(r.x2 - r.x1);
const dy = Math.abs(r.y2 - r.y1);
// Reject degenerate (single-click) drags — Smart Inpaint needs
// an actual rectangle. Threshold in image-pixel space.
if (dx < 8 || dy < 8) {
dbg("rect drag too small — ignored", { dx, dy });
redrawCanvasWithOverlays(node);
return;
}
dbg("smart inpaint rect submitted", r);
triggerRectRefine(node, [r.x1, r.y1, r.x2, r.y2]);
redrawCanvasWithOverlays(node);
}
function endTouchup() {
if (!node._AngeloTouchup) return;
node._AngeloTouchup = null;
if (node._AngeloPointerId !== undefined) {
try { canvas.releasePointerCapture(node._AngeloPointerId); }
catch (e) { /* already released */ }
node._AngeloPointerId = undefined;
}
redrawCanvasWithOverlays(node);
}
function endOutpaintProtect() {
if (!node._AngeloOutpaintPainting) return;
node._AngeloOutpaintPainting = false;
if (node._AngeloPointerId !== undefined) {
try { canvas.releasePointerCapture(node._AngeloPointerId); }
catch (e) { /* already released */ }
node._AngeloPointerId = undefined;
}
syncOutpaintControls(node); // refresh the Clear-protect chip count
redrawCanvasWithOverlays(node);
}
canvas.addEventListener("pointerup", (event) => {
if (event.button !== 0) return;
if (node._AngeloOutpaintPainting) endOutpaintProtect();
else if (node._AngeloTouchup) endTouchup();
else if (node._AngeloDraggingRect) endRectDrag();
else endPaintStroke(event);
});
canvas.addEventListener("pointercancel", (event) => {
if (node._AngeloOutpaintPainting) endOutpaintProtect();
else if (node._AngeloTouchup) endTouchup();
else if (node._AngeloDraggingRect) endRectDrag();
else endPaintStroke(event);
});
// --- Single-click refine (click mode only — paint mode and
// Smart Inpaint handle the canvas via pointer drag above). ---
canvas.addEventListener("click", (event) => {
// Detection select mode owns the click (Refine + Smart Inpaint):
// clicking a candidate edits it and keeps the rest up for more
// edits. Empty-space clicks do nothing (so you can't accidentally
// exit mid-batch) — leave via Cancel Detect / Esc / Space.
if (node._AngeloDetections && node._AngeloDetections.length) {
// Shift/Alt click is the touch-up brush, not a confirm.
if (event.shiftKey || event.altKey) return;
const p = eventToImagePixel(event);
const det = p ? _detAtPoint(node, p.pixelX, p.pixelY) : null;
if (det) confirmDetection(node, det);
return;
}
// Outpaint mode: a click near an edge extends the canvas that way.
// Shift-clicks belong to the protect brush, never the extension.
if (isOutpaintMode(node)) {
if (node._AngeloOutpaintHoverDir && !event.shiftKey) {
triggerOutpaint(node, node._AngeloOutpaintHoverDir);
}
return;
}
if (isSmartGuidedInpaintMode(node)) return; // no canvas interaction
if (isSmartInpaintMode(node)) return; // rectangle-drag owns it
if (isPaintModeOn(node)) return; // paint mode owns the interaction
const rect = canvas.getBoundingClientRect();
const cx = event.clientX - rect.left;
const cy = event.clientY - rect.top;
const img = node._AngeloImg;
if (!img || !img.naturalWidth) {
dbg("click ignored — no image loaded yet");
return;
}
const pixelX = Math.floor((cx / rect.width) * img.naturalWidth);
const pixelY = Math.floor((cy / rect.height) * img.naturalHeight);
dbg("click", { cx, cy, pixelX, pixelY });
triggerRefine(node, pixelX, pixelY, cx, cy);
flashClickOverlay(node, cx, cy);
});
// Add as a DOM widget on the node so LiteGraph manages its layout.
// getMinHeight floors the DOM-widget area; the toolbar now spans 4
// rows so we give the canvas a sensible minimum below it. The canvas
// itself scales to fill whatever space is left (fitCanvasDisplaySize),
// so resizing the node taller just grows the image.
const widget = node.addDOMWidget("Angelo_preview_canvas", "Angelo_canvas", container, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => 320,
});
// Make the preview widget fill the node's full width.
//
// LiteGraph computes a DOM widget's wrapper-element width as
// (widget.width ?? node.width) - 2 * margin
// (see the Vue node renderer). The `?? node.width` fallback gives full
// width ONLY while `widget.width` is unset; once LiteGraph's widget-draw
// path assigns `widget.width` (it sets it to the narrow content-min when
// the node's hidden widgets report ~340), that pinned value wins. So a
// node dragged wide shows a wide frame but the preview collapses to ~340
// on the left, and any relayout (e.g. clicking a control) re-pins it.
// Defining `width` as a getter that always returns the live node width
// keeps the wrapper at full width and ignores the narrow re-pin. Setter is
// a no-op so LiteGraph's assignment can't shrink it again.
try {
Object.defineProperty(widget, "width", {
configurable: true,
enumerable: true,
get() { return node.size ? node.size[0] : undefined; },
set(_v) { /* ignore — width is derived from the node, see above */ },
});
} catch (e) {
dbg("could not pin widget.width getter", e);
}
node._AngeloWidget = widget;
node._AngeloCanvas = canvas;
node._AngeloCanvasWrap = canvasWrap;
node._AngeloPlaceholder = placeholder;
node._AngeloContainer = container;
// Re-fit the canvas whenever its available area changes — node
// resize, area-prompt box show/hide, etc. ResizeObserver fires on
// the wrap's rendered-size changes, which covers all of them.
if (typeof ResizeObserver !== "undefined") {
const ro = new ResizeObserver(() => fitCanvasDisplaySize(node));
ro.observe(canvasWrap);
node._AngeloCanvasResizeObserver = ro;
const onRemoved = node.onRemoved;
node.onRemoved = function () {
try { ro.disconnect(); } catch (e) { /* noop */ }
// Deleting the node mid-fullscreen would otherwise leave the
// overlay (and its detached container) orphaned in the body.
try { if (node._AngeloFSOverlay) exitAngeloFullscreen(node); } catch (e) { /* noop */ }
if (onRemoved) onRemoved.apply(this, arguments);
};
}
fitCanvasDisplaySize(node);
dbg("attached preview canvas to node", node.id);
}
function _angeloIsZoomed(node) {
// Epsilon so float drift near 1.0 doesn't read as "zoomed".
return Math.abs((node._AngeloZoom || 1) - 1) > 1e-3
|| (node._AngeloPanX || 0) !== 0
|| (node._AngeloPanY || 0) !== 0;
}
// Compute the BASE (fit) display size: the canvas size at zoom=1, fitting
// the wrap while preserving aspect ratio. Stored as _AngeloBaseW/H; the
// live size is base × zoom, applied by applyView().
//
// IMPORTANT: when the user has zoomed/panned (zoom != 1 or pan != 0), this
// is a NO-OP — the auto-fit must not stomp on a manual zoom. The view only
// re-fits at the neutral state (e.g. after resetView or a new image).
function fitCanvasDisplaySize(node) {
if (_angeloIsZoomed(node)) return; // never auto-fit while zoomed/panned
const canvas = node._AngeloCanvas;
const wrap = node._AngeloCanvasWrap;
if (!canvas || !wrap) return;
const availW = wrap.clientWidth;
const availH = wrap.clientHeight;
if (availW <= 0 || availH <= 0) return;
const img = node._AngeloImg;
const natW = (img && img.naturalWidth) ? img.naturalWidth : canvas.width;
const natH = (img && img.naturalHeight) ? img.naturalHeight : canvas.height;
if (natW <= 0 || natH <= 0) return;
const scale = Math.min(availW / natW, availH / natH);
node._AngeloBaseW = Math.max(1, Math.floor(natW * scale));
node._AngeloBaseH = Math.max(1, Math.floor(natH * scale));
applyView(node);
}
// Apply the current zoom/pan: size + position the absolutely-placed
// canvas. Display size = base × zoom; positioned centred in the wrap with
// the pan offset added. Then refresh the minimap.
function applyView(node) {
const canvas = node._AngeloCanvas;
const wrap = node._AngeloCanvasWrap;
if (!canvas || !wrap) return;
const baseW = node._AngeloBaseW, baseH = node._AngeloBaseH;
if (!baseW || !baseH) return;
const z = node._AngeloZoom || 1;
const dispW = baseW * z, dispH = baseH * z;
const wrapW = wrap.clientWidth, wrapH = wrap.clientHeight;
const left = (wrapW - dispW) / 2 + (node._AngeloPanX || 0);
const top = (wrapH - dispH) / 2 + (node._AngeloPanY || 0);
canvas.style.width = dispW + "px";
canvas.style.height = dispH + "px";
canvas.style.left = left + "px";
canvas.style.top = top + "px";
updateMinimap(node);
}
// Reset to the neutral fit view (zoom=1, no pan) and re-fit to the node.
function resetView(node) {
node._AngeloZoom = 1;
node._AngeloPanX = 0;
node._AngeloPanY = 0;
fitCanvasDisplaySize(node); // recomputes base + applyView (now neutral)
}
// Draw the corner minimap: full-image thumbnail + a rectangle marking the
// currently-visible viewport. Shown only when zoomed in (zoom > 1).
function updateMinimap(node) {
const mm = node._AngeloMinimap;
const wrap = node._AngeloCanvasWrap;
const img = node._AngeloImg;
if (!mm || !wrap) return;
const z = node._AngeloZoom || 1;
// Epsilon so a near-1.0 (drifted) zoom doesn't flash the minimap.
if (z <= 1.001 || !img || !img.naturalWidth) {
mm.style.display = "none";
return;
}
// Thumbnail size: cap the long edge at 140 px, preserve aspect.
const natW = img.naturalWidth, natH = img.naturalHeight;
const cap = 140;
const mmScale = Math.min(cap / natW, cap / natH);
const mmW = Math.max(1, Math.round(natW * mmScale));
const mmH = Math.max(1, Math.round(natH * mmScale));
if (mm.width !== mmW) mm.width = mmW;
if (mm.height !== mmH) mm.height = mmH;
mm.style.width = mmW + "px";
mm.style.height = mmH + "px";
mm.style.display = "block";
const ctx = mm.getContext("2d");
ctx.clearRect(0, 0, mmW, mmH);
ctx.drawImage(img, 0, 0, mmW, mmH);
// Visible image region (normalised 0..1) = which part of the canvas
// currently lands inside the wrap viewport.
const baseW = node._AngeloBaseW, baseH = node._AngeloBaseH;
const dispW = baseW * z, dispH = baseH * z;
const wrapW = wrap.clientWidth, wrapH = wrap.clientHeight;
const left = (wrapW - dispW) / 2 + (node._AngeloPanX || 0);
const top = (wrapH - dispH) / 2 + (node._AngeloPanY || 0);
const vx0 = Math.max(0, Math.min(1, (0 - left) / dispW));
const vy0 = Math.max(0, Math.min(1, (0 - top) / dispH));
const vx1 = Math.max(0, Math.min(1, (wrapW - left) / dispW));
const vy1 = Math.max(0, Math.min(1, (wrapH - top) / dispH));
ctx.strokeStyle = "rgba(255, 220, 80, 0.95)";
ctx.lineWidth = 1.5;
ctx.strokeRect(vx0 * mmW, vy0 * mmH, (vx1 - vx0) * mmW, (vy1 - vy0) * mmH);
ctx.fillStyle = "rgba(255, 220, 80, 0.12)";
ctx.fillRect(vx0 * mmW, vy0 * mmH, (vx1 - vx0) * mmW, (vy1 - vy0) * mmH);
}
// Area Prompt box: a single textarea below the canvas plus a Pos/Neg
// toggle. The toggle decides which underlying widget the textarea is
// bound to (area_text_positive / area_text_negative). We keep the
// non-edited prompt's value in its widget untouched, so flipping the
// toggle just re-points the textarea at the other widget's text.
function attachAreaPromptBox(node, container) {
node._AngeloAreaPromptTarget = "positive";
const wrap = document.createElement("div");
wrap.style.display = "flex"; // toggled to "none" by syncAreaPromptVisibility
wrap.style.flexDirection = "column";
wrap.style.background = "#222";
wrap.style.borderTop = "1px solid #333";
wrap.style.borderBottom = "1px solid #333";
wrap.style.padding = "4px";
wrap.style.gap = "4px";
// Header row: label + Pos/Neg toggle.
const header = document.createElement("div");
header.style.display = "flex";
header.style.alignItems = "center";
header.style.justifyContent = "space-between";
header.style.gap = "6px";
const label = document.createElement("span");
label.textContent = "Area Prompt";
label.style.color = "#bbb";
label.style.fontSize = "11px";
label.style.userSelect = "none";
header.appendChild(label);
// Prompt slots (#12): six numbered presets, each holding its own
// positive + negative Area Prompt. Click a number to switch — the
// current text is stashed into the outgoing slot first, so nothing
// is ever lost. Persisted (with the active index) in the hidden
// area_prompt_slots widget, so slots survive workflow save/load.
const slotStrip = document.createElement("div");
slotStrip.style.cssText = "display:flex; gap:3px; align-items:center;";
slotStrip.title = "Prompt slots — six independent Area Prompts saved with the workflow. "
+ "Click a number to switch (the current text is kept in its slot). "
+ "E.g. slot 1 = 'mushrooms', slot 2 = 'bones': paint, switch, paint.";
const slotBtns = [];
for (let i = 0; i < _ANGELO_NUM_SLOTS; i++) {
const b = document.createElement("button");
b.type = "button";
b.textContent = String(i + 1);
b.style.cssText = "width:21px; height:19px; padding:0; font-size:10px; font-weight:bold; "
+ "border:1px solid #555; border-radius:3px; background:#2a2a2a; color:#888; "
+ "cursor:pointer; line-height:1; user-select:none;";
for (const ev of ["pointerdown", "mousedown"]) {
b.addEventListener(ev, (e) => e.stopPropagation());
}
b.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
selectPromptSlot(node, i);
});
slotStrip.appendChild(b);
slotBtns.push(b);
}
header.appendChild(slotStrip);
node._AngeloPromptSlotBtns = slotBtns;
const posNegBtn = document.createElement("button");
posNegBtn.textContent = "Positive";
posNegBtn.style.fontSize = "11px";
posNegBtn.style.padding = "2px 8px";
posNegBtn.style.borderRadius = "3px";
posNegBtn.style.border = "1px solid #555";
posNegBtn.style.background = "rgba(30, 120, 80, 0.95)";
posNegBtn.style.color = "#fff";
posNegBtn.style.cursor = "pointer";
posNegBtn.style.userSelect = "none";
posNegBtn.title = "Switch which Area Prompt this box edits. Positive = what to draw; "
+ "Negative = what to avoid (ignored by CFG=1 / distilled models like Klein, kept for others).";
header.appendChild(posNegBtn);
const textarea = document.createElement("textarea");
textarea.placeholder = "Area prompt — describes the masked region (used when Area Prompt is ON).";
textarea.style.width = "100%";
textarea.style.boxSizing = "border-box";
textarea.style.minHeight = "48px";
textarea.style.resize = "vertical";
textarea.style.fontSize = "12px";
textarea.style.fontFamily = "inherit";
textarea.style.padding = "4px";
textarea.style.border = "1px solid #555";
textarea.style.borderRadius = "3px";
textarea.style.background = "#1a1a1a";
textarea.style.color = "#ddd";
// Stop pointer/key events from bubbling to the graph canvas (node
// drag / delete / canvas shortcuts) while editing.
for (const ev of ["pointerdown", "mousedown", "keydown", "keyup", "wheel"]) {
textarea.addEventListener(ev, (e) => e.stopPropagation());
}
const targetWidgetName = () =>
node._AngeloAreaPromptTarget === "negative" ? "area_text_negative" : "area_text_positive";
textarea.addEventListener("input", () => {
const w = findWidget(node, targetWidgetName());
if (w) setWidget(w, textarea.value);
// Mirror into the active prompt slot so slots survive switches
// and workflow saves without a separate "save slot" action.
_angeloPersistActiveSlot(node);
// Keep the Outpaint combined-prompt preview live while typing.
syncOutpaintPromptPreview(node);
});
posNegBtn.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
// Persist current text before switching (input handler already
// did, but be safe), then flip the target and reload.
const curW = findWidget(node, targetWidgetName());
if (curW) setWidget(curW, textarea.value);
node._AngeloAreaPromptTarget =
node._AngeloAreaPromptTarget === "negative" ? "positive" : "negative";
syncAreaPromptBox(node);
});
// "Insert Smart Phrasing" — opens a popup of edit-preservation
// constraints; ticked ones get appended to the active Area Prompt.
const smartBtn = document.createElement("button");
smartBtn.type = "button";
smartBtn.textContent = "Insert Smart Phrasing";
smartBtn.style.cssText = "align-self:flex-start; font-size:11px; padding:3px 10px; "
+ "border:1px solid #555; border-radius:3px; background:#2a2a2a; color:#bbb; cursor:pointer;";
smartBtn.title = "Append edit-preservation phrases (keep lighting / pose / clothes / faces the same) to the Area Prompt above.";
for (const ev of ["pointerdown", "mousedown"]) {
smartBtn.addEventListener(ev, (e) => e.stopPropagation());
}
smartBtn.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
showSmartPhrasingPopup(node);
});
// Location dropdown (Smart Guided Inpaint only) — sits below the
// "Area Prompt" heading and above the textarea. Picks the spatial
// prefix prepended to the prompt at run time.
const locationSelect = makeDropdown("Location", _Angelo_GUIDED_LOCATIONS, (val) => {
const w = findWidget(node, "guided_location");
if (w) setWidget(w, val);
});
locationSelect.title = "Where to place the new content. Prepended to your prompt at run time "
+ "(e.g. 'In the top left of the image, ...'). Smart Guided Inpaint only.";
locationSelect.style.padding = "0";
// "Generate Guided Edit" run button (Smart Guided Inpaint only) —
// there's no click/drag to trigger a run in this mode.
const runBtn = document.createElement("button");
runBtn.type = "button";
runBtn.textContent = "Generate Guided Edit";
runBtn.style.cssText = "align-self:center; font-size:11px; padding:4px 12px; "
+ "border:1px solid #4a7; border-radius:3px; background:rgba(30,120,80,0.95); "
+ "color:#fff; font-weight:bold; cursor:pointer;";
runBtn.title = "Run the whole-image guided edit using the Location + Area Prompt.";
for (const ev of ["pointerdown", "mousedown"]) {
runBtn.addEventListener(ev, (e) => e.stopPropagation());
}
runBtn.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
triggerGuidedRefine(node);
});
// Outpaint prompt review — shown only in Outpaint mode. An order
// selector (instruction first vs my text first; some models weight
// the head of the prompt more) plus a live read-only preview of the
// EXACT combined prompt that will be encoded, so there's no guessing
// what the instruction did to your text.
const opPromptWrap = document.createElement("div");
opPromptWrap.style.cssText = "display:none; flex-direction:column; gap:3px; "
+ "padding:2px 0 0 0;";
const opOrderRow = document.createElement("div");
opOrderRow.style.cssText = "display:flex; align-items:center; gap:6px;";
const opOrderSelect = makeDropdown("Order", ["Instruction first", "My text first"], (val) => {
const w = findWidget(node, "outpaint_instruction_pos");
if (w) setWidget(w, val === "My text first" ? "append" : "prepend");
syncOutpaintPromptPreview(node);
});
opOrderSelect.title = "Where Angelo's extend-the-scene instruction sits relative to your text. "
+ "Instruction first is the default; try 'My text first' if the model is leaning too hard "
+ "on the instruction and under-weighting your description (some models weight the start "
+ "of the prompt more heavily). The preview below shows exactly what gets encoded.";
opOrderRow.appendChild(opOrderSelect);
const opPreviewLabel = document.createElement("span");
opPreviewLabel.textContent = "Final prompt:";
opPreviewLabel.style.cssText = "font-size:10px; color:#8aa;";
opOrderRow.appendChild(opPreviewLabel);
const opPromptPreview = document.createElement("div");
opPromptPreview.style.cssText = "font-size:10px; font-style:italic; color:#9ab; "
+ "background:#1a1a1a; border:1px solid #3a3a3a; border-radius:3px; "
+ "padding:4px 6px; white-space:pre-wrap; word-break:break-word; "
+ "max-height:64px; overflow-y:auto; user-select:text;";
opPromptPreview.title = "The exact prompt the outpaint will encode (direction phrase follows "
+ "the last-used / next-clicked direction).";
for (const ev of ["pointerdown", "mousedown", "wheel"]) {
opPromptPreview.addEventListener(ev, (e) => e.stopPropagation());
}
opPromptWrap.appendChild(opOrderRow);
opPromptWrap.appendChild(opPromptPreview);
node._AngeloOutpaintPromptWrap = opPromptWrap;
node._AngeloOutpaintOrderSelect = opOrderSelect;
node._AngeloOutpaintPromptPreview = opPromptPreview;
wrap.appendChild(header);
wrap.appendChild(locationSelect);
wrap.appendChild(textarea);
wrap.appendChild(opPromptWrap);
wrap.appendChild(smartBtn);
wrap.appendChild(runBtn);
container.appendChild(wrap);
node._AngeloAreaPromptWrap = wrap;
node._AngeloAreaPromptTextarea = textarea;
node._AngeloAreaPromptPosNegBtn = posNegBtn;
node._AngeloAreaPromptLabel = label;
node._AngeloSmartPhrasingBtn = smartBtn;
node._AngeloGuidedLocationSelect = locationSelect;
node._AngeloGuidedRunBtn = runBtn;
syncAreaPromptBox(node);
syncAreaPromptVisibility(node);
}
// Edit-preservation phrases for the Smart Phrasing popup. Adding more
// here automatically adds a checkbox.
const _Angelo_SMART_PHRASES = [
"Keep the lighting the same",
"Keep the pose the same",
"Keep the clothes the same",
"Keep the faces the same",
];
// Append the chosen phrases to the currently-active Area Prompt textarea
// (positive or negative per the Pos/Neg toggle), comma-joined onto any
// existing text. Skips phrases already present (case-insensitive) so
// re-opening the popup doesn't duplicate them. Persists to the widget.
function appendSmartPhrases(node, phrases) {
const textarea = node._AngeloAreaPromptTextarea;
if (!textarea || !phrases.length) return;
const cur = textarea.value.trim();
const lower = cur.toLowerCase();
const fresh = phrases.filter((p) => !lower.includes(p.toLowerCase()));
if (!fresh.length) return;
const addition = fresh.join(", ");
const next = cur ? (cur.replace(/,\s*$/, "") + ", " + addition) : addition;
textarea.value = next;
const wname = node._AngeloAreaPromptTarget === "negative"
? "area_text_negative" : "area_text_positive";
const w = findWidget(node, wname);
if (w) setWidget(w, next);
_angeloPersistActiveSlot(node);
}
// ===== Prompt slots (#12) — six numbered Area Prompt presets ============
// State shape (JSON in the hidden area_prompt_slots widget):
// { active: 0-5, slots: [{pos, neg} × 6] }
// area_text_positive/negative stay the encode source of truth; the slots
// are a UI-level store the active slot mirrors into / out of.
const _ANGELO_NUM_SLOTS = 6;
function _angeloDefaultSlots() {
return {
active: 0,
slots: Array.from({ length: _ANGELO_NUM_SLOTS }, () => ({ pos: "", neg: "" })),
};
}
function _angeloLoadSlots(node) {
const w = findWidget(node, "area_prompt_slots");
let data = null;
try { data = JSON.parse(String(w?.value || "")); } catch (e) { /* fresh */ }
if (!data || !Array.isArray(data.slots) || data.slots.length !== _ANGELO_NUM_SLOTS) {
data = _angeloDefaultSlots();
// Seed slot 1 from whatever the area_text widgets already hold so
// upgrading users keep their existing prompt.
data.slots[0].pos = String(findWidget(node, "area_text_positive")?.value || "");
data.slots[0].neg = String(findWidget(node, "area_text_negative")?.value || "");
}
data.active = Math.max(0, Math.min(_ANGELO_NUM_SLOTS - 1, Number(data.active) || 0));
for (let i = 0; i < _ANGELO_NUM_SLOTS; i++) {
if (!data.slots[i] || typeof data.slots[i] !== "object") data.slots[i] = { pos: "", neg: "" };
data.slots[i].pos = String(data.slots[i].pos || "");
data.slots[i].neg = String(data.slots[i].neg || "");
}
return data;
}
function _angeloSaveSlots(node, data) {
const w = findWidget(node, "area_prompt_slots");
if (w) setWidget(w, JSON.stringify(data));
}
// Stash the current area_text widget values into the active slot.
function _angeloPersistActiveSlot(node) {
const data = _angeloLoadSlots(node);
data.slots[data.active].pos = String(findWidget(node, "area_text_positive")?.value || "");
data.slots[data.active].neg = String(findWidget(node, "area_text_negative")?.value || "");
_angeloSaveSlots(node, data);
syncPromptSlotButtons(node);
}
// Switch the active slot: persist the outgoing slot, load the incoming
// one into the area_text widgets + textarea, restyle the buttons.
function selectPromptSlot(node, idx) {
const data = _angeloLoadSlots(node);
if (idx === data.active) return;
data.slots[data.active].pos = String(findWidget(node, "area_text_positive")?.value || "");
data.slots[data.active].neg = String(findWidget(node, "area_text_negative")?.value || "");
data.active = idx;
const slot = data.slots[idx];
const wp = findWidget(node, "area_text_positive");
const wn = findWidget(node, "area_text_negative");
if (wp) setWidget(wp, slot.pos);
if (wn) setWidget(wn, slot.neg);
_angeloSaveSlots(node, data);
syncAreaPromptBox(node);
syncPromptSlotButtons(node);
}
// Active slot = purple (matches the Area Prompt toggle); slots holding
// text show brighter digits than empty ones so you can see at a glance
// which presets are loaded.
function syncPromptSlotButtons(node) {
const btns = node._AngeloPromptSlotBtns;
if (!btns) return;
const data = _angeloLoadSlots(node);
btns.forEach((b, i) => {
const active = i === data.active;
const filled = !!(data.slots[i].pos || data.slots[i].neg);
b.style.background = active ? "rgba(95, 50, 130, 0.95)" : "#2a2a2a";
b.style.color = active ? "#fff" : (filled ? "#ccc" : "#777");
b.style.borderColor = active ? "rgba(180, 140, 220, 0.9)" : "#555";
});
}
// Body-level modal popups (Smart Phrasing / Load Image / Shrink) normally
// attach to document.body so they overlay the whole page. But while the node is
// in fullscreen, the fullscreen overlay sits ABOVE document.body's popups, so
// they'd be hidden until you exit. Parent them into the overlay instead when
// it's active — the overlay owns the stacking context, so a fixed backdrop
// inside it still covers the viewport and renders above the editor.
function _angeloModalParent(node) {
return (node && node._AngeloFSOverlay) ? node._AngeloFSOverlay : document.body;
}
// Attach a modal backdrop: tags it (so the fullscreen Esc handler can defer
// to it, and exitAngeloFullscreen can rescue it), appends it to the right
// parent, and wires a generic capture-phase Esc that closes the popup. The
// listener self-removes when the backdrop is gone regardless of HOW it was
// closed (button, backdrop click, fullscreen teardown), so nothing leaks.
function _angeloShowModal(node, backdrop) {
backdrop.classList.add("angelo-modal-backdrop");
const onKey = (e) => {
if (!backdrop.isConnected) {
document.removeEventListener("keydown", onKey, true);
return;
}
if (e.key !== "Escape") return;
e.preventDefault();
e.stopImmediatePropagation();
document.removeEventListener("keydown", onKey, true);
backdrop.remove();
};
document.addEventListener("keydown", onKey, true);
_angeloModalParent(node).appendChild(backdrop);
}
function showSmartPhrasingPopup(node) {
const backdrop = document.createElement("div");
backdrop.style.cssText = "position:fixed; inset:0; background:rgba(0,0,0,0.6); "
+ "display:flex; align-items:center; justify-content:center; z-index:10000; "
+ "font-family:Arial,sans-serif;";
const modal = document.createElement("div");
modal.style.cssText = "background:#2a2a2a; color:#ddd; border:1px solid #555; "
+ "border-radius:8px; padding:16px; width:340px; max-width:90vw; "
+ "display:flex; flex-direction:column; gap:8px;";
const header = document.createElement("div");
header.textContent = "Insert Smart Phrasing";
header.style.cssText = "font-size:14px; font-weight:bold; color:#aaa;";
modal.appendChild(header);
const hint = document.createElement("div");
hint.textContent = "Tick the constraints to add to the Area Prompt.";
hint.style.cssText = "font-size:11px; color:#888; margin-bottom:4px;";
modal.appendChild(hint);
const checks = [];
for (const phrase of _Angelo_SMART_PHRASES) {
const row = document.createElement("label");
row.style.cssText = "display:flex; align-items:center; gap:8px; font-size:13px; "
+ "cursor:pointer; padding:3px 2px;";
const cb = document.createElement("input");
cb.type = "checkbox";
const span = document.createElement("span");
span.textContent = phrase;
row.appendChild(cb);
row.appendChild(span);
modal.appendChild(row);
checks.push({ cb, phrase });
}
const footer = document.createElement("div");
footer.style.cssText = "display:flex; justify-content:flex-end; gap:8px; margin-top:8px;";
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
cancelBtn.style.cssText = "background:#444; color:#ddd; border:none; padding:6px 14px; "
+ "border-radius:4px; cursor:pointer;";
const insertBtn = document.createElement("button");
insertBtn.textContent = "Insert";
insertBtn.style.cssText = "background:rgba(30,120,80,0.95); color:#fff; border:none; "
+ "padding:6px 14px; border-radius:4px; cursor:pointer;";
footer.appendChild(cancelBtn);
footer.appendChild(insertBtn);
modal.appendChild(footer);
backdrop.appendChild(modal);
_angeloShowModal(node, backdrop);
const close = () => { if (backdrop.parentNode) backdrop.parentNode.removeChild(backdrop); };
insertBtn.addEventListener("click", () => {
const chosen = checks.filter((c) => c.cb.checked).map((c) => c.phrase);
appendSmartPhrases(node, chosen);
close();
});
cancelBtn.addEventListener("click", close);
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) close(); });
// Esc-to-close is handled generically by _angeloShowModal (its listener
// self-removes with the backdrop — the bespoke one here used to leak).
}
// Reflect the current edit-target widget value into the textarea and
// update the Pos/Neg button styling. Called on creation, on toggle,
// and from the full toolbar sync (workflow load).
function syncAreaPromptBox(node) {
const textarea = node._AngeloAreaPromptTextarea;
const btn = node._AngeloAreaPromptPosNegBtn;
if (!textarea || !btn) return;
const onNeg = node._AngeloAreaPromptTarget === "negative";
const wname = onNeg ? "area_text_negative" : "area_text_positive";
const w = findWidget(node, wname);
const val = w ? String(w.value ?? "") : "";
if (textarea.value !== val) textarea.value = val;
btn.textContent = onNeg ? "Negative" : "Positive";
btn.style.background = onNeg ? "rgba(140, 60, 60, 0.95)" : "rgba(30, 120, 80, 0.95)";
}
// Show the Area Prompt box only when Area Prompt is effectively ON —
// either the area_prompt widget is true, or Smart Inpaint mode forces
// it on. Hiding is display:none only; the text lives in the
// area_text_* widgets and is reloaded by syncAreaPromptBox, so toggling
// visibility never loses what was typed.
function syncAreaPromptVisibility(node) {
const guided = isSmartGuidedInpaintMode(node);
const anySmart = isSmartInpaintMode(node) || guided;
// Smart Phrasing button: both smart modes (both use reference-image
// conditioning, so the "keep X the same" constraints apply).
if (node._AngeloSmartPhrasingBtn) {
node._AngeloSmartPhrasingBtn.style.display = anySmart ? "block" : "none";
}
// Location dropdown + Generate button: Smart Guided Inpaint only.
if (node._AngeloGuidedLocationSelect) {
node._AngeloGuidedLocationSelect.style.display = guided ? "inline-flex" : "none";
}
if (node._AngeloGuidedRunBtn) {
node._AngeloGuidedRunBtn.style.display = guided ? "block" : "none";
}
const wrap = node._AngeloAreaPromptWrap;
if (!wrap) return;
const w = findWidget(node, "area_prompt");
const on = (w && !!w.value) || anySmart;
const next = on ? "flex" : "none";
if (wrap.style.display === next) return; // no change → no reflow
wrap.style.display = next;
// The container's height just changed — nudge LiteGraph to recompute
// the node's reserved space for the DOM widget so the box isn't clipped.
if (node.graph && node.graph.setDirtyCanvas) node.graph.setDirtyCanvas(true, true);
}
function loadIntoCanvas(node, url) {
if (!node._AngeloCanvas) {
attachPreviewCanvas(node);
}
const canvas = node._AngeloCanvas;
const placeholder = node._AngeloPlaceholder;
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const prev = node._AngeloImg;
const sameDims = prev
&& prev.naturalWidth === img.naturalWidth
&& prev.naturalHeight === img.naturalHeight;
node._AngeloImg = img;
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
if (placeholder) placeholder.style.display = "none";
// Tell Python the actual image dimensions so it can derive the
// correct pixel→latent scale per-axis (FLUX 2 is 16×, FLUX 1
// / SDXL are 8×; we don't want to hardcode either).
const wi = findWidget(node, "image_w");
const hi = findWidget(node, "image_h");
if (wi) setWidget(wi, img.naturalWidth);
if (hi) setWidget(hi, img.naturalHeight);
// If this is a refine of the SAME image (same dims) and the user
// is zoomed in, keep their view — so clicking to refine a detail
// doesn't pop them back to fit. A genuinely new image (different
// dims) or the first load resets to fit.
if (sameDims && _angeloIsZoomed(node)) {
applyView(node);
} else {
resetView(node);
}
// If we're in detect mode (candidates persist for batch editing),
// re-overlay them on the freshly-refined preview so they stay put.
if (node._AngeloDetections && node._AngeloDetections.length) {
redrawCanvasWithOverlays(node);
}
// Force a node redraw so LiteGraph re-computes the canvas widget size.
if (node.graph && node.graph.setDirtyCanvas) {
node.graph.setDirtyCanvas(true, false);
}
dbg("image drawn", { w: img.naturalWidth, h: img.naturalHeight });
};
img.onerror = (e) => {
dbg("image load error", e, url);
};
img.src = url;
}
/**
* Redraw the canvas with the underlying image + any active overlays
* (hover ring, paint stroke). Called on every mousemove + on demand
* from the paint handlers. Cheap: ~1 image blit + a few arcs per frame.
*/
function redrawCanvasWithOverlays(node) {
const canvas = node._AngeloCanvas;
if (!canvas) return;
const ctx = canvas.getContext("2d");
// Before/after compare ('\' held): show the BASE image with a badge,
// skip every overlay (read-only peek — no rings / strokes / detections).
if (node._AngeloCompareHold && node._AngeloSourceImg) {
ctx.drawImage(node._AngeloSourceImg, 0, 0, canvas.width, canvas.height);
const fs = Math.max(16, Math.round(canvas.width * 0.022));
const pad = Math.round(fs * 0.55);
ctx.save();
ctx.font = `bold ${fs}px Arial, sans-serif`;
const label = "BEFORE — release \\ for after";
const tw = ctx.measureText(label).width;
ctx.fillStyle = "rgba(0, 0, 0, 0.62)";
ctx.fillRect(pad, pad, tw + pad * 2, fs + pad * 1.4);
ctx.fillStyle = "rgba(255, 220, 80, 0.95)";
ctx.textBaseline = "top";
ctx.fillText(label, pad * 2, pad * 1.7);
ctx.restore();
return;
}
if (node._AngeloImg) {
ctx.drawImage(node._AngeloImg, 0, 0);
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
// Cursor changes by mode — only remaining visual indicator now
// that the corner pills are gone.
if (canvas) {
if (isOutpaintMode(node)) {
canvas.style.cursor = node._AngeloOutpaintHoverDir ? "pointer" : "default";
} else if (isSmartGuidedInpaintMode(node)) {
canvas.style.cursor = "default"; // no canvas interaction
} else if (isSmartInpaintMode(node)) {
canvas.style.cursor = "crosshair";
} else {
canvas.style.cursor = isPaintModeOn(node) ? "cell" : "crosshair";
}
}
// Outpaint mode: a glowing band along the hovered edge previews the
// extension direction (with the amount), then nothing else — no hover
// ring, no strokes, no detections in this mode.
if (isOutpaintMode(node)) {
const dir = node._AngeloOutpaintHoverDir;
if (dir && node._AngeloImg) {
const W = canvas.width, H = canvas.height;
const t = Math.max(24, Math.round(Math.min(W, H) * 0.12));
let x = 0, y = 0, w = W, h = H, glyph = "➡";
if (dir === "left") { w = t; glyph = "⬅"; }
else if (dir === "right") { x = W - t; w = t; glyph = "➡"; }
else if (dir === "up") { h = t; glyph = "⬆"; }
else if (dir === "down") { y = H - t; h = t; glyph = "⬇"; }
ctx.save();
let grad;
if (dir === "left") grad = ctx.createLinearGradient(x, 0, x + w, 0);
else if (dir === "right") grad = ctx.createLinearGradient(x + w, 0, x, 0);
else if (dir === "up") grad = ctx.createLinearGradient(0, y, 0, y + h);
else grad = ctx.createLinearGradient(0, y + h, 0, y);
grad.addColorStop(0, "rgba(120, 190, 235, 0.55)");
grad.addColorStop(1, "rgba(120, 190, 235, 0.0)");
ctx.fillStyle = grad;
ctx.fillRect(x, y, w, h);
const amtW = findWidget(node, "outpaint_amount");
const amt = (amtW && amtW.value) || 256;
const fs = Math.max(16, Math.round(Math.min(W, H) * 0.04));
ctx.font = `bold ${fs}px Arial, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.shadowColor = "rgba(0, 0, 0, 0.85)";
ctx.shadowBlur = 6;
ctx.fillStyle = "rgba(255, 255, 255, 0.95)";
ctx.fillText(`${glyph} +${amt}px`, x + w / 2, y + h / 2);
ctx.restore();
}
// Protect-brush overlay: painted circles in red (these stay frozen
// inside the overlap band), plus a red brush ring at the cursor
// while it's in the interior (i.e. when a drag would paint).
const prot = node._AngeloProtect;
if (prot && prot.length) {
ctx.save();
ctx.fillStyle = "rgba(255, 90, 90, 0.30)";
for (const [px2, py2, pr2] of prot) {
ctx.beginPath();
ctx.arc(px2, py2, pr2, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
if (!dir && node._AngeloHover && node._AngeloImg) {
const rect2 = canvas.getBoundingClientRect();
const sx = rect2.width > 0 ? canvas.width / rect2.width : 1;
const sy = rect2.height > 0 ? canvas.height / rect2.height : 1;
const radiusW = findWidget(node, "click_radius");
ctx.save();
ctx.strokeStyle = "rgba(255, 90, 90, 0.8)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(node._AngeloHover.x * sx, node._AngeloHover.y * sy,
(radiusW && radiusW.value) || 96, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
return;
}
const rect = canvas.getBoundingClientRect();
const scaleX = rect.width > 0 ? canvas.width / rect.width : 1;
const scaleY = rect.height > 0 ? canvas.height / rect.height : 1;
const radiusWidget = findWidget(node, "click_radius");
const radiusPixel = radiusWidget ? radiusWidget.value : 96;
// 1. Paint stroke (if actively painting) — draw the union of brush
// circles so far at 50% opacity (the user wanted it visibly
// blue without fully occluding the underlying image).
if (node._AngeloPainting && node._AngeloStroke?.length) {
ctx.save();
ctx.fillStyle = "rgba(80, 180, 255, 0.5)";
for (const [px, py] of node._AngeloStroke) {
ctx.beginPath();
ctx.arc(px, py, radiusPixel, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
// 1b. Smart Inpaint live rectangle while the user is dragging.
if (node._AngeloDraggingRect) {
const r = node._AngeloDraggingRect;
const x = Math.min(r.x1, r.x2);
const y = Math.min(r.y1, r.y2);
const w = Math.abs(r.x2 - r.x1);
const h = Math.abs(r.y2 - r.y1);
ctx.save();
ctx.fillStyle = "rgba(80, 180, 255, 0.35)";
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = "rgba(80, 180, 255, 1.0)";
ctx.lineWidth = 3;
ctx.strokeRect(x, y, w, h);
ctx.restore();
}
// 2. Hover ring at cursor position (skip during active paint or
// rect drag — those have their own active visuals; skip in
// Smart Inpaint mode entirely since the click_radius circle
// is irrelevant to a rectangle workflow).
if (node._AngeloHover
&& !node._AngeloPainting
&& !node._AngeloDraggingRect
&& !isSmartInpaintMode(node)) {
const px = node._AngeloHover.x * scaleX;
const py = node._AngeloHover.y * scaleY;
ctx.save();
ctx.strokeStyle = "rgba(255, 200, 80, 0.7)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(px, py, radiusPixel, 0, Math.PI * 2);
ctx.stroke();
// Tiny cross-hair at the centre
ctx.strokeStyle = "rgba(255, 200, 80, 0.9)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(px - 6, py); ctx.lineTo(px + 6, py);
ctx.moveTo(px, py - 6); ctx.lineTo(px, py + 6);
ctx.stroke();
ctx.restore();
}
// 3. Detection candidates (SAM 3 / YOLO) awaiting a click-to-confirm.
drawDetections(node, ctx);
}
function flashClickOverlay(node, cx, cy) {
// Draw a fading ring on the canvas at the click point.
// We don't have the click_radius -> pixel scaling on hand here cheaply;
// just draw a 24px ring as visual feedback that the click registered.
const canvas = node._AngeloCanvas;
if (!canvas) return;
const ctx = canvas.getContext("2d");
// Scale the click point from display coords to canvas coords (canvas
// is at natural image size; display is css-scaled).
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const px = cx * scaleX;
const py = cy * scaleY;
const radiusWidget = findWidget(node, "click_radius");
const radiusPixel = radiusWidget ? radiusWidget.value : 96;
let t0 = performance.now();
const tick = (t) => {
const elapsed = t - t0;
const alpha = Math.max(0, 1 - elapsed / 1500);
if (alpha <= 0) return;
// Redraw the underlying image then the ring on top
if (node._AngeloImg) {
ctx.drawImage(node._AngeloImg, 0, 0);
}
ctx.save();
ctx.strokeStyle = `rgba(255, 200, 80, ${alpha})`;
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(px, py, radiusPixel, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = `rgba(255, 220, 120, ${alpha})`;
ctx.beginPath();
ctx.arc(px, py, 6, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
// ============================================================
// Refine trigger — update hidden widgets + queue
// ============================================================
function triggerRefine(node, pixelX, pixelY, displayCX, displayCY) {
const wx = findWidget(node, "click_x");
const wy = findWidget(node, "click_y");
const ws = findWidget(node, "click_seq");
const wr = findWidget(node, "reset");
const wsp = findWidget(node, "stroke_points");
if (!wx || !wy || !ws) {
dbg("ERROR: hidden widgets not found", { wx: !!wx, wy: !!wy, ws: !!ws });
return;
}
setWidget(wx, pixelX);
setWidget(wy, pixelY);
setWidget(ws, ((ws.value || 0) + 1) & 0x7FFFFFFF);
if (wr) setWidget(wr, false);
// Clear stroke_points + seg_polygon so a leftover paint stroke or a
// SAM-detected silhouette from earlier doesn't bleed into a single
// click refine. (seg_polygon persists across Persistent Mask re-rolls
// until a manual action like this resets it.)
if (wsp) setWidget(wsp, "");
const wseg = findWidget(node, "seg_polygon");
if (wseg) setWidget(wseg, "");
const wmaskpng = findWidget(node, "seg_mask_png");
if (wmaskpng) setWidget(wmaskpng, "");
dbg("queueing workflow (click)", { click_x: wx.value, click_y: wy.value, click_seq: ws.value });
queuePrompt(node);
}
function triggerPaintRefine(node, strokePoints) {
const wsp = findWidget(node, "stroke_points");
const ws = findWidget(node, "click_seq");
const wx = findWidget(node, "click_x");
const wy = findWidget(node, "click_y");
const wr = findWidget(node, "reset");
if (!wsp || !ws) {
dbg("ERROR: stroke widgets not found", { wsp: !!wsp, ws: !!ws });
return;
}
// Round to ints to keep the JSON tight. Sub-pixel precision is
// wasted — Python rasterises into latent space anyway.
const compact = strokePoints.map(([x, y]) => [Math.round(x), Math.round(y)]);
setWidget(wsp, JSON.stringify(compact));
setWidget(ws, ((ws.value || 0) + 1) & 0x7FFFFFFF);
// Also set click_x/y to the first stroke point as a fallback target,
// in case anything downstream reads them.
if (wx) setWidget(wx, compact[0][0]);
if (wy) setWidget(wy, compact[0][1]);
if (wr) setWidget(wr, false);
// A paint stroke replaces any SAM-detected silhouette.
const wseg = findWidget(node, "seg_polygon");
if (wseg) setWidget(wseg, "");
const wmaskpng = findWidget(node, "seg_mask_png");
if (wmaskpng) setWidget(wmaskpng, "");
dbg("queueing workflow (paint)", { points: compact.length, click_seq: ws.value });
queuePrompt(node);
}
// Queue the workflow. Pass the initiating node for Angelo-internal action
// queues (a click/undo/re-roll/… on ONE node) — the persistent-mask queue
// hook then leaves every OTHER Angelo node alone, so editing node A can't
// re-run node B's held mask. Call with NO argument for a "real" Queue
// press (the in-node ▶ Queue button), which should bump all holders just
// like ComfyUI's own button.
function queuePrompt(node) {
if (typeof app.queuePrompt === "function") {
app._AngeloQueueInitiator = (node && node.id != null) ? node.id : null;
try {
const ret = app.queuePrompt(0);
if (ret && typeof ret.then === "function") {
ret.catch(e => dbg("queuePrompt promise rejected", e));
}
} finally {
// The hook reads the flag synchronously during app.queuePrompt,
// so it's safe (and important) to clear immediately after.
app._AngeloQueueInitiator = null;
}
} else {
dbg("ERROR: app.queuePrompt is not a function");
}
}
// =====================================================================
// Load Image — bring an external photo in as the base latent.
// Button → file picker → resolution popup → upload (/upload/image) →
// set hidden widgets → queue. run() VAE-encodes the upload into the
// base; Reset/Undo then return to it.
// =====================================================================
// Persistent in-app notice bar (top of the preview). For actionable
// messages that must be read — unlike _angeloToast which auto-hides.
function showAngeloNotice(node, message) {
if (!node._AngeloNotice) return;
node._AngeloNoticeText.textContent = message;
node._AngeloNotice.style.display = "block";
}
function hideAngeloNotice(node) {
if (node._AngeloNotice) node._AngeloNotice.style.display = "none";
}
function showAngeloLoading(node, message) {
if (!node._AngeloLoading) return;
if (message) node._AngeloLoadingText.textContent = message;
node._AngeloLoading.style.display = "flex";
}
function hideAngeloLoading(node) {
if (node._AngeloLoading) node._AngeloLoading.style.display = "none";
}
function _angeloToast(message) {
const t = document.createElement("div");
t.textContent = message;
t.style.cssText = "position:fixed; top:20px; right:20px; background:#333; color:#fff; "
+ "padding:10px 16px; border-radius:6px; z-index:100000; font:13px Arial,sans-serif; "
+ "box-shadow:0 2px 8px rgba(0,0,0,0.5); opacity:0; transition:opacity 0.18s;";
document.body.appendChild(t);
requestAnimationFrame(() => { t.style.opacity = "1"; });
setTimeout(() => { t.style.opacity = "0"; setTimeout(() => t.remove(), 220); }, 1600);
}
function triggerLoadImage(node) {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.style.display = "none";
input.addEventListener("change", () => {
const file = input.files && input.files[0];
if (file) showLoadImagePopup(node, file);
input.remove();
});
document.body.appendChild(input);
input.click();
}
// ---- Right-click image actions (#7) -------------------------------------
// Copy / Open, like ComfyUI's image nodes. Loading an image is via drag-drop
// onto the node, the Load Image button, or Ctrl+V / Cmd+V while hovering
// the preview (the window-level paste handler near the top of this file).
function _angeloOpenImageInTab(node) {
let url = null;
if (node._AngeloPreviewRef) url = makeViewUrl(node._AngeloPreviewRef);
else if (node._AngeloImg && node._AngeloImg.src) url = node._AngeloImg.src;
if (url) window.open(url, "_blank");
else _angeloToast("No image to open yet");
}
async function _angeloCopyImageToClipboard(node) {
const img = node._AngeloImg;
if (!img) { _angeloToast("No image to copy yet"); return; }
if (!navigator.clipboard || !window.ClipboardItem) {
_angeloToast("Clipboard image copy not supported in this browser");
return;
}
try {
const c = document.createElement("canvas");
c.width = img.naturalWidth || img.width;
c.height = img.naturalHeight || img.height;
c.getContext("2d").drawImage(img, 0, 0);
const blob = await new Promise((res) => c.toBlob(res, "image/png"));
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
_angeloToast("Image copied to clipboard");
} catch (e) {
dbg("[Angelo] copy image failed", e);
_angeloToast("Copy failed — see console");
}
}
function _angeloShowImageContextMenu(node, event) {
const hasImg = !!node._AngeloImg;
const items = [
{ content: "Open image in new tab", disabled: !hasImg,
callback: () => _angeloOpenImageInTab(node) },
{ content: "Copy image", disabled: !hasImg,
callback: () => _angeloCopyImageToClipboard(node) },
];
const LG = window.LiteGraph;
if (LG && LG.ContextMenu) {
const menu = new LG.ContextMenu(items, { event, title: "Angelo" });
// In fullscreen the overlay sits at z-index 99990 — above the stock
// litecontextmenu z-index — so lift the menu root or it renders
// invisibly behind the overlay.
if (node._AngeloFSOverlay && menu && menu.root) {
menu.root.style.zIndex = "100001";
}
}
}
async function _uploadLoadedImage(node, file, mode, mp) {
const fd = new FormData();
fd.append("image", file, file.name);
fd.append("overwrite", "false");
_angeloToast("Uploading image…");
let data;
try {
const res = await api.fetchApi("/upload/image", { method: "POST", body: fd });
if (!res.ok) { _angeloToast("Upload failed"); return; }
data = await res.json();
} catch (e) {
dbg("[Angelo] upload failed", e);
_angeloToast("Upload failed — see console");
return;
}
const ref = JSON.stringify({
name: data.name,
subfolder: data.subfolder || "",
type: data.type || "input",
});
const wImg = findWidget(node, "loaded_image");
const wMode = findWidget(node, "loaded_resize_mode");
const wMp = findWidget(node, "loaded_target_mp");
const wSeq = findWidget(node, "loaded_image_seq");
if (wImg) setWidget(wImg, ref);
if (wMode) setWidget(wMode, mode);
if (wMp) setWidget(wMp, mp);
if (wSeq) setWidget(wSeq, ((wSeq.value || 0) + 1) & 0x7FFFFFFF);
// Loading a photo means you want to EDIT it — flip to Edit Mode so
// the queue refines/previews the loaded base instead of regenerating
// it from noise (Sampler Mode at denoise 1.0). Fires the wrapped mode
// callback (lock + grey sync); refresh the Mode dropdown to match.
const wNodeMode = findWidget(node, "mode");
if (wNodeMode && String(wNodeMode.value) !== "Edit Mode") {
setWidget(wNodeMode, "Edit Mode");
syncModeSelect(node);
}
syncLoadImageControls(node); // reveal the Unload button
_angeloToast("Loading as base…");
queuePrompt(node);
}
// Clear the loaded image → the wired latent input takes over as base.
function unloadImage(node) {
const wImg = findWidget(node, "loaded_image");
if (wImg) setWidget(wImg, "");
syncLoadImageControls(node);
_angeloToast("Unloaded — using latent input");
queuePrompt(node);
}
// Show the Unload button only while an image is loaded.
function syncLoadImageControls(node) {
const btn = node._AngeloUnloadImageBtn;
if (!btn) return;
const w = findWidget(node, "loaded_image");
const active = !!(w && String(w.value || "").trim());
btn.style.display = active ? "" : "none";
}
// =====================================================================
// Detect (SAM 3 auto-segment): text → /angelo/detect → highlighted
// candidates on the canvas → click-to-confirm → mask per mode → refine.
// Refine uses the silhouette polygons; Smart Inpaint uses the bbox.
// =====================================================================
async function runDetect(node, conceptOverride) {
// conceptOverride (from a quick-select preset) runs that term directly
// WITHOUT touching the text box; otherwise use what the user typed.
const text = (conceptOverride != null && String(conceptOverride).trim())
? String(conceptOverride).trim()
: (node._AngeloDetectText?.value || "").trim();
if (!text) { showAngeloNotice(node, "Type what to segment first (e.g. \"the face\")."); return; }
const ref = node._AngeloPreviewRef;
if (!ref || !ref.filename) { showAngeloNotice(node, "Generate or load an image first, then Detect."); return; }
const confEl = node._AngeloDetectConf && node._AngeloDetectConf._AngeloInput;
const conf = confEl ? Math.max(0.05, Math.min(0.95, parseFloat(confEl.value) || 0.3)) : 0.3;
hideAngeloNotice(node); // clear any prior error before a new attempt
// In-app overlay (NOT a toast) while the request is in flight — the
// first detect builds the SAM 3 model and can take several seconds.
showAngeloLoading(node, "Loading SAM 3…");
try {
const res = await api.fetchApi("/angelo/detect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
method: "sam3_text",
text,
confidence_threshold: conf,
filename: ref.filename,
subfolder: ref.subfolder || "",
type: ref.type || "temp",
}),
});
const data = await res.json();
if (!res.ok || data.error) {
dbg("[Angelo] detect error", data);
// Persistent in-app notice — esp. the "SAM 3 not installed →
// run the installer" message, which needs reading + acting on.
showAngeloNotice(node, data.error || `Detect failed (HTTP ${res.status}).`);
return;
}
const dets = data.detections || [];
node._AngeloDetections = dets;
node._AngeloHoverDet = -1;
node._AngeloEditedDets = new Set(); // which candidates have been edited
node._AngeloMaskGrow = 0; // each detect starts at no grow
_syncMaskGrowReadout(node);
syncDetectModeButton(node);
if (!dets.length) {
// Nothing highlights, so say so where it can be read + acted on.
showAngeloNotice(node, "No matches — try a lower Conf or different words.");
}
redrawCanvasWithOverlays(node);
} catch (e) {
dbg("[Angelo] detect failed", e);
showAngeloNotice(node, "Detect request failed — is the ComfyUI server reachable? See the console.");
} finally {
hideAngeloLoading(node); // self-dismiss on every exit path
}
}
// ===== Fix All — sequential auto-edit of every remaining candidate =====
// State: node._AngeloFixAll = { queue: [det indices], total }. The loop is
// event-driven: confirmDetection queues a run; when that run's onExecuted
// arrives, _angeloFixAllStep fires the next candidate. Stopping (button,
// Cancel Detect, Esc) clears the state — the in-flight candidate still
// lands, nothing after it fires.
function toggleFixAll(node) {
if (node._AngeloFixAll) {
_angeloFixAllFinish(node, "stopped");
return;
}
const dets = node._AngeloDetections || [];
const edited = node._AngeloEditedDets || new Set();
const queue = dets.map((_, i) => i).filter((i) => !edited.has(i));
if (!queue.length) {
_angeloToast("Nothing left to fix — every candidate is already done");
return;
}
node._AngeloFixAll = { queue, total: queue.length };
_angeloFixAllUpdateButton(node);
_angeloFixAllStep(node);
}
function _angeloFixAllStep(node) {
const fa = node._AngeloFixAll;
if (!fa) return;
const dets = node._AngeloDetections;
if (!dets || !dets.length || !fa.queue.length) {
_angeloFixAllFinish(node, "complete");
return;
}
const idx = fa.queue.shift();
const det = dets[idx];
if (!det) {
_angeloFixAllStep(node);
return;
}
_angeloFixAllUpdateButton(node);
confirmDetection(node, det);
}
function _angeloFixAllUpdateButton(node) {
const btn = node._AngeloFixAllBtn;
if (!btn) return;
const fa = node._AngeloFixAll;
if (!fa) {
btn.textContent = "⚡ Fix All";
btn.style.background = "rgba(30,120,80,0.95)";
btn.style.borderColor = "#4a7";
return;
}
const done = fa.total - fa.queue.length;
btn.textContent = `■ Stop (${done}/${fa.total})`;
btn.style.background = "rgba(150,60,40,0.95)";
btn.style.borderColor = "#e96";
}
function _angeloFixAllFinish(node, why) {
if (!node._AngeloFixAll) return;
node._AngeloFixAll = null;
_angeloFixAllUpdateButton(node);
_angeloToast(why === "complete" ? "Fix All complete ✓" : "Fix All stopped");
}
function clearDetections(node) {
if (!node._AngeloDetections) return;
// Leaving detect mode aborts a running Fix All (silently — the toast
// would be noise when the user explicitly cancelled).
if (node._AngeloFixAll) {
node._AngeloFixAll = null;
_angeloFixAllUpdateButton(node);
}
node._AngeloDetections = null; // candidate objects (+ their _editMask) drop here
node._AngeloHoverDet = -1;
node._AngeloEditedDets = null;
node._AngeloTouchup = null;
node._AngeloBrushPreview = null;
// Reset the highlight opacity to default so the next detect starts full.
node._AngeloDetOpacity = 1.0;
if (node._AngeloDetOpacitySlider) node._AngeloDetOpacitySlider.value = "1";
node._AngeloMaskGrow = 0;
_syncMaskGrowReadout(node);
syncDetectModeButton(node);
redrawCanvasWithOverlays(node);
}
// Show the floating detect-mode panel (Cancel + opacity slider) while
// candidates are active.
function syncDetectModeButton(node) {
const panel = node._AngeloDetectPanel;
if (!panel) return;
panel.style.display = (node._AngeloDetections && node._AngeloDetections.length) ? "flex" : "none";
}
// Topmost (tightest) detection whose bbox contains the image-pixel point.
function _detAtPoint(node, px, py) {
const dets = node._AngeloDetections || [];
let best = null, bestArea = Infinity;
for (const d of dets) {
const b = _detBbox(node, d);
if (!b) continue;
if (px >= b[0] && px <= b[2] && py >= b[1] && py <= b[3]) {
const area = Math.max(1, (b[2] - b[0]) * (b[3] - b[1]));
if (area < bestArea) { bestArea = area; best = d; }
}
}
return best;
}
function confirmDetection(node, det) {
if (isSmartGuidedInpaintMode(node)) return;
const ws = findWidget(node, "click_seq");
if (!ws) return;
const wx = findWidget(node, "click_x");
const wy = findWidget(node, "click_y");
const wr = findWidget(node, "reset");
const wsp = findWidget(node, "stroke_points");
const wrp = findWidget(node, "rect_points");
const wseg = findWidget(node, "seg_polygon");
const wmask = findWidget(node, "seg_mask_png");
const smart = isSmartInpaintMode(node);
// Apply the current Mask grow/shrink to the committed shape (the same
// offset that's being drawn), so what you edit matches what you see.
const b = _detBbox(node, det) || [0, 0, 0, 0];
if (wx) setWidget(wx, Math.round((b[0] + b[2]) / 2));
if (wy) setWidget(wy, Math.round((b[1] + b[3]) / 2));
if (wr) setWidget(wr, false);
if (wsp) setWidget(wsp, "");
if (smart) {
// Smart Inpaint: the (grown) bbox is the rectangle. (No touch-up
// brush in Smart Inpaint — it's Refine-only.)
if (wrp) setWidget(wrp, JSON.stringify([[
Math.round(b[0]), Math.round(b[1]), Math.round(b[2]), Math.round(b[3]),
]]));
if (wseg) setWidget(wseg, "");
if (wmask) setWidget(wmask, "");
} else if (det._editMask && wmask) {
// Refine + brushed: send the raster edit-mask (handles brushed holes /
// additions a polygon can't), and clear the polygon path.
const png = det._editMask.toDataURL("image/png").split(",")[1] || "";
setWidget(wmask, png);
if (wseg) setWidget(wseg, "");
if (wrp) setWidget(wrp, "");
} else {
// Refine: the (grown) silhouette polygons are the mask.
if (wseg) setWidget(wseg, JSON.stringify(_detPolys(node, det)));
if (wmask) setWidget(wmask, "");
if (wrp) setWidget(wrp, "");
}
setWidget(ws, ((ws.value || 0) + 1) & 0x7FFFFFFF);
// Keep the candidates up (batch editing): mark this one edited (drawn
// green) and leave the rest clickable. Exit via Cancel / Esc / Space.
const idx = node._AngeloDetections ? node._AngeloDetections.indexOf(det) : -1;
if (idx >= 0) {
node._AngeloEditedDets = node._AngeloEditedDets || new Set();
node._AngeloEditedDets.add(idx);
}
redrawCanvasWithOverlays(node); // immediate feedback: clicked one turns green
queuePrompt(node);
}
// Offset a closed polygon outward (delta>0) or inward (delta<0) by ~|delta|
// pixels, perpendicular to its edges. Miter join with a spike clamp; the
// outward direction is fixed per-vertex via the centroid so winding order
// doesn't matter. flat = [x0,y0,x1,y1,...] in image-pixel coords.
function _offsetPolygon(flat, delta) {
const n = flat.length >> 1;
if (n < 3 || !delta) return flat.slice();
const P = [];
for (let i = 0; i < n; i++) P.push([flat[2 * i], flat[2 * i + 1]]);
let cx = 0, cy = 0;
for (const [x, y] of P) { cx += x; cy += y; }
cx /= n; cy /= n;
const out = new Array(n * 2);
for (let i = 0; i < n; i++) {
const prev = P[(i - 1 + n) % n], cur = P[i], next = P[(i + 1) % n];
let e1x = cur[0] - prev[0], e1y = cur[1] - prev[1];
let e2x = next[0] - cur[0], e2y = next[1] - cur[1];
const l1 = Math.hypot(e1x, e1y) || 1, l2 = Math.hypot(e2x, e2y) || 1;
e1x /= l1; e1y /= l1; e2x /= l2; e2y /= l2;
// Unit edge normals (one consistent side); miter = their sum.
const n1x = -e1y, n1y = e1x, n2x = -e2y, n2y = e2x;
let mx = n1x + n2x, my = n1y + n2y;
let ml = Math.hypot(mx, my);
if (ml < 1e-6) { mx = n1x; my = n1y; ml = Math.hypot(mx, my) || 1; }
mx /= ml; my /= ml;
let cosA = Math.abs(mx * n1x + my * n1y); // cos(half-angle)
if (cosA < 0.25) cosA = 0.25; // clamp miter spike at sharp corners
const len = Math.abs(delta) / cosA;
// Orient the miter outward (away from centroid), then push out/in.
if (mx * (cur[0] - cx) + my * (cur[1] - cy) < 0) { mx = -mx; my = -my; }
const s = delta >= 0 ? 1 : -1;
out[2 * i] = cur[0] + mx * len * s;
out[2 * i + 1] = cur[1] + my * len * s;
}
return out;
}
// The detection's polygons / bbox AS DISPLAYED + COMMITTED, with the
// current mask grow/shrink applied. grow==0 returns the originals.
function _detPolys(node, det) {
const g = node._AngeloMaskGrow || 0;
const polys = det.polygons || [];
if (!g) return polys;
return polys.map((p) => (p && p.length >= 6) ? _offsetPolygon(p, g) : p);
}
function _detBbox(node, det) {
if (!det.bbox) return det.bbox;
const g = node._AngeloMaskGrow || 0;
let b = det.bbox;
if (g) {
const img = node._AngeloImg;
const W = (img && img.naturalWidth) ? img.naturalWidth : 1e9;
const H = (img && img.naturalHeight) ? img.naturalHeight : 1e9;
b = [
Math.max(0, b[0] - g), Math.max(0, b[1] - g),
Math.min(W, b[2] + g), Math.min(H, b[3] + g),
];
}
// Include any brushed extent so the whole touched shape is hit-testable.
const e = det._editBounds;
if (e) {
b = [Math.min(b[0], e[0]), Math.min(b[1], e[1]), Math.max(b[2], e[2]), Math.max(b[3], e[3])];
}
return b;
}
function _syncMaskGrowReadout(node) {
const r = node._AngeloMaskGrowReadout;
if (!r) return;
const g = node._AngeloMaskGrow || 0;
r.textContent = (g > 0 ? "+" : "") + g + "px";
}
// +/- buttons: nudge the grow value and re-draw every highlight together.
function adjustMaskGrow(node, delta) {
if (!node._AngeloDetections || !node._AngeloDetections.length) return;
const cur = node._AngeloMaskGrow || 0;
node._AngeloMaskGrow = Math.max(-40, Math.min(200, cur + delta));
_syncMaskGrowReadout(node);
redrawCanvasWithOverlays(node);
}
// ===== Touch-up brush (Refine only) — Shift-drag adds to a detected mask,
// Alt-drag subtracts (holes allowed). Once brushed, a detection holds
// a raster edit-mask (`_editMask`, an offscreen canvas at image res,
// white = masked) that is its source of truth for display + commit. =====
function _brushRadius(node) {
const w = findWidget(node, "click_radius");
return Math.max(2, (w && Number(w.value)) || 96);
}
// Lazily promote a detection to a raster edit-mask, seeded from its current
// (grown) silhouette so brushing starts from exactly what's on screen.
function _ensureEditMask(node, det) {
if (det._editMask) return det._editMask;
const img = node._AngeloImg;
const W = (img && img.naturalWidth) || 512;
const H = (img && img.naturalHeight) || 512;
const c = document.createElement("canvas");
c.width = W; c.height = H;
const cx = c.getContext("2d");
cx.fillStyle = "#fff";
for (const poly of _detPolys(node, det)) {
if (!poly || poly.length < 6) continue;
cx.beginPath();
cx.moveTo(poly[0], poly[1]);
for (let k = 2; k < poly.length - 1; k += 2) cx.lineTo(poly[k], poly[k + 1]);
cx.closePath();
cx.fill();
}
det._editMask = c;
return c;
}
function _brushStamp(det, px, py, radius, subtract) {
const cx = det._editMask.getContext("2d");
cx.save();
cx.globalCompositeOperation = subtract ? "destination-out" : "source-over";
cx.fillStyle = "#fff";
cx.beginPath();
cx.arc(px, py, Math.max(1, radius), 0, Math.PI * 2);
cx.fill();
cx.restore();
// Grow the hit-box to cover added paint so the whole brushed shape stays
// hoverable / clickable even where it extends past the original bbox.
if (!subtract) {
const nb = [px - radius, py - radius, px + radius, py + radius];
const b = det._editBounds;
det._editBounds = b
? [Math.min(b[0], nb[0]), Math.min(b[1], nb[1]), Math.max(b[2], nb[2]), Math.max(b[3], nb[3])]
: nb;
}
}
// Stamp circles along a segment so a fast drag leaves a continuous stroke.
function _brushLine(det, a, b, radius, subtract) {
const dx = b[0] - a[0], dy = b[1] - a[1];
const dist = Math.hypot(dx, dy);
const n = Math.max(1, Math.ceil(dist / Math.max(1, radius * 0.4)));
for (let i = 1; i <= n; i++) {
const t = i / n;
_brushStamp(det, a[0] + dx * t, a[1] + dy * t, radius, subtract);
}
}
// Which candidate a brush stroke acts on: the one under the cursor (smallest
// bbox), else the nearest by bbox-centre so a stroke starting just outside a
// mask still attaches to it.
function _pickTouchupTarget(node, px, py) {
const dets = node._AngeloDetections || [];
if (!dets.length) return null;
const hit = _detAtPoint(node, px, py);
if (hit) return hit;
let best = null, bestD = Infinity;
for (const d of dets) {
const b = _detBbox(node, d);
if (!b) continue;
const dxc = (b[0] + b[2]) / 2 - px, dyc = (b[1] + b[3]) / 2 - py;
const dist = Math.hypot(dxc, dyc);
if (dist < bestD) { bestD = dist; best = d; }
}
return best;
}
// Reusable scratch canvas for tinting a raster edit-mask in a candidate's
// colour before compositing it onto the overlay.
let _angeloScratch = null;
function _getScratch(w, h) {
if (!_angeloScratch) _angeloScratch = document.createElement("canvas");
if (_angeloScratch.width !== w) _angeloScratch.width = w;
if (_angeloScratch.height !== h) _angeloScratch.height = h;
return _angeloScratch;
}
function _drawTintedMask(ctx, det, color, fillAlpha) {
const m = det._editMask;
const W = m.width, H = m.height;
const s = _getScratch(W, H);
const sx = s.getContext("2d");
sx.clearRect(0, 0, W, H);
sx.globalCompositeOperation = "source-over";
sx.fillStyle = color;
sx.fillRect(0, 0, W, H);
sx.globalCompositeOperation = "destination-in";
sx.drawImage(m, 0, 0);
const prev = ctx.globalAlpha;
ctx.globalAlpha = prev * fillAlpha;
ctx.drawImage(s, 0, 0, W, H);
ctx.globalAlpha = prev;
}
// Draw candidate outlines (called from redrawCanvasWithOverlays, image-px ctx).
function drawDetections(node, ctx) {
const dets = node._AngeloDetections;
if (!dets || !dets.length) return;
const smart = isSmartInpaintMode(node);
ctx.save();
// Selection-highlight opacity (the floating slider) scales the whole
// overlay so the user can dim it and inspect the edited region's edges.
ctx.globalAlpha = (typeof node._AngeloDetOpacity === "number") ? node._AngeloDetOpacity : 1;
const edited = node._AngeloEditedDets;
dets.forEach((d, i) => {
const hot = (i === node._AngeloHoverDet);
const done = edited && edited.has(i);
let tint; // solid colour for the raster-mask fill
if (hot) {
ctx.lineWidth = 4;
ctx.strokeStyle = "rgba(255, 220, 80, 1.0)";
ctx.fillStyle = "rgba(255, 220, 80, 0.25)";
tint = "rgb(255, 220, 80)";
} else if (done) {
// already edited this session — green so the user can track progress
ctx.lineWidth = 2;
ctx.strokeStyle = "rgba(90, 220, 120, 0.95)";
ctx.fillStyle = "rgba(90, 220, 120, 0.18)";
tint = "rgb(90, 220, 120)";
} else {
ctx.lineWidth = 2;
ctx.strokeStyle = "rgba(80, 200, 255, 0.9)";
ctx.fillStyle = "rgba(80, 200, 255, 0.15)";
tint = "rgb(80, 200, 255)";
}
if (!smart && d._editMask) {
// Brushed candidate: its raster edit-mask is the source of truth.
// A raster has no outline to thicken on hover like the polygon
// candidates, so instead brighten the fill + add a glow so the
// hovered one still pops clearly.
if (hot) {
ctx.save();
ctx.shadowColor = tint;
ctx.shadowBlur = 16;
_drawTintedMask(ctx, d, tint, 0.55);
ctx.restore();
} else {
_drawTintedMask(ctx, d, tint, 0.30);
}
} else if (smart) {
const b = _detBbox(node, d);
if (b) {
ctx.beginPath();
ctx.rect(b[0], b[1], b[2] - b[0], b[3] - b[1]);
ctx.fill(); ctx.stroke();
}
} else {
for (const poly of _detPolys(node, d)) {
if (!poly || poly.length < 6) continue;
ctx.beginPath();
ctx.moveTo(poly[0], poly[1]);
for (let k = 2; k < poly.length - 1; k += 2) ctx.lineTo(poly[k], poly[k + 1]);
ctx.closePath();
ctx.fill(); ctx.stroke();
}
}
});
// Touch-up brush preview — a circle at the cursor while Shift/Alt is held
// (green = add, red = subtract).
const bp = node._AngeloBrushPreview;
if (bp) {
ctx.globalAlpha = (typeof node._AngeloDetOpacity === "number") ? node._AngeloDetOpacity : 1;
ctx.lineWidth = 2;
ctx.strokeStyle = bp.subtract ? "rgba(255, 90, 90, 0.95)" : "rgba(90, 230, 130, 0.95)";
ctx.fillStyle = bp.subtract ? "rgba(255, 90, 90, 0.12)" : "rgba(90, 230, 130, 0.12)";
ctx.beginPath();
ctx.arc(bp.x, bp.y, bp.r, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
}
ctx.restore();
}
// Show the Detect row only in Edit Mode's masked sub-modes (Refine +
// Smart Inpaint); hide in Smart Guided (no mask) and Sampler Mode.
function syncDetectControls(node) {
const row = node._AngeloDetectRow;
if (!row) return;
const modeW = findWidget(node, "mode");
const inEdit = modeW && String(modeW.value) === "Edit Mode";
const show = inEdit && !isSmartGuidedInpaintMode(node) && !isOutpaintMode(node);
// Must restore "flex" (not "") — an empty string reverts the row to a
// <div>'s default display:block, which kills flex-wrap:nowrap and the
// separator's align-self:stretch (dropdown drops to a new line, sep
// vanishes).
row.style.display = show ? "flex" : "none";
if (!show) clearDetections(node);
}
function showLoadImagePopup(node, file) {
const backdrop = document.createElement("div");
backdrop.style.cssText = "position:fixed; inset:0; background:rgba(0,0,0,0.6); "
+ "display:flex; align-items:center; justify-content:center; z-index:10000; "
+ "font-family:Arial,sans-serif;";
const modal = document.createElement("div");
modal.style.cssText = "background:#2a2a2a; color:#ddd; border:1px solid #555; "
+ "border-radius:8px; padding:16px; width:360px; max-width:90vw; "
+ "display:flex; flex-direction:column; gap:10px;";
const header = document.createElement("div");
header.textContent = "Load Image — resolution";
header.style.cssText = "font-size:14px; font-weight:bold; color:#aaa;";
modal.appendChild(header);
const hint = document.createElement("div");
hint.textContent = `"${file.name}" — both options round dimensions to a multiple of 16.`;
hint.style.cssText = "font-size:11px; color:#888;";
modal.appendChild(hint);
// Two mutually-exclusive choices via radio inputs.
const mkRadio = (value, labelText, checked) => {
const row = document.createElement("label");
row.style.cssText = "display:flex; align-items:center; gap:8px; font-size:13px; cursor:pointer; padding:3px 2px;";
const r = document.createElement("input");
r.type = "radio";
r.name = "angelo_loadres";
r.value = value;
r.checked = !!checked;
const span = document.createElement("span");
span.textContent = labelText;
row.appendChild(r);
row.appendChild(span);
return { row, radio: r };
};
const keep = mkRadio("keep", "Keep current resolution", true);
const resize = mkRadio("mp", "Resize to", false);
modal.appendChild(keep.row);
// Resize row: radio + MP input + "MP" label, inline.
const mpInput = document.createElement("input");
mpInput.type = "number";
mpInput.min = "0.1"; mpInput.max = "8"; mpInput.step = "0.1"; mpInput.value = "1.5";
mpInput.style.cssText = "width:60px; background:#1a1a1a; color:#eee; border:1px solid #555; border-radius:3px; padding:2px 6px; font-size:12px;";
const mpLabel = document.createElement("span");
mpLabel.textContent = "MP";
mpLabel.style.cssText = "font-size:13px;";
resize.row.appendChild(mpInput);
resize.row.appendChild(mpLabel);
modal.appendChild(resize.row);
// Picking the MP field implies the resize choice.
mpInput.addEventListener("focus", () => { resize.radio.checked = true; });
// Discoverability: the load dialog is where the restoration workflow
// starts, so teach it here.
const tip = document.createElement("div");
tip.style.cssText = "margin-top:4px; padding:8px 10px; font-size:11.5px; line-height:1.5; "
+ "color:#ffe9b0; background:rgba(110,85,25,0.25); "
+ "border:1px solid rgba(240,200,90,0.45); border-radius:5px;";
tip.innerHTML = "💡 <b>Low-quality or small photo?</b> Load it at <b>1.5–3 MP</b> with "
+ "“Resize to”, then press <b>✨ Quick Photo Refine</b> once it's in. The image is "
+ "rebuilt sharp at the new size, anchored to the original — same person, same scene, "
+ "real detail.";
modal.appendChild(tip);
const footer = document.createElement("div");
footer.style.cssText = "display:flex; justify-content:flex-end; gap:8px; margin-top:8px;";
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
cancelBtn.style.cssText = "background:#444; color:#ddd; border:none; padding:6px 14px; border-radius:4px; cursor:pointer;";
const loadBtn = document.createElement("button");
loadBtn.textContent = "Load";
loadBtn.style.cssText = "background:rgba(30,120,80,0.95); color:#fff; border:none; padding:6px 14px; border-radius:4px; cursor:pointer;";
footer.appendChild(cancelBtn);
footer.appendChild(loadBtn);
modal.appendChild(footer);
backdrop.appendChild(modal);
_angeloShowModal(node, backdrop);
const close = () => { if (backdrop.parentNode) backdrop.parentNode.removeChild(backdrop); };
cancelBtn.addEventListener("click", close);
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) close(); });
loadBtn.addEventListener("click", () => {
const mode = resize.radio.checked ? "mp" : "keep";
const mp = Math.max(0.1, Math.min(8, parseFloat(mpInput.value) || 1.5));
close();
_uploadLoadedImage(node, file, mode, mp);
});
}
function isPaintModeOn(node) {
const w = findWidget(node, "paint_mode");
if (!w) {
dbg("isPaintModeOn: paint_mode widget NOT FOUND on node — widget list:",
(node.widgets || []).map(x => x.name));
return false;
}
const v = w.value;
// Coerce defensively — different ComfyUI versions may store BOOLEAN
// widgets as actual booleans, the strings "true"/"false", or 0/1.
const on = (v === true || v === 1 || v === "true" || v === "True" || v === "1");
return on;
}
function isSmartInpaintMode(node) {
const w = findWidget(node, "inpainting_mode");
if (!w) return false;
return w.value === "Smart Inpaint";
}
function isSmartGuidedInpaintMode(node) {
const w = findWidget(node, "inpainting_mode");
if (!w) return false;
return w.value === "Smart Guided Inpaint";
}
// Either edit-model mode (both inject reference_latents).
function isAnySmartMode(node) {
return isSmartInpaintMode(node) || isSmartGuidedInpaintMode(node);
}
// Outpaint instruction texts — MUST match Python's _OUTPAINT_INSTRUCTIONS
// exactly (Python owns the encode; this mirror only feeds the live
// combined-prompt preview under the Area Prompt box).
const _Angelo_OUTPAINT_INSTRUCTIONS = {
left: "Extend the image to the left, continuing the scene and background naturally. Do not repeat or add new subjects. ",
right: "Extend the image to the right, continuing the scene and background naturally. Do not repeat or add new subjects. ",
up: "Extend the image upward, continuing the scene and background naturally. Do not repeat or add new subjects. ",
down: "Extend the image downward, continuing the scene and background naturally. Do not repeat or add new subjects. ",
all: "Extend the image outward on all sides, continuing the scene and background naturally. Do not repeat or add new subjects. ",
};
// Smart Guided Inpaint location labels — MUST match the Python
// _GUIDED_LOCATION_PREFIXES keys exactly (Python owns the label→prefix
// mapping; JS only stores the chosen label).
const _Angelo_GUIDED_LOCATIONS = [
"(none)", "Whole image",
"Top left", "Top middle", "Top right",
"Middle left", "Center", "Middle right",
"Bottom left", "Bottom middle", "Bottom right",
"Left edge", "Right edge", "Top edge", "Bottom edge",
"Top half", "Bottom half", "Left half", "Right half",
"Top of the image", "Bottom of the image",
];
// Smart Guided Inpaint has no click/drag — this fires the backend's
// new_click gate so the whole-image guided edit runs. Sets a valid
// (image-centre) click point, bumps click_seq, clears any stale
// stroke/rect, and queues.
function triggerGuidedRefine(node) {
const ws = findWidget(node, "click_seq");
if (!ws) return;
const wx = findWidget(node, "click_x");
const wy = findWidget(node, "click_y");
const wr = findWidget(node, "reset");
const wsp = findWidget(node, "stroke_points");
const wrp = findWidget(node, "rect_points");
const img = node._AngeloImg;
const cx = img && img.naturalWidth ? Math.round(img.naturalWidth / 2) : 0;
const cy = img && img.naturalHeight ? Math.round(img.naturalHeight / 2) : 0;
if (wx) setWidget(wx, cx);
if (wy) setWidget(wy, cy);
setWidget(ws, ((ws.value || 0) + 1) & 0x7FFFFFFF);
if (wr) setWidget(wr, false);
if (wsp) setWidget(wsp, "");
if (wrp) setWidget(wrp, "");
dbg("queueing workflow (smart guided edit)", { click_seq: ws.value });
queuePrompt(node);
}
function triggerRectRefine(node, rect) {
const wrp = findWidget(node, "rect_points");
const ws = findWidget(node, "click_seq");
const wx = findWidget(node, "click_x");
const wy = findWidget(node, "click_y");
const wr = findWidget(node, "reset");
const wsp = findWidget(node, "stroke_points");
if (!wrp || !ws) {
dbg("ERROR: rect widgets not found", { wrp: !!wrp, ws: !!ws });
return;
}
// Round to ints; the backend rasterises into latent space anyway.
const [x1, y1, x2, y2] = rect.map(v => Math.round(v));
setWidget(wrp, JSON.stringify([[x1, y1, x2, y2]]));
setWidget(ws, ((ws.value || 0) + 1) & 0x7FFFFFFF);
// Stash a fallback target at the rect centre (some downstream UI
// reads click_x/y).
if (wx) setWidget(wx, Math.round((x1 + x2) / 2));
if (wy) setWidget(wy, Math.round((y1 + y2) / 2));
if (wr) setWidget(wr, false);
// Clear stroke_points + seg_polygon so a previous paint stroke or a
// SAM-detected silhouette can't fall through.
if (wsp) setWidget(wsp, "");
const wseg = findWidget(node, "seg_polygon");
if (wseg) setWidget(wseg, "");
const wmaskpng = findWidget(node, "seg_mask_png");
if (wmaskpng) setWidget(wmaskpng, "");
dbg("queueing workflow (smart inpaint rect)", { x1, y1, x2, y2, click_seq: ws.value });
queuePrompt(node);
}
// ============================================================
// Reset button (canvas-rendered on the title bar)
// ============================================================
// (Title-bar button rect/draw helpers removed when Reset/Undo moved to
// the DOM toggle bar. Earlier versions had resetButtonRect /
// undoButtonRect / drawTitleButton / hitRect / roundedRect here; see
// git history if we ever want canvas-rendered buttons again.)
function triggerUndo(node) {
const wu = findWidget(node, "undo_seq");
if (!wu) return;
// An open Vary chooser refers to the pre-undo history — close it (the
// backend drops its candidate stash on undo too, so a pick after this
// would be a no-op at best and target the wrong entry at worst).
if (isVaryChooserOpen(node)) hideVaryChooser(node);
setWidget(wu, ((wu.value || 0) + 1) & 0x7FFFFFFF);
dbg("queue undo", { undo_seq: wu.value });
queuePrompt(node);
}
// Redo: re-apply the edit Undo most recently removed. Pure restore (like
// Undo), so it just bumps redo_seq and re-queues — Python pops its redo
// stack back onto history. No seed change, no re-sample.
function triggerRedo(node) {
const wr = findWidget(node, "redo_seq");
if (!wr) return;
if (isVaryChooserOpen(node)) hideVaryChooser(node); // same as Undo
setWidget(wr, ((wr.value || 0) + 1) & 0x7FFFFFFF);
dbg("queue redo", { redo_seq: wr.value });
queuePrompt(node);
}
// Re-roll: redo the most recent edit with a fresh seed, same mask, same
// pre-edit base. Force a NEW random seed even if Seed Ctrl is "fixed" —
// a re-roll is by definition new dice — then bump reroll_seq so Python
// pops the last attempt and re-runs the (unchanged) mask widgets in its
// place. The mask widgets (click_x/y, stroke_points, rect_points,
// seg_polygon) are deliberately left untouched so the same region is
// reused.
function triggerReroll(node) {
const wr = findWidget(node, "reroll_seq");
if (!wr) return;
const wseed = findWidget(node, "seed");
if (wseed) {
setWidget(wseed, Math.floor(Math.random() * Number.MAX_SAFE_INTEGER));
syncSeedInput(node);
}
setWidget(wr, ((wr.value || 0) + 1) & 0x7FFFFFFF);
dbg("queue reroll", { reroll_seq: wr.value, seed: wseed && wseed.value });
queuePrompt(node);
}
// Vary ×4: like Re-roll, but four candidates at once + a visual pick.
// Bumps vary_seq with a fresh base seed (Python derives the other three
// from it); the run stashes candidates server-side and ships previews,
// which onExecuted hands to showVaryChooser. Nothing commits until
// triggerVaryPick.
function triggerVary(node) {
const restoreW = findWidget(node, "restore_mode");
if (restoreW && restoreW.value && !isAnySmartMode(node)) {
_angeloToast("Turn Restore OFF to use Vary — restores are deterministic");
return;
}
const wv = findWidget(node, "vary_seq");
if (!wv) return;
const wseed = findWidget(node, "seed");
if (wseed) {
setWidget(wseed, Math.floor(Math.random() * Number.MAX_SAFE_INTEGER));
syncSeedInput(node);
}
setWidget(wv, ((wv.value || 0) + 1) & 0x7FFFFFFF);
_angeloToast("Generating 4 variations…");
dbg("queue vary", { vary_seq: wv.value });
queuePrompt(node);
}
function showVaryChooser(node, refs) {
const ov = node._AngeloVaryOverlay;
const grid = node._AngeloVaryGrid;
if (!ov || !grid) return;
grid.innerHTML = "";
refs.forEach((ref, i) => {
const cell = document.createElement("div");
cell.style.cssText = "position:relative; min-height:0; min-width:0; overflow:hidden; "
+ "border:2px solid #555; border-radius:4px; background:#111; cursor:pointer;";
const img = document.createElement("img");
img.src = makeViewUrl(ref);
img.style.cssText = "width:100%; height:100%; object-fit:contain; display:block;";
img.draggable = false;
const tag = document.createElement("span");
tag.textContent = String(i + 1);
tag.style.cssText = "position:absolute; left:5px; top:5px; padding:1px 7px; "
+ "background:rgba(0,0,0,0.65); border-radius:3px; color:#ffdc50; "
+ "font:bold 12px Arial,sans-serif; pointer-events:none;";
cell.addEventListener("mouseenter", () => { cell.style.borderColor = "rgba(255,220,80,0.95)"; });
cell.addEventListener("mouseleave", () => { cell.style.borderColor = "#555"; });
cell.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
hideVaryChooser(node);
triggerVaryPick(node, i);
});
cell.appendChild(img);
cell.appendChild(tag);
grid.appendChild(cell);
});
ov.style.display = "flex";
}
function hideVaryChooser(node) {
if (node._AngeloVaryOverlay) node._AngeloVaryOverlay.style.display = "none";
}
function isVaryChooserOpen(node) {
return !!(node._AngeloVaryOverlay && node._AngeloVaryOverlay.style.display !== "none");
}
function triggerVaryPick(node, idx) {
const wp = findWidget(node, "vary_pick");
const ws = findWidget(node, "vary_pick_seq");
if (!wp || !ws) return;
setWidget(wp, idx);
setWidget(ws, ((ws.value || 0) + 1) & 0x7FFFFFFF);
dbg("queue vary pick", { vary_pick: idx, vary_pick_seq: ws.value });
queuePrompt(node);
}
// Quick Photo Refine: the restoration recipe as one click. Python owns the
// whole pass (whole-canvas, denoise 1.0, internal prompt, reference anchor)
// — the JS only bumps the seq, so no toolbar values get mutated.
function triggerQuickPhotoRefine(node) {
if (isAnySmartMode(node) || isOutpaintMode(node)) return; // dimmed there anyway
if (!node._AngeloImg) {
_angeloToast("Generate or load an image first");
return;
}
const ws = findWidget(node, "quick_refine_seq");
if (!ws) return;
setWidget(ws, ((ws.value || 0) + 1) & 0x7FFFFFFF);
_angeloToast("✨ Quick Photo Refine — restoring the photo…");
dbg("queue quick photo refine", { quick_refine_seq: ws.value });
queuePrompt(node);
}
// ⬆ 2× Pixel: pure lanczos upscale, no AI, committed directly as the new
// session base (deterministic — no review step).
function triggerPixelUpscale(node) {
if (isAnySmartMode(node) || isOutpaintMode(node)) return; // dimmed there anyway
if (!node._AngeloImg) {
_angeloToast("Generate or load an image first");
return;
}
const ws = findWidget(node, "upscale_seq");
if (!ws) return;
setWidget(ws, ((ws.value || 0) + 1) & 0x7FFFFFFF);
_angeloToast("⬆ 2× Pixel — lanczos upscale, new base (history resets)");
dbg("queue pixel upscale", { upscale_seq: ws.value });
queuePrompt(node);
}
// ⬇ Shrink: pick a scale factor in a popup (with a live new-dimensions
// readout), then a pure area-resample downscale — no AI — committed as the
// new session base. triggerShrink does the queue; showShrinkPopup is the UI.
function triggerShrink(node, scale) {
if (isAnySmartMode(node) || isOutpaintMode(node)) return;
if (!node._AngeloImg) {
_angeloToast("Generate or load an image first");
return;
}
const scW = findWidget(node, "shrink_scale");
const sqW = findWidget(node, "shrink_seq");
if (!scW || !sqW) return;
setWidget(scW, scale);
setWidget(sqW, ((sqW.value || 0) + 1) & 0x7FFFFFFF);
_angeloToast("⬇ Shrink — downscaling, new base (history resets)");
dbg("queue shrink", { shrink_scale: scale, shrink_seq: sqW.value });
queuePrompt(node);
}
function showShrinkPopup(node) {
if (isAnySmartMode(node) || isOutpaintMode(node)) {
_angeloToast("Shrink is a Refine-mode action");
return;
}
const img = node._AngeloImg;
if (!img || !img.naturalWidth) {
_angeloToast("Generate or load an image first");
return;
}
const inW = img.naturalWidth, inH = img.naturalHeight;
const snap16 = (v) => Math.max(16, Math.round(v / 16) * 16);
const backdrop = document.createElement("div");
backdrop.style.cssText = "position:fixed; inset:0; background:rgba(0,0,0,0.6); "
+ "display:flex; align-items:center; justify-content:center; z-index:10000; "
+ "font-family:Arial,sans-serif;";
const modal = document.createElement("div");
modal.style.cssText = "background:#2a2a2a; color:#ddd; border:1px solid #555; "
+ "border-radius:8px; padding:16px; width:340px; max-width:90vw; "
+ "display:flex; flex-direction:column; gap:10px;";
const header = document.createElement("div");
header.textContent = "Shrink Image";
header.style.cssText = "font-size:14px; font-weight:bold; color:#aaa;";
modal.appendChild(header);
const cur = document.createElement("div");
cur.textContent = `Current: ${inW} × ${inH}`;
cur.style.cssText = "font-size:12px; color:#888;";
modal.appendChild(cur);
const scaleRow = document.createElement("div");
scaleRow.style.cssText = "display:flex; align-items:center; gap:8px; font-size:13px;";
const pctInput = document.createElement("input");
pctInput.type = "number";
pctInput.min = "5"; pctInput.max = "95"; pctInput.step = "1"; pctInput.value = "50";
pctInput.style.cssText = "width:64px; background:#1a1a1a; color:#eee; border:1px solid #555; "
+ "border-radius:3px; padding:3px 6px; font-size:13px;";
const pctLabel = document.createElement("span");
pctLabel.textContent = "% of original";
scaleRow.appendChild(pctInput);
scaleRow.appendChild(pctLabel);
modal.appendChild(scaleRow);
const presetRow = document.createElement("div");
presetRow.style.cssText = "display:flex; gap:6px;";
[75, 50, 33, 25].forEach((p) => {
const b = document.createElement("button");
b.textContent = p + "%";
b.style.cssText = "flex:1; background:#3a3a3a; color:#ddd; border:1px solid #555; "
+ "border-radius:4px; padding:4px 0; cursor:pointer; font-size:12px;";
b.addEventListener("click", () => { pctInput.value = String(p); update(); });
presetRow.appendChild(b);
});
modal.appendChild(presetRow);
const out = document.createElement("div");
out.style.cssText = "font-size:13px; color:#ffe9b0; font-weight:bold;";
modal.appendChild(out);
const clampPct = () => Math.max(5, Math.min(95, parseFloat(pctInput.value) || 50));
const update = () => {
const sc = clampPct() / 100;
out.textContent = `New: ${snap16(inW * sc)} × ${snap16(inH * sc)}`;
};
pctInput.addEventListener("input", update);
update();
const footer = document.createElement("div");
footer.style.cssText = "display:flex; justify-content:flex-end; gap:8px; margin-top:6px;";
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
cancelBtn.style.cssText = "background:#444; color:#ddd; border:none; padding:6px 14px; border-radius:4px; cursor:pointer;";
const okBtn = document.createElement("button");
okBtn.textContent = "Shrink";
okBtn.style.cssText = "background:rgba(30,120,80,0.95); color:#fff; border:none; padding:6px 14px; border-radius:4px; cursor:pointer;";
footer.appendChild(cancelBtn);
footer.appendChild(okBtn);
modal.appendChild(footer);
backdrop.appendChild(modal);
_angeloShowModal(node, backdrop);
const close = () => { if (backdrop.parentNode) backdrop.parentNode.removeChild(backdrop); };
cancelBtn.addEventListener("click", close);
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) close(); });
okBtn.addEventListener("click", () => {
const sc = clampPct() / 100;
close();
triggerShrink(node, sc);
});
}
// ===== Outpaint — directional canvas extension with review-before-commit =====
function isOutpaintMode(node) {
const w = findWidget(node, "inpainting_mode");
return !!w && w.value === "Outpaint";
}
// Fire one extension. Direction from the arrows or the edge-click; amount
// + overlap already live in their widgets via the Outpaint row inputs.
function triggerOutpaint(node, dir) {
const ws = findWidget(node, "outpaint_seq");
const wd = findWidget(node, "outpaint_dir");
if (!ws || !wd) return;
if (!node._AngeloImg) {
_angeloToast("Generate or load an image first, then outpaint");
return;
}
setWidget(wd, dir);
node._AngeloPendingOp = "outpaint";
setWidget(ws, ((ws.value || 0) + 1) & 0x7FFFFFFF);
// The direction just changed — refresh the combined-prompt preview.
syncOutpaintPromptPreview(node);
// Ship the current protect circles (kept client-side so they survive
// Try-again retries without re-painting).
const wpr = findWidget(node, "outpaint_protect");
if (wpr) {
const prot = (node._AngeloProtect || []).map(
(c) => [Math.round(c[0]), Math.round(c[1]), Math.round(c[2])]);
setWidget(wpr, prot.length ? JSON.stringify(prot) : "");
}
const amtW = findWidget(node, "outpaint_amount");
const amt = (amtW && amtW.value) || 256;
const dirLabel = { left: "left", right: "right", up: "up", down: "down", all: "all sides" }[dir] || dir;
_angeloToast(`Outpainting ${dirLabel} +${amt}px…`);
dbg("queue outpaint", { dir, amt, outpaint_seq: ws.value });
queuePrompt(node);
}
function showOutpaintReview(node, ref) {
const ov = node._AngeloOutpaintOverlay;
const img = node._AngeloOutpaintImg;
if (!ov || !img) return;
if (node._AngeloOutpaintTitle) {
node._AngeloOutpaintTitle.textContent = "Outpaint preview — keep it?";
}
img.src = makeViewUrl(ref);
ov.style.display = "flex";
}
function hideOutpaintReview(node) {
if (node._AngeloOutpaintOverlay) node._AngeloOutpaintOverlay.style.display = "none";
}
function isOutpaintReviewOpen(node) {
return !!(node._AngeloOutpaintOverlay && node._AngeloOutpaintOverlay.style.display !== "none");
}
function triggerOutpaintAccept(node) {
const ws = findWidget(node, "outpaint_accept_seq");
if (!ws) return;
setWidget(ws, ((ws.value || 0) + 1) & 0x7FFFFFFF);
// The protect circles were drawn on the OLD canvas — stale coords now.
node._AngeloProtect = [];
const wpr = findWidget(node, "outpaint_protect");
if (wpr) setWidget(wpr, "");
syncOutpaintControls(node);
// Optimistic swap: the approved pixels are ALREADY loaded in the review
// overlay's <img> — show them on the canvas NOW instead of making the
// user stare at the old canvas for the whole commit round-trip. The URL
// is browser-cached so this is instant, and loadIntoCanvas also updates
// image_w/h + resets the view for the new dimensions. The commit run's
// own preview (identical pixels) replaces it seamlessly when it lands.
const rimg = node._AngeloOutpaintImg;
if (rimg && rimg.src && rimg.complete && rimg.naturalWidth > 0) {
loadIntoCanvas(node, rimg.src);
}
_angeloToast("New base committed — history reset");
dbg("queue outpaint accept", { outpaint_accept_seq: ws.value });
queuePrompt(node);
}
// Same direction + amount, fresh seed. The stale stash is simply
// overwritten by the new run.
function triggerOutpaintRetry(node) {
const ws = findWidget(node, "outpaint_seq");
if (!ws) return;
const wseed = findWidget(node, "seed");
if (wseed) {
setWidget(wseed, Math.floor(Math.random() * Number.MAX_SAFE_INTEGER));
syncSeedInput(node);
}
setWidget(ws, ((ws.value || 0) + 1) & 0x7FFFFFFF);
_angeloToast("Trying the extension again with a fresh seed…");
queuePrompt(node);
}
// Which edge (if any) the cursor is near enough to for the edge-click
// extension. Zones are 18% of each dimension; nearest edge wins so the
// corners resolve cleanly to one direction.
function _outpaintEdgeDir(node, p) {
const img = node._AngeloImg;
if (!img || !img.naturalWidth) return null;
const W = img.naturalWidth, H = img.naturalHeight;
const zx = W * 0.18, zy = H * 0.18;
const cands = [];
if (p.pixelX <= zx) cands.push(["left", p.pixelX / zx]);
if (W - p.pixelX <= zx) cands.push(["right", (W - p.pixelX) / zx]);
if (p.pixelY <= zy) cands.push(["up", p.pixelY / zy]);
if (H - p.pixelY <= zy) cands.push(["down", (H - p.pixelY) / zy]);
if (!cands.length) return null;
cands.sort((a, b) => a[1] - b[1]);
return cands[0][0];
}
// Live preview of the EXACT combined prompt the next outpaint will encode.
// Composition MUST stay in lockstep with Python's outpaint conditioning
// (instruction prepend/append around the trimmed Area text).
function syncOutpaintPromptPreview(node) {
const wrapEl = node._AngeloOutpaintPromptWrap;
if (!wrapEl) return;
const show = isOutpaintMode(node);
const next = show ? "flex" : "none";
if (wrapEl.style.display !== next) wrapEl.style.display = next;
if (!show) return;
// Mirror the order selector from the widget.
const posW = findWidget(node, "outpaint_instruction_pos");
const pos = (posW && posW.value) === "append" ? "append" : "prepend";
const sel = node._AngeloOutpaintOrderSelect;
if (sel && sel._AngeloSelect) {
const want = pos === "append" ? "My text first" : "Instruction first";
if (sel._AngeloSelect.value !== want) sel._AngeloSelect.value = want;
}
const dirW = findWidget(node, "outpaint_dir");
const dir = (dirW && dirW.value) || "right";
const instr = _Angelo_OUTPAINT_INSTRUCTIONS[dir] || _Angelo_OUTPAINT_INSTRUCTIONS.right;
const apW = findWidget(node, "area_prompt");
const txtW = findWidget(node, "area_text_positive");
const userTxt = (apW && apW.value) ? String(txtW?.value || "").trim() : "";
let combined;
if (userTxt && pos === "append") {
combined = userTxt.replace(/[ .,]+$/, "") + ". " + instr.trim();
} else {
combined = instr + userTxt;
}
const pv = node._AngeloOutpaintPromptPreview;
if (pv && pv.textContent !== combined) pv.textContent = combined;
}
// Outpaint row visibility + input mirrors + protect-chip state.
function syncOutpaintControls(node) {
const row = node._AngeloOutpaintRow;
if (row) {
const next = isOutpaintMode(node) ? "flex" : "none";
if (row.style.display !== next) row.style.display = next;
}
_syncNumberInput(node._AngeloOutpaintAmountInput, findWidget(node, "outpaint_amount")?.value);
_syncNumberInput(node._AngeloOutpaintOverlapInput, findWidget(node, "outpaint_overlap")?.value);
const chip = node._AngeloOutpaintClearProtectBtn;
if (chip) {
const n = (node._AngeloProtect || []).length;
chip.style.display = n ? "" : "none";
chip.textContent = `✕ Protect (${n})`;
}
if (!isOutpaintMode(node)) {
node._AngeloOutpaintHoverDir = null;
node._AngeloOutpaintPainting = false;
// Leaving the mode drops the protect region — the canvas may be
// edited before the user comes back, making the coords stale.
if (node._AngeloProtect && node._AngeloProtect.length) {
node._AngeloProtect = [];
const wpr = findWidget(node, "outpaint_protect");
if (wpr) setWidget(wpr, "");
}
hideOutpaintReview(node);
}
syncOutpaintPromptPreview(node);
}
function triggerReset(node) {
const wr = findWidget(node, "reset");
const ws = findWidget(node, "click_seq");
const wx = findWidget(node, "click_x");
const wy = findWidget(node, "click_y");
if (!wr) return;
wr.value = true;
if (wx) wx.value = -1;
if (wy) wy.value = -1;
if (ws) ws.value = ((ws.value || 0) + 1) & 0x7FFFFFFF;
node._AngeloImg = null;
if (node._AngeloCanvas) {
const ctx = node._AngeloCanvas.getContext("2d");
ctx.clearRect(0, 0, node._AngeloCanvas.width, node._AngeloCanvas.height);
}
if (node._AngeloPlaceholder) node._AngeloPlaceholder.style.display = "flex";
app.graph.setDirtyCanvas(true, true);
queuePrompt(node);
setTimeout(() => {
if (wr.value === true) {
wr.value = false;
app.graph.setDirtyCanvas(true, true);
}
}, 1000);
}
// ============================================================
// Helpers
// ============================================================
function findWidget(node, name) {
if (!node.widgets) return null;
return node.widgets.find(w => w.name === name);
}
/**
* Build a momentary action button (Reset, Undo). Click → onClick().
* `kind` selects a colour theme so different actions are visually
* distinct in the bar.
*/
function makeActionButton(label, onClick, kind = "neutral") {
const themes = {
reset: { fg: "#ffe0d0", bg: "rgba(70, 50, 50, 0.95)", border: "rgba(220, 140, 100, 0.9)" },
undo: { fg: "#dde7ff", bg: "rgba(50, 60, 70, 0.95)", border: "rgba(120, 170, 220, 0.9)" },
redo: { fg: "#d2f3e2", bg: "rgba(48, 66, 60, 0.95)", border: "rgba(110, 200, 160, 0.9)" },
reroll: { fg: "#ecdcff", bg: "rgba(58, 50, 72, 0.95)", border: "rgba(170, 130, 220, 0.9)" },
vary: { fg: "#d8eeff", bg: "rgba(40, 62, 82, 0.95)", border: "rgba(120, 190, 235, 0.9)" },
quickfix:{ fg: "#fff3d0", bg: "rgba(110, 85, 25, 0.95)", border: "rgba(240, 200, 90, 0.9)" },
neutral: { fg: "#ccc", bg: "#2a2a2a", border: "#555" },
};
const th = themes[kind] || themes.neutral;
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = label;
btn.style.cursor = "pointer";
btn.style.padding = "3px 10px";
btn.style.fontSize = "11px";
// Pin the line box so emoji glyphs (✨ ⚡) can't inflate a button's
// height relative to its text-only neighbours.
btn.style.lineHeight = "15px";
btn.style.fontWeight = "bold";
btn.style.border = `1px solid ${th.border}`;
btn.style.borderRadius = "3px";
btn.style.background = th.bg;
btn.style.color = th.fg;
btn.style.userSelect = "none";
btn.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
onClick();
});
return btn;
}
/** Thin vertical separator for the toggle bar. */
function makeSeparator() {
const sep = document.createElement("div");
sep.style.width = "1px";
sep.style.alignSelf = "stretch";
sep.style.background = "#444";
sep.style.margin = "0 4px";
return sep;
}
/** A horizontal row of controls inside the toolbar wrapper. */
function makeToolbarRow() {
const row = document.createElement("div");
row.style.display = "flex";
row.style.flexDirection = "row";
row.style.flexWrap = "wrap";
row.style.alignItems = "center";
row.style.gap = "4px";
row.style.padding = "4px 6px";
return row;
}
// ============================================================
// Fullscreen overlay
// ============================================================
//
// Pops the whole editing UI (toolbar + area-prompt box + canvas) into a
// fixed, viewport-filling overlay. We reparent node._AngeloContainer INTACT
// rather than cloning anything, so every existing click/paint/zoom/pan
// handler keeps working — they all map through canvas.getBoundingClientRect()
// which reflects the new (much larger) size — and the ResizeObserver on
// canvasWrap auto-refits the image. On exit the container goes back exactly
// where LiteGraph's DOM widget put it (tracked via a comment placeholder).
//
// We also make a best-effort request for TRUE browser fullscreen on the
// overlay so the browser chrome hides too; if the browser refuses (needs a
// user gesture / disallowed), the plain overlay already covers the ComfyUI
// viewport, so the feature still works.
function isAngeloFullscreen(node) {
return !!node._AngeloFSOverlay;
}
function toggleAngeloFullscreen(node) {
if (isAngeloFullscreen(node)) exitAngeloFullscreen(node);
else enterAngeloFullscreen(node);
}
function syncFullscreenButton(node) {
const btn = node._AngeloFullscreenBtn;
if (!btn) return;
const on = isAngeloFullscreen(node);
btn.textContent = on ? "⛶ Exit" : "⛶ Fullscreen";
btn.style.background = on ? "rgba(40, 62, 82, 0.95)" : "#2a2a2a";
btn.style.borderColor = on ? "rgba(120, 190, 235, 0.9)" : "#555";
btn.style.color = on ? "#d8eeff" : "#ccc";
btn.title = on
? "Exit fullscreen and return the editor to the node (Esc)."
: "Pop the editor into a fullscreen overlay — a much bigger canvas for precise "
+ "clicks/paint/zoom. The toolbar and all editing work exactly as normal. "
+ "Esc or this button returns it to the node. Requests true browser fullscreen "
+ "too where the browser allows it.";
}
function enterAngeloFullscreen(node) {
const container = node._AngeloContainer;
if (!container || node._AngeloFSOverlay) return;
const overlay = document.createElement("div");
overlay.style.cssText = "position:fixed; inset:0; z-index:99990; background:#0d0d0d; "
+ "display:flex; flex-direction:column;";
// Keep graph-level pointer/wheel handling from firing underneath — clicks
// inside the toolbar/canvas still reach their own listeners normally.
overlay.addEventListener("pointerdown", (e) => e.stopPropagation());
overlay.addEventListener("wheel", (e) => e.stopPropagation(), { passive: false });
// Remember where the container lives so we can put it back precisely.
const placeholder = document.createComment("angelo-fullscreen-slot");
node._AngeloFSPrevParent = container.parentNode;
node._AngeloFSPlaceholder = placeholder;
node._AngeloFSPrevCss = container.style.cssText;
if (container.parentNode) container.parentNode.insertBefore(placeholder, container);
// Fill the overlay. Same flex-column shape as the in-node container, so
// the toolbar keeps its height and canvasWrap flex-grows into the rest.
container.style.cssText = "width:100%; height:100%; flex:1 1 auto; min-height:0; "
+ "border:none; border-radius:0; background:#1a1a1a; overflow:hidden; "
+ "display:flex; flex-direction:column;";
overlay.appendChild(container);
// Floating Exit button — the discoverable escape hatch when true browser
// fullscreen isn't granted (Esc also works; see the keydown handler).
const exitBtn = document.createElement("button");
exitBtn.type = "button";
exitBtn.textContent = "✕ Exit Fullscreen (Esc)";
exitBtn.title = "Return the editor to the node (Esc).";
exitBtn.style.cssText = "position:absolute; right:12px; top:10px; z-index:2; cursor:pointer; "
+ "padding:5px 12px; font-size:12px; font-weight:bold; border-radius:4px; "
+ "border:1px solid rgba(255,255,255,0.35); background:rgba(30,30,30,0.92); color:#eee; "
+ "user-select:none;";
for (const ev of ["pointerdown", "mousedown"]) {
exitBtn.addEventListener(ev, (e) => e.stopPropagation());
}
exitBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
exitAngeloFullscreen(node);
});
overlay.appendChild(exitBtn);
document.body.appendChild(overlay);
node._AngeloFSOverlay = overlay;
// Esc-to-close — self-contained so it doesn't depend on canvas hover.
// Defers to any open sub-overlay (detect / vary / outpaint) so the first
// Esc dismisses that and a second Esc leaves fullscreen.
const onKey = (e) => {
if (e.key !== "Escape") return;
if (isOutpaintReviewOpen(node) || isVaryChooserOpen(node)
|| (node._AngeloDetections && node._AngeloDetections.length)) return;
// A modal popup (Smart Phrasing / Load Image / Shrink) is open
// inside the overlay — its own Esc handling closes it; exiting
// fullscreen here would destroy it (and silently discard e.g. a
// pasted image). Defer; the next Esc exits fullscreen.
if (overlay.querySelector(".angelo-modal-backdrop")) return;
e.preventDefault();
e.stopPropagation();
exitAngeloFullscreen(node);
};
node._AngeloFSKeyHandler = onKey;
document.addEventListener("keydown", onKey, true);
// If the user drops OUT of true browser-fullscreen (e.g. the browser eats
// Esc to exit fullscreen before our keydown sees it), tear the overlay
// down to match, so the two never get out of sync.
const onFsChange = () => {
if (!document.fullscreenElement && node._AngeloFSOverlay) {
exitAngeloFullscreen(node);
}
};
node._AngeloFSFsChangeHandler = onFsChange;
document.addEventListener("fullscreenchange", onFsChange);
// Best-effort true fullscreen (hides browser chrome). Rejection is fine.
if (overlay.requestFullscreen) {
try { overlay.requestFullscreen().catch(() => {}); }
catch (e) { /* older browsers: ignore */ }
}
// Refit the image to the now-huge canvas area. rAF lets the overlay lay
// out first so clientWidth/Height are real before we measure.
requestAnimationFrame(() => {
try { resetView(node); redrawCanvasWithOverlays(node); }
catch (e) { dbg("fullscreen refit threw", e); }
});
syncFullscreenButton(node);
}
function exitAngeloFullscreen(node) {
const overlay = node._AngeloFSOverlay;
const container = node._AngeloContainer;
if (!overlay || !container) return;
// Detach listeners FIRST so tearing down browser-fullscreen below can't
// re-enter this via the fullscreenchange handler.
if (node._AngeloFSKeyHandler) {
document.removeEventListener("keydown", node._AngeloFSKeyHandler, true);
node._AngeloFSKeyHandler = null;
}
if (node._AngeloFSFsChangeHandler) {
document.removeEventListener("fullscreenchange", node._AngeloFSFsChangeHandler);
node._AngeloFSFsChangeHandler = null;
}
if (document.fullscreenElement) {
try { document.exitFullscreen(); } catch (e) { /* noop */ }
}
// Put the container back exactly where it came from.
container.style.cssText = node._AngeloFSPrevCss || "";
const placeholder = node._AngeloFSPlaceholder;
const prevParent = node._AngeloFSPrevParent;
if (placeholder && placeholder.parentNode) {
placeholder.parentNode.insertBefore(container, placeholder);
placeholder.parentNode.removeChild(placeholder);
} else if (prevParent) {
prevParent.appendChild(container);
}
node._AngeloFSPlaceholder = null;
node._AngeloFSPrevParent = null;
node._AngeloFSPrevCss = null;
// Rescue any open modal popup (Smart Phrasing / Load Image / Shrink)
// before the overlay is destroyed — it was parented into the overlay to
// render above it, and must not silently die with it (e.g. a pasted
// image awaiting the Load Image confirm).
for (const md of Array.from(overlay.querySelectorAll(":scope > .angelo-modal-backdrop"))) {
document.body.appendChild(md);
}
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
node._AngeloFSOverlay = null;
requestAnimationFrame(() => {
try { resetView(node); redrawCanvasWithOverlays(node); }
catch (e) { dbg("fullscreen restore refit threw", e); }
});
syncFullscreenButton(node);
}
/**
* Build a click-to-toggle button. Returns the DOM element; the caller
* is responsible for syncing its visual state to whatever underlying
* widget value it's bound to (call syncPersistentMaskToggle / similar on
* the relevant node after the widget value changes).
*/
function makeToggleButton(label, onToggle) {
const btn = document.createElement("button");
btn.type = "button";
// State is shown by colour alone (lit fill = ON, flat grey = OFF) —
// no ": ON/OFF" text suffix. The suffix nearly doubled each button's
// width and the row holds five toggles; creative tools light buttons
// up rather than labelling them.
btn.textContent = label;
btn.dataset.state = "off";
btn.style.cursor = "pointer";
btn.style.padding = "3px 10px";
btn.style.fontSize = "11px";
// Same pinned line box as makeActionButton — every button in the
// row computes the same height.
btn.style.lineHeight = "15px";
btn.style.fontWeight = "bold";
btn.style.border = "1px solid #555";
btn.style.borderRadius = "3px";
btn.style.background = "#2a2a2a";
btn.style.color = "#bbb";
btn.style.userSelect = "none";
btn._AngeloLabel = label;
btn.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
onToggle();
});
return btn;
}
// --- Sync helpers: keep DOM toolbar controls in lockstep with the
// underlying widget values. Called once on node creation (so persisted
// widget state reflects in the UI) and after every toggle click.
function _syncToggle(btn, widgetValue, onColor) {
if (!btn) return;
const on = !!widgetValue;
btn.dataset.state = on ? "on" : "off";
btn.style.background = on ? onColor.bg : "#2a2a2a";
btn.style.color = on ? "#fff" : "#bbb";
btn.style.borderColor = on ? onColor.border : "#555";
}
const _TOGGLE_ON_COLORS = {
blue: { bg: "rgba(20, 80, 140, 0.95)", border: "rgba(120, 170, 220, 0.9)" },
green: { bg: "rgba(30, 120, 80, 0.95)", border: "rgba(140, 220, 170, 0.9)" },
purple: { bg: "rgba(95, 50, 130, 0.95)", border: "rgba(180, 140, 220, 0.9)" },
teal: { bg: "rgba(30, 110, 130, 0.95)", border: "rgba(140, 200, 220, 0.9)" },
amber: { bg: "rgba(160, 110, 30, 0.95)", border: "rgba(230, 185, 110, 0.9)" },
sky: { bg: "rgba(40, 100, 150, 0.95)", border: "rgba(130, 195, 235, 0.9)" },
rose: { bg: "rgba(150, 45, 70, 0.95)", border: "rgba(230, 120, 150, 0.9)" },
plum: { bg: "rgba(110, 45, 120, 0.95)", border: "rgba(195, 130, 220, 0.9)" },
};
function syncPersistentMaskToggle(node) {
// Backend forces persistent_mask OFF in Smart Guided Inpaint (no
// mask to persist) — reflect that as OFF rather than the stale
// widget value.
const effective = isSmartGuidedInpaintMode(node)
? false
: findWidget(node, "persistent_mask")?.value;
_syncToggle(node._AngeloPersistentMaskToggle, effective, _TOGGLE_ON_COLORS.blue);
}
function syncAreaPromptToggle(node) {
// Backend forces area_prompt=True in Smart Inpaint regardless of
// widget state — reflect that in the displayed toggle so it reads
// ON instead of misleadingly showing the underlying widget value.
const effective = isSmartInpaintMode(node)
? true
: findWidget(node, "area_prompt")?.value;
_syncToggle(node._AngeloAreaPromptToggle, effective, _TOGGLE_ON_COLORS.purple);
}
function syncPaintModeToggle(node) {
_syncToggle(node._AngeloPaintModeToggle, findWidget(node, "paint_mode")?.value, _TOGGLE_ON_COLORS.teal);
}
function syncRestoreToggle(node) {
// Backend honours restore_mode in Refine only — the Smart modes ignore
// it, so display OFF there rather than a stale widget value.
const effective = isAnySmartMode(node)
? false
: findWidget(node, "restore_mode")?.value;
_syncToggle(node._AngeloRestoreToggle, effective, _TOGGLE_ON_COLORS.amber);
}
function syncRemoveToggle(node) {
// Backend honours remove_mode in Refine only (edit models) — the Smart
// modes ignore it, so display OFF there rather than a stale widget value.
const effective = isAnySmartMode(node)
? false
: findWidget(node, "remove_mode")?.value;
_syncToggle(node._AngeloRemoveToggle, effective, _TOGGLE_ON_COLORS.rose);
}
function syncQuickPromptSelect(node) {
_syncDropdownWrap(node._AngeloQuickPromptSelect, findWidget(node, "quick_prompt_mode")?.value);
}
function syncReferenceControls(node) {
// Effective OFF in the Smart modes / Outpaint (their own reference
// logic) regardless of the stored widget value.
const on = !!(findWidget(node, "refine_reference")?.value)
&& !isAnySmartMode(node) && !isOutpaintMode(node);
_syncToggle(node._AngeloRefineRefToggle, on, _TOGGLE_ON_COLORS.sky);
const wrap = node._AngeloRefineRefInput;
if (wrap) wrap.style.display = on ? "inline-flex" : "none";
if (node._AngeloRefineRefToggle) {
node._AngeloRefineRefToggle.style.borderRadius = on ? "3px 0 0 3px" : "3px";
}
_syncNumberInput(wrap, findWidget(node, "reference_strength")?.value);
}
function syncFineUpscaleToggle(node) {
// Backend forces fine_upscaling ON in Smart Inpaint and OFF in
// Smart Guided Inpaint regardless of widget state — reflect that in
// the displayed toggle rather than the (stale) underlying value.
let effective;
if (isSmartInpaintMode(node)) effective = true;
else if (isSmartGuidedInpaintMode(node)) effective = false;
else if (findWidget(node, "remove_mode")?.value) effective = true; // Remove forces Xtra-Fine on
else effective = findWidget(node, "fine_upscaling")?.value;
_syncToggle(node._AngeloFineUpscaleToggle, effective, _TOGGLE_ON_COLORS.green);
// The Xtra-Fine value group (MP / Max / Ctx Pad / Method + their
// separator) only applies while Xtra-Fine is effectively ON — show
// it with the toggle, hide it otherwise. Explicit display values
// because hiding clears the inline display these wraps were built
// with (inline-flex for the inputs, default block for the separator).
const showFine = !!effective;
const fineGroup = [
[node._AngeloFineSep, ""],
[node._AngeloMpInput, "inline-flex"],
[node._AngeloMaxInput, "inline-flex"],
[node._AngeloCtxPadInput, "inline-flex"],
[node._AngeloMethodSelect, "inline-flex"],
];
for (const [el, shown] of fineGroup) {
if (el) el.style.display = showFine ? shown : "none";
}
}
// Smart Inpaint mode hard-locks several params on the backend
// regardless of widget state:
// denoise=1.0, fine_upscaling=ON, fine_context_pad=0, area_prompt=ON
// Dim the corresponding UI controls + paint_mode + click_radius so
// the user can see at a glance that they're not driving anything.
// Feather is NOT locked — a soft edge can help blend the insert, so
// it stays under user control.
const _Angelo_LOCK_SUFFIX = "\n\n[Locked in this Inpaint mode.]";
function _dimControls(node, ids, dim) {
for (const id of ids) {
const el = node[id];
if (!el) continue;
el.style.opacity = dim ? "0.35" : "";
el.style.pointerEvents = dim ? "none" : "";
if (dim) {
el.title = (el.title?.split("\n\n[")[0] || el.title || "") + _Angelo_LOCK_SUFFIX;
} else if (el.title && el.title.includes(_Angelo_LOCK_SUFFIX.trim())) {
el.title = el.title.split(_Angelo_LOCK_SUFFIX)[0];
}
}
}
function syncSmartInpaintLockedWidgets(node) {
const guided = isSmartGuidedInpaintMode(node);
const anySmart = isSmartInpaintMode(node) || guided;
const outp = isOutpaintMode(node);
// Common locks for BOTH smart modes — backend forces these or they
// don't apply: denoise, fine_upscale toggle, paint_mode, click
// radius, area_prompt toggle. Outpaint dims the mask-editing set
// too (the canvas is a direction picker there), but Area Prompt
// stays LIVE — it describes what fills the new space.
_dimControls(node, [
"_AngeloDenoiseInput",
"_AngeloFineUpscaleToggle",
"_AngeloPaintModeToggle",
"_AngeloRestoreToggle",
"_AngeloRemoveToggle",
"_AngeloRefGroup",
"_AngeloCtxPadInput",
], anySmart || outp);
// Click R stays LIVE in Outpaint — it's the protect-brush size there.
_dimControls(node, ["_AngeloClickRadiusInput"], anySmart);
_dimControls(node, ["_AngeloAreaPromptToggle"], anySmart);
// Feather: live in Smart Inpaint (a soft edge can help blend the
// insert), disabled in Smart Guided (whole-image edit, no mask edge)
// and in Outpaint (its seam blend is the Overlap input instead).
_dimControls(node, ["_AngeloFeatherInput"], guided || outp);
// Persistent Mask: meaningless in Smart Guided (no mask) and in
// Outpaint (no held mask to re-run). Dimmed + forced OFF there;
// left alone in Smart Inpaint (re-rolls the rect).
_dimControls(node, ["_AngeloPersistentMaskToggle"], guided || outp);
// Re-roll / Vary act on the edit history — confusing mid-outpaint,
// and the review overlay's "Try again" covers the re-roll need.
_dimControls(node, ["_AngeloRerollBtn", "_AngeloVaryBtn"], outp);
// Quick Photo Refine is a Refine-mode action.
_dimControls(node, ["_AngeloQuickRow"], anySmart || outp);
// Outpaint row visibility + input mirrors.
syncOutpaintControls(node);
// Fine Upscale + Area Prompt toggles' displayed state is forced by
// the backend (ON for Smart Inpaint; Fine Upscale OFF for Smart
// Guided; Area Prompt ON for both). Re-run their sync so the labels
// reflect the forced state, and refresh the Area Prompt box + guided
// controls visibility.
syncFineUpscaleToggle(node);
syncAreaPromptToggle(node);
syncPersistentMaskToggle(node);
syncRestoreToggle(node);
syncRemoveToggle(node);
// Lite toggle just mirrors quick_lite (no mode forcing).
_syncToggle(node._AngeloLiteToggle, findWidget(node, "quick_lite")?.value, _TOGGLE_ON_COLORS.teal);
syncReferenceControls(node);
syncAreaPromptVisibility(node);
// Detect row hides in Smart Guided (no mask), shows in Refine/Smart Inpaint.
syncDetectControls(node);
}
function _syncNumberInput(wrap, widgetValue) {
if (!wrap || !wrap._AngeloInput || widgetValue == null) return;
if (wrap._AngeloInput.value !== String(widgetValue)) {
wrap._AngeloInput.value = String(widgetValue);
}
}
function syncClickRadiusInput(node) {
_syncNumberInput(node._AngeloClickRadiusInput, findWidget(node, "click_radius")?.value);
}
function syncFeatherInput(node) {
_syncNumberInput(node._AngeloFeatherInput, findWidget(node, "feather_radius")?.value);
}
function syncDenoiseInput(node) {
_syncNumberInput(node._AngeloDenoiseInput, findWidget(node, "denoise")?.value);
}
function syncSeedInput(node) {
_syncNumberInput(node._AngeloSeedInput, findWidget(node, "seed")?.value);
}
function syncSeedCtrlSelect(node) {
const wrap = node._AngeloSeedCtrlSelect;
const w = findWidget(node, "seed_control");
if (!wrap || !w || !wrap._AngeloSelect) return;
if (wrap._AngeloSelect.value !== String(w.value)) {
wrap._AngeloSelect.value = String(w.value);
}
}
/**
* Mode-state sync: when mode == "Sampler Mode" grey out the toolbar
* + canvas (controls inert); when "Edit Mode" un-grey. Also
* auto-forces sampler_seed_control to "fixed" when switching INTO
* Edit Mode, so subsequent Queue presses don't regenerate the
* cached base.
*/
function syncModeState(node) {
const modeW = findWidget(node, "mode");
if (!modeW) return;
const inSampler = String(modeW.value) === "Sampler Mode";
// Row 3 (mode + shared gen config) is ALWAYS active — both modes use
// steps/cfg/sampler/scheduler, and the Mode dropdown must always work.
//
// Refinement control rows (1+2) grey out in Sampler Mode — they don't
// apply while generating a base.
const refineRows = node._AngeloRefineRowsWrap;
if (refineRows) {
refineRows.style.opacity = inSampler ? "0.4" : "1";
refineRows.style.pointerEvents = inSampler ? "none" : "auto";
}
// Sampler-seed row (4) greys in Edit Mode — that seed group only
// drives the base generation.
const samplerSeedRow = node._AngeloSamplerSeedRow;
if (samplerSeedRow) {
samplerSeedRow.style.opacity = inSampler ? "1" : "0.4";
samplerSeedRow.style.pointerEvents = inSampler ? "auto" : "none";
}
// Detect row shows only in Edit Mode's masked sub-modes.
syncDetectControls(node);
// Cursor: default in Sampler Mode (clicks do nothing); crosshair
// in Smart Inpaint (rectangle drag); cell when paint mode is on
// for Refine; crosshair otherwise.
if (node._AngeloCanvas) {
if (inSampler || isSmartGuidedInpaintMode(node) || isOutpaintMode(node)) {
// Sampler Mode: clicks do nothing. Smart Guided: no canvas
// interaction (location dropdown + Generate button drive it).
// Outpaint: default until an edge-hover flips it to pointer.
node._AngeloCanvas.style.cursor = "default";
} else if (isSmartInpaintMode(node)) {
node._AngeloCanvas.style.cursor = "crosshair";
} else {
node._AngeloCanvas.style.cursor = isPaintModeOn(node) ? "cell" : "crosshair";
}
}
}
function syncModeSwitchToFixed(node, prevMode) {
// Called from the mode widget's callback. If switching INTO Refinement
// Mode, force sampler_seed_control to "fixed" AND restore sampler_seed
// to the seed Python actually used for the cached base (which after
// after-gen control may have drifted from the current widget value).
const modeW = findWidget(node, "mode");
if (!modeW) return;
const nowInRefine = String(modeW.value) === "Edit Mode";
if (nowInRefine && prevMode === "Sampler Mode") {
lockSeedToAtRun(node, "sampler_seed", "sampler_seed_control");
dbg("syncMode: forced sampler_seed_control → fixed + restored sampler_seed");
}
}
/**
* Apply after-gen control (fixed / increment / decrement / randomize) to
* a seed widget. ComfyUI's built-in seed widgets auto-attach this dropdown;
* ours are explicit ENUM widgets so we do the modification ourselves.
* Runs AFTER the response is processed so seed_at_run capture happens
* first (lock-on-fixed restores from the pre-modification value).
*/
function applyAfterGenControl(node, seedWidgetName, controlWidgetName) {
const seedW = findWidget(node, seedWidgetName);
const ctrlW = findWidget(node, controlWidgetName);
if (!seedW || !ctrlW) return;
const ctrl = String(ctrlW.value);
const maxSeed = 0xFFFFFFFFFFFFFFFF;
let cur = Number(seedW.value || 0);
let next = cur;
switch (ctrl) {
case "fixed": return; // no change
case "increment": next = (cur + 1) % (maxSeed + 1); break;
case "decrement": next = cur > 0 ? cur - 1 : maxSeed; break;
case "randomize":
// 53 bits is the safe integer range; that's plenty of seed
// entropy for any sampler.
next = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
break;
default: return;
}
setWidget(seedW, next);
// Mirror to the matching toolbar input.
if (seedWidgetName === "seed") {
syncSeedInput(node);
} else if (seedWidgetName === "sampler_seed") {
syncSamplerSeedInput(node);
}
dbg("after-gen", seedWidgetName, ctrl, cur, "→", next);
}
/**
* "Lock seed to the value Python actually used at the last run." Used when
* a control widget flips to "fixed" — restores the seed widget to what was
* sent to Python (before ComfyUI's after-gen modified it), then forces the
* control to "fixed". The "_AngeloSamplerSeedAtRun" / "_AngeloRefineSeedAtRun"
* fields are captured from the Angelo_*_seed_at_run keys in the ui message
* each onExecuted.
*/
function lockSeedToAtRun(node, seedWidgetName, controlWidgetName) {
const seedW = findWidget(node, seedWidgetName);
const ctrlW = findWidget(node, controlWidgetName);
if (!seedW || !ctrlW) return;
const stored = seedWidgetName === "sampler_seed"
? node._AngeloSamplerSeedAtRun
: node._AngeloRefineSeedAtRun;
// Always set control to fixed.
if (ctrlW.value !== "fixed") setWidget(ctrlW, "fixed");
// Restore seed value if we have a known-used value to fall back to.
if (stored != null) {
if (Number(seedW.value) !== Number(stored)) {
setWidget(seedW, Number(stored));
}
}
// Mirror to toolbar.
if (seedWidgetName === "seed") {
syncSeedInput(node);
syncSeedCtrlSelect(node);
} else if (seedWidgetName === "sampler_seed") {
syncSamplerSeedInput(node);
syncSamplerSeedCtrlSelect(node);
}
}
function syncMpInput(node) {
_syncNumberInput(node._AngeloMpInput, findWidget(node, "min_megapixels")?.value);
}
function syncMaxInput(node) {
_syncNumberInput(node._AngeloMaxInput, findWidget(node, "max_upscale")?.value);
}
function syncCtxPadInput(node) {
_syncNumberInput(node._AngeloCtxPadInput, findWidget(node, "fine_context_pad")?.value);
}
function syncMethodSelect(node) {
const wrap = node._AngeloMethodSelect;
const w = findWidget(node, "resize_method");
if (!wrap || !w || !wrap._AngeloSelect) return;
if (wrap._AngeloSelect.value !== String(w.value)) {
wrap._AngeloSelect.value = String(w.value);
}
}
// --- Sync helpers for the row 3/4 sampler + generation controls ---
function _syncDropdownWrap(wrap, widgetValue) {
if (!wrap || !wrap._AngeloSelect || widgetValue == null) return;
if (wrap._AngeloSelect.value !== String(widgetValue)) {
wrap._AngeloSelect.value = String(widgetValue);
}
}
function syncModeSelect(node) {
_syncDropdownWrap(node._AngeloModeSelect, findWidget(node, "mode")?.value);
}
function syncStepsInput(node) {
_syncNumberInput(node._AngeloStepsInput, findWidget(node, "steps")?.value);
}
function syncCfgInput(node) {
_syncNumberInput(node._AngeloCfgInput, findWidget(node, "cfg")?.value);
}
function syncSamplerSelect(node) {
_syncDropdownWrap(node._AngeloSamplerSelect, findWidget(node, "sampler_name")?.value);
}
function syncSchedulerSelect(node) {
_syncDropdownWrap(node._AngeloSchedulerSelect, findWidget(node, "scheduler")?.value);
}
function syncSamplerSeedInput(node) {
_syncNumberInput(node._AngeloSamplerSeedInput, findWidget(node, "sampler_seed")?.value);
}
function syncSamplerSeedCtrlSelect(node) {
_syncDropdownWrap(node._AngeloSamplerSeedCtrlSelect, findWidget(node, "sampler_seed_control")?.value);
}
function syncSamplerDenoiseInput(node) {
_syncNumberInput(node._AngeloSamplerDenoiseInput, findWidget(node, "sampler_denoise")?.value);
}
function syncGuidedLocationSelect(node) {
_syncDropdownWrap(node._AngeloGuidedLocationSelect, findWidget(node, "guided_location")?.value);
}
function syncInpaintModeSelect(node) {
const wrap = node._AngeloInpaintModeSelect;
const w = findWidget(node, "inpainting_mode");
if (!wrap || !w || !wrap._AngeloSelect) return;
if (wrap._AngeloSelect.value !== String(w.value)) {
wrap._AngeloSelect.value = String(w.value);
}
// Keep the locked-widget UI in sync — the mode value may have
// changed via a workflow load, undo, or any path other than the
// dropdown's own click handler.
syncSmartInpaintLockedWidgets(node);
}
// Reflect ALL persisted widget state into the DOM toolbar controls.
// Call this both on node creation AND on configure (workflow load) —
// onNodeCreated runs BEFORE ComfyUI restores serialized widget values,
// so a sync there alone leaves toggles like Paint Mode showing the
// default instead of the saved value. onConfigure fires after the
// restore, so a second pass there fixes the mismatch.
function syncAllToolbarControls(node) {
syncPersistentMaskToggle(node);
syncAreaPromptToggle(node);
syncPaintModeToggle(node);
syncRestoreToggle(node);
syncRemoveToggle(node);
syncReferenceControls(node);
syncQuickPromptSelect(node);
syncPromptSlotButtons(node);
syncFineUpscaleToggle(node);
syncClickRadiusInput(node);
syncFeatherInput(node);
syncDenoiseInput(node);
syncSeedInput(node);
syncSeedCtrlSelect(node);
syncMpInput(node);
syncMaxInput(node);
syncCtxPadInput(node);
syncMethodSelect(node);
syncInpaintModeSelect(node);
// Row 3/4 sampler + generation controls.
syncModeSelect(node);
syncStepsInput(node);
syncCfgInput(node);
syncSamplerSelect(node);
syncSchedulerSelect(node);
syncSamplerSeedInput(node);
syncSamplerSeedCtrlSelect(node);
syncSamplerDenoiseInput(node);
syncGuidedLocationSelect(node);
syncLoadImageControls(node);
syncOutpaintControls(node);
syncDetectControls(node);
syncSmartInpaintLockedWidgets(node);
syncAreaPromptBox(node);
syncAreaPromptVisibility(node);
syncModeState(node);
}
function makeDropdown(label, options, onChange) {
const wrap = document.createElement("div");
wrap.style.display = "inline-flex";
wrap.style.alignItems = "center";
wrap.style.gap = "4px";
wrap.style.fontSize = "11px";
wrap.style.color = "#bbb";
wrap.style.padding = "0 4px";
const lbl = document.createElement("span");
lbl.textContent = label;
wrap.appendChild(lbl);
const sel = document.createElement("select");
sel.style.fontSize = "11px";
sel.style.padding = "2px 4px";
sel.style.border = "1px solid #555";
sel.style.borderRadius = "3px";
sel.style.background = "#1a1a1a";
sel.style.color = "#ddd";
for (const opt of options) {
const o = document.createElement("option");
o.value = opt;
o.textContent = opt;
sel.appendChild(o);
}
sel.addEventListener("change", () => onChange(sel.value));
sel.addEventListener("mousedown", (e) => e.stopPropagation());
sel.addEventListener("pointerdown", (e) => e.stopPropagation());
wrap.appendChild(sel);
wrap._AngeloSelect = sel;
return wrap;
}
/**
* Small inline numeric input with a label. width is the input width in
* pixels (label adds ~20px). Calls onChange(numericValue) when committed.
*/
function makeNumberInput(label, opts, onChange) {
const wrap = document.createElement("div");
wrap.style.display = "inline-flex";
wrap.style.alignItems = "center";
wrap.style.gap = "4px";
wrap.style.fontSize = "11px";
wrap.style.color = "#bbb";
wrap.style.padding = "0 4px";
const lbl = document.createElement("span");
lbl.textContent = label;
wrap.appendChild(lbl);
const input = document.createElement("input");
input.type = "number";
input.min = String(opts.min ?? 0);
input.max = String(opts.max ?? 100);
input.step = String(opts.step ?? 1);
input.style.width = (opts.width || 60) + "px";
input.style.padding = "2px 4px";
input.style.fontSize = "11px";
input.style.border = "1px solid #555";
input.style.borderRadius = "3px";
input.style.background = "#1a1a1a";
input.style.color = "#ddd";
input.addEventListener("change", () => {
let v = parseFloat(input.value);
if (!isFinite(v)) v = opts.min ?? 0;
const lo = opts.min ?? -Infinity, hi = opts.max ?? Infinity;
v = Math.max(lo, Math.min(hi, v));
input.value = String(v);
onChange(v);
});
// Stop click on the input from selecting / dragging the node behind it.
input.addEventListener("mousedown", (e) => e.stopPropagation());
input.addEventListener("pointerdown", (e) => e.stopPropagation());
wrap.appendChild(input);
wrap._AngeloInput = input;
return wrap;
}
function setWidget(widget, value) {
if (!widget) return;
widget.value = value;
// Some ComfyUI versions sync the value-for-serialization through the
// widget's callback rather than reading widget.value directly. Fire
// it both with and without graph args to maximise compatibility.
try {
if (typeof widget.callback === "function") {
widget.callback(value, app.canvas, widget.node || null);
}
} catch (e) {
dbg("widget callback threw", widget.name, e);
}
}
function hideMechanicalWidgets(node) {
// JS-driven widgets that don't need to clutter the user-visible UI.
// Skipped while Angelo_DEBUG is on so we can watch them update.
if (Angelo_DEBUG) return;
const hideNames = [
// JS-driven plumbing (never user-visible)
"click_x", "click_y", "click_seq",
"image_w", "image_h", "undo_seq",
"stroke_points", "rect_points",
// Area Prompt text (driven by the DOM textarea below the canvas)
"area_text_positive", "area_text_negative",
// Smart Guided Inpaint location (driven by the DOM dropdown)
"guided_location",
// Load Image — driven by the Load Image button + popup
"loaded_image", "loaded_image_seq", "loaded_resize_mode", "loaded_target_mp",
// Detect (SAM 3 / YOLO) — driven by the Detect row + click-confirm
"seg_polygon",
// Detect touch-up brush — base64 raster mask
"seg_mask_png",
// Re-roll button — bumps to re-run the last edit with a new seed
"reroll_seq",
// Redo button — bumps to restore an edit that Undo removed
"redo_seq",
// Restore brush — driven by the Restore toggle on the toolbar
"restore_mode",
// Remove brush — driven by the Remove toggle on the toolbar
"remove_mode",
// Prompt slots — JSON state for the Area Prompt slot strip
"area_prompt_slots",
// Vary ×4 — driven by the Vary button + chooser overlay
"vary_seq", "vary_pick", "vary_pick_seq",
// Outpaint — driven by the Outpaint row + edge-click + review overlay
"outpaint_seq", "outpaint_dir", "outpaint_amount", "outpaint_overlap",
"outpaint_accept_seq", "outpaint_protect", "outpaint_instruction_pos",
// Reference — deprecated bool + the live strength value (Ref box)
"refine_reference", "reference_strength",
// Quick Photo Refine — driven by the ✨ button
"quick_refine_seq",
// ⬆ 2× Pixel — driven by its button
"upscale_seq",
// ⬇ Shrink — driven by the button + its popup
"shrink_seq", "shrink_scale",
// ✨ prompt selector + Lite toggle — driven by the dropdown/toggle
"quick_prompt_mode", "quick_lite",
// Toolbar-driven (visible via the bar above the canvas)
"persistent_mask", "area_prompt", "paint_mode", "fine_upscaling",
"click_radius", "feather_radius", "denoise",
"seed", "seed_control",
"min_megapixels", "max_upscale", "resize_method", "fine_context_pad",
"inpainting_mode",
// Sampler / generation config — now in toolbar rows 3 & 4
"mode", "sampler_denoise", "sampler_seed", "sampler_seed_control",
"steps", "cfg", "sampler_name", "scheduler",
// Deprecated control — always-on, kept declared for serialization
"auto_decode",
// Driven by the Reset button on the toolbar — no need to see the widget too
"reset",
];
if (!node.widgets) return;
for (const w of node.widgets) {
// Hide explicit-name matches PLUS any ComfyUI-auto-added
// "control_after_generate" dropdowns that may have been
// attached to seed widgets (legacy auto-attach for widgets
// literally named "seed"). The Python widgets opt out via
// `"control_after_generate": False`, but some ComfyUI versions
// ignore that opt-out, so this pattern-match catches the
// orphaned dropdowns either way.
const isAutoControl = typeof w.name === "string"
&& /control_after_generate/i.test(w.name);
if (!hideNames.includes(w.name) && !isAutoControl) continue;
// The "type = hidden" trick alone isn't enough — some renderers
// still reserve the widget's natural height. Belt + suspenders:
// use ComfyUI's own "converted-widget" type (which the frontend
// explicitly skips), set hidden=true (newer LiteGraph respects
// it), zero-out computeSize (-4 cancels the 4px inter-widget
// gap), and null the draw function so nothing paints even if a
// renderer iterates over hidden widgets.
w.type = "converted-widget";
w.hidden = true;
w.computeSize = () => [0, -4];
w.draw = () => {};
}
}
function makeViewUrl(ref) {
const params = new URLSearchParams();
params.set("filename", ref.filename);
if (ref.subfolder) params.set("subfolder", ref.subfolder);
if (ref.type) params.set("type", ref.type);
return `/view?${params.toString()}`;
}