Files
olivv-cs-ComfyUI-FunPack/movie_editor/frontend/models.js
T
digital-garbageandClaude Opus 4.8 330cb1a9e4 Movie Editor: disable built-in pipeline + global video/audio outputs
- Models: 'Disable built-in pipeline' toggle (config.disable_core). When off, the build
  skips the entire fixed FunPack core graph and runs only user-wired nodes.
- Global editor outputs: wire any slot's IMAGE output to 'global:video' and AUDIO to
  'global:audio' (shown at the top of the wire-target list). The builder synthesizes a
  VHS_VideoCombine on demand that muxes the wired frames WITH the wired audio into one
  video+audio file, which the editor previews from history. Works with core on or off.
- Guards: skip core producers/targets that don't exist when core is disabled; non-blocking
  warning if nothing is wired to the global video output with core off. _project_models
  recognizes a project whose only config is disable_core.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 01:32:27 +03:00

847 lines
42 KiB
JavaScript
Raw 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.
// "Models" settings modal: configure pluggable node slots for the fixed pipeline.
// Pick a Model Type -> a Loader Node -> the editor exposes that node's inputs.
(function () {
const { el, clear } = window.dom;
const API = window.MovieEditorAPI;
let roles = []; // [{key,label,category}]
let ports = []; // pipeline connection points [{id,type,label}]
let allNodes = null; // [{class,display_name,category}] for "any node" picker
const candCache = {}; // role -> [candidate]
const specByClass = {}; // class -> full node spec (inputs/outputs/connection_inputs)
let config = { slots: [] }; // {slots:[{id,role,role_label,node_class,inputs:{},wires:{},input_sources:{}}]}
let coreNodes = []; // built-in pipeline description (from /core-graph)
let coreOpen = false; // built-in pipeline panel expanded?
let coreProducers = []; // [{id,type,label}]
let requirements = []; // [{id,type,label,required,role_hint,hint}]
let overlay = null;
const expanded = new Set(); // slot ids currently expanded (collapsed by default)
let linkMode = false; // selecting inputs to bind into one shared control
let linkSel = []; // [{slotId, input, kind, choices, label}]
function roleLabel(key) { const r = roles.find((x) => x.key === key); return r ? r.label : (key === "custom" ? "Node" : (key || "?")); }
function slotName(slot) { return slot.label || slot.node_class; }
// Full display name for use in pickers: "RoleLabel · NodeName"
function slotFullLabel(slot) {
const rl = slot.role_label || roleLabel(slot.role);
const nn = slotName(slot);
return rl && rl !== nn ? `${rl} · ${nn}` : nn;
}
function slotById(id) { return config.slots.find((s) => s.id === id); }
// ── linked inputs (one control drives several node inputs) ────────────────────
function ensureLinks() { if (!config.links) config.links = []; return config.links; }
function linkOf(slotId, input) { return (config.links || []).find((l) => (l.members || []).some((m) => m.slotId === slotId && m.input === input)); }
function linkSelHas(slotId, input) { return linkSel.some((s) => s.slotId === slotId && s.input === input); }
function applyLinkValue(link, value) {
link.value = value;
(link.members || []).forEach((m) => { const s = slotById(m.slotId); if (s) { s.inputs = s.inputs || {}; s.inputs[m.input] = value; } });
}
function uid() { return Math.random().toString(36).slice(2, 9); }
async function ensureRoles() { if (!roles.length) roles = (await API.nodeRoles()).roles || []; }
async function ensureAllNodes() { if (!allNodes) allNodes = (await API.allNodes()).nodes || []; return allNodes; }
function cache(spec) { if (spec && spec.class) specByClass[spec.class] = spec; return spec; }
async function candidates(role, refresh) {
if (!candCache[role] || refresh) {
candCache[role] = (await API.nodeCandidates(role, refresh)).candidates || [];
candCache[role].forEach(cache);
}
return candCache[role];
}
async function loadSpec(cls) { if (!specByClass[cls]) cache(await API.nodeSpec(cls)); return specByClass[cls]; }
function specFor(slot) { return specByClass[slot.node_class] || null; }
async function persist() {
// Keep the SAME `config` object — every wire/widget handler closes over it, so
// reassigning it to the server's returned object detaches those closures and the
// next edit wouldn't persist until the node is re-rendered. Save in place instead.
try { await API.saveModels(window.Store?.get().project?.id, config); window.dispatchEvent(new Event("funpack-models-changed")); }
catch (e) { console.error(e); }
}
// ── "expose to main editor" (eye toggle) ─────────────────────────────────────
function isExposed(slot, name) { return (slot.exposed || []).some((e) => e.name === name); }
function toggleExpose(slot, spec) {
slot.exposed = slot.exposed || [];
if (isExposed(slot, spec.name)) slot.exposed = slot.exposed.filter((e) => e.name !== spec.name);
else slot.exposed.push({ name: spec.name, kind: spec.kind,
choices: spec.kind === "combo" ? (spec.choices || []) : undefined, label: spec.name });
}
function eyeButton(slot, spec) {
const b = el("button", "eye-btn" + (isExposed(slot, spec.name) ? " on" : ""), "◉");
b.type = "button";
b.title = isExposed(slot, spec.name) ? "Hide from main editor window" : "Show in main editor window";
b.onclick = async (e) => { e.preventDefault(); e.stopPropagation(); toggleExpose(slot, spec); await persist(); render(); };
return b;
}
// ── widget field rendering from object_info spec ─────────────────────────────
function widgetField(spec, value, onChange) {
const wrap = el("label", "field");
wrap.append(el("span", null, spec.name + (spec.required ? "" : " ·opt")));
let ctrl;
if (spec.kind === "combo") {
ctrl = el("select");
(spec.choices || []).forEach((c) => { const o = el("option", null, String(c)); o.value = c; if (c === value) o.selected = true; ctrl.append(o); });
if (!spec.choices || !spec.choices.length) { ctrl.append(el("option", null, "(none installed)")); ctrl.disabled = true; }
ctrl.onchange = () => onChange(ctrl.value);
} else if (spec.kind === "boolean") {
ctrl = el("input"); ctrl.type = "checkbox"; ctrl.checked = !!value; ctrl.style.width = "auto";
ctrl.onchange = () => onChange(ctrl.checked);
} else if (spec.kind === "int" || spec.kind === "float") {
ctrl = el("input"); ctrl.type = "number"; if (spec.kind === "float") ctrl.step = "any";
ctrl.value = value != null ? value : (spec.default != null ? spec.default : "");
ctrl.oninput = () => onChange(spec.kind === "int" ? parseInt(ctrl.value || "0", 10) : parseFloat(ctrl.value || "0"));
} else {
ctrl = el("input"); ctrl.type = "text"; ctrl.value = value != null ? value : (spec.default || "");
ctrl.oninput = () => onChange(ctrl.value);
}
wrap.append(ctrl);
return wrap;
}
function defaultsFor(cand) {
const o = {};
(cand.inputs || []).forEach((s) => { if (s.default != null) o[s.name] = s.default; else if (s.kind === "combo" && s.choices?.length) o[s.name] = s.choices[0]; });
return o;
}
// ── validation ───────────────────────────────────────────────────────────────
// Roles the fixed FunPack path cannot generate without (at least one slot each).
const ESSENTIAL = [["unet", "Unet / Diffusion Model"], ["clip", "CLIP / Text Encoder"], ["video_vae", "Video VAE"]];
// A slot is "active" (part of the pipeline) if it wires an output somewhere or a core
// override sources from it. Inert slots are left alone (no required-input errors).
function slotIsActive(slot) {
if (Object.values(slot.wires || {}).some((tg) => wireTargets(tg).some(Boolean))) return true;
const ov = config.core_overrides || {};
return Object.values(ov).some((ins) =>
Object.values(ins || {}).some((src) => typeof src === "string" && src.startsWith(`out:${slot.id}:`)));
}
function validateSlot(slot) {
const issues = [];
const spec = specFor(slot);
if (!spec) {
issues.push({ level: "error", msg: "Node spec unavailable — node not installed, or ComfyUI offline." });
return issues;
}
(spec.inputs || []).forEach((w) => {
if (!w.required) return;
const v = slot.inputs[w.name];
if (w.kind === "combo" && (!w.choices || !w.choices.length)) {
issues.push({ level: "error", msg: `"${w.name}" has no installed options to pick from.` });
} else if (v == null || v === "") {
// Not blocking: the builder emits a type-appropriate empty for any widget left
// blank (matching ComfyUI's frontend), so an empty field won't stall generation.
issues.push({ level: "warn", msg: `"${w.name}" is empty — using its default.` });
}
});
const outs = spec.outputs || [];
const wires = slot.wires || {};
if (outs.length && !outs.some((o) => wireTargets(wires[o.name]).some(Boolean))) {
issues.push({ level: "warn", msg: "No outputs wired — this node feeds nothing." });
}
const dests = (o) => destinations(slot, o.type);
outs.forEach((o) => {
wireTargets(wires[o.name]).filter(Boolean).forEach((t) => {
if (!dests(o).some((d) => d.value === t))
issues.push({ level: "error", msg: `"${o.name}" is wired to a destination that no longer exists.` });
});
});
// Required connection INPUTS must actually be fed (matches the builder's blocking):
// explicit input source, an incoming wire from another node, or a unique auto-wire
// producer. Only checked for ACTIVE slots (wired into the pipeline) — an unused node
// that feeds nothing must not be flagged or block generation.
if (slotIsActive(slot)) (spec.connection_inputs || []).forEach((ci) => {
if (!ci.required) return;
if (ci.type === "IMAGE") return; // a scene/timeline image can always feed an IMAGE input
const src = (slot.input_sources || {})[ci.name];
if (src && src !== "auto") return; // explicitly sourced (incl. "timeline")
const incoming = (config.slots || []).some((s2) =>
s2.id !== slot.id && Object.values(s2.wires || {}).some((tg) => wireTargets(tg).includes(`node:${slot.id}:${ci.name}`)));
if (incoming) return;
const prod = sources(slot, ci.type).filter((o) => o.value && o.value !== "timeline").length;
if (prod === 1) return; // a single producer auto-wires
if (prod > 1)
issues.push({ level: "error", msg: `Input "${ci.name}" (${ci.type}): ${prod} possible sources — set its Input source.` });
else
issues.push({ level: "error", msg: `Input "${ci.name}" (${ci.type}) has no source — set its Input source or add a node that outputs ${ci.type}.` });
});
return issues;
}
function missingEssentials() {
return ESSENTIAL.filter(([role]) => !config.slots.some((s) => s.role === role)).map(([, label]) => label);
}
function validation() {
const perSlot = {};
let errors = 0, warns = 0;
config.slots.forEach((s) => {
const list = validateSlot(s);
perSlot[s.id] = list;
list.forEach((i) => (i.level === "error" ? errors++ : warns++));
});
return { perSlot, errors, warns, missing: missingEssentials() };
}
function issuesBox(list) {
if (!list || !list.length) return null;
const box = el("div", "issue-box");
list.forEach((i) => {
const row = el("div", "issue issue-" + i.level);
row.append(el("span", "issue-dot", i.level === "error" ? "✕" : "▲"));
row.append(el("span", "issue-msg", i.msg));
box.append(row);
});
return box;
}
// ── slot row (configured) ────────────────────────────────────────────────────
function slotRow(slot, issues) {
const role = roles.find((r) => r.key === slot.role);
const isExp = expanded.has(slot.id);
const card = el("div", "slot-card" + (isExp ? " open" : ""));
const errs = (issues || []).filter((i) => i.level === "error").length;
const warns = (issues || []).filter((i) => i.level === "warn").length;
if (errs) card.classList.add("slot-bad");
else if (warns) card.classList.add("slot-warn");
const head = el("div", "slot-head");
head.append(el("span", "slot-chev", isExp ? "▾" : "▸"));
const roleText = slot.role_label || (role ? role.label : slot.role) || "?";
const roleSpan = el("span", "slot-role", roleText);
roleSpan.title = "Click ✎ to rename this label";
head.append(roleSpan);
head.append(el("span", "slot-node", slotName(slot)));
const ren = el("button", "ic-btn", "✎"); ren.title = "Rename role label";
ren.onclick = async (e) => {
e.stopPropagation();
const cur = slot.role_label || (role ? role.label : slot.role) || "";
const n = prompt("Role label (shown in pickers and headers):", cur);
if (n != null) { slot.role_label = n.trim() || undefined; await persist(); render(); }
};
head.append(ren);
const nExp = (slot.exposed || []).length;
if (nExp) head.append(el("span", "slot-badge exposed", `◉ ${nExp}`));
if (errs) head.append(el("span", "slot-badge bad", `${errs} error${errs > 1 ? "s" : ""}`));
else if (warns) head.append(el("span", "slot-badge warn", `${warns} warning${warns > 1 ? "s" : ""}`));
else head.append(el("span", "slot-badge ok", "ready"));
const rm = el("button", "btn ghost tiny danger", "remove");
rm.onclick = async (e) => { e.stopPropagation(); config.slots = config.slots.filter((s) => s.id !== slot.id); expanded.delete(slot.id); await persist(); render(); };
head.append(rm);
head.onclick = () => { isExp ? expanded.delete(slot.id) : expanded.add(slot.id); render(); };
card.append(head);
if (!isExp) return card; // collapsed: header only
const ib = issuesBox(issues);
if (ib) card.append(ib);
const cand = specFor(slot);
if (cand && cand.inputs.length) {
const grid = el("div", "slot-fields");
cand.inputs.forEach((spec) => {
const lk = linkOf(slot.id, spec.name);
const f = widgetField(spec, slot.inputs[spec.name], async (v) => {
slot.inputs[spec.name] = v;
const l2 = linkOf(slot.id, spec.name); if (l2) applyLinkValue(l2, v); // keep group in sync
await persist();
});
f.classList.add("with-eye");
if (lk) {
f.classList.add("linked");
const ctrl = f.querySelector("input,select"); if (ctrl) ctrl.disabled = true;
f.append(el("span", "link-tag", "🔗 " + lk.name));
} else if (linkMode) {
const chk = el("button", "eye-btn link-pick" + (linkSelHas(slot.id, spec.name) ? " on" : ""), "+");
chk.type = "button"; chk.title = "Add to link selection";
chk.onclick = (e) => {
e.preventDefault(); e.stopPropagation();
if (linkSelHas(slot.id, spec.name)) linkSel = linkSel.filter((s) => !(s.slotId === slot.id && s.input === spec.name));
else linkSel.push({ slotId: slot.id, input: spec.name, kind: spec.kind, choices: spec.choices, label: `${slotName(slot)} · ${spec.name}` });
render();
};
f.append(chk);
} else {
f.append(eyeButton(slot, spec));
}
grid.append(f);
});
card.append(grid);
}
// Wiring: each output -> one or more destinations.
if (cand && (cand.outputs || []).length) {
slot.wires = slot.wires || {};
const wbox = el("div", "wire-box");
wbox.append(el("div", "wire-title", "Wire outputs to"));
cand.outputs.forEach((out) => {
const row = el("div", "wire-row");
row.append(el("span", "wire-out", `${out.name} (${out.type})`));
row.append(el("span", "wire-arrow", "→"));
row.append(destMulti(slot, out));
wbox.append(row);
});
card.append(wbox);
}
// Input sources: explicitly choose where each connection input comes from.
if (cand && (cand.connection_inputs || []).length) {
slot.input_sources = slot.input_sources || {};
const sbox = el("div", "wire-box");
sbox.append(el("div", "wire-title", "Input sources"));
cand.connection_inputs.forEach((ci) => {
const row = el("div", "wire-row");
row.append(el("span", "wire-out", `${ci.name} (${ci.type})`));
row.append(el("span", "wire-arrow", "←"));
const srcs = sources(slot, ci.type);
const sel = el("select", "wire-select");
const cur = slot.input_sources[ci.name] || "";
srcs.forEach((s) => { const o = el("option", null, s.label); o.value = s.value; if (s.value === cur) o.selected = true; sel.append(o); });
if (cur && !srcs.some((s) => s.value === cur)) { const o = el("option", null, cur + " (missing)"); o.value = cur; o.selected = true; sel.append(o); }
sel.onchange = async () => { _setInputSource(slot, ci.name, sel.value); await persist(); render(); };
row.append(sel);
sbox.append(row);
});
card.append(sbox);
}
return card;
}
const _KIND2T = { int: "INT", float: "FLOAT", string: "STRING", boolean: "BOOLEAN" };
function destinations(slot, type) {
const out = [{ value: "", label: "— unwired —" }];
// Global editor outputs: wire your final video/audio producer here and the editor shows
// it (works with or without the built-in pipeline). IMAGE -> video, AUDIO -> audio.
if (type === "IMAGE") out.push({ value: "global:video", label: "🌐 Global video output (shown in editor)" });
if (type === "AUDIO") out.push({ value: "global:audio", label: "🌐 Global audio output (shown in editor)" });
ports.filter((p) => p.type === type).forEach((p) => out.push({ value: "port:" + p.id, label: p.label }));
config.slots.filter((s) => s.id !== slot.id).forEach((s2) => {
const c2 = specFor(s2);
(c2?.connection_inputs || []).filter((ci) => ci.type === type).forEach((ci) =>
out.push({ value: `node:${s2.id}:${ci.name}`, label: `${slotFullLabel(s2)} · ${ci.name}` }));
// Widget inputs can also receive a connection (ComfyUI converts a widget to an
// input when wired) — e.g. EmptyLatentVideo.width/height. Offer them as targets.
(c2?.inputs || []).filter((w) => _KIND2T[w.kind] === type).forEach((w) =>
out.push({ value: `node:${s2.id}:${w.name}`, label: `${slotFullLabel(s2)} · ${w.name} (widget)` }));
});
return out;
}
// Available sources for a slot connection input of a given type.
// Source IDs: "" = auto, "out:<slotId>:<outName>", "core:<coreId>:<outIdx>", "timeline" (IMAGE only).
function sources(slot, type) {
const out = [{ value: "", label: "(auto-wire)" }];
if (type === "IMAGE") out.push({ value: "timeline", label: "Timeline (scene image)" });
coreProducers.filter((p) => p.type === type).forEach((p) =>
out.push({ value: p.id, label: p.label }));
config.slots.filter((s) => s.id !== slot.id).forEach((s2) => {
const c2 = specFor(s2);
(c2?.outputs || []).filter((o) => o.type === type).forEach((o) =>
out.push({ value: `out:${s2.id}:${o.name}`, label: `${slotFullLabel(s2)} → ${o.name}` }));
});
return out;
}
// Normalize wires[outName] to always be an array (supports legacy string format).
function wireTargets(raw) {
if (!raw) return [];
return Array.isArray(raw) ? raw : [raw];
}
// ── edge mirroring ────────────────────────────────────────────────────────────
// An output→input edge can be authored from either side: a slot's wires
// ("node:<destId>:<input>") OR the destination's input_sources ("out:<srcId>:<out>").
// They describe the SAME connection, so we keep both in sync — wiring an output shows
// the input as connected on the other node, and vice-versa.
function _parseNodeTarget(t) {
if (typeof t !== "string" || !t.startsWith("node:")) return null;
const parts = t.split(":");
return { slotId: parts[1], input: parts.slice(2).join(":") };
}
function _parseOutSource(s) {
if (typeof s !== "string" || !s.startsWith("out:")) return null;
const parts = s.split(":");
return { slotId: parts[1], out: parts.slice(2).join(":") };
}
function _addWire(slotId, outName, target) {
const s = slotById(slotId); if (!s) return;
s.wires = s.wires || {};
const arr = wireTargets(s.wires[outName]);
if (!arr.includes(target)) arr.push(target);
s.wires[outName] = arr;
}
function _removeWire(slotId, outName, target) {
const s = slotById(slotId); if (!s || !s.wires) return;
s.wires[outName] = wireTargets(s.wires[outName]).filter((t) => t !== target);
}
// Set a destination's input source + mirror it as a wire on the source slot.
function _setInputSource(destSlot, inp, value) {
const prev = _parseOutSource(destSlot.input_sources[inp]);
if (prev) _removeWire(prev.slotId, prev.out, `node:${destSlot.id}:${inp}`);
destSlot.input_sources[inp] = value;
const next = _parseOutSource(value);
if (next) _addWire(next.slotId, next.out, `node:${destSlot.id}:${inp}`);
}
// Replace a wire target + mirror it as an input source on the destination slot.
function _setWireTarget(srcSlot, outName, oldTarget, newTarget) {
const old = _parseNodeTarget(oldTarget);
if (old) {
const ds = slotById(old.slotId);
if (ds && ds.input_sources && ds.input_sources[old.input] === `out:${srcSlot.id}:${outName}`)
ds.input_sources[old.input] = "";
}
const nu = _parseNodeTarget(newTarget);
if (nu) {
const ds = slotById(nu.slotId);
if (ds) { ds.input_sources = ds.input_sources || {}; ds.input_sources[nu.input] = `out:${srcSlot.id}:${outName}`; }
}
}
function destMulti(slot, out) {
slot.wires = slot.wires || {};
// normalize to array in-place
const raw = slot.wires[out.name];
let targets = wireTargets(raw);
slot.wires[out.name] = targets;
const wrap = el("div", "wire-multi");
function renderRows() {
clear(wrap);
const dests = destinations(slot, out.type);
targets.forEach((t, i) => {
const row = el("div", "wire-multi-row");
const sel = el("select", "wire-select");
dests.forEach((d) => { const o = el("option", null, d.label); o.value = d.value; if (d.value === t) o.selected = true; sel.append(o); });
if (t && !dests.some((d) => d.value === t)) { const o = el("option", null, t + " (missing)"); o.value = t; o.selected = true; sel.append(o); }
sel.onchange = async () => { _setWireTarget(slot, out.name, targets[i], sel.value); targets[i] = sel.value; await persist(); render(); };
const rm = el("button", "btn ghost tiny wire-rm", "×");
rm.title = "Remove this wire";
rm.onclick = async () => { _setWireTarget(slot, out.name, targets[i], ""); targets.splice(i, 1); await persist(); render(); };
row.append(sel, rm);
wrap.append(row);
});
const add = el("button", "btn ghost tiny wire-add", "+ Add");
add.onclick = () => { targets.push(""); renderRows(); };
wrap.append(add);
}
renderRows();
return wrap;
}
// ── add-model composer ───────────────────────────────────────────────────────
function composer() {
const box = el("div", "composer");
box.append(el("div", "composer-title", "Add Model / Node"));
const row = el("div", "composer-row");
const typeSel = el("select");
typeSel.append(new Option("Model Type…", ""));
const cats = {};
roles.forEach((r) => { (cats[r.category] = cats[r.category] || []).push(r); });
Object.keys(cats).forEach((cat) => {
const og = document.createElement("optgroup"); og.label = cat;
cats[cat].forEach((r) => { og.append(new Option(r.label, r.key)); });
typeSel.append(og);
});
const ogAny = document.createElement("optgroup"); ogAny.label = "Advanced";
ogAny.append(new Option("Any node…", "__any__")); typeSel.append(ogAny);
const search = el("input", "composer-search"); search.type = "text"; search.placeholder = "Filter nodes…"; search.style.display = "none";
const nodeSel = el("select"); nodeSel.disabled = true; nodeSel.append(new Option("Node…", ""));
const addBtn = el("button", "btn primary", "Add"); addBtn.disabled = true;
const preview = el("div", "composer-preview");
let curRole = "", curCand = null, curList = [];
function showPreview() {
clear(preview);
if (!curCand) return;
const d = defaultsFor(curCand);
(curCand.inputs || []).forEach((spec) => preview.append(widgetField(spec, d[spec.name], (v) => { d[spec.name] = v; })));
preview._draft = d;
}
function fill(list, placeholder) {
nodeSel.innerHTML = ""; nodeSel.append(new Option(placeholder, ""));
list.forEach((c) => nodeSel.append(new Option(`${c.display_name} · ${c.class}`, c.class)));
nodeSel.disabled = !list.length;
}
function applyFilter() {
const q = search.value.trim().toLowerCase();
const f = q ? curList.filter((c) => (c.display_name + " " + c.class + " " + (c.category || "")).toLowerCase().includes(q)) : curList;
const shown = f.slice(0, 300);
fill(shown, f.length ? `${f.length} node${f.length > 1 ? "s" : ""} — pick one…${f.length > 300 ? " (showing first 300)" : ""}` : "No match");
}
search.oninput = applyFilter;
typeSel.onchange = async () => {
curRole = typeSel.value; curCand = null; curList = []; addBtn.disabled = true; clear(preview);
search.style.display = curRole === "__any__" ? "" : "none"; search.value = "";
nodeSel.innerHTML = ""; nodeSel.append(new Option("Loading…", "")); nodeSel.disabled = true;
if (!curRole) { nodeSel.innerHTML = ""; nodeSel.append(new Option("Node…", "")); return; }
try {
if (curRole === "__any__") {
curList = await ensureAllNodes();
applyFilter();
} else {
curList = await candidates(curRole);
fill(curList, curList.length ? "Loader Node…" : "No matching nodes");
}
} catch (e) {
nodeSel.innerHTML = ""; nodeSel.append(new Option("Error: " + (e && e.message ? e.message : "node list unavailable"), ""));
console.error("[Models] node list failed:", e);
}
};
nodeSel.onchange = async () => {
addBtn.disabled = true; curCand = null; clear(preview);
if (!nodeSel.value) return;
curCand = specByClass[nodeSel.value];
if (!curCand) { try { curCand = await loadSpec(nodeSel.value); } catch (e) { return; } }
addBtn.disabled = !curCand; showPreview();
};
addBtn.onclick = async () => {
if (!curCand) return;
const role = curRole === "__any__" ? "custom" : curRole;
config.slots.push({ id: uid(), role, node_class: curCand.class, inputs: preview._draft || defaultsFor(curCand), wires: {} });
await persist();
typeSel.value = ""; nodeSel.innerHTML = ""; nodeSel.append(new Option("Node…", "")); nodeSel.disabled = true;
search.style.display = "none"; search.value = "";
addBtn.disabled = true; curCand = null; curList = []; clear(preview); render();
};
row.append(typeSel); row.append(search); row.append(nodeSel); row.append(addBtn);
box.append(row); box.append(preview);
return box;
}
// Does any configured slot produce the given type (optionally filtered by role)?
function slotProducesType(type, roleHint) {
return config.slots.some((s) => {
if (roleHint && s.role !== roleHint) return false;
const spec = specFor(s);
return (spec?.outputs || []).some((o) => o.type === type);
});
}
// For VAE: need two separate producers (video_vae + audio_vae roles).
// For others: at least one producer of the type (regardless of role).
function requirementSatisfied(req) {
if (req.role_hint) return slotProducesType(req.type, req.role_hint);
return slotProducesType(req.type, null);
}
function requirementsPanel() {
const sec = el("div", "req-panel");
sec.append(el("div", "req-title", "Pipeline requirements"));
if (!requirements.length) {
sec.append(el("div", "req-empty", "Requirements not loaded — open Models to refresh."));
return sec;
}
const required = requirements.filter((r) => r.required);
const optional = requirements.filter((r) => !r.required);
const allOk = required.every(requirementSatisfied);
required.forEach((req) => {
const ok = requirementSatisfied(req);
const row = el("div", "req-row" + (ok ? " ok" : " missing"));
row.append(el("span", "req-dot", ok ? "✓" : "✕"));
const lbl = el("span", "req-label", req.label);
const badge = el("span", "req-type", req.type);
row.append(lbl); row.append(badge);
if (!ok) {
const hint = el("div", "req-hint", req.hint);
row.append(hint);
if (req.role_hint) {
const addBtn = el("button", "btn ghost tiny req-add", "+ Add");
addBtn.onclick = () => {
// pre-scroll the composer to the right role and expand it
const typeSelEl = document.querySelector(".composer select");
if (typeSelEl) { typeSelEl.value = req.role_hint; typeSelEl.dispatchEvent(new Event("change")); }
};
row.append(addBtn);
}
}
sec.append(row);
});
if (optional.length) {
const optHead = el("div", "req-opt-head", "Optional");
sec.append(optHead);
optional.forEach((req) => {
const ok = requirementSatisfied(req);
const row = el("div", "req-row opt" + (ok ? " ok" : ""));
row.append(el("span", "req-dot", ok ? "✓" : "·"));
row.append(el("span", "req-label", req.label));
row.append(el("span", "req-type", req.type));
sec.append(row);
});
}
if (allOk && config.slots.length) {
const v = validation();
const bits = [];
if (v.errors) bits.push(`${v.errors} slot error${v.errors > 1 ? "s" : ""}`);
if (v.warns) bits.push(`${v.warns} warning${v.warns > 1 ? "s" : ""}`);
const bar = el("div", "valid-banner " + (v.errors ? "bad" : (v.warns ? "warn" : "ok")));
bar.append(el("span", "valid-dot", v.errors ? "!" : "✓"));
bar.append(el("span", null, v.errors ? `Not ready — ${bits.join(", ")} to fix.`
: (v.warns ? `Pipeline ready — ${bits.join(", ")}.` : "Pipeline ready.")));
sec.append(bar);
}
return sec;
}
function summaryBanner(v) {
// Kept for any callers — but requirementsPanel() is now shown instead.
return null;
}
// ── linked-inputs section ──────────────────────────────────────────────────────
function linkExposeBtn(link) {
const b = el("button", "eye-btn" + (link.exposed ? " on" : ""), "◉"); b.type = "button";
b.title = link.exposed ? "Hide from main editor window" : "Show in main editor window";
b.onclick = async () => { link.exposed = !link.exposed; await persist(); render(); };
return b;
}
function linkCard(link) {
const card = el("div", "link-card");
const head = el("div", "link-head");
head.append(el("span", "link-ico", "🔗"));
const name = el("input", "link-name"); name.value = link.name || "";
name.onchange = async () => { link.name = name.value.trim() || link.name; await persist(); render(); };
head.append(name);
head.append(linkExposeBtn(link));
const rm = el("button", "btn ghost tiny danger", "unlink all");
rm.onclick = async () => { config.links = (config.links || []).filter((l) => l.id !== link.id); await persist(); render(); };
head.append(rm);
card.append(head);
// source: a manual value, or driven by a project/editor setting
const srcRow = el("label", "field link-source");
srcRow.append(el("span", null, "Driven by"));
const srcSel = el("select");
[["", "Manual value"], ["frame_rate", "Project · FPS"], ["num_frames_per_scene", "Project · Frames"],
["width", "Project · Width"], ["height", "Project · Height"]].forEach(([v, label]) => {
const o = new Option(label, v); if ((link.source === "editor" ? link.editor_key : "") === v) o.selected = true; srcSel.append(o);
});
srcSel.onchange = async () => {
if (srcSel.value) { link.source = "editor"; link.editor_key = srcSel.value; }
else { link.source = "manual"; delete link.editor_key; }
await persist(); render();
};
srcRow.append(srcSel);
card.append(srcRow);
if (link.source === "editor") {
card.append(el("div", "link-bound", `Value comes from the project setting at generate.`));
} else {
const spec = { name: "shared value", kind: link.kind, choices: link.choices, required: false };
const vf = widgetField(spec, link.value, async (v) => { applyLinkValue(link, v); await persist(); });
vf.classList.add("link-value");
card.append(vf);
}
const mem = el("div", "link-members");
(link.members || []).forEach((m) => {
const s = slotById(m.slotId);
const chip = el("span", "link-chip", `${s ? slotName(s) : "?"} · ${m.input}`);
const x = el("button", "chip-x", "✕"); x.title = "Remove from link";
x.onclick = async () => {
link.members = (link.members || []).filter((mm) => !(mm.slotId === m.slotId && mm.input === m.input));
if (link.members.length < 1) config.links = (config.links || []).filter((l) => l.id !== link.id);
await persist(); render();
};
chip.append(x); mem.append(chip);
});
card.append(mem);
return card;
}
function linksSection() {
const sec = el("div", "links-section");
const head = el("div", "links-head");
head.append(el("div", "composer-title", "Linked inputs"));
const right = el("div", "links-head-right");
if (!linkMode) {
const b = el("button", "btn ghost tiny", "🔗 Link inputs");
b.onclick = () => { linkMode = true; linkSel = []; config.slots.forEach((s) => expanded.add(s.id)); render(); };
right.append(b);
} else {
const save = el("button", "btn primary tiny", `Save link (${linkSel.length})`);
save.disabled = linkSel.length < 1;
save.onclick = async () => {
if (linkSel.length < 1) return;
const def = "size " + ((config.links || []).length + 1);
const nm = prompt("Link name:", def); if (nm == null) return;
const first = linkSel[0]; const s0 = slotById(first.slotId);
const val = s0 ? (s0.inputs || {})[first.input] : undefined;
const link = { id: uid(), name: nm.trim() || def, kind: first.kind,
choices: first.kind === "combo" ? (first.choices || []) : undefined, value: val,
members: linkSel.map((s) => ({ slotId: s.slotId, input: s.input })), exposed: false };
ensureLinks().push(link);
applyLinkValue(link, val);
linkMode = false; linkSel = []; await persist(); render();
};
const cancel = el("button", "btn ghost tiny", "Cancel");
cancel.onclick = () => { linkMode = false; linkSel = []; render(); };
right.append(save); right.append(cancel);
}
head.append(right);
sec.append(head);
if (linkMode) sec.append(el("div", "links-hint", "Click + next to any input below to add it to the link, then Save. Pick a single input to tie it to a project value (e.g. FPS), or several to drive them together."));
(config.links || []).forEach((l) => sec.append(linkCard(l)));
if (!linkMode && !(config.links || []).length)
sec.append(el("div", "links-empty", "No links yet. Use 'Link inputs' to bind a node input to a project value (e.g. tie a loader's FPS to Project · FPS), or to drive several inputs from one control."));
return sec;
}
// ── built-in pipeline (core) — visible + re-wireable ──────────────────────────
function setCoreOverride(cid, inp, value) {
config.core_overrides = config.core_overrides || {};
config.core_overrides[cid] = config.core_overrides[cid] || {};
if (value) config.core_overrides[cid][inp] = value;
else delete config.core_overrides[cid][inp];
}
function coreCard(n) {
const card = el("div", "slot-card open" + (n.installed ? "" : " slot-bad"));
const head = el("div", "slot-head");
head.append(el("span", "slot-role", n.display_name));
head.append(el("span", "slot-node", n.class));
if (!n.installed) head.append(el("span", "slot-badge bad", "not installed"));
card.append(head);
if ((n.inputs || []).length) {
const box = el("div", "wire-box");
box.append(el("div", "wire-title", "Inputs ← source"));
n.inputs.forEach((inp) => {
const row = el("div", "wire-row");
row.append(el("span", "wire-out", `${inp.name} (${inp.type})`));
row.append(el("span", "wire-arrow", "←"));
const sel = el("select", "wire-select");
(inp.options || []).forEach((o) => { const op = el("option", null, o.label); op.value = o.value; if (o.value === (inp.value || "")) op.selected = true; sel.append(op); });
if (inp.value && !(inp.options || []).some((o) => o.value === inp.value)) { const op = el("option", null, inp.value + " (missing)"); op.value = inp.value; op.selected = true; sel.append(op); }
sel.onchange = async () => { setCoreOverride(n.id, inp.name, sel.value); await persist(); coreNodes = (await API.coreGraph(window.Store?.get().project?.id).catch(() => ({}))).nodes || coreNodes; render(); };
row.append(sel);
box.append(row);
});
card.append(box);
}
const outs = (n.outputs || []).filter((o) => (o.to || []).length);
if (outs.length) {
const box = el("div", "wire-box");
box.append(el("div", "wire-title", "Outputs → destinations"));
outs.forEach((o) => {
const row = el("div", "wire-row");
row.append(el("span", "wire-out", `${o.name} (${o.type})`));
row.append(el("span", "wire-arrow", "→"));
row.append(el("span", "wire-dest", o.to.join(", ")));
box.append(row);
});
card.append(box);
}
return card;
}
function coreSection() {
const sec = el("div", "links-section");
const disabled = !!config.disable_core;
const head = el("div", "links-head");
const toggle = el("button", "btn ghost tiny", (coreOpen ? "▾ " : "▸ ") + "Built-in pipeline");
toggle.onclick = () => { coreOpen = !coreOpen; render(); };
head.append(toggle);
head.append(el("span", "lib-sub", disabled ? "disabled" : `${coreNodes.length} fixed nodes`));
const dis = el("button", "btn ghost tiny" + (disabled ? " danger" : ""), disabled ? "↺ Enable built-in pipeline" : "⏻ Disable built-in pipeline");
dis.title = disabled
? "Re-enable the built-in FunPack Studio → Chain Sampler → decode → combine pipeline."
: "Drop the built-in pipeline entirely and run only your wired nodes. Wire your final image to 🌐 Global video output (and audio to 🌐 Global audio output) so the editor shows the result.";
dis.onclick = async () => { config.disable_core = !disabled; await persist(); render(); };
head.append(dis);
sec.append(head);
if (disabled) {
sec.append(el("div", "links-hint", "Built-in pipeline is OFF — generation runs only your configured nodes. Wire your final IMAGE output to 🌐 Global video output (and AUDIO to 🌐 Global audio output) so the result shows in the editor."));
return sec;
}
if (coreOpen) {
sec.append(el("div", "links-hint", "The fixed FunPack nodes and their wiring. Each input defaults to its built-in source — pick another to re-wire it."));
coreNodes.forEach((n) => sec.append(coreCard(n)));
}
return sec;
}
function body() {
const b = el("div", "models-body");
b.append(requirementsPanel());
b.append(coreSection());
b.append(composer());
b.append(linksSection());
const v = validation();
const list = el("div", "slot-list");
if (!config.slots.length) list.append(el("div", "empty-stage", "No models configured yet — use the form above to add loaders."));
else config.slots.forEach((s) => list.append(slotRow(s, v.perSlot[s.id])));
b.append(list);
return b;
}
function render() {
if (!overlay) return;
const content = overlay.querySelector(".modal-content");
clear(content);
content.append(body());
}
async function prewarmSpecs() {
// resolve the spec for every configured node (by class) so fields + wiring render
for (const cls of new Set(config.slots.map((s) => s.node_class))) { try { await loadSpec(cls); } catch (_) {} }
}
async function refreshList() {
Object.keys(candCache).forEach((k) => delete candCache[k]);
Object.keys(specByClass).forEach((k) => delete specByClass[k]);
allNodes = null;
try { await API.refreshModels(); } catch (_) {}
try { coreNodes = (await API.coreGraph(window.Store?.get().project?.id)).nodes || []; } catch (_) {}
await prewarmSpecs();
render();
}
async function open() {
if (overlay) return; // already open — don't stack a second modal (orphans handlers)
await ensureRoles();
try { const pp = await API.pipelinePorts(); ports = pp.ports || []; coreProducers = pp.core_producers || []; requirements = pp.requirements || []; } catch (_) { ports = []; coreProducers = []; requirements = []; }
try { config = await API.getModels(window.Store?.get().project?.id); } catch (_) { config = { slots: [] }; }
try { coreNodes = (await API.coreGraph(window.Store?.get().project?.id)).nodes || []; } catch (_) { coreNodes = []; }
await prewarmSpecs();
overlay = el("div", "modal-overlay");
const modal = el("div", "modal");
const head = el("div", "modal-head");
head.append(el("div", "modal-title", "Models & Pipeline Nodes"));
const refresh = el("button", "btn ghost", "↻ Refresh model list"); refresh.onclick = refreshList;
const closeModal = () => { if (overlay) overlay.remove(); overlay = null; };
const close = el("button", "btn ghost", "✕"); close.onclick = closeModal;
const heRight = el("div", "modal-head-right"); heRight.append(refresh); heRight.append(close);
head.append(heRight);
modal.append(head);
modal.append(el("div", "modal-content"));
overlay.append(modal);
overlay.addEventListener("click", (e) => { if (overlay && e.target === overlay) closeModal(); });
document.body.append(overlay);
render();
}
window.ModelsModal = { open, refresh: async () => { await ensureRoles().catch(() => {}); await (window.MovieEditorAPI.refreshModels().catch(() => {})); } };
})();