Files
Karim KhanandClaude Opus 4.6 f9d5cb9698 Initial DeepSoch Toolkit: Prompt Preset, Load Images, Set/Get nodes
Scaffolds the toolkit with a scalable layout (src/deepsoch_toolkit
package, per-category node subfolders, shared core helpers, shipped vs
user-writable preset directories) and ships four nodes:

* Prompt Preset (deepsoch/prompt) — checkbox-based multi-select over a
  preset library, with select_all toggle and optional custom prompt;
  outputs as a STRING list so downstream auto-runs once per prompt.
* Load Images (deepsoch/image) — upload-multi widget with native-style
  multi-image preview (grid + click-to-fullscreen, X close, counter
  cycling) mirroring ComfyUI's SaveImage UX via onDrawBackground.
* Set Node / Get Node (deepsoch/util) — wireless reroutes implemented
  as Python stubs enhanced by JS into virtual nodes; getInputLink /
  getInputNode redirect graph traversal through the matching Set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:16:13 +05:30

480 lines
20 KiB
JavaScript

// DeepSoch Toolkit — Load Images frontend.
//
// Mirrors ComfyUI's native multi-image preview UX (the one used by
// SaveImage / PreviewImage). Reference implementation:
// https://github.com/comfyanonymous/ComfyUI/blob/v0.0.4/web/scripts/app.js
//
// Key mechanics copied from native:
// * All drawing happens in `onDrawBackground` (not foreground).
// * Mouse position read from `canvas.graph_mouse` and `canvas.pointer_is_down`
// inside the draw callback — no separate onMouseDown handler.
// * `pointerDown` state captures press and applies on release at same
// position (drag-vs-click detection).
// * `calculateGrid(w, h, n)` picks a square cell size that fits all
// images, then expands until N cells fit.
// * `imageIndex == null` -> grid view, otherwise fullscreen for that index.
// * Counter button bottom-right ((dw-40, dh+top-40), 30x30) cycles forward.
// * Close "x" button top-right ((dw-40, top+10), 30x30) returns to grid.
// * Hover applies `ctx.filter = contrast(110%) brightness(110%)`,
// pressed state uses 125%.
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const NODE_NAME = "DeepSochLoadImages";
const PADDING_BELOW_WIDGETS = 4;
async function uploadOne(file) {
const fd = new FormData();
fd.append("image", file);
fd.append("overwrite", "true");
const resp = await api.fetchApi("/upload/image", { method: "POST", body: fd });
if (resp.status !== 200) {
throw new Error(`upload failed (${resp.status}): ${file.name}`);
}
const data = await resp.json();
const sub = data.subfolder ? `${data.subfolder}/` : "";
return `${sub}${data.name}`;
}
function readList(widget) {
try {
const v = JSON.parse(widget.value || "[]");
return Array.isArray(v) ? v : [];
} catch {
return [];
}
}
function writeList(widget, list) {
widget.value = JSON.stringify(list);
}
function getImageTop(node) {
// Y coordinate where the preview area starts: just below the last widget.
let maxY = 0;
let anyRendered = false;
for (const w of node.widgets) {
if (w.last_y != null) {
const h = w.computedHeight ?? LiteGraph.NODE_WIDGET_HEIGHT + 4;
const bottom = w.last_y + h;
if (bottom > maxY) maxY = bottom;
anyRendered = true;
}
}
if (anyRendered) return maxY + PADDING_BELOW_WIDGETS;
// Fallback before the first render pass.
let y = LiteGraph.NODE_TITLE_HEIGHT || 30;
for (const w of node.widgets) {
const isHidden = w.type === "hidden" || w.type === "converted-widget" || w.hidden;
if (isHidden) continue;
y += w.computedHeight ?? LiteGraph.NODE_WIDGET_HEIGHT + 4;
}
return y + PADDING_BELOW_WIDGETS;
}
// Native ComfyUI grid algorithm: square cells, expand cellsize until everything fits.
function calculateGrid(w, h, n) {
let columns, rows, cellsize;
if (w > h) {
cellsize = h;
columns = Math.ceil(w / cellsize);
rows = Math.ceil(n / columns);
} else {
cellsize = w;
rows = Math.ceil(h / cellsize);
columns = Math.ceil(n / rows);
}
while (columns * rows < n) {
cellsize++;
if (w >= h) {
columns = Math.ceil(w / cellsize);
rows = Math.ceil(n / columns);
} else {
rows = Math.ceil(h / cellsize);
columns = Math.ceil(n / rows);
}
}
const cell_size = Math.min(w / columns, h / rows);
return { cell_size, columns, rows };
}
function isAllSameAspectRatio(imgs) {
if (imgs.length < 2) return true;
const r0 = imgs[0].naturalWidth / imgs[0].naturalHeight;
for (let i = 1; i < imgs.length; i++) {
const r = imgs[i].naturalWidth / imgs[i].naturalHeight;
if (Math.abs(r - r0) > 0.001) return false;
}
return true;
}
// Native ComfyUI compact-mode layout (for images that all share the same
// aspect ratio). Picks the column count that maximizes total displayed
// image area. Source:
// https://github.com/comfyanonymous/ComfyUI/blob/v0.0.4/web/scripts/ui/imagePreview.js
function calculateImageGrid(imgs, dw, dh) {
let best = 0;
const w = imgs[0].naturalWidth;
const h = imgs[0].naturalHeight;
const numImages = imgs.length;
let cellWidth, cellHeight, cols, rows, shiftX;
for (let c = 1; c <= numImages; c++) {
const r = Math.ceil(numImages / c);
const cW = dw / c;
const cH = dh / r;
const scaleX = cW / w;
const scaleY = cH / h;
const scale = Math.min(scaleX, scaleY, 1);
const imageW = w * scale;
const imageH = h * scale;
const area = imageW * imageH * numImages;
if (area > best) {
best = area;
cellWidth = imageW;
cellHeight = imageH;
cols = c;
rows = r;
shiftX = c * ((cW - imageW) / 2);
}
}
return { cellWidth, cellHeight, cols, rows, shiftX };
}
// Unique key under which we stash the prototype's *truly original*
// onNodeCreated. Re-patching always rebases off this original, so hot
// reloads cannot accumulate a chain of stale patches.
const ORIG_ONC_KEY = "__deepsochLoadImages_origOnNodeCreated";
app.registerExtension({
name: "deepsoch.toolkit.LoadImages",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_NAME) return;
if (!Object.prototype.hasOwnProperty.call(nodeType.prototype, ORIG_ONC_KEY)) {
nodeType.prototype[ORIG_ONC_KEY] = nodeType.prototype.onNodeCreated;
}
const originalOnNodeCreated = nodeType.prototype[ORIG_ONC_KEY];
nodeType.prototype.onNodeCreated = function () {
originalOnNodeCreated?.apply(this, arguments);
const node = this;
const filesWidget = node.widgets.find((w) => w.name === "files");
if (!filesWidget) return;
// Hide the JSON widget — users only interact via buttons + previews.
filesWidget.type = "converted-widget";
filesWidget.computeSize = () => [0, -4];
filesWidget.hidden = true;
filesWidget.serializeValue = () => filesWidget.value;
// State, mirroring the native preview node.
node.imgs = []; // HTMLImageElement[] for the current uploads
node.imageIndex = null; // null => grid, otherwise the fullscreen index
node.imageRects = []; // hit-test rects for grid cells
node.pointerDown = null; // {index, pos} pending click
node.overIndex = null;
node._loadedFor = ""; // last `files` JSON we loaded into node.imgs
// ---- Load HTMLImageElements for the current files list ----
const reloadImagesIfChanged = () => {
const json = filesWidget.value || "[]";
if (json === node._loadedFor) return;
node._loadedFor = json;
const list = readList(filesWidget);
if (list.length === 0) {
node.imgs = [];
node.imageIndex = null;
return;
}
Promise.all(
list.map((name) => new Promise((r) => {
const img = new Image();
img.onload = () => r(img);
img.onerror = () => r(null);
let subfolder = "";
let pureName = name;
const slash = name.lastIndexOf("/");
if (slash !== -1) {
subfolder = name.slice(0, slash);
pureName = name.slice(slash + 1);
}
const qs = new URLSearchParams({
filename: pureName,
type: "input",
subfolder,
});
img.src = api.apiURL(`/view?${qs.toString()}`);
})),
).then((imgs) => {
if (filesWidget.value === json) {
node.imgs = imgs.filter(Boolean);
node.imageIndex = null;
app.graph.setDirtyCanvas(true);
}
});
};
// ---- Upload button ----
node.addWidget("button", "Upload Images", null, () => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.multiple = true;
input.onchange = async () => {
const files = Array.from(input.files || []);
if (files.length === 0) return;
const uploaded = [];
for (const f of files) {
try {
uploaded.push(await uploadOne(f));
} catch (e) {
console.error("[deepsoch-toolkit]", e);
}
}
const current = readList(filesWidget);
current.push(...uploaded);
writeList(filesWidget, current);
reloadImagesIfChanged();
app.graph.setDirtyCanvas(true);
};
input.click();
});
// ---- Clear button ----
node.addWidget("button", "Clear", null, () => {
writeList(filesWidget, []);
node.imgs = [];
node.imageIndex = null;
node._loadedFor = "[]";
app.graph.setDirtyCanvas(true);
});
// ---- Native-style draw callback (background, not foreground) ----
// Override cleanly — do NOT chain through any previous
// onDrawBackground, otherwise stale patches from hot reloads
// would render their UI on top of ours.
node.onDrawBackground = function (ctx) {
if (this.flags?.collapsed) return;
// Make sure node.imgs reflects the current uploaded list.
reloadImagesIfChanged();
if (!this.imgs || this.imgs.length === 0) {
// Empty state hint.
const top = getImageTop(this);
ctx.fillStyle = "#888";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(
"(no images uploaded)",
this.size[0] / 2,
top + (this.size[1] - top) / 2,
);
return;
}
const canvas = app.graph.list_of_graphcanvas[0];
const mouse = canvas.graph_mouse;
// Apply pending pointer-down on release at same position
// (this is how native distinguishes click from drag).
if (!canvas.pointer_is_down && this.pointerDown) {
if (mouse[0] === this.pointerDown.pos[0] && mouse[1] === this.pointerDown.pos[1]) {
this.imageIndex = this.pointerDown.index;
}
this.pointerDown = null;
}
let imageIndex = this.imageIndex;
const numImages = this.imgs.length;
if (numImages === 1 && imageIndex == null) {
this.imageIndex = imageIndex = 0;
}
const top = getImageTop(this);
let shiftY = top;
let dw = this.size[0];
let dh = this.size[1] - shiftY;
if (imageIndex == null) {
// ---- Grid view ----
const compact = isAllSameAspectRatio(this.imgs);
let cell_padding, cellWidth, cellHeight, cols, shiftX;
if (!compact) {
// Mixed aspect ratios -> square cells with thin borders.
cell_padding = 2;
const { cell_size, columns, rows } = calculateGrid(dw, dh, numImages);
cols = columns;
cellWidth = cell_size;
cellHeight = cell_size;
shiftX = (dw - cell_size * cols) / 2;
shiftY = (dh - cell_size * rows) / 2 + top;
} else {
// Same aspect ratios -> non-square cells, max area, no border.
cell_padding = 0;
({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(this.imgs, dw, dh));
const rows = Math.ceil(numImages / cols);
shiftY = (dh - cellHeight * rows) / 2 + top;
}
let anyHovered = false;
this.imageRects = [];
for (let i = 0; i < numImages; i++) {
const img = this.imgs[i];
const row = Math.floor(i / cols);
const col = i % cols;
const x = col * cellWidth + shiftX;
const y = row * cellHeight + shiftY;
if (!anyHovered) {
anyHovered = LiteGraph.isInsideRectangle(
mouse[0],
mouse[1],
x + this.pos[0],
y + this.pos[1],
cellWidth,
cellHeight,
);
if (anyHovered) {
this.overIndex = i;
let value = 110;
if (canvas.pointer_is_down) {
if (!this.pointerDown || this.pointerDown.index !== i) {
this.pointerDown = { index: i, pos: [...mouse] };
}
value = 125;
}
ctx.filter = `contrast(${value}%) brightness(${value}%)`;
canvas.canvas.style.cursor = "pointer";
}
}
this.imageRects.push([x, y, cellWidth, cellHeight]);
let imgWidth, imgHeight;
if (compact) {
// In compact mode the cell dimensions ARE the
// scaled image dimensions, so no further fitting.
imgWidth = cellWidth;
imgHeight = cellHeight;
} else {
const wratio = cellWidth / img.naturalWidth;
const hratio = cellHeight / img.naturalHeight;
const ratio = Math.min(wratio, hratio);
imgWidth = ratio * img.naturalWidth;
imgHeight = ratio * img.naturalHeight;
}
const imgY = row * cellHeight + shiftY + (cellHeight - imgHeight) / 2;
const imgX = col * cellWidth + shiftX + (cellWidth - imgWidth) / 2;
ctx.drawImage(
img,
imgX + cell_padding,
imgY + cell_padding,
imgWidth - cell_padding * 2,
imgHeight - cell_padding * 2,
);
if (!compact) {
ctx.strokeStyle = "#8F8F8F";
ctx.lineWidth = 1;
ctx.strokeRect(
x + cell_padding,
y + cell_padding,
cellWidth - cell_padding * 2,
cellHeight - cell_padding * 2,
);
}
ctx.filter = "none";
}
if (!anyHovered) {
this.pointerDown = null;
this.overIndex = null;
}
} else {
// ---- Fullscreen view ----
const img = this.imgs[imageIndex];
let w = img.naturalWidth;
let h = img.naturalHeight;
const scale = Math.min(dw / w, dh / h, 1);
w *= scale;
h *= scale;
const x = (dw - w) / 2;
const y = (dh - h) / 2 + shiftY;
ctx.drawImage(img, x, y, w, h);
// Native-style drawButton helper.
const self = this;
const drawButton = (bx, by, sz, text) => {
const hovered = LiteGraph.isInsideRectangle(
mouse[0],
mouse[1],
bx + self.pos[0],
by + self.pos[1],
sz,
sz,
);
let fill = "#333";
let textFill = "#fff";
let isClicking = false;
if (hovered) {
canvas.canvas.style.cursor = "pointer";
if (canvas.pointer_is_down) {
fill = "#1e90ff";
isClicking = true;
} else {
fill = "#eee";
textFill = "#000";
}
}
ctx.fillStyle = fill;
ctx.beginPath();
if (typeof ctx.roundRect === "function") {
ctx.roundRect(bx, by, sz, sz, [4]);
} else {
ctx.rect(bx, by, sz, sz);
}
ctx.fill();
ctx.fillStyle = textFill;
ctx.font = "12px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
ctx.fillText(text, bx + sz / 2, by + sz / 2 + 4);
return isClicking;
};
if (numImages > 1) {
// Counter button bottom-right -> next image.
if (drawButton(dw - 40, dh + top - 40, 30, `${imageIndex + 1}/${numImages}`)) {
const next = imageIndex + 1 >= numImages ? 0 : imageIndex + 1;
if (!this.pointerDown || this.pointerDown.index !== next) {
this.pointerDown = { index: next, pos: [...mouse] };
}
}
// Close button top-right -> back to grid.
if (drawButton(dw - 40, top + 10, 30, "x")) {
if (!this.pointerDown || this.pointerDown.index !== null) {
this.pointerDown = { index: null, pos: [...mouse] };
}
}
}
}
};
// Reasonable default size for the preview area.
node.size = [340, 440];
// Defensive cleanup: if a previous (hot-reloaded) version of this
// extension installed an `onDrawForeground` on this prototype,
// wipe it on the instance so we don't double-draw the X / counter.
// All current drawing happens in `onDrawBackground` only.
node.onDrawForeground = null;
};
},
});