915 lines
40 KiB
JavaScript
915 lines
40 KiB
JavaScript
/**
|
||
* 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";
|
||
}
|
||
}
|
||
|
||
function ensureSpinnerCSS() {
|
||
if (document.getElementById("sam3-spinner-css")) return;
|
||
const style = document.createElement("style");
|
||
style.id = "sam3-spinner-css";
|
||
style.textContent = `
|
||
@keyframes sam3spin {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
.sam3-spinner {
|
||
display: inline-block;
|
||
width: 10px; height: 10px;
|
||
border: 2px solid rgba(136, 204, 255, 0.2);
|
||
border-top-color: #8cf;
|
||
border-radius: 50%;
|
||
animation: sam3spin 0.65s linear infinite;
|
||
vertical-align: middle;
|
||
margin-left: 4px;
|
||
flex-shrink: 0;
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
}
|
||
|
||
app.registerExtension({
|
||
name: "Comfy.SAM3.InteractiveCollector",
|
||
|
||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||
if (nodeData.name !== "SAM3InteractiveCollector") return;
|
||
|
||
console.log("[SAM3] Registering SAM3InteractiveCollector node");
|
||
ensureSpinnerCSS();
|
||
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) {
|
||
const spinner = document.createElement("span");
|
||
spinner.className = "sam3-spinner";
|
||
tab.appendChild(spinner);
|
||
// 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})`;
|
||
};
|
||
}
|
||
});
|