Files
PozzettiAndrea-ComfyUI-SAM3/javascript/sam3_interactive_widget.js
T
PozzettiAndrea 4888e4886e Vendor viewer JS, drop comfy-3d-viewers
Vendor the viewer JavaScript into javascript/ and drop the
comfy-3d-viewers dependency. The viewers were previously copied into web/ at
every startup by prestartup_script.py, which made the served frontend depend on
a second package resolving at runtime.

Bundles are vendored as .mjs so ComfyUI's **/*.js auto-import glob skips them:
they are iframe-only and must never enter the shared main browser realm. Only
the widget entry points stay .js.

Also replace comfy_env.copy_files with a local helper. Its two extra features
(frame-derived source dir, silent no-op on a missing source) were unused here,
and the asset seeding now resolves the input directory through
folder_paths.get_input_directory() so it follows ComfyUI Desktop and
--input-directory instead of writing into the code tree.

comfy-test.toml moves to the current schema: [test.workflows] cuda (there is no
"gpu" key -- the accelerator is named by backend, and the old spelling parsed to
an empty list, so CUDA runners silently ran the full unfiltered set), an
explicit [test.platforms] platforms allowlist (per-platform booleans are no
longer supported), and the javascript level.

Namespace every registerExtension name under sam3. and inline the spinner
styles: the stylesheet used to be injected into document.head, which is shared
state every other pack inherits and nobody can clean up. It is now inline on the
element with the rotation driven by the Web Animations API.
2026-08-22 19:44:03 +02:00

906 lines
40 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.
/**
* SAM3 Interactive Collector Widget
* Multi-region prompt collection with live segmentation preview via "Run" button.
* Based on sam3_multiregion_widget.js
*/
import { app } from "../../scripts/app.js";
const PROMPT_COLORS = [
{ name: "cyan", primary: "#00FFFF", dim: "#006666" },
{ name: "yellow", primary: "#FFFF00", dim: "#666600" },
{ name: "magenta", primary: "#FF00FF", dim: "#660066" },
{ name: "lime", primary: "#00FF00", dim: "#006600" },
{ name: "orange", primary: "#FF8000", dim: "#663300" },
{ name: "pink", primary: "#FF69B4", dim: "#662944" },
{ name: "blue", primary: "#4169E1", dim: "#1a2a5c" },
{ name: "teal", primary: "#20B2AA", dim: "#0d4744" },
];
const MAX_PROMPTS = PROMPT_COLORS.length;
function hideWidgetForGood(node, widget, suffix = '') {
if (!widget) return;
widget.origType = widget.type;
widget.origComputeSize = widget.computeSize;
widget.computeSize = () => [0, -4];
widget.type = "converted-widget" + suffix;
widget.hidden = true;
if (widget.element) {
widget.element.style.display = "none";
widget.element.style.visibility = "hidden";
}
}
// Spinner styling is inlined on the element and animated via the Web
// Animations API rather than an injected <style>. A stylesheet in
// document.head is shared-realm state every other extension inherits and
// nobody can clean up; this keeps the whole spinner inside our own DOM.
function makeSpinner() {
const spinner = document.createElement("span");
spinner.style.cssText =
"display: inline-block; width: 10px; height: 10px;" +
"border: 2px solid rgba(136, 204, 255, 0.2); border-top-color: #8cf;" +
"border-radius: 50%; vertical-align: middle; margin-left: 4px;" +
"flex-shrink: 0;";
spinner.animate(
[{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }],
{ duration: 650, iterations: Infinity, easing: "linear" },
);
return spinner;
}
app.registerExtension({
name: "sam3.interactivecollector",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "SAM3InteractiveCollector") return;
console.log("[SAM3] Registering SAM3InteractiveCollector node");
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
// ---- DOM structure ----
const container = document.createElement("div");
container.style.cssText = "position: relative; width: 100%; background: #222; overflow: hidden; box-sizing: border-box; display: flex; flex-direction: column;";
// Info bar (top)
const infoBar = document.createElement("div");
infoBar.style.cssText = "position: absolute; top: 5px; left: 5px; right: 5px; z-index: 10; display: flex; justify-content: space-between; align-items: center;";
container.appendChild(infoBar);
// Counter
const counter = document.createElement("div");
counter.style.cssText = "padding: 5px 10px; background: rgba(0,0,0,0.7); color: #fff; border-radius: 3px; font-size: 12px; font-family: monospace;";
counter.textContent = "Prompt 1: 0 pts, 0 boxes";
infoBar.appendChild(counter);
// Button container
const buttonContainer = document.createElement("div");
buttonContainer.style.cssText = "display: flex; gap: 5px;";
infoBar.appendChild(buttonContainer);
// ---- RUN button (the key addition) ----
const runBtn = document.createElement("button");
runBtn.textContent = "Run";
runBtn.style.cssText = "padding: 5px 14px; background: #2a7a2a; color: #fff; border: 1px solid #3a9a3a; border-radius: 3px; cursor: pointer; font-size: 12px; font-weight: bold;";
runBtn.onmouseover = () => { if (!runBtn.disabled) runBtn.style.background = "#3a9a3a"; };
runBtn.onmouseout = () => { if (!runBtn.disabled) runBtn.style.background = "#2a7a2a"; };
buttonContainer.appendChild(runBtn);
// Clear Prompt button
const clearPromptBtn = document.createElement("button");
clearPromptBtn.textContent = "Clear Prompt";
clearPromptBtn.style.cssText = "padding: 5px 10px; background: #a50; color: #fff; border: 1px solid #830; border-radius: 3px; cursor: pointer; font-size: 11px;";
clearPromptBtn.onmouseover = () => clearPromptBtn.style.background = "#c60";
clearPromptBtn.onmouseout = () => clearPromptBtn.style.background = "#a50";
buttonContainer.appendChild(clearPromptBtn);
// Clear All button
const clearAllBtn = document.createElement("button");
clearAllBtn.textContent = "Clear All";
clearAllBtn.style.cssText = "padding: 5px 10px; background: #d44; color: #fff; border: 1px solid #a22; border-radius: 3px; cursor: pointer; font-size: 11px;";
clearAllBtn.onmouseover = () => clearAllBtn.style.background = "#e55";
clearAllBtn.onmouseout = () => clearAllBtn.style.background = "#d44";
buttonContainer.appendChild(clearAllBtn);
// Canvas wrapper
const canvasWrapper = document.createElement("div");
canvasWrapper.style.cssText = "flex: 1; display: flex; align-items: center; justify-content: center; min-height: 200px;";
container.appendChild(canvasWrapper);
// Canvas
const canvas = document.createElement("canvas");
canvas.width = 512;
canvas.height = 512;
canvas.style.cssText = "display: block; max-width: 100%; max-height: 100%; object-fit: contain; cursor: crosshair;";
canvasWrapper.appendChild(canvas);
const ctx = canvas.getContext("2d");
// Tab bar (bottom)
const tabBar = document.createElement("div");
tabBar.style.cssText = "display: flex; flex-wrap: wrap; gap: 4px; padding: 6px; background: #1a1a1a; border-top: 1px solid #333;";
container.appendChild(tabBar);
// Queue panel — shown below tab bar when something is running/queued
const queuePanel = document.createElement("div");
queuePanel.style.cssText = "display: none; padding: 4px 8px; background: #111; border-top: 1px solid #2a2a2a; font-size: 11px; font-family: monospace; color: #aaa;";
container.appendChild(queuePanel);
// State
this.canvasWidget = {
canvas, ctx, container, canvasWrapper,
image: null,
overlayImage: null, // mask overlay from Run
overlayStale: false, // true when prompts changed since last Run
modelReady: false, // true after first successful execute()
isProcessing: false, // true while the processing loop is active
promptQueue: [], // {prompt, index} entries waiting to be processed
completedPrompts: new Set(), // prompt objects successfully segmented
queuePanel,
prompts: [{
positive_points: [],
negative_points: [],
positive_boxes: [],
negative_boxes: [],
name: "Prompt 1",
}],
activePromptIndex: 0,
currentBox: null,
isDrawingBox: false,
hoveredItem: null,
tabBar, counter, runBtn
};
// Add DOM widget
const widget = this.addDOMWidget("canvas", "customCanvas", container);
this.canvasWidget.domWidget = widget;
widget.computeSize = (width) => {
const nodeHeight = this.size ? this.size[1] : 520;
return [width, Math.max(250, nodeHeight - 80)];
};
this.rebuildTabBar();
this.updateRunButton();
// ---- Run button handler ----
runBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
this.runInteractiveSegment();
});
clearPromptBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
this.clearActivePrompt();
});
clearAllBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
this.clearAllPrompts();
});
// Hide storage widget
const storeWidget = this.widgets.find(w => w.name === "multi_prompts_store");
if (storeWidget) {
storeWidget.value = storeWidget.value || "[]";
this._hiddenWidgets = { multi_prompts_store: storeWidget };
hideWidgetForGood(this, storeWidget);
}
// Override draw foreground
const originalDrawForeground = this.onDrawForeground;
this.onDrawForeground = function(ctx) {
const hiddenWidgets = this.widgets.filter(w => w.type?.includes("converted-widget"));
const originalTypes = hiddenWidgets.map(w => w.type);
hiddenWidgets.forEach(w => w.type = null);
if (originalDrawForeground) originalDrawForeground.apply(this, arguments);
hiddenWidgets.forEach((w, i) => w.type = originalTypes[i]);
const containerHeight = Math.max(250, this.size[1] - 80);
if (container.style.height !== containerHeight + "px") {
container.style.height = containerHeight + "px";
}
};
// ---- Mouse events (same as multiregion) ----
canvas.addEventListener("mousedown", (e) => {
const rect = canvas.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * canvas.width;
const y = ((e.clientY - rect.top) / rect.height) * canvas.height;
const activePrompt = this.canvasWidget.prompts[this.canvasWidget.activePromptIndex];
const isNegative = e.button === 2;
if (e.shiftKey) {
this.canvasWidget.currentBox = { x1: x, y1: y, x2: x, y2: y, isNegative };
this.canvasWidget.isDrawingBox = true;
return;
}
const pointList = isNegative ? activePrompt.negative_points : activePrompt.positive_points;
pointList.push({ x, y });
this.updateStorage();
this.redrawCanvas();
});
canvas.addEventListener("mousemove", (e) => {
const rect = canvas.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * canvas.width;
const y = ((e.clientY - rect.top) / rect.height) * canvas.height;
if (this.canvasWidget.isDrawingBox && this.canvasWidget.currentBox) {
this.canvasWidget.currentBox.x2 = x;
this.canvasWidget.currentBox.y2 = y;
this.redrawCanvas();
} else {
const hovered = this.findItemAt(x, y);
if (hovered !== this.canvasWidget.hoveredItem) {
this.canvasWidget.hoveredItem = hovered;
this.redrawCanvas();
}
}
});
canvas.addEventListener("mouseup", (e) => {
if (this.canvasWidget.isDrawingBox && this.canvasWidget.currentBox) {
const box = this.canvasWidget.currentBox;
const width = Math.abs(box.x2 - box.x1);
const height = Math.abs(box.y2 - box.y1);
if (width > 5 && height > 5) {
const normalizedBox = {
x1: Math.min(box.x1, box.x2),
y1: Math.min(box.y1, box.y2),
x2: Math.max(box.x1, box.x2),
y2: Math.max(box.y1, box.y2)
};
const activePrompt = this.canvasWidget.prompts[this.canvasWidget.activePromptIndex];
const boxList = box.isNegative ? activePrompt.negative_boxes : activePrompt.positive_boxes;
boxList.push(normalizedBox);
this.updateStorage();
}
this.canvasWidget.currentBox = null;
this.canvasWidget.isDrawingBox = false;
this.redrawCanvas();
}
});
canvas.addEventListener("contextmenu", (e) => {
e.preventDefault();
canvas.dispatchEvent(new MouseEvent('mousedown', {
button: 2,
clientX: e.clientX,
clientY: e.clientY,
shiftKey: e.shiftKey
}));
});
// ---- onExecuted — receive images from workflow execution ----
this.onExecuted = (message) => {
this.canvasWidget.modelReady = true;
if (message.bg_image && message.bg_image[0]) {
const img = new Image();
img.onload = () => {
this.canvasWidget.image = img;
canvas.width = img.width;
canvas.height = img.height;
this.redrawCanvas();
};
img.src = "data:image/jpeg;base64," + message.bg_image[0];
}
if (message.overlay_image && message.overlay_image[0]) {
const oimg = new Image();
oimg.onload = () => {
this.canvasWidget.overlayImage = oimg;
this.canvasWidget.overlayStale = false;
this.redrawCanvas();
};
oimg.src = "data:image/jpeg;base64," + message.overlay_image[0];
}
};
// Handle resize
const originalOnResize = this.onResize;
this.onResize = function(size) {
if (originalOnResize) originalOnResize.apply(this, arguments);
container.style.height = Math.max(250, size[1] - 80) + "px";
};
this.redrawCanvas();
this.setSize([400, 520]);
container.style.height = "440px";
return result;
};
// ---- Restore on configure ----
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(info) {
onConfigure?.apply(this, arguments);
const storeWidget = this._hiddenWidgets?.multi_prompts_store;
if (storeWidget && storeWidget.value) {
try {
const stored = JSON.parse(storeWidget.value);
if (Array.isArray(stored) && stored.length > 0) {
// Ensure each restored prompt has a name
stored.forEach((p, i) => {
if (!p.name) p.name = `Prompt ${i + 1}`;
});
this.canvasWidget.prompts = stored;
this.canvasWidget.activePromptIndex = 0;
this.rebuildTabBar();
this.redrawCanvas();
}
} catch (e) {
console.log("[SAM3] Failed to restore prompts:", e);
}
}
};
// ---- Run: dispatch only the active prompt ----
nodeType.prototype.runInteractiveSegment = function() {
const cw = this.canvasWidget;
const idx = cw.activePromptIndex;
const prompt = cw.prompts[idx];
// Already queued or running — button should be disabled, but guard here too
if (prompt.isRunning || prompt.isPending) return;
// Nothing to dispatch
const hasContent = prompt.positive_points.length > 0 || prompt.negative_points.length > 0 ||
prompt.positive_boxes.length > 0 || prompt.negative_boxes.length > 0;
if (!hasContent) return;
// Enqueue this prompt
prompt.isPending = true;
cw.promptQueue.push({ prompt, index: idx });
this.rebuildTabBar();
this.updateRunButton();
this.updateQueuePanel();
// Open an empty tab so the user can draw the next prompt immediately
if (!this.hasEmptyPrompt()) this.addNewPrompt();
// Kick off the processing loop if it isn't already running
this.processQueue();
};
// ---- Auto-queue workflow to populate model cache ----
nodeType.prototype.ensureModelLoaded = function() {
const cw = this.canvasWidget;
if (cw.modelReady) return Promise.resolve();
// Queue the workflow so execute() runs, which loads the model + image
return new Promise((resolve) => {
const origOnExecuted = this.onExecuted;
this.onExecuted = (message) => {
origOnExecuted.call(this, message);
resolve();
};
console.log("[SAM3] Model not loaded — auto-queuing workflow...");
app.queuePrompt(0, 1);
});
};
// ---- Sequential processing loop ----
nodeType.prototype.processQueue = async function() {
const cw = this.canvasWidget;
if (cw.isProcessing) return;
cw.isProcessing = true;
// If model hasn't been loaded yet, auto-queue the workflow first
if (!cw.modelReady) {
this.updateQueuePanel();
await this.ensureModelLoaded();
}
while (cw.promptQueue.length > 0) {
const { prompt, index } = cw.promptQueue.shift();
this.updateQueuePanel();
prompt.isPending = false;
prompt.isRunning = true;
this.rebuildTabBar();
this.updateRunButton();
const name = prompt.name;
console.log(`[SAM3] Prompt "${name}" dispatched`);
try {
const resp = await fetch("/sam3/interactive_segment_one", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
node_id: String(this.id),
prompt,
prompt_name: name,
prompt_index: index,
})
});
const data = await resp.json();
if (!resp.ok || data.error) {
console.error(`[SAM3] Prompt "${name}" error:`, data.error);
} else {
console.log(`[SAM3] Prompt "${name}" result received`);
cw.completedPrompts.add(prompt);
if (data.overlay) {
const oimg = new Image();
oimg.onload = () => {
cw.overlayImage = oimg;
cw.overlayStale = false;
this.redrawCanvas();
};
oimg.src = "data:image/jpeg;base64," + data.overlay;
}
}
} catch (err) {
console.error(`[SAM3] Prompt "${name}" fetch failed:`, err);
} finally {
prompt.isRunning = false;
prompt.isPending = false;
this.rebuildTabBar();
this.updateRunButton();
}
}
// Loop done — sweep any stuck flags
cw.prompts.forEach(p => { p.isRunning = false; p.isPending = false; });
cw.isProcessing = false;
this.rebuildTabBar();
this.updateRunButton();
this.updateQueuePanel();
};
// ---- Run button state ----
nodeType.prototype.updateRunButton = function() {
const cw = this.canvasWidget;
const active = cw.prompts[cw.activePromptIndex];
const blocked = active && (active.isRunning || active.isPending);
cw.runBtn.textContent = "Run";
cw.runBtn.disabled = !!blocked;
cw.runBtn.style.background = blocked ? "#333" : "#2a7a2a";
cw.runBtn.style.borderColor = blocked ? "#444" : "#3a9a3a";
cw.runBtn.style.color = blocked ? "#555" : "#fff";
};
// ---- Tab bar ----
nodeType.prototype.rebuildTabBar = function() {
const tabBar = this.canvasWidget.tabBar;
tabBar.innerHTML = "";
this.canvasWidget.prompts.forEach((prompt, idx) => {
const tab = document.createElement("div");
const color = PROMPT_COLORS[idx % PROMPT_COLORS.length];
const isActive = idx === this.canvasWidget.activePromptIndex;
tab.style.cssText = `
display: flex; align-items: center; gap: 6px;
padding: 4px 8px; background: ${isActive ? '#333' : '#2a2a2a'};
border: 1px solid ${isActive ? color.primary : '#444'};
border-radius: 4px; cursor: pointer; font-size: 11px;
color: ${isActive ? '#fff' : '#aaa'};
`;
const colorDot = document.createElement("span");
colorDot.style.cssText = `width: 10px; height: 10px; border-radius: 2px; background: ${color.primary}; flex-shrink: 0;`;
tab.appendChild(colorDot);
// Ensure every prompt has a stable name before rendering
if (!prompt.name) {
prompt.name = `Prompt ${idx + 1}`;
}
// Rename-able label
const label = document.createElement("span");
label.textContent = prompt.name;
label.dataset.renameTarget = String(idx);
label.title = "Right-click to rename";
label.style.cssText = "cursor: default; user-select: none; min-width: 40px;";
label.oncontextmenu = (e) => {
e.preventDefault();
e.stopPropagation();
this.startRename(idx);
};
tab.appendChild(label);
// Running: full CSS circle spinner
if (this.canvasWidget.isProcessing && prompt.isRunning) {
tab.appendChild(makeSpinner());
// Pending: waiting its turn in the queue
} else if (this.canvasWidget.isProcessing && prompt.isPending) {
const dots = document.createElement("span");
dots.textContent = "...";
dots.style.cssText = "color: #555; margin-left: 4px; font-size: 10px; letter-spacing: 1px; vertical-align: middle;";
tab.appendChild(dots);
}
if (this.canvasWidget.prompts.length > 1) {
const deleteBtn = document.createElement("span");
deleteBtn.textContent = "×";
deleteBtn.style.cssText = "color: #888; cursor: pointer; font-size: 14px; padding: 0 2px; margin-left: 2px;";
deleteBtn.onmouseover = () => deleteBtn.style.color = "#f00";
deleteBtn.onmouseout = () => deleteBtn.style.color = "#888";
deleteBtn.onclick = (e) => {
e.stopPropagation();
this.deletePrompt(idx);
};
tab.appendChild(deleteBtn);
}
tab.onclick = () => this.setActivePrompt(idx);
tab.onmouseover = () => { if (!isActive) tab.style.background = '#3a3a3a'; };
tab.onmouseout = () => { if (!isActive) tab.style.background = '#2a2a2a'; };
tabBar.appendChild(tab);
});
if (this.canvasWidget.prompts.length < MAX_PROMPTS) {
const addBtn = document.createElement("button");
addBtn.textContent = "+";
addBtn.style.cssText = `
padding: 4px 12px; background: #2a5a2a; border: 1px solid #3a7a3a;
border-radius: 4px; color: #8f8; cursor: pointer; font-size: 14px; font-weight: bold;
`;
addBtn.onmouseover = () => addBtn.style.background = "#3a6a3a";
addBtn.onmouseout = () => addBtn.style.background = "#2a5a2a";
addBtn.onclick = () => this.addNewPrompt();
tabBar.appendChild(addBtn);
}
this.updateCounter();
};
// ---- Inline rename ----
nodeType.prototype.startRename = function(idx) {
// Rebuild tab bar with this tab active first
this.rebuildTabBar();
// Find the label by data attribute
const labelEl = this.canvasWidget.tabBar.querySelector(`[data-rename-target="${idx}"]`);
if (!labelEl) return;
const prompt = this.canvasWidget.prompts[idx];
const color = PROMPT_COLORS[idx % PROMPT_COLORS.length];
const currentName = prompt.name || `Prompt ${idx + 1}`;
const input = document.createElement("input");
input.type = "text";
input.value = currentName;
input.style.cssText = `
background: #1a1a1a; color: #fff;
border: 1px solid ${color.primary}; border-radius: 2px;
font-size: 11px; width: 80px; padding: 1px 3px;
outline: none;
`;
// Swap label for input
labelEl.replaceWith(input);
input.focus();
input.select();
const commit = () => {
const newName = input.value.trim() || `Prompt ${idx + 1}`;
prompt.name = newName;
this.updateStorage();
this.rebuildTabBar();
};
input.onblur = commit;
input.onkeydown = (e) => {
if (e.key === "Enter") { e.preventDefault(); input.blur(); }
if (e.key === "Escape") {
input.value = currentName;
input.blur();
}
e.stopPropagation();
};
// Don't trigger tab switch while typing
input.onclick = (e) => e.stopPropagation();
};
// ---- Queue panel ----
nodeType.prototype.updateQueuePanel = function() {
const cw = this.canvasWidget;
const panel = cw.queuePanel;
const n = cw.promptQueue.length;
if (n === 0) {
panel.style.display = "none";
return;
}
panel.style.display = "block";
panel.textContent = `${n} prompt${n !== 1 ? "s" : ""} queued`;
};
nodeType.prototype.setActivePrompt = function(index) {
this.canvasWidget.activePromptIndex = index;
this.rebuildTabBar();
this.updateRunButton();
this.redrawCanvas();
};
nodeType.prototype.addNewPrompt = function() {
if (this.canvasWidget.prompts.length >= MAX_PROMPTS) return;
const newIndex = this.canvasWidget.prompts.length + 1;
this.canvasWidget.prompts.push({
positive_points: [],
negative_points: [],
positive_boxes: [],
negative_boxes: [],
name: `Prompt ${newIndex}`,
});
this.canvasWidget.activePromptIndex = this.canvasWidget.prompts.length - 1;
this.rebuildTabBar();
this.updateRunButton();
this.updateStorage();
this.redrawCanvas();
};
nodeType.prototype.deletePrompt = function(index) {
const cw = this.canvasWidget;
if (cw.prompts.length <= 1) {
this.clearActivePrompt();
return;
}
const removed = cw.prompts[index];
cw.prompts.splice(index, 1);
// Clean up queue and completed set for the removed prompt
cw.promptQueue = cw.promptQueue.filter(e => e.prompt !== removed);
cw.completedPrompts.delete(removed);
if (cw.activePromptIndex >= cw.prompts.length) {
cw.activePromptIndex = cw.prompts.length - 1;
}
this.rebuildTabBar();
this.updateRunButton();
this.updateQueuePanel();
this.updateStorage();
this.redrawCanvas();
};
nodeType.prototype.clearActivePrompt = function() {
const prompt = this.canvasWidget.prompts[this.canvasWidget.activePromptIndex];
prompt.positive_points = [];
prompt.negative_points = [];
prompt.positive_boxes = [];
prompt.negative_boxes = [];
this.updateStorage();
this.redrawCanvas();
};
nodeType.prototype.clearAllPrompts = function() {
const cw = this.canvasWidget;
cw.prompts = [{
positive_points: [],
negative_points: [],
positive_boxes: [],
negative_boxes: [],
name: "Prompt 1",
}];
cw.activePromptIndex = 0;
cw.overlayImage = null;
cw.overlayStale = false;
cw.promptQueue = [];
cw.completedPrompts.clear();
this.rebuildTabBar();
this.updateRunButton();
this.updateQueuePanel();
this.updateStorage();
this.redrawCanvas();
};
nodeType.prototype.hasEmptyPrompt = function() {
return this.canvasWidget.prompts.some(p =>
p.positive_points.length === 0 &&
p.negative_points.length === 0 &&
p.positive_boxes.length === 0 &&
p.negative_boxes.length === 0
);
};
nodeType.prototype.findItemAt = function(x, y) {
const threshold = 10;
const pIdx = this.canvasWidget.activePromptIndex;
const prompt = this.canvasWidget.prompts[pIdx];
for (let i = 0; i < prompt.positive_points.length; i++) {
const pt = prompt.positive_points[i];
if (Math.abs(pt.x - x) < threshold && Math.abs(pt.y - y) < threshold)
return { type: "point", index: i, promptIndex: pIdx, isNegative: false };
}
for (let i = 0; i < prompt.negative_points.length; i++) {
const pt = prompt.negative_points[i];
if (Math.abs(pt.x - x) < threshold && Math.abs(pt.y - y) < threshold)
return { type: "point", index: i, promptIndex: pIdx, isNegative: true };
}
for (let i = 0; i < prompt.positive_boxes.length; i++) {
const box = prompt.positive_boxes[i];
if (x >= box.x1 && x <= box.x2 && y >= box.y1 && y <= box.y2)
return { type: "box", index: i, promptIndex: pIdx, isNegative: false };
}
for (let i = 0; i < prompt.negative_boxes.length; i++) {
const box = prompt.negative_boxes[i];
if (x >= box.x1 && x <= box.x2 && y >= box.y1 && y <= box.y2)
return { type: "box", index: i, promptIndex: pIdx, isNegative: true };
}
return null;
};
nodeType.prototype.updateStorage = function() {
const widget = this._hiddenWidgets?.multi_prompts_store;
if (widget) {
// Strip transient runtime state before serializing
const toStore = this.canvasWidget.prompts.map(p => ({
positive_points: p.positive_points,
negative_points: p.negative_points,
positive_boxes: p.positive_boxes,
negative_boxes: p.negative_boxes,
name: p.name,
}));
widget.value = JSON.stringify(toStore);
}
// Mark overlay as stale since prompts changed
this.canvasWidget.overlayStale = true;
this.updateCounter();
};
nodeType.prototype.updateCounter = function() {
const idx = this.canvasWidget.activePromptIndex;
const prompt = this.canvasWidget.prompts[idx];
const pts = prompt.positive_points.length + prompt.negative_points.length;
const boxes = prompt.positive_boxes.length + prompt.negative_boxes.length;
this.canvasWidget.counter.textContent = `${prompt.name}: ${pts} pts, ${boxes} boxes`;
};
// ---- Canvas rendering ----
nodeType.prototype.redrawCanvas = function() {
const { canvas, ctx, image, overlayImage, overlayStale,
prompts, activePromptIndex, currentBox, hoveredItem } = this.canvasWidget;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw background: overlay (if exists) or original image
if (overlayImage) {
if (overlayStale) {
ctx.globalAlpha = 0.45;
ctx.drawImage(overlayImage, 0, 0, canvas.width, canvas.height);
ctx.globalAlpha = 1.0;
// Draw "stale" indicator
ctx.fillStyle = "rgba(0,0,0,0.6)";
ctx.fillRect(canvas.width / 2 - 55, canvas.height - 50, 110, 24);
ctx.fillStyle = "#ff8";
ctx.font = "12px monospace";
ctx.textAlign = "center";
ctx.fillText("prompts changed", canvas.width / 2, canvas.height - 33);
} else {
ctx.drawImage(overlayImage, 0, 0, canvas.width, canvas.height);
}
} else if (image) {
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
} else {
ctx.fillStyle = "#333";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#666";
ctx.font = "14px sans-serif";
ctx.textAlign = "center";
ctx.fillText("Queue workflow to load image (Ctrl+Enter)", canvas.width / 2, canvas.height / 2 - 20);
ctx.fillText("Click: Positive point | Right-click: Negative point", canvas.width / 2, canvas.height / 2 + 5);
ctx.fillText("Shift+Drag: Box | Then click Run for live masks", canvas.width / 2, canvas.height / 2 + 30);
}
// Draw active prompt annotations
const prompt = prompts[activePromptIndex];
const color = PROMPT_COLORS[activePromptIndex % PROMPT_COLORS.length];
this.drawBoxes(ctx, prompt.positive_boxes, color.primary, 1.0, false, activePromptIndex, hoveredItem);
this.drawBoxes(ctx, prompt.negative_boxes, color.primary, 1.0, true, activePromptIndex, hoveredItem);
this.drawPoints(ctx, prompt.positive_points, color.primary, 1.0, false, activePromptIndex, hoveredItem);
this.drawPoints(ctx, prompt.negative_points, color.primary, 1.0, true, activePromptIndex, hoveredItem);
// Current box being drawn
if (currentBox) {
const c = PROMPT_COLORS[activePromptIndex % PROMPT_COLORS.length];
ctx.setLineDash([5, 5]);
ctx.strokeStyle = currentBox.isNegative ? "#f80" : c.primary;
ctx.lineWidth = 2;
const w = currentBox.x2 - currentBox.x1;
const h = currentBox.y2 - currentBox.y1;
ctx.strokeRect(currentBox.x1, currentBox.y1, w, h);
ctx.setLineDash([]);
ctx.fillStyle = currentBox.isNegative ? "rgba(255,128,0,0.1)" : this.colorWithAlpha(c.primary, 0.1);
ctx.fillRect(currentBox.x1, currentBox.y1, w, h);
}
// Image dimensions
if (image || overlayImage) {
ctx.fillStyle = "rgba(0,0,0,0.7)";
ctx.fillRect(5, canvas.height - 25, 150, 20);
ctx.fillStyle = "#0f0";
ctx.font = "12px monospace";
ctx.textAlign = "left";
ctx.fillText(`Image: ${canvas.width}x${canvas.height}`, 10, canvas.height - 10);
}
};
nodeType.prototype.drawPoints = function(ctx, points, color, alpha, isNegative, promptIndex, hoveredItem) {
const canvas = this.canvasWidget.canvas;
const scaleFactor = Math.max(0.5, canvas.height / 1080);
const baseRadius = 6 * scaleFactor;
const hoverRadius = 8 * scaleFactor;
for (let i = 0; i < points.length; i++) {
const pt = points[i];
const isHovered = hoveredItem?.type === "point" &&
hoveredItem?.promptIndex === promptIndex &&
hoveredItem?.index === i &&
hoveredItem?.isNegative === isNegative;
const radius = isHovered ? hoverRadius : baseRadius;
ctx.beginPath();
ctx.arc(pt.x, pt.y, radius, 0, 2 * Math.PI);
if (isNegative) {
ctx.fillStyle = `rgba(255, 0, 0, ${alpha * 0.8})`;
} else {
ctx.fillStyle = this.colorWithAlpha(color, alpha * 0.8);
}
ctx.fill();
ctx.strokeStyle = isHovered ? "#fff" : this.colorWithAlpha(color, alpha);
ctx.lineWidth = (isHovered ? 3 : 2) * scaleFactor;
ctx.stroke();
if (isNegative) {
const xSize = 3 * scaleFactor;
ctx.strokeStyle = "#fff";
ctx.lineWidth = 2 * scaleFactor;
ctx.beginPath();
ctx.moveTo(pt.x - xSize, pt.y - xSize);
ctx.lineTo(pt.x + xSize, pt.y + xSize);
ctx.moveTo(pt.x + xSize, pt.y - xSize);
ctx.lineTo(pt.x - xSize, pt.y + xSize);
ctx.stroke();
}
}
};
nodeType.prototype.drawBoxes = function(ctx, boxes, color, alpha, isNegative, promptIndex, hoveredItem) {
for (let i = 0; i < boxes.length; i++) {
const box = boxes[i];
const w = box.x2 - box.x1;
const h = box.y2 - box.y1;
const isHovered = hoveredItem?.type === "box" &&
hoveredItem?.promptIndex === promptIndex &&
hoveredItem?.index === i &&
hoveredItem?.isNegative === isNegative;
if (isNegative) {
ctx.fillStyle = `rgba(255, 0, 0, ${alpha * 0.15})`;
} else {
ctx.fillStyle = this.colorWithAlpha(color, alpha * 0.15);
}
ctx.fillRect(box.x1, box.y1, w, h);
ctx.strokeStyle = isHovered ? "#fff" : (isNegative ? `rgba(255,0,0,${alpha})` : this.colorWithAlpha(color, alpha));
ctx.lineWidth = isHovered ? 3 : 2;
if (isNegative) ctx.setLineDash([4, 4]);
ctx.strokeRect(box.x1, box.y1, w, h);
ctx.setLineDash([]);
}
};
nodeType.prototype.colorWithAlpha = function(hexColor, alpha) {
const r = parseInt(hexColor.slice(1, 3), 16);
const g = parseInt(hexColor.slice(3, 5), 16);
const b = parseInt(hexColor.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
}
});