Add batch image chooser gate
This commit is contained in:
@@ -0,0 +1,479 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
// Image Chooser Gate (Batch) — pauses a prompt, displays every image in the
|
||||
// incoming batch, and resumes with the selected subset as a new IMAGE batch.
|
||||
|
||||
const NODE = "ImageChooserGate";
|
||||
const R = "/datasete_image_chooser";
|
||||
|
||||
const MIN_W = 420;
|
||||
const MIN_GRID_H = 190;
|
||||
const TOOLBAR_H = 72;
|
||||
const MARGIN = 10;
|
||||
const chooserNodes = new Set();
|
||||
|
||||
function graphNodeById(graph, id) {
|
||||
if (!graph?.getNodeById) return null;
|
||||
return graph.getNodeById(id) ?? graph.getNodeById(parseInt(id, 10));
|
||||
}
|
||||
|
||||
function chooserByLocator(locator) {
|
||||
const direct = graphNodeById(app.graph, locator);
|
||||
if (direct?.type === NODE) return direct;
|
||||
|
||||
// Native subgraphs identify executing nodes as colon-separated locators:
|
||||
// root subgraph-node id -> nested node id -> ... -> chooser id.
|
||||
let graph = app.rootGraph ?? app.graph?.rootGraph ?? app.graph;
|
||||
let node = null;
|
||||
for (const id of String(locator).split(":")) {
|
||||
node = graphNodeById(graph, id);
|
||||
if (!node) return null;
|
||||
graph = node.subgraph;
|
||||
}
|
||||
return node?.type === NODE ? node : null;
|
||||
}
|
||||
|
||||
function widgetFloor(node) {
|
||||
return node._icgState === "idle" ? 0 : 2 * MARGIN + MIN_GRID_H + TOOLBAR_H;
|
||||
}
|
||||
|
||||
function syncWidgetWidth(node) {
|
||||
if (node._icgWidget) node._icgWidget.width = node.size?.[0] || MIN_W;
|
||||
}
|
||||
|
||||
function resizeChooser(node) {
|
||||
const shown = node._icgState !== "idle";
|
||||
if (node._icg?.wrap) node._icg.wrap.style.display = shown ? "flex" : "none";
|
||||
const width = node.size?.[0] || MIN_W;
|
||||
const target = shown
|
||||
? Math.max(node.size?.[1] || 0, node.computeSize()[1])
|
||||
: node.computeSize()[1];
|
||||
node.setSize([width, target]);
|
||||
syncWidgetWidth(node);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
|
||||
async function postChooser(node, fields) {
|
||||
const form = new FormData();
|
||||
form.append("id", String(node._icgWaitId ?? node.id));
|
||||
form.append("token", String(node._icgToken ?? ""));
|
||||
for (const [key, value] of Object.entries(fields)) form.append(key, value);
|
||||
const response = await api.fetchApi(`${R}/select`, { method: "POST", body: form });
|
||||
if (!response.ok) throw new Error(`chooser request failed (${response.status})`);
|
||||
}
|
||||
|
||||
async function queueFromHere() {
|
||||
const command = app.extensionManager?.command;
|
||||
if (command?.execute) {
|
||||
try {
|
||||
await command.execute("Comfy.QueuePrompt");
|
||||
return true;
|
||||
} catch (e) { /* use the legacy path below */ }
|
||||
}
|
||||
try {
|
||||
await app.queuePrompt(0, 1);
|
||||
return true;
|
||||
} catch (e) {
|
||||
try {
|
||||
await app.queuePrompt(0);
|
||||
return true;
|
||||
} catch (fallbackError) {
|
||||
console.error("[image-chooser] queue failed", fallbackError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectedIndices(node) {
|
||||
return [...node._icgSelected].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function updateCells(node) {
|
||||
for (const [index, cell] of node._icgCells) {
|
||||
const selected = node._icgSelected.has(index);
|
||||
cell.classList.toggle("icg-selected", selected);
|
||||
cell.setAttribute("aria-pressed", selected ? "true" : "false");
|
||||
const check = cell.querySelector(".icg-check");
|
||||
if (check) check.textContent = selected ? "✓" : "";
|
||||
}
|
||||
}
|
||||
|
||||
function select(node, indices) {
|
||||
if (node._icgState !== "paused" || node._icgBusy) return;
|
||||
node._icgSelected = new Set(indices);
|
||||
node._icgError = "";
|
||||
updateCells(node);
|
||||
renderToolbar(node);
|
||||
}
|
||||
|
||||
function button(text, className, onclick) {
|
||||
const element = document.createElement("button");
|
||||
element.type = "button";
|
||||
element.textContent = text;
|
||||
element.className = className;
|
||||
element.onclick = onclick;
|
||||
return element;
|
||||
}
|
||||
|
||||
function renderToolbar(node) {
|
||||
const { toolbar } = node._icg;
|
||||
toolbar.innerHTML = "";
|
||||
const count = node._icgItems.length;
|
||||
const chosen = selectedIndices(node);
|
||||
|
||||
if (node._icgState === "paused") {
|
||||
const all = button("Select all", "icg-secondary", () => {
|
||||
select(node, node._icgItems.map((item) => item.index));
|
||||
});
|
||||
all.disabled = node._icgBusy || chosen.length === count;
|
||||
|
||||
const clear = button("Clear", "icg-secondary", () => select(node, []));
|
||||
clear.disabled = node._icgBusy || chosen.length === 0;
|
||||
|
||||
const pass = button(
|
||||
chosen.length ? `▶ Pass selected (${chosen.length})` : "▶ Pass selected",
|
||||
"icg-pass",
|
||||
async () => {
|
||||
if (!chosen.length || node._icgBusy) return;
|
||||
const revision = node._icgRevision;
|
||||
node._icgBusy = true;
|
||||
node._icgError = "";
|
||||
renderGridState(node);
|
||||
renderToolbar(node);
|
||||
try {
|
||||
await postChooser(node, { selection: JSON.stringify(chosen) });
|
||||
if (node._icgRevision === revision) {
|
||||
node._icgState = "resolved";
|
||||
node._icgResolvedCount = chosen.length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[image-chooser] pass failed", error);
|
||||
if (node._icgRevision === revision) {
|
||||
node._icgError = "Could not pass the selection";
|
||||
}
|
||||
} finally {
|
||||
if (node._icgRevision !== revision) return;
|
||||
node._icgBusy = false;
|
||||
renderGridState(node);
|
||||
renderToolbar(node);
|
||||
resizeChooser(node);
|
||||
}
|
||||
},
|
||||
);
|
||||
pass.disabled = node._icgBusy || chosen.length === 0;
|
||||
|
||||
const stop = button("■ Stop", "icg-stop", async () => {
|
||||
if (node._icgBusy) return;
|
||||
const revision = node._icgRevision;
|
||||
node._icgBusy = true;
|
||||
node._icgError = "";
|
||||
renderGridState(node);
|
||||
renderToolbar(node);
|
||||
try {
|
||||
await postChooser(node, { action: "cancel" });
|
||||
if (node._icgRevision === revision) {
|
||||
node._icgState = "resolved";
|
||||
node._icgStopped = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[image-chooser] stop failed", error);
|
||||
if (node._icgRevision === revision) {
|
||||
node._icgError = "Could not stop the run";
|
||||
}
|
||||
} finally {
|
||||
if (node._icgRevision !== revision) return;
|
||||
node._icgBusy = false;
|
||||
renderGridState(node);
|
||||
renderToolbar(node);
|
||||
}
|
||||
});
|
||||
stop.disabled = node._icgBusy;
|
||||
|
||||
const status = document.createElement("span");
|
||||
status.className = `icg-status${node._icgError ? " icg-error" : ""}`;
|
||||
status.textContent = node._icgError || (node._icgBusy
|
||||
? "sending…"
|
||||
: `${chosen.length} of ${count} selected`);
|
||||
|
||||
toolbar.appendChild(all);
|
||||
toolbar.appendChild(clear);
|
||||
toolbar.appendChild(pass);
|
||||
toolbar.appendChild(status);
|
||||
toolbar.appendChild(stop);
|
||||
} else if (node._icgState === "resolved") {
|
||||
const status = document.createElement("span");
|
||||
status.className = `icg-status icg-resolved${node._icgError ? " icg-error" : ""}`;
|
||||
status.textContent = node._icgError || (node._icgQueueBusy
|
||||
? "queuing…"
|
||||
: node._icgStopped
|
||||
? "■ run stopped"
|
||||
: `✓ passed ${node._icgResolvedCount} of ${count}`);
|
||||
const run = button("▶ Run from here", "icg-run", async () => {
|
||||
if (node._icgQueueBusy) return;
|
||||
node._icgQueueBusy = true;
|
||||
node._icgError = "";
|
||||
renderToolbar(node);
|
||||
if (!await queueFromHere()) {
|
||||
node._icgQueueBusy = false;
|
||||
node._icgError = "Could not queue the workflow";
|
||||
renderToolbar(node);
|
||||
}
|
||||
});
|
||||
run.disabled = node._icgQueueBusy;
|
||||
toolbar.appendChild(status);
|
||||
toolbar.appendChild(run);
|
||||
}
|
||||
}
|
||||
|
||||
function renderGridState(node) {
|
||||
const editable = node._icgState === "paused" && !node._icgBusy;
|
||||
for (const cell of node._icgCells.values()) cell.disabled = !editable;
|
||||
updateCells(node);
|
||||
}
|
||||
|
||||
function toggleImage(node, index) {
|
||||
if (node._icgState !== "paused" || node._icgBusy) return;
|
||||
const next = new Set(node._icgSelected);
|
||||
if (next.has(index)) next.delete(index);
|
||||
else next.add(index);
|
||||
select(node, next);
|
||||
}
|
||||
|
||||
function renderGrid(node) {
|
||||
const { grid } = node._icg;
|
||||
grid.innerHTML = "";
|
||||
node._icgCells = new Map();
|
||||
|
||||
for (const item of node._icgItems) {
|
||||
const cell = document.createElement("button");
|
||||
cell.type = "button";
|
||||
cell.className = "icg-cell";
|
||||
cell.title = `Image ${item.index + 1} — ${item.width}×${item.height}`;
|
||||
cell.setAttribute("aria-label", `Select image ${item.index + 1}`);
|
||||
cell.onclick = () => toggleImage(node, item.index);
|
||||
|
||||
const image = document.createElement("img");
|
||||
image.className = "icg-thumb";
|
||||
image.alt = `Batch image ${item.index + 1}`;
|
||||
image.draggable = false;
|
||||
image.src = `data:image/jpeg;base64,${item.image}`;
|
||||
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "icg-badge";
|
||||
badge.textContent = String(item.index + 1);
|
||||
|
||||
const dimensions = document.createElement("span");
|
||||
dimensions.className = "icg-dimensions";
|
||||
dimensions.textContent = `${item.width}×${item.height}`;
|
||||
|
||||
const check = document.createElement("span");
|
||||
check.className = "icg-check";
|
||||
|
||||
cell.appendChild(image);
|
||||
cell.appendChild(badge);
|
||||
cell.appendChild(dimensions);
|
||||
cell.appendChild(check);
|
||||
grid.appendChild(cell);
|
||||
node._icgCells.set(item.index, cell);
|
||||
}
|
||||
renderGridState(node);
|
||||
}
|
||||
|
||||
function showBatch(node, waitId, token, items) {
|
||||
node._icgRevision += 1;
|
||||
node._icgWaitId = waitId;
|
||||
node._icgToken = token;
|
||||
node._icgItems = Array.isArray(items) ? items : [];
|
||||
node._icgSelected = new Set();
|
||||
node._icgBusy = false;
|
||||
node._icgError = "";
|
||||
node._icgStopped = false;
|
||||
node._icgResolvedCount = 0;
|
||||
node._icgQueueBusy = false;
|
||||
node._icgState = "paused";
|
||||
renderGrid(node);
|
||||
renderToolbar(node);
|
||||
resizeChooser(node);
|
||||
}
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById("icg-styles")) return;
|
||||
const css = `
|
||||
.icg-wrap { display:flex; flex-direction:column; gap:7px; box-sizing:border-box;
|
||||
height:100%; min-height:0; }
|
||||
.icg-grid { flex:1 1 auto; min-height:0; overflow:auto; display:grid;
|
||||
grid-template-columns:repeat(auto-fill,minmax(108px,1fr));
|
||||
grid-auto-rows:max-content; gap:7px; align-content:start; padding:2px; }
|
||||
.icg-cell { position:relative; aspect-ratio:1; min-width:0; overflow:hidden; padding:0;
|
||||
border:2px solid transparent; border-radius:5px; background:#1d1d1d;
|
||||
cursor:pointer; transition:border-color .1s, box-shadow .1s; }
|
||||
.icg-cell:hover:not(:disabled) { border-color:#777; }
|
||||
.icg-cell:disabled { cursor:default; opacity:1; }
|
||||
.icg-cell.icg-selected { border-color:#55d78a; box-shadow:0 0 0 1px #55d78a; }
|
||||
.icg-thumb { position:absolute; inset:0; width:100%; height:100%; object-fit:contain;
|
||||
background:#181818; pointer-events:none; }
|
||||
.icg-badge, .icg-dimensions, .icg-check { position:absolute; color:#fff;
|
||||
background:rgba(0,0,0,.72); border-radius:3px; pointer-events:none; }
|
||||
.icg-badge { top:4px; left:4px; min-width:18px; padding:1px 4px; font-size:11px;
|
||||
text-align:center; }
|
||||
.icg-dimensions { left:4px; bottom:4px; padding:1px 4px; font-size:9px; opacity:.78; }
|
||||
.icg-check { top:4px; right:4px; width:20px; height:20px; line-height:20px;
|
||||
text-align:center; font-size:14px; font-weight:bold; }
|
||||
.icg-cell.icg-selected .icg-check { background:rgba(35,145,80,.92); }
|
||||
.icg-toolbar { flex:0 0 auto; display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
|
||||
.icg-toolbar button { font-size:12px; padding:4px 10px; cursor:pointer; border-radius:3px;
|
||||
border:1px solid #555; color:#fff; }
|
||||
.icg-toolbar button:disabled { opacity:.42; cursor:default; }
|
||||
.icg-secondary { background:rgba(45,45,45,.95); }
|
||||
.icg-pass { background:rgba(40,130,70,.95); }
|
||||
.icg-pass:hover:not(:disabled) { background:rgba(55,160,90,.98); }
|
||||
.icg-run { background:rgba(40,90,140,.95); }
|
||||
.icg-stop { background:rgba(160,40,40,.9); margin-left:auto; }
|
||||
.icg-status { font-size:11px; opacity:.72; padding:0 3px; }
|
||||
.icg-error { color:#ff8a8a; opacity:1; }
|
||||
.icg-resolved { color:#83e5aa; opacity:.95; }
|
||||
`;
|
||||
const style = document.createElement("style");
|
||||
style.id = "icg-styles";
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function setupChooserNode(node) {
|
||||
injectStyles();
|
||||
chooserNodes.add(node);
|
||||
|
||||
// This node owns its preview UI; suppress ComfyUI's output-image preview.
|
||||
try {
|
||||
Object.defineProperty(node, "imgs", {
|
||||
configurable: true,
|
||||
get() { return undefined; },
|
||||
set() { /* suppress */ },
|
||||
});
|
||||
} catch (e) { /* best effort */ }
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "icg-wrap";
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "icg-grid";
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "icg-toolbar";
|
||||
wrap.appendChild(grid);
|
||||
wrap.appendChild(toolbar);
|
||||
|
||||
node._icg = { wrap, grid, toolbar };
|
||||
node._icgItems = [];
|
||||
node._icgCells = new Map();
|
||||
node._icgSelected = new Set();
|
||||
node._icgState = "idle";
|
||||
node._icgBusy = false;
|
||||
node._icgRevision = 0;
|
||||
node._icgWaitId = null;
|
||||
node._icgToken = null;
|
||||
node._icgQueueBusy = false;
|
||||
|
||||
node._icgWidget = node.addDOMWidget("image_chooser", "div", wrap, {
|
||||
serialize: false,
|
||||
getMinHeight: () => widgetFloor(node),
|
||||
});
|
||||
|
||||
const onResize = node.onResize;
|
||||
node.onResize = function () {
|
||||
const result = onResize?.apply(this, arguments);
|
||||
syncWidgetWidth(node);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Removing an actively waiting chooser must not strand the executor.
|
||||
const onRemoved = node.onRemoved;
|
||||
node.onRemoved = function () {
|
||||
if (node._icgState === "paused" && node._icgToken) {
|
||||
postChooser(node, { action: "cancel" }).catch(() => {});
|
||||
}
|
||||
chooserNodes.delete(node);
|
||||
return onRemoved?.apply(this, arguments);
|
||||
};
|
||||
|
||||
node.setSize([Math.max(node.size?.[0] || 0, MIN_W), node.computeSize()[1]]);
|
||||
resizeChooser(node);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "datasete.gates.imagechooser",
|
||||
|
||||
setup() {
|
||||
api.addEventListener("datasete-image-chooser-show", (event) => {
|
||||
const data = event.detail || {};
|
||||
const displayId = data.display_id ?? data.id;
|
||||
const node = chooserByLocator(displayId);
|
||||
if (!node || node.type !== NODE || !node._icg) return;
|
||||
showBatch(node, data.id, data.token, data.images);
|
||||
});
|
||||
|
||||
// Collapse the previous review as a new execution starts; a fresh chooser
|
||||
// event will reopen it if this run reaches the node. An interrupt leaves an
|
||||
// honest stopped state.
|
||||
api.addEventListener("execution_start", () => {
|
||||
for (const node of chooserNodes) {
|
||||
if (node.type !== NODE || node._icgState === "idle") continue;
|
||||
node._icgRevision += 1;
|
||||
node._icgState = "idle";
|
||||
node._icgBusy = false;
|
||||
node._icgQueueBusy = false;
|
||||
node._icgWaitId = null;
|
||||
node._icgToken = null;
|
||||
resizeChooser(node);
|
||||
}
|
||||
});
|
||||
api.addEventListener("execution_interrupted", () => {
|
||||
for (const node of chooserNodes) {
|
||||
if (node.type !== NODE
|
||||
|| (node._icgState !== "paused" && !node._icgQueueBusy)) continue;
|
||||
node._icgRevision += 1;
|
||||
node._icgState = "resolved";
|
||||
node._icgBusy = false;
|
||||
node._icgQueueBusy = false;
|
||||
node._icgStopped = true;
|
||||
node._icgWaitId = null;
|
||||
node._icgToken = null;
|
||||
renderGridState(node);
|
||||
renderToolbar(node);
|
||||
}
|
||||
});
|
||||
api.addEventListener("execution_error", () => {
|
||||
for (const node of chooserNodes) {
|
||||
if (node.type !== NODE
|
||||
|| (node._icgState !== "paused" && !node._icgQueueBusy)) continue;
|
||||
node._icgRevision += 1;
|
||||
node._icgState = "resolved";
|
||||
node._icgBusy = false;
|
||||
node._icgQueueBusy = false;
|
||||
node._icgStopped = true;
|
||||
node._icgError = "Execution stopped with an error";
|
||||
node._icgWaitId = null;
|
||||
node._icgToken = null;
|
||||
renderGridState(node);
|
||||
renderToolbar(node);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== NODE) return;
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const result = onNodeCreated?.apply(this, arguments);
|
||||
setupChooserNode(this);
|
||||
return result;
|
||||
};
|
||||
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
const result = onConfigure?.apply(this, arguments);
|
||||
if (this._icg) syncWidgetWidth(this);
|
||||
return result;
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user