Files
Raykosan-ComfyUI_RaykoStudio/web/image_selector.js
T
2026-03-26 17:26:16 +03:00

443 lines
17 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.
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
app.registerExtension({
name: "RaykoStudio.ImageSelector",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "RS Image Selector") return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) onNodeCreated.apply(this, arguments);
this.images = [];
this.selectedIndices = new Set();
this.imagePaths = [];
this.userResized = false;
this.heartbeatInterval = null;
this.setSize([450, 500]);
this.buttons = [
{ label: "➕ SELECT ALL", key: "select_all", color: "#2196F3", hover: false, row: 0, col: 0 },
{ label: "✔️ ACCEPT", key: "accept", color: "#4CAF50", hover: false, row: 0, col: 1 },
{ label: "⭕ DESELECT ALL", key: "deselect_all", color: "#9E9E9E", hover: false, row: 1, col: 0 },
{ label: "❌ CANCEL", key: "cancel", color: "#dc3545", hover: false, row: 1, col: 1 }
];
this.onVisibilityChange = () => {
if (document.visibilityState === "visible") {
this.setDirtyCanvas(true);
}
};
document.addEventListener("visibilitychange", this.onVisibilityChange);
};
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
if (onRemoved) onRemoved.apply(this, arguments);
if (this.onVisibilityChange) {
document.removeEventListener("visibilitychange", this.onVisibilityChange);
}
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
console.log(`[ImSELECT 🦊] Node ${this.id} removed, sending cleanup signal...`);
fetch("/rayko/imselect/cleanup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: String(this.id) })
}).catch(err => {
console.error("[ImSELECT 🦊] Cleanup signal failed: ", err);
});
};
const onResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function(size) {
if (onResize) onResize.apply(this, arguments);
this.userResized = true;
};
nodeType.prototype.startHeartbeat = function() {
if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
this.heartbeatInterval = setInterval(async () => {
try {
await api.fetchApi("/rayko/imselect/heartbeat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: String(this.id) })
});
} catch (e) {
console.log("[ImSELECT 🦊] Heartbeat failed");
}
}, 5000);
};
nodeType.prototype.stopHeartbeat = function() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
};
nodeType.prototype.autoSize = function(imageCount) {
const CONFIG = {
minImageSize: 120,
maxImageSize: 200,
targetImageSize: 150,
minCols: 2,
maxCols: 6,
margin: 12,
topBarH: 10,
bottomBarH: 85,
minWidth: 400,
maxWidth: 1200,
minHeight: 300,
maxHeight: 900
};
let cols = Math.ceil(Math.sqrt(imageCount * (CONFIG.targetImageSize / CONFIG.targetImageSize)));
cols = Math.max(CONFIG.minCols, Math.min(CONFIG.maxCols, cols));
if (imageCount <= 2) cols = 2;
else if (imageCount <= 6) cols = 3;
else if (imageCount <= 12) cols = 4;
else if (imageCount <= 20) cols = 5;
else cols = 6;
const rows = Math.ceil(imageCount / cols);
const contentWidth = (cols * CONFIG.targetImageSize) + ((cols - 1) * CONFIG.margin);
const contentHeight = (rows * CONFIG.targetImageSize) + ((rows - 1) * CONFIG.margin);
const targetWidth = Math.max(CONFIG.minWidth, Math.min(CONFIG.maxWidth, contentWidth + (CONFIG.margin * 2)));
const targetHeight = Math.max(CONFIG.minHeight, Math.min(CONFIG.maxHeight,
contentHeight + CONFIG.topBarH + CONFIG.bottomBarH));
const shouldResize =
this.size[0] < targetWidth - 50 ||
this.size[1] < targetHeight - 50;
if (shouldResize && !this.userResized) {
this.setSize([
Math.max(this.size[0], targetWidth),
Math.max(this.size[1], targetHeight)
]);
}
return { cols, rows, targetWidth, targetHeight };
};
const onDrawForeground = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function (ctx) {
if (onDrawForeground) onDrawForeground.apply(this, arguments);
if (!this.imagePaths.length) {
ctx.fillStyle = "#888";
ctx.font = "italic 13px 'Segoe UI', Roboto, sans-serif";
ctx.textAlign = "center";
const titleBarHeight = LiteGraph.NODE_TITLE_HEIGHT || 30;
const padding = 10;
const footerHeight = 70;
let widgetsTotalHeight = 0;
if (this.widgets) {
for (const w of this.widgets) {
if (!w.hidden && w.type !== "button") {
widgetsTotalHeight += (w.computeSize ? w.computeSize(this.size[0])[1] : 20);
}
}
}
const startY = titleBarHeight + widgetsTotalHeight + padding;
const availableHeight = this.size[1] - startY - footerHeight - padding;
ctx.fillText("Ready for images...", this.size[0] / 2, startY + availableHeight / 2);
return;
}
const titleBarHeight = LiteGraph.NODE_TITLE_HEIGHT || 30;
const padding = 10;
const footerHeight = 70;
const btnH = 28;
const btnGap = 5;
const btnY = this.size[1] - 65;
let widgetsTotalHeight = 0;
if (this.widgets) {
for (const w of this.widgets) {
if (!w.hidden && w.type !== "button") {
widgetsTotalHeight += (w.computeSize ? w.computeSize(this.size[0])[1] : 20);
}
}
}
const startY = titleBarHeight + widgetsTotalHeight + padding;
const availableHeight = this.size[1] - startY - footerHeight - padding;
const availableWidth = this.size[0] - (padding * 2);
const contentX = padding;
const contentY = startY;
const contentW = availableWidth;
const contentH = availableHeight;
const count = this.imagePaths.length;
const approxSide = Math.sqrt((contentW * contentH) / count);
let cols = Math.floor(contentW / approxSide);
if (cols < 1) cols = 1;
const rows = Math.ceil(count / cols);
const margin = 12;
const cellW = (contentW - (margin * (cols - 1))) / cols;
const cellH = (contentH - (margin * (rows - 1))) / rows;
this.imageRects = [];
try {
this.imagePaths.forEach((path, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const x = contentX + (col * (cellW + margin));
const y = contentY + (row * (cellH + margin));
this.imageRects.push({ i, x, y, w: cellW, h: cellH });
ctx.save();
ctx.beginPath();
ctx.roundRect(x, y, cellW, cellH, 6);
ctx.clip();
ctx.fillStyle = "#151515";
ctx.fillRect(x, y, cellW, cellH);
const img = this.images[i];
if (img && img.complete && img.width > 0) {
const imgRatio = img.width / img.height;
const cellRatio = cellW / cellH;
let dx, dy, dw, dh;
if (imgRatio > cellRatio) {
dw = cellW;
dh = cellW / imgRatio;
dx = x;
dy = y + (cellH - dh) / 2;
} else {
dh = cellH;
dw = cellH * imgRatio;
dy = y;
dx = x + (cellW - dw) / 2;
}
ctx.drawImage(img, 0, 0, img.width, img.height, dx, dy, dw, dh);
} else if (img && img.broken) {
ctx.fillStyle = "#331111";
ctx.fillRect(x, y, cellW, cellH);
ctx.fillStyle = "#cc5555";
ctx.font = "10px Arial";
ctx.textAlign = "center";
ctx.fillText("Error", x + cellW / 2, y + cellH / 2);
}
ctx.restore();
if (this.selectedIndices.has(i)) {
ctx.lineWidth = 2;
ctx.strokeStyle = "#e0e0e0";
ctx.strokeRect(x, y, cellW, cellH);
const badgeSize = 20;
const bx = x + cellW - badgeSize - 6;
const by = y + 6;
ctx.beginPath();
ctx.roundRect(bx, by, badgeSize, badgeSize, 4);
ctx.fillStyle = "#e0e0e0";
ctx.fill();
ctx.fillStyle = "#111";
ctx.font = "bold 12px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("✓", bx + badgeSize / 2, by + badgeSize / 2 + 1);
}
});
} catch (error) {
console.error("Batch Selector: Error rendering images", error);
ctx.fillStyle = "red";
ctx.font = "12px Arial";
ctx.fillText("Render Error", 10, 10);
}
const totalBtnW = this.size[0] - 50;
const btnW = (totalBtnW - btnGap) / 2;
this.btnRects = [];
this.buttons.forEach((btn, i) => {
const col = btn.col;
const row = btn.row;
const x = 15 + (col * (btnW + btnGap));
const y = btnY + (row * (btnH + btnGap));
this.btnRects.push({
key: btn.key,
x: x,
y: y,
w: btnW,
h: btnH
});
ctx.fillStyle = btn.hover ? "#444" : "#2a2a2a";
ctx.beginPath();
if (ctx.roundRect) {
ctx.roundRect(x, y, btnW, btnH, 6);
} else {
ctx.rect(x, y, btnW, btnH);
}
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = btn.color;
ctx.stroke();
ctx.fillStyle = btn.color;
ctx.font = "bold 11px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(btn.label, x + btnW / 2, y + btnH / 2);
if (btn.hover && app.canvas.canvas) {
app.canvas.canvas.style.cursor = "pointer";
}
});
ctx.fillStyle = "#555";
ctx.font = "10px Arial";
ctx.textAlign = "right";
ctx.fillText(this.userResized ? "📐 Manual size" : "📐 Auto-sized", this.size[0] - 8, 18);
};
const onMouseDown = nodeType.prototype.onMouseDown;
nodeType.prototype.onMouseDown = function (event, pos, graphPos) {
if (onMouseDown) onMouseDown.apply(this, arguments);
const x = pos[0];
const y = pos[1];
if (this.imageRects) {
for (const r of this.imageRects) {
if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) {
if (this.selectedIndices.has(r.i)) this.selectedIndices.delete(r.i);
else this.selectedIndices.add(r.i);
this.setDirtyCanvas(true);
return true;
}
}
}
if (this.btnRects) {
for (const b of this.btnRects) {
if (x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h) {
this.handleButtonClick(b.key);
return true;
}
}
}
};
const onMouseMove = nodeType.prototype.onMouseMove;
nodeType.prototype.onMouseMove = function (event, pos, graphPos) {
if (onMouseMove) onMouseMove.apply(this, arguments);
const x = pos[0];
const y = pos[1];
let needsRedraw = false;
if (this.btnRects) {
for (let i = 0; i < this.btnRects.length; i++) {
const b = this.btnRects[i];
const isOver = x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h;
if (this.buttons[i].hover !== isOver) {
this.buttons[i].hover = isOver;
needsRedraw = true;
}
}
}
if (needsRedraw) this.setDirtyCanvas(true);
};
nodeType.prototype.handleButtonClick = async function (key) {
if (key === 'select_all') {
this.imagePaths.forEach((_, i) => this.selectedIndices.add(i));
} else if (key === 'deselect_all') {
this.selectedIndices.clear();
} else if (key === 'accept') {
const sorted = Array.from(this.selectedIndices).sort((a, b) => a - b);
this.sendSelection(sorted);
} else if (key === 'cancel') {
try { await api.interrupt(); } catch (e) { }
this.sendSelection([]);
}
this.setDirtyCanvas(true);
};
nodeType.prototype.sendSelection = async function (indices) {
try {
const response = await api.fetchApi("/rayko/image_selector", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: String(this.id), selection: indices })
});
if (response.ok) {
console.log(`[ImSELECT 🦊] Selection sent for node ${this.id}`);
this.stopHeartbeat();
this.imagePaths = [];
this.setDirtyCanvas(true);
}
} catch (e) {
console.error(`[ImSELECT 🦊] Send selection error: `, e);
alert(e);
}
};
api.addEventListener("rs-image-selector-start", (event) => {
const { id, images } = event.detail;
const node = app.graph.getNodeById(id);
if (!node) return;
if (node.imagePaths && node.imagePaths.length === images.length) {
const isSame = node.imagePaths.every((path, index) => path === images[index]);
if (isSame) {
node.setDirtyCanvas(true);
return;
}
}
node.imagePaths = images;
node.images = new Array(images.length);
node.selectedIndices.clear();
node.userResized = false;
images.forEach((f, i) => {
const img = new Image();
img.src = api.apiURL(`/view?filename=${f}&type=temp`);
img.onload = () => node.setDirtyCanvas(true);
img.onerror = () => {
img.broken = true;
node.setDirtyCanvas(true);
}
node.images[i] = img;
});
node.startHeartbeat();
console.log(`[ImSELECT 🦊] Heartbeat started for node ${id}`);
if (node.autoSize) {
node.autoSize(images.length);
}
node.setDirtyCanvas(true);
});
}
});