Files
ChrisandClaude Opus 5 f1a2e77e46 feat: front-end support for the Multi Video Loader pair
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>
2026-08-14 19:03:58 +02:00

587 lines
29 KiB
JavaScript
Raw Permalink 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.
/*
* 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;
});
},
});