226 lines
10 KiB
JavaScript
226 lines
10 KiB
JavaScript
/*
|
||
* Part of ComfyUI-AI2Go-Utils.
|
||
*
|
||
* Shared front-end for the four Batch Loader nodes. GPL-3.0, like the rest of the pack.
|
||
*
|
||
* Files dropped/picked are uploaded once to ComfyUI's input/ folder; the hidden `files_json`
|
||
* widget (a JSON array of {name, subfolder, type}) is the single source of truth for save,
|
||
* restore and execution — the Prompt Batch pattern. The Python schema declares MAX_FILES
|
||
* output groups; syncOutputs() trims node.outputs to `count` + the loaded groups and re-adds
|
||
* up to the ceiling when files come back. Trimming only ever cuts from the end — ComfyUI
|
||
* validates output types by slot position, so used slots must stay contiguous from slot 0.
|
||
* parseFiles mirrors parse_files in nodes/batch_loader_core.py — keep the two in sync.
|
||
*/
|
||
import { chainCallback } from "./utility.js";
|
||
const { app } = window.comfyAPI.app;
|
||
|
||
const MAX_FILES = 8;
|
||
|
||
// group: [prefix, TYPE] per output within one file's group, in schema order.
|
||
const NODES = {
|
||
AI2GoBatchImageLoader: { kind: "image", group: [["image_", "IMAGE"]] },
|
||
AI2GoBatchImageLoaderAdvanced: { kind: "image", group: [["image_", "IMAGE"], ["mask_", "MASK"], ["filename_", "STRING"]] },
|
||
AI2GoBatchAudioLoader: { kind: "audio", group: [["audio_", "AUDIO"]] },
|
||
AI2GoBatchAudioLoaderAdvanced: { kind: "audio", group: [["audio_", "AUDIO"], ["filename_", "STRING"]] },
|
||
};
|
||
|
||
// ── Mirror of parse_files in nodes/batch_loader_core.py ──
|
||
function parseFiles(raw) {
|
||
const text = (raw || "").trim();
|
||
if (!text) return { ok: true, files: [] }; // empty is fine in the UI; Python rejects at run time
|
||
let data;
|
||
try { data = JSON.parse(text); } catch (e) { return { ok: false, error: "Malformed files_json: " + e.message }; }
|
||
if (!Array.isArray(data)) return { ok: false, error: "Expected a JSON array of files." };
|
||
const files = [];
|
||
for (let i = 0; i < data.length && i < MAX_FILES; i++) {
|
||
const e = data[i];
|
||
if (!e || typeof e !== "object" || typeof e.name !== "string" || !e.name.trim()) {
|
||
return { ok: false, error: `File #${i + 1}: 'name' must be a non-empty string.` };
|
||
}
|
||
files.push({ name: e.name, subfolder: typeof e.subfolder === "string" ? e.subfolder : "", type: "input" });
|
||
}
|
||
return { ok: true, files };
|
||
}
|
||
|
||
const findWidget = (node, name) => node.widgets?.find((w) => w.name === name);
|
||
|
||
function hideWidget(w) {
|
||
if (!w) return;
|
||
w.hidden = true;
|
||
w.computeSize = () => [0, -4];
|
||
}
|
||
|
||
function ensureStyles() {
|
||
if (document.getElementById("ai2go-bl-style")) return;
|
||
const s = document.createElement("style");
|
||
s.id = "ai2go-bl-style";
|
||
s.textContent = `
|
||
.ai2go-bl-drop{box-sizing:border-box;width:100%;padding:10px;margin:2px 0;text-align:center;
|
||
font:11.5px -apple-system,"Segoe UI",Roboto,sans-serif;color:#7ab8e6;background:#1d2733;
|
||
border:1px dashed #46b4e6;border-radius:8px;cursor:copy}
|
||
.ai2go-bl-drop.over{background:#24384c;border-style:solid}
|
||
.ai2go-bl{display:flex;flex-direction:column;gap:5px;width:100%;box-sizing:border-box;
|
||
font:12px/1.4 -apple-system,"Segoe UI",Roboto,sans-serif;color:#d3d3d0}
|
||
.ai2go-bl .bl-row{display:flex;align-items:center;gap:7px;background:#262625;
|
||
border:1px solid #3a3a38;border-radius:8px;padding:5px 7px}
|
||
.ai2go-bl .bl-row.bl-drag{opacity:.45}
|
||
.ai2go-bl .bl-row.bl-over{border-color:#46b4e6;box-shadow:0 0 0 1px #46b4e6 inset}
|
||
.ai2go-bl .bl-grip{color:#6d6d68;font-size:14px;cursor:grab;user-select:none;flex:none}
|
||
.ai2go-bl .bl-num{flex:none;width:18px;height:18px;border-radius:50%;background:#333331;
|
||
color:#8b8b86;font:600 10px/18px ui-monospace,Consolas,monospace;text-align:center}
|
||
.ai2go-bl .bl-thumb{flex:none;width:34px;height:34px;border-radius:4px;object-fit:cover;background:#1a1a19}
|
||
.ai2go-bl .bl-wave{flex:none;width:34px;height:34px;border-radius:4px;background:#13332b;
|
||
color:#46cca8;font-size:15px;line-height:34px;text-align:center}
|
||
.ai2go-bl .bl-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11.5px}
|
||
.ai2go-bl .bl-meta{flex:none;color:#8b8b86;font:10px ui-monospace,Consolas,monospace}
|
||
.ai2go-bl .bl-x{flex:none;color:#6d6d68;font-size:13px;cursor:pointer;padding:2px}
|
||
.ai2go-bl .bl-x:hover{color:#c86b6b}
|
||
.ai2go-bl .bl-empty{padding:6px;text-align:center;color:#6d6d68;font-size:11px}
|
||
`;
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
// Trim node.outputs to count + fileCount groups; re-add (in schema order) up to the ceiling.
|
||
// removeOutput disconnects any links on the removed slot — that is the intended behavior.
|
||
function syncOutputs(node, cfg, fileCount) {
|
||
const want = 1 + Math.min(fileCount, MAX_FILES) * cfg.group.length;
|
||
while (node.outputs.length > want) node.removeOutput(node.outputs.length - 1);
|
||
while (node.outputs.length < want) {
|
||
const slot = node.outputs.length; // next slot index to create
|
||
const gi = (slot - 1) % cfg.group.length; // position within the file's group
|
||
const fi = Math.floor((slot - 1) / cfg.group.length) + 1; // 1-based file number
|
||
const [prefix, type] = cfg.group[gi];
|
||
node.addOutput(prefix + fi, type);
|
||
}
|
||
node.setDirtyCanvas?.(true, true);
|
||
}
|
||
|
||
// Upload one File to ComfyUI's input/ via the same endpoint the stock upload widget uses.
|
||
// The endpoint is generic despite its name; the form field is "image" even for audio.
|
||
async function uploadFile(file) {
|
||
const form = new FormData();
|
||
form.append("image", file);
|
||
form.append("type", "input");
|
||
const api = window.comfyAPI?.api?.api;
|
||
const res = api?.fetchApi
|
||
? await api.fetchApi("/upload/image", { method: "POST", body: form })
|
||
: await fetch("/upload/image", { method: "POST", body: form });
|
||
if (res.status !== 200) throw new Error(`upload of ${file.name} failed (HTTP ${res.status})`);
|
||
const data = await res.json();
|
||
return { name: data.name, subfolder: data.subfolder || "", type: "input" };
|
||
}
|
||
|
||
app.registerExtension({
|
||
name: "AI2Go.BatchLoader",
|
||
|
||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||
const cfg = NODES[nodeData?.name];
|
||
if (!cfg) return;
|
||
ensureStyles();
|
||
|
||
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||
const node = this;
|
||
const jsonW = findWidget(node, "files_json");
|
||
hideWidget(jsonW);
|
||
node._blRows = [];
|
||
|
||
// INT guard (image nodes): a number widget can serialize '' which fails INT validation.
|
||
const maxSizeW = findWidget(node, "max_size");
|
||
if (maxSizeW) {
|
||
maxSizeW.serializeValue = () => {
|
||
const v = parseInt(maxSizeW.value, 10);
|
||
return Number.isFinite(v) && v >= 8 ? v : 1200;
|
||
};
|
||
}
|
||
|
||
// Status line (read-only DOM widget).
|
||
const statusEl = document.createElement("div");
|
||
statusEl.style.cssText = "width:100%;box-sizing:border-box;padding:3px 6px;text-align:center;line-height:1.4;font:12px sans-serif;";
|
||
const setStatus = (text, color) => { statusEl.textContent = text; statusEl.style.color = color; node.setDirtyCanvas?.(true, true); };
|
||
node._blSetStatus = setStatus;
|
||
|
||
function syncJson() {
|
||
if (jsonW) jsonW.value = JSON.stringify(node._blRows);
|
||
}
|
||
node._blSyncJson = syncJson;
|
||
node._blSyncOutputs = () => syncOutputs(node, cfg, node._blRows.length);
|
||
|
||
async function addFiles(fileList) {
|
||
const files = [...fileList].filter((f) => f.type.startsWith(cfg.kind + "/"));
|
||
const rejected = fileList.length - files.length;
|
||
const free = MAX_FILES - node._blRows.length;
|
||
const taking = files.slice(0, free);
|
||
const skipped = files.length - taking.length;
|
||
let failed = null;
|
||
for (const f of taking) {
|
||
try {
|
||
node._blRows.push(await uploadFile(f));
|
||
} catch (e) {
|
||
failed = e.message;
|
||
break;
|
||
}
|
||
}
|
||
syncJson();
|
||
node._blSyncOutputs();
|
||
node._blRender?.();
|
||
if (failed) {
|
||
setStatus(`❌ ${failed} — ${node._blRows.length} file${node._blRows.length === 1 ? "" : "s"} loaded before the error.`, "#e0555a");
|
||
} else {
|
||
const parts = [`${node._blRows.length} file${node._blRows.length === 1 ? "" : "s"} loaded`];
|
||
if (skipped) parts.push(`only ${MAX_FILES} fit — ${skipped} skipped`);
|
||
if (rejected) parts.push(`${rejected} not ${cfg.kind} — ignored`);
|
||
setStatus((skipped || rejected ? "⚠ " : "✅ ") + parts.join("; "), skipped || rejected ? "#e0a03c" : "#46b4e6");
|
||
}
|
||
}
|
||
node._blAddFiles = addFiles;
|
||
|
||
// ── Drop zone (DOM widget). stopPropagation beats ComfyUI's global drop handler,
|
||
// which would otherwise try to load the files as a workflow. ──
|
||
const dropEl = document.createElement("div");
|
||
dropEl.className = "ai2go-bl-drop";
|
||
dropEl.textContent = cfg.kind === "image" ? "Drop images here" : "Drop audio here";
|
||
for (const ev of ["dragenter", "dragover"]) {
|
||
dropEl.addEventListener(ev, (e) => { e.preventDefault(); e.stopPropagation(); dropEl.classList.add("over"); });
|
||
}
|
||
dropEl.addEventListener("dragleave", () => dropEl.classList.remove("over"));
|
||
dropEl.addEventListener("drop", (e) => {
|
||
e.preventDefault(); e.stopPropagation();
|
||
dropEl.classList.remove("over");
|
||
if (e.dataTransfer?.files?.length) addFiles(e.dataTransfer.files);
|
||
});
|
||
node._blDropEl = dropEl;
|
||
node.addDOMWidget("batch_loader_drop", "drop", dropEl, { serialize: false });
|
||
|
||
const addBtn = node.addWidget("button", cfg.kind === "image" ? "+ Add images" : "+ Add audio", null, () => {
|
||
const picker = document.createElement("input");
|
||
picker.type = "file";
|
||
picker.multiple = true;
|
||
picker.accept = cfg.kind + "/*";
|
||
picker.onchange = () => picker.files?.length && addFiles(picker.files);
|
||
picker.click();
|
||
});
|
||
addBtn.serialize = false;
|
||
|
||
node.addDOMWidget("batch_loader_status", "info", statusEl, { serialize: false });
|
||
setStatus(`Drop ${cfg.kind} files here or press + Add.`, "#8a8a8a");
|
||
|
||
// Fresh node: no files yet -> trim the declared ceiling down to just `count`.
|
||
node._blSyncOutputs();
|
||
});
|
||
|
||
// After a workflow loads: rebuild rows from the restored files_json, then re-trim.
|
||
// (Serialized nodes save their trimmed outputs, so this is normally a no-op — it heals
|
||
// hand-edited or older workflows.)
|
||
chainCallback(nodeType.prototype, "onConfigure", function () {
|
||
const node = this;
|
||
requestAnimationFrame(() => {
|
||
const res = parseFiles(findWidget(node, "files_json")?.value);
|
||
node._blRows = res.ok ? res.files : [];
|
||
node._blSyncJson?.();
|
||
node._blSyncOutputs?.();
|
||
node._blRender?.();
|
||
});
|
||
});
|
||
},
|
||
});
|