Extends the shared web/js/multi_loader.js to a third kind ("video") for
AI2GoMultiVideoLoader / AI2GoMultiVideoLoaderAdvanced: NODES map entries,
video/* MIME acceptance, drop-zone wording, a video-row preview (mm:ss +
WxH probed via a hidden <video>), and a serializeValue guard on force_rate
mirroring the existing max_size guard so a '' value can't fail validation.
README and the design spec's amendments section are extended to match.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
587 lines
29 KiB
JavaScript
587 lines
29 KiB
JavaScript
/*
|
||
* Part of ComfyUI-AI2Go-Utils.
|
||
*
|
||
* Shared front-end for the six Multi Loader nodes (image/audio/video, each simple + Advanced).
|
||
* 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, enabled}) is the single source of truth for
|
||
* save, restore and execution — the Prompt Batch pattern. A row's `enabled` flag (per-row
|
||
* pill toggle, default true) holds its socket position when off — the file is skipped but the
|
||
* slot isn't removed, so later files don't shift up; see nodes/multi_loader_core.py for why
|
||
* feeding None to a connected OPTIONAL input is safe (it is NOT safe for a REQUIRED input).
|
||
* The Python schema declares MAX_FILES
|
||
* output groups; syncOutputs() trims node.outputs to `count` + a slot count and re-adds up to
|
||
* the ceiling when the slot count grows. That slot count is `output_slots` in "auto" mode (it
|
||
* tracks the file count) or a pinned number, so wires on fixed sockets survive file-list edits.
|
||
* 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/multi_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 = {
|
||
AI2GoMultiImageLoader: { kind: "image", group: [["image_", "IMAGE"]] },
|
||
AI2GoMultiImageLoaderAdvanced: { kind: "image", group: [["image_", "IMAGE"], ["mask_", "MASK"], ["filename_", "STRING"]] },
|
||
AI2GoMultiAudioLoader: { kind: "audio", group: [["audio_", "AUDIO"]] },
|
||
AI2GoMultiAudioLoaderAdvanced: { kind: "audio", group: [["audio_", "AUDIO"], ["filename_", "STRING"]] },
|
||
AI2GoMultiVideoLoader: { kind: "video", group: [["video_", "VIDEO"], ["audio_", "AUDIO"]] },
|
||
AI2GoMultiVideoLoaderAdvanced: { kind: "video", group: [["video_", "VIDEO"], ["audio_", "AUDIO"], ["filename_", "STRING"]] },
|
||
};
|
||
|
||
// ── Mirror of parse_files in nodes/multi_loader_core.py — three intentional deviations:
|
||
// (1) empty files_json is OK here (Python raises "No files loaded" only at run time),
|
||
// (2) entries beyond MAX_FILES are silently ignored here instead of raising (Python rejects
|
||
// the whole list with a "too many files" error), and
|
||
// (3) a non-boolean 'enabled' is coerced to true here instead of raising (Python rejects it) —
|
||
// the row list should still render from a hand-edited/older files_json. ──
|
||
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",
|
||
enabled: typeof e.enabled === "boolean" ? e.enabled : true,
|
||
});
|
||
}
|
||
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:pointer}
|
||
.ai2go-bl-drop:hover{background:#24384c}
|
||
.ai2go-bl-drop.over{background:#24384c;border-style:solid}
|
||
.ai2go-bl{width:100%;box-sizing:border-box;
|
||
font:12px/1.4 -apple-system,"Segoe UI",Roboto,sans-serif;color:#d3d3d0}
|
||
.ai2go-bl-content{display:flex;flex-direction:column;gap:5px;width:100%}
|
||
.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-row.bl-off{opacity:.5}
|
||
.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-thumb.bl-off{filter:grayscale(1)}
|
||
.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-wave.bl-off{background:#2b2b29;color:#6d6d68}
|
||
.ai2go-bl .bl-clap{flex:none;width:34px;height:34px;border-radius:4px;background:#2a1f3d;
|
||
color:#b48ce6;font-size:15px;line-height:34px;text-align:center}
|
||
.ai2go-bl .bl-clap.bl-off{background:#2b2b29;color:#6d6d68}
|
||
.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}
|
||
.ai2go-bl .bl-toggle{flex:none;width:26px;height:14px;border-radius:7px;background:#3a3a38;
|
||
border:1px solid #4a4a47;cursor:pointer;position:relative;box-sizing:border-box;padding:0}
|
||
.ai2go-bl .bl-toggle .bl-knob{position:absolute;top:1px;left:1px;width:10px;height:10px;
|
||
border-radius:50%;background:#8b8b86;transition:left .12s ease,background .12s ease}
|
||
.ai2go-bl .bl-toggle.bl-on{background:#1d3644;border-color:#46b4e6}
|
||
.ai2go-bl .bl-toggle.bl-on .bl-knob{left:13px;background:#46b4e6}
|
||
.ai2go-bl .bl-header{display:flex;align-items:center;gap:7px;padding:2px 7px 5px}
|
||
.ai2go-bl .bl-header .bl-toggle-all-label{flex:1;color:#8b8b86;font:600 10.5px -apple-system,"Segoe UI",Roboto,sans-serif;
|
||
text-transform:uppercase;letter-spacing:.03em}
|
||
`;
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
// Trim node.outputs to count + slotCount groups; re-add (in schema order) up to the ceiling.
|
||
// slotCount is the number of sockets to *show* — in "auto" mode that's the file count, but a
|
||
// pinned output_slots value overrides it so sockets (and wires) survive file-list edits.
|
||
// removeOutput disconnects any links on the removed slot — that is the intended behavior.
|
||
function syncOutputs(node, cfg, slotCount) {
|
||
const want = 1 + Math.min(slotCount, 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", enabled: true };
|
||
}
|
||
|
||
app.registerExtension({
|
||
name: "AI2Go.MultiLoader",
|
||
|
||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||
const cfg = NODES[nodeData?.name];
|
||
if (!cfg) return;
|
||
ensureStyles();
|
||
|
||
const MIRRORED = ["downscale_mode", "max_size", "output_slots", "force_rate"]; // downscale_mode/max_size are image-only, force_rate is video-only; findWidget just misses on the other kinds
|
||
|
||
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;
|
||
};
|
||
}
|
||
|
||
// FLOAT guard (video nodes): same '' -> validation-failure trap as max_size above, but
|
||
// for a FLOAT widget; 0 is the sentinel meaning "off" so a bad value coerces to 0, not
|
||
// some non-zero default.
|
||
const forceRateW = findWidget(node, "force_rate");
|
||
if (forceRateW) {
|
||
forceRateW.serializeValue = () => {
|
||
const v = parseFloat(forceRateW.value);
|
||
return Number.isFinite(v) && v >= 0 ? v : 0.0;
|
||
};
|
||
}
|
||
|
||
// 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;
|
||
|
||
// Resolve how many sockets to show: "auto" (or a missing/unparsable value — e.g. a
|
||
// restored workflow handing back null) follows the file count; otherwise the pinned
|
||
// number, clamped to 1..MAX_FILES.
|
||
function slotCount() {
|
||
const w = findWidget(node, "output_slots");
|
||
const raw = w?.value;
|
||
if (!raw || raw === "auto") return node._blRows.length;
|
||
const n = parseInt(raw, 10);
|
||
return Number.isFinite(n) ? Math.max(1, Math.min(MAX_FILES, n)) : node._blRows.length;
|
||
}
|
||
node._blSyncOutputs = () => syncOutputs(node, cfg, slotCount());
|
||
|
||
// In manual mode the socket count no longer tracks the file count; say so plainly, because a
|
||
// wired-but-empty socket outputs nothing and fails at run time.
|
||
function slotNote() {
|
||
const w = findWidget(node, "output_slots");
|
||
const raw = w?.value;
|
||
if (!raw || raw === "auto") return "";
|
||
const slots = Math.max(1, Math.min(MAX_FILES, parseInt(raw, 10) || 0));
|
||
const n = node._blRows.length;
|
||
// Format a range as "a" for a single item, "a-b" for a span.
|
||
const formatRange = (start, end) => (start === end ? String(start) : `${start}-${end}`);
|
||
if (n < slots) {
|
||
const range = formatRange(n + 1, slots);
|
||
const one = n + 1 === slots;
|
||
return ` — socket${one ? "" : "s"} ${range} ${one ? "has" : "have"} no file yet; leave ${one ? "it" : "them"} unwired until you add more.`;
|
||
}
|
||
if (n > slots) {
|
||
const range = formatRange(slots + 1, n);
|
||
return ` — only ${slots} socket${slots === 1 ? "" : "s"} shown, so file${n - slots === 1 ? "" : "s"} ${range} can't be reached.`;
|
||
}
|
||
return "";
|
||
}
|
||
node._blSlotNote = slotNote;
|
||
|
||
// Terse reminder appended to add/remove status lines when some rows are switched off —
|
||
// those rows still occupy a socket but emit nothing, so it's worth flagging inline.
|
||
function offSuffix() {
|
||
const n = node._blRows.filter((f) => !f.enabled).length;
|
||
return n ? ` (${n} off)` : "";
|
||
}
|
||
node._blOffSuffix = offSuffix;
|
||
|
||
// Changing the pinned slot count re-syncs sockets immediately and reports the new
|
||
// file/socket mismatch (if any) in the status line.
|
||
const slotsW = findWidget(node, "output_slots");
|
||
if (slotsW) {
|
||
slotsW.callback = () => {
|
||
node._blSyncOutputs();
|
||
node._blRender?.();
|
||
setStatus(`Output slots: ${slotsW.value}.${slotNote()}`, "#8a8a8a");
|
||
};
|
||
}
|
||
|
||
// Audio containers often self-report as video/* (e.g. .ogg) or with no MIME at all on
|
||
// Windows for unregistered extensions; the backend decoder handles both fine, so accept
|
||
// them here too. Video containers can likewise report no MIME on Windows for
|
||
// unregistered extensions. Images: only the empty-MIME case needs the same allowance.
|
||
const acceptFile = cfg.kind === "audio"
|
||
? (f) => f.type.startsWith("audio/") || f.type.startsWith("video/") || f.type === ""
|
||
: cfg.kind === "video"
|
||
? (f) => f.type.startsWith("video/") || f.type === ""
|
||
: (f) => f.type.startsWith("image/") || f.type === "";
|
||
|
||
async function addFiles(fileList) {
|
||
const files = [...fileList].filter(acceptFile);
|
||
const rejected = fileList.length - files.length;
|
||
const free = MAX_FILES - node._blRows.length;
|
||
const taking = files.slice(0, free);
|
||
let skipped = files.length - taking.length;
|
||
let failed = null;
|
||
// taking.length is capped against a snapshot of node._blRows.length taken above; if
|
||
// another drop is uploading concurrently (interleaved awaits), that snapshot goes
|
||
// stale. Re-check right before each push so two overlapping drops can't push the
|
||
// node past MAX_FILES between them.
|
||
for (let idx = 0; idx < taking.length; idx++) {
|
||
const f = taking[idx];
|
||
let uploaded;
|
||
try {
|
||
uploaded = await uploadFile(f);
|
||
} catch (e) {
|
||
failed = e.message;
|
||
break;
|
||
}
|
||
if (node._blRows.length >= MAX_FILES) {
|
||
skipped += taking.length - idx; // safety net: a concurrent drop filled the node while this file was uploading
|
||
break;
|
||
}
|
||
node._blRows.push(uploaded);
|
||
}
|
||
syncJson();
|
||
node._blSyncOutputs();
|
||
node._blRender?.();
|
||
if (failed) {
|
||
setStatus(`❌ ${failed} — ${node._blRows.length} file${node._blRows.length === 1 ? "" : "s"} loaded before the error.${offSuffix()}`, "#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("; ") + offSuffix() + slotNote(), skipped || rejected ? "#e0a03c" : "#46b4e6");
|
||
}
|
||
}
|
||
node._blAddFiles = addFiles;
|
||
|
||
// ── Drop zone (DOM widget): also doubles as the file picker (click to browse), so there is
|
||
// one field for both gestures instead of a drop zone plus a separate + Add button.
|
||
// 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 — or click to browse"
|
||
: cfg.kind === "audio" ? "Drop audio here — or click to browse"
|
||
: "Drop videos here — or click to browse";
|
||
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);
|
||
});
|
||
dropEl.addEventListener("click", () => {
|
||
const picker = document.createElement("input");
|
||
picker.type = "file";
|
||
picker.multiple = true;
|
||
picker.accept = cfg.kind === "audio" ? "audio/*,video/*" : cfg.kind === "video" ? "video/*" : "image/*";
|
||
picker.onchange = () => picker.files?.length && addFiles(picker.files);
|
||
picker.click();
|
||
});
|
||
node._blDropEl = dropEl;
|
||
node.addDOMWidget("multi_loader_drop", "drop", dropEl, { serialize: false });
|
||
|
||
// ── Rows list (DOM widget): one row per file, in socket order. listEl is the widget root
|
||
// (ComfyUI pins its height each frame); the rows live in contentEl, whose *natural* height
|
||
// we measure so the node can both grow and shrink. Measuring listEl instead would report
|
||
// the pinned height back to itself, so the node could never learn it needs to grow (or
|
||
// shrink) — the prompt_batch.js editor/content split, applied here. ──
|
||
const listEl = document.createElement("div");
|
||
listEl.className = "ai2go-bl";
|
||
const contentEl = document.createElement("div");
|
||
contentEl.className = "ai2go-bl-content";
|
||
listEl.append(contentEl);
|
||
const rowsWidget = node.addDOMWidget("multi_loader_rows", "rows", listEl, { serialize: false });
|
||
let dragIndex = -1;
|
||
|
||
// Auto-fit node height to the rows (measured; the prompt_batch pattern). Measure contentEl
|
||
// (natural height), NOT listEl (height pinned by ComfyUI) — otherwise a removed row
|
||
// couldn't shrink the node, because the pinned element keeps its old, larger height.
|
||
function fitToContent() {
|
||
const h = Math.max(contentEl.scrollHeight, 8);
|
||
rowsWidget.computeSize = () => [node.size?.[0] || 300, h + 8];
|
||
const want = node.computeSize?.();
|
||
if (want) node.setSize([node.size[0], want[1]]);
|
||
node.setDirtyCanvas?.(true, true);
|
||
}
|
||
let lastFitH = 0;
|
||
const ro = new ResizeObserver(() => {
|
||
const h = contentEl.scrollHeight;
|
||
if (h && h !== lastFitH) { lastFitH = h; fitToContent(); }
|
||
});
|
||
ro.observe(contentEl);
|
||
chainCallback(node, "onRemoved", () => ro.disconnect());
|
||
|
||
const viewUrl = (f) =>
|
||
`/view?filename=${encodeURIComponent(f.name)}&type=input&subfolder=${encodeURIComponent(f.subfolder)}`;
|
||
|
||
// Any wire on any group socket means a reorder/delete changes what flows where.
|
||
const anyGroupWired = () =>
|
||
(node.outputs || []).slice(1).some((o) => o.links && o.links.length);
|
||
|
||
// Pill toggle (Power Lora Loader style): rounded track + sliding knob, on = accent
|
||
// colour, off = muted. Click stops propagation so it can never arm the row's drag
|
||
// (the grip is the only drag affordance) or bubble into a row/header click.
|
||
function makeToggle(checked, title, onToggle) {
|
||
const el = document.createElement("span");
|
||
el.className = "bl-toggle" + (checked ? " bl-on" : "");
|
||
el.title = title;
|
||
const knob = document.createElement("span");
|
||
knob.className = "bl-knob";
|
||
el.appendChild(knob);
|
||
el.addEventListener("mousedown", (e) => e.stopPropagation());
|
||
el.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
onToggle();
|
||
});
|
||
return el;
|
||
}
|
||
|
||
// Flip one row's enabled flag. A disabled row keeps its socket position — the Python
|
||
// side emits None for it instead of shifting later rows up (hold-position semantics).
|
||
function toggleRow(k) {
|
||
const f = node._blRows[k];
|
||
f.enabled = !f.enabled;
|
||
node._blSyncJson(); render();
|
||
node._blSetStatus(
|
||
`${f.enabled ? "Enabled" : "Disabled"} ${f.name}${f.enabled ? "" : " — its socket now outputs nothing"}.${offSuffix()}`,
|
||
"#8a8a8a",
|
||
);
|
||
}
|
||
|
||
// Toggle All: on when every row is enabled; clicking flips ALL rows to the opposite of
|
||
// that state (any row off -> turn everything on; all on -> turn everything off).
|
||
function toggleAllRows() {
|
||
const allOn = node._blRows.length > 0 && node._blRows.every((r) => r.enabled);
|
||
const next = !allOn;
|
||
node._blRows.forEach((r) => { r.enabled = next; });
|
||
node._blSyncJson(); render();
|
||
const n = node._blRows.length;
|
||
node._blSetStatus(`${next ? "Enabled" : "Disabled"} all ${n} row${n === 1 ? "" : "s"}.${offSuffix()}`, "#8a8a8a");
|
||
}
|
||
|
||
function removeAt(k) {
|
||
const removed = node._blRows[k].name;
|
||
const moved = node._blRows.slice(k + 1).map((f) => f.name); // mirror of core remove_file
|
||
const start = 1 + k * cfg.group.length;
|
||
const disturbed = (node.outputs || []).slice(start).some((o) => o.links && o.links.length);
|
||
node._blRows.splice(k, 1);
|
||
node._blSyncJson(); node._blSyncOutputs(); render();
|
||
if (disturbed && moved.length) {
|
||
node._blSetStatus(`⚠ Removed ${removed} — ${moved.join(", ")} moved up a slot. Check your wires.${offSuffix()}${slotNote()}`, "#e0a03c");
|
||
} else if (disturbed) {
|
||
node._blSetStatus(`⚠ Removed ${removed} — its socket was wired; that connection is gone.${offSuffix()}${slotNote()}`, "#e0a03c");
|
||
} else {
|
||
node._blSetStatus(`Removed ${removed}.${offSuffix()}${slotNote()}`, "#8a8a8a");
|
||
}
|
||
}
|
||
|
||
function render() {
|
||
contentEl.replaceChildren();
|
||
if (!node._blRows.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "bl-empty";
|
||
empty.textContent = "No files loaded.";
|
||
contentEl.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
// Toggle All header — reads the aggregate state of the rows below it, not its own.
|
||
const allOn = node._blRows.every((r) => r.enabled);
|
||
const header = document.createElement("div");
|
||
header.className = "bl-header";
|
||
const headerToggle = makeToggle(allOn, allOn ? "Turn all off" : "Turn all on", toggleAllRows);
|
||
const headerLabel = document.createElement("span");
|
||
headerLabel.className = "bl-toggle-all-label";
|
||
headerLabel.textContent = "Toggle All";
|
||
header.append(headerToggle, headerLabel);
|
||
contentEl.appendChild(header);
|
||
|
||
node._blRows.forEach((f, k) => {
|
||
const row = document.createElement("div");
|
||
row.className = "bl-row" + (f.enabled ? "" : " bl-off");
|
||
|
||
const toggle = makeToggle(f.enabled, f.enabled ? "Disable this row" : "Enable this row", () => toggleRow(k));
|
||
|
||
const grip = document.createElement("span");
|
||
grip.className = "bl-grip";
|
||
grip.textContent = "⠿";
|
||
grip.title = "Drag to reorder";
|
||
grip.addEventListener("mousedown", () => { row.draggable = true; });
|
||
row.addEventListener("mouseup", () => { row.draggable = false; });
|
||
row.addEventListener("dragstart", (e) => { dragIndex = k; e.dataTransfer.effectAllowed = "move"; row.classList.add("bl-drag"); });
|
||
row.addEventListener("dragend", () => { row.draggable = false; dragIndex = -1; row.classList.remove("bl-drag"); contentEl.querySelectorAll(".bl-over").forEach((n) => n.classList.remove("bl-over")); });
|
||
row.addEventListener("dragover", (e) => { e.preventDefault(); if (dragIndex > -1 && dragIndex !== k) row.classList.add("bl-over"); });
|
||
row.addEventListener("dragleave", () => row.classList.remove("bl-over"));
|
||
row.addEventListener("drop", (e) => {
|
||
e.preventDefault(); e.stopPropagation();
|
||
row.classList.remove("bl-over");
|
||
if (dragIndex > -1 && dragIndex !== k) {
|
||
const wired = anyGroupWired();
|
||
const [movedRow] = node._blRows.splice(dragIndex, 1);
|
||
node._blRows.splice(k, 0, movedRow);
|
||
node._blSyncJson(); render();
|
||
if (wired) node._blSetStatus("⚠ Reordered — sockets now carry different files. Check your wires.", "#e0a03c");
|
||
}
|
||
});
|
||
|
||
const num = document.createElement("span");
|
||
num.className = "bl-num";
|
||
num.textContent = String(k + 1);
|
||
|
||
let preview;
|
||
const meta = document.createElement("span");
|
||
meta.className = "bl-meta";
|
||
if (cfg.kind === "image") {
|
||
preview = document.createElement("img");
|
||
preview.className = "bl-thumb" + (f.enabled ? "" : " bl-off");
|
||
preview.src = viewUrl(f);
|
||
preview.addEventListener("load", () => { meta.textContent = `${preview.naturalWidth}×${preview.naturalHeight}`; });
|
||
} else if (cfg.kind === "video") {
|
||
preview = document.createElement("span");
|
||
preview.className = "bl-clap" + (f.enabled ? "" : " bl-off");
|
||
preview.textContent = "🎬";
|
||
// Like the Audio() probe below, this element is never appended to the DOM —
|
||
// loadedmetadata fires from the network fetch alone, no layout needed.
|
||
const probe = document.createElement("video");
|
||
probe.preload = "metadata";
|
||
probe.src = viewUrl(f);
|
||
probe.addEventListener("loadedmetadata", () => {
|
||
const s = Math.round(probe.duration);
|
||
const time = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||
meta.textContent = `${time} · ${probe.videoWidth}×${probe.videoHeight}`;
|
||
});
|
||
} else {
|
||
preview = document.createElement("span");
|
||
preview.className = "bl-wave" + (f.enabled ? "" : " bl-off");
|
||
preview.textContent = "♪";
|
||
const probe = new Audio();
|
||
probe.preload = "metadata";
|
||
probe.src = viewUrl(f);
|
||
probe.addEventListener("loadedmetadata", () => {
|
||
const s = Math.round(probe.duration);
|
||
meta.textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||
});
|
||
}
|
||
|
||
const name = document.createElement("span");
|
||
name.className = "bl-name";
|
||
name.textContent = f.name;
|
||
name.title = (f.subfolder ? f.subfolder + "/" : "") + f.name;
|
||
|
||
const x = document.createElement("span");
|
||
x.className = "bl-x";
|
||
x.textContent = "✕";
|
||
x.title = "Remove this file";
|
||
x.addEventListener("click", () => removeAt(k));
|
||
|
||
row.append(toggle, grip, num, preview, name, meta, x);
|
||
contentEl.appendChild(row);
|
||
});
|
||
}
|
||
node._blRender = render;
|
||
|
||
const sortBtn = node.addWidget("button", "Sort by name", null, () => {
|
||
if (node._blRows.length < 2) return;
|
||
const wired = anyGroupWired();
|
||
node._blRows.sort((a, b) => a.name.localeCompare(b.name));
|
||
node._blSyncJson(); render();
|
||
node._blSetStatus(wired ? "⚠ Sorted — sockets now carry different files. Check your wires." : "Sorted by name.", wired ? "#e0a03c" : "#8a8a8a");
|
||
});
|
||
sortBtn.serialize = false;
|
||
|
||
render();
|
||
|
||
node.addDOMWidget("multi_loader_status", "info", statusEl, { serialize: false });
|
||
setStatus(`Drop ${cfg.kind} files here, or click the field to browse.`, "#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 (info) {
|
||
const node = this;
|
||
requestAnimationFrame(() => {
|
||
const mirror = info?.properties?.ai2go_bl;
|
||
if (mirror && typeof mirror === "object") {
|
||
for (const name of MIRRORED) {
|
||
const w = findWidget(node, name);
|
||
if (w && mirror[name] !== undefined) w.value = mirror[name];
|
||
}
|
||
}
|
||
const res = parseFiles(findWidget(node, "files_json")?.value);
|
||
node._blRows = res.ok ? res.files : [];
|
||
if (res.ok) {
|
||
node._blSyncJson?.();
|
||
} else {
|
||
// Don't overwrite a possibly hand-edited files_json with "[]" — leave the widget
|
||
// value alone so the user can see and fix what's actually there.
|
||
node._blSetStatus?.(`❌ ${res.error}`, "#e0555a");
|
||
}
|
||
node._blSyncOutputs?.();
|
||
node._blRender?.();
|
||
});
|
||
});
|
||
|
||
chainCallback(nodeType.prototype, "onSerialize", function (o) {
|
||
const mirror = {};
|
||
for (const name of MIRRORED) {
|
||
const w = findWidget(this, name);
|
||
if (w) mirror[name] = w.value;
|
||
}
|
||
o.properties = o.properties || {};
|
||
o.properties.ai2go_bl = mirror;
|
||
});
|
||
},
|
||
});
|