Files
olivv-cs-ComfyUI-FunPack/movie_editor/frontend/models.js
T
digital-garbageandClaude Opus 5 5af0a1b5d3 Models: treat V3 MultiType widgets as widgets, not required sockets
LTXVEmptyLatentAudio.frame_rate arrives from object_info as
("FLOAT,INT", {"widgetType": "INT", ...}) — a V3 MultiType wrapped around an
int widget. The editor split types on exact string match, so the union looked
like an unknown connection type: the field vanished from the node's widgets,
turned into a required input source no node could satisfy, and blocked
generation with 'has no source'.

Classification now goes through nodes.widget_type_of(): an input whose members
are all widget types (or that carries a widgetType hint) is a widget, rendered
and defaulted as its widget member. Genuine unions ("IMAGE,MASK") stay sockets
and match a producer of any member via type_accepts(), used by auto-wire,
bypass passthrough, the source/destination pickers and the image-target list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:25:12 +03:00

1385 lines
66 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 section: configure pluggable node slots for the fixed pipeline.
// Pick a Model Type -> a Loader Node -> the editor exposes that node's inputs.
// Lives in the unified Settings window; ModelsModal.open() deep-links to it.
(function () {
const { el, clear } = window.dom;
const API = window.MovieEditorAPI;
// Easy Gen has no per-scene inspector / main editor window for an exposed widget or
// bypass toggle to actually surface in — the eye buttons below would just be inert
// clutter (or worse, look actionable and do nothing visible), so they're hidden there.
const EASY = !!window.FunPackAppName;
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 wiringRules = {}; // guided wiring rules from /pipeline-ports
let container = null; // mounted content root inside the Settings window
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); }
function pipelineLocked() {
return !config.disable_core && !config.full_control;
}
function defaultExtrasForRole(role, cand) {
if (!pipelineLocked() || !cand) return {};
const extras = {};
const wires = wiringRules.default_wires?.[role];
if (wires) {
extras.wires = {};
(cand.outputs || []).forEach((o) => {
const t = wires[o.name] || wires[o.type];
if (t) extras.wires[o.name] = t;
});
}
const srcs = wiringRules.default_input_sources?.[role];
if (srcs) extras.input_sources = { ...srcs };
return extras;
}
function allowedDestinations(slot, out) {
const all = destinations(slot, out.type);
if (!pipelineLocked()) return all;
const role = slot.role || "custom";
const rules = wiringRules.role_targets?.[role] || [];
const chainPorts = wiringRules.type_chain_terminals?.[out.type] || [];
const hidden = new Set(wiringRules.guided_hidden_ports || []);
const allowedPorts = [...rules
.filter((r) => r.type === out.type && (r.output_name == null || r.output_name === out.name))
.map((r) => "port:" + r.port),
...chainPorts.map((p) => "port:" + p),
].filter((p, i, a) => a.indexOf(p) === i);
return all.filter((d) => {
if (!d.value.startsWith("port:")) return true;
const id = d.value.slice(5);
if (hidden.has(id)) return false;
return allowedPorts.includes(d.value);
});
}
function portToOpenCore(portId) {
const hit = (wiringRules.open_core_ports || []).find((p) => p.port === portId);
return hit ? [hit.core_id, hit.input] : null;
}
function _clearPortWires(portId, exceptSlotId, exceptOut) {
const portTarget = "port:" + portId;
for (const slot of config.slots) {
slot.wires = slot.wires || {};
for (const outName of Object.keys(slot.wires)) {
if (slot.id === exceptSlotId && outName === exceptOut) continue;
slot.wires[outName] = wireTargets(slot.wires[outName]).filter((t) => t !== portTarget);
}
}
}
// Keep core_overrides and slot port wires in sync so loader wiring survives reload.
function reconcileOpenPortWiring() {
config.core_overrides = config.core_overrides || {};
for (const slot of config.slots) {
for (const [outName, raw] of Object.entries(slot.wires || {})) {
for (const t of wireTargets(raw)) {
if (!t.startsWith("port:")) continue;
const portId = t.slice(5);
const map = portToOpenCore(portId);
if (!map) continue;
const [cid, inp] = map;
config.core_overrides[cid] = config.core_overrides[cid] || {};
config.core_overrides[cid][inp] = `out:${slot.id}:${outName}`;
}
}
}
for (const entry of wiringRules.open_core_ports || []) {
const portId = entry.port;
const cid = entry.core_id;
const inp = entry.input;
const src = config.core_overrides?.[cid]?.[inp];
const parsed = _parseOutSource(src);
if (!parsed) continue;
_clearPortWires(portId, parsed.slotId, parsed.out);
_addWire(parsed.slotId, parsed.out, "port:" + portId);
}
}
function clearInternalCoreOverrides() {
if (!config.core_overrides) return;
const open = new Set(
(wiringRules.open_core_ports || []).map((p) => `${p.core_id}.${p.input}`),
);
for (const cid of Object.keys(config.core_overrides)) {
for (const inp of Object.keys(config.core_overrides[cid] || {})) {
if (!open.has(`${cid}.${inp}`)) delete config.core_overrides[cid][inp];
}
if (!Object.keys(config.core_overrides[cid] || {}).length) delete config.core_overrides[cid];
}
}
function allowedSources(slot, ci) {
const all = sources(slot, ci.type);
if (!pipelineLocked()) return all;
if (slot.role === "image_processing" && ci.name === "image" && typeAccepts(ci.type, "IMAGE")) {
return all.filter((s) => !s.value || s.value === "timeline" || s.value.startsWith("out:"));
}
return all.filter((s) => !s.value || s.value.startsWith("out:") || s.value === "timeline");
}
// ── 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; }
function inputSpecFor(slot, name) { return ((specFor(slot) || {}).inputs || []).find((w) => w.name === name) || null; }
// Numeric widget bounds/step/default straight from the node's object_info opts, so an
// exposed control (e.g. a LoRA strength) gets its real min/max/step instead of step=1.
function numMeta(spec) {
const o = (spec && spec.options) || {};
const m = {};
if (o.min != null) m.min = o.min;
if (o.max != null) m.max = o.max;
if (o.step != null) m.step = o.step;
const def = o.default != null ? o.default : (spec && spec.default);
if (def != null) m.default = def;
return m;
}
function applyNumMeta(ctrl, meta, isFloat) {
if (meta.min != null) ctrl.min = String(meta.min);
if (meta.max != null) ctrl.max = String(meta.max);
if (meta.step != null) ctrl.step = String(meta.step);
else if (isFloat) ctrl.step = "any";
}
// Exposed controls and shared links snapshot their combo `choices` at expose time (so the main
// editor can render dropdowns without re-fetching object_info). Those snapshots go stale after
// a model-list refresh — a newly installed LoRA/checkpoint shows up inside the Models menu (live
// spec) but not in the exposed project-settings dropdown. Re-pull each exposed/link combo's
// choices from the freshly loaded spec. Returns true if anything changed (caller persists).
function refreshExposedChoices() {
let changed = false;
const syncMeta = (target, w) => {
if (!w) return;
if (target.kind === "combo") {
if (Array.isArray(w.choices) && JSON.stringify(w.choices) !== JSON.stringify(target.choices || [])) {
target.choices = w.choices.slice(); changed = true;
}
} else if (target.kind === "int" || target.kind === "float") {
const meta = numMeta(w);
["min", "max", "step", "default"].forEach((k) => {
if (meta[k] != null && target[k] !== meta[k]) { target[k] = meta[k]; changed = true; }
});
}
};
(config.slots || []).forEach((slot) => {
(slot.exposed || []).forEach((e) => syncMeta(e, inputSpecFor(slot, e.name)));
});
(config.links || []).forEach((link) => {
const first = (link.members || [])[0];
const slot = first && slotById(first.slotId);
syncMeta(link, slot && inputSpecFor(slot, first.input));
});
return changed;
}
// Shared full refresh used by the modal button, the public ModelsModal.refresh() and the menubar:
// drop caches, refresh the backend model list, reload specs, then re-sync exposed/link choices and
// persist so the main-editor dropdowns update too. Loads config only when it isn't already held
// (keeps the open modal's live `config` object so its edit closures stay attached — see persist()).
async function doFullRefresh() {
Object.keys(candCache).forEach((k) => delete candCache[k]);
Object.keys(specByClass).forEach((k) => delete specByClass[k]);
allNodes = null;
try { await API.refreshModels(); } catch (_) {}
if (!config || !config.slots) {
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 (_) {}
await prewarmSpecs();
if (refreshExposedChoices()) { try { await persist(); } catch (_) {} }
}
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); return; }
const e = { name: spec.name, kind: spec.kind, label: spec.name };
if (spec.kind === "combo") e.choices = spec.choices || [];
else if (spec.kind === "int" || spec.kind === "float") Object.assign(e, numMeta(spec));
slot.exposed.push(e);
}
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;
}
// ── bypass: skip this node's effect (pass its inputs straight through) without
// losing its configuration — same "expose to main editor" pattern as a widget value.
const BYPASS_NAME = "__bypass";
function toggleBypassExpose(slot) {
slot.exposed = slot.exposed || [];
if (isExposed(slot, BYPASS_NAME)) { slot.exposed = slot.exposed.filter((e) => e.name !== BYPASS_NAME); return; }
slot.exposed.push({ name: BYPASS_NAME, kind: "boolean", label: `Bypass ${slotName(slot)}`, isBypass: true });
}
function bypassEyeButton(slot) {
const b = el("button", "eye-btn" + (isExposed(slot, BYPASS_NAME) ? " on" : ""), "◉");
b.type = "button";
b.title = isExposed(slot, BYPASS_NAME) ? "Hide bypass toggle from main editor window" : "Show bypass toggle in main editor window";
b.onclick = async (e) => { e.preventDefault(); e.stopPropagation(); toggleBypassExpose(slot); 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; }
// A saved value that no longer exists (renamed/removed file) leaves nothing
// selected — the browser then shows the first option without persisting it,
// so the stale value silently goes to generation. Persist what's displayed.
else if (value != null && !spec.choices.includes(value)) onChange(spec.choices[0]);
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";
applyNumMeta(ctrl, numMeta(spec), spec.kind === "float");
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) => allowedDestinations(slot, o);
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 (typeAccepts(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;
}
// ── rename (pencil): role label shown in pickers, headers, and the sidebar ────
async function renameSlot(slot) {
const role = roles.find((r) => r.key === slot.role);
const cur = slot.role_label || (role ? role.label : slot.role) || "";
const n = prompt("Node label (shown in pickers, headers, and the sidebar):", cur);
if (n != null) { slot.role_label = n.trim() || undefined; await persist(); render(); }
}
function slotDisplayLabel(slot) {
const role = roles.find((r) => r.key === slot.role);
return slot.role_label || (role ? role.label : slot.role) || slotName(slot) || "?";
}
// ── node page (one configured slot: values, wiring, sources) ──────────────────
function nodePage(slot, issues) {
const errs = (issues || []).filter((i) => i.level === "error").length;
const warns = (issues || []).filter((i) => i.level === "warn").length;
const card = el("div", "slot-card open node-page");
if (errs) card.classList.add("slot-bad");
else if (warns) card.classList.add("slot-warn");
const head = el("div", "slot-head node-page-head");
const roleSpan = el("span", "slot-role", slotDisplayLabel(slot));
head.append(roleSpan);
const ren = el("button", "ic-btn", "✎"); ren.title = "Rename node label";
ren.onclick = (e) => { e.stopPropagation(); renameSlot(slot); };
head.append(ren);
head.append(el("span", "slot-node", slotName(slot)));
const nExp = (slot.exposed || []).length;
if (nExp) head.append(el("span", "slot-badge exposed", `◉ ${nExp}`));
if (slot.bypassed) head.append(el("span", "slot-badge warn", "bypassed"));
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 acts = el("span", "slot-actions");
const byp = el("button", "btn ghost tiny" + (slot.bypassed ? " on" : ""), slot.bypassed ? "bypassed" : "bypass");
byp.title = "Skip this node's effect (pass its inputs straight through) without losing its configuration.";
byp.onclick = async (e) => { e.stopPropagation(); slot.bypassed = !slot.bypassed; await persist(); render(); };
acts.append(byp);
if (!EASY) acts.append(bypassEyeButton(slot));
const rm = el("button", "btn ghost tiny danger", "remove");
rm.onclick = async (e) => {
e.stopPropagation();
if (!confirm(`Remove "${slotDisplayLabel(slot)}" (${slotName(slot)}) from the pipeline?`)) return;
config.slots = config.slots.filter((s) => s.id !== slot.id);
await persist(); setView("pipeline");
};
acts.append(rm);
head.append(acts);
card.append(head);
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 iw = incomingWidgetWire(slot, 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();
});
if (!EASY || iw || lk || linkMode) f.classList.add("with-eye");
if (iw) {
// Wired from another node's output: the widget value is replaced by the
// connection at generation — lock the field and say where it comes from
// (same treatment as a linked control; the eye button is skipped because an
// exposed control would edit a value generation ignores).
f.classList.add("linked");
const ctrl = f.querySelector("input,select"); if (ctrl) ctrl.disabled = true;
const tag = el("span", "link-tag", `⇐ ${slotName(iw.slot)} · ${iw.out}`);
tag.title = `This value comes from ${slotFullLabel(iw.slot)}'s "${iw.out}" output at generation — the local field is ignored while wired.`;
f.append(tag);
} else 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 if (!EASY) {
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 = allowedSources(slot, ci);
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); }
if (pipelineLocked() && slot.role === "image_processing" && ci.name === "image" && !cur) {
slot.input_sources[ci.name] = "timeline";
sel.value = "timeline";
}
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" };
// A ComfyUI type string can name several types at once — a V3 MultiType socket serializes
// its members comma-joined ("IMAGE,MASK"). An output feeds an input when they share one.
function typeAccepts(inputType, outputType) {
if (inputType === outputType) return true;
const parts = (t) => String(t || "").split(",").map((p) => p.trim()).filter(Boolean);
const a = parts(inputType), b = parts(outputType);
return a.some((x) => b.includes(x));
}
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 (typeAccepts(type, "IMAGE")) out.push({ value: "global:video", label: "🌐 Global video output (shown in editor)" });
if (typeAccepts(type, "AUDIO")) out.push({ value: "global:audio", label: "🌐 Global audio output (shown in editor)" });
ports.filter((p) => typeAccepts(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) => typeAccepts(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 (typeAccepts(type, "IMAGE")) out.push({ value: "timeline", label: "Timeline (scene image)" });
coreProducers.filter((p) => typeAccepts(type, p.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) => typeAccepts(type, o.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];
}
// The wire (if any) feeding a WIDGET input of `slot` from another slot's output —
// e.g. ImageTransform.width → EmptyLatent.width. The builder replaces the widget's
// value with that connection at generation, so the local field is dead while wired
// and must render locked (same treatment as a linked control).
function incomingWidgetWire(slot, inputName) {
const target = `node:${slot.id}:${inputName}`;
for (const s2 of config.slots) {
if (s2.id === slot.id) continue;
for (const [outName, raw] of Object.entries(s2.wires || {})) {
if (wireTargets(raw).includes(target)) return { slot: s2, out: outName };
}
}
return null;
}
// ── 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 = allowedDestinations(slot, out);
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 + " (not allowed)"); o.value = t; o.selected = true; sel.append(o); }
sel.onchange = async () => { _setWireTarget(slot, out.name, targets[i], sel.value); targets[i] = sel.value; reconcileOpenPortWiring(); 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); reconcileOpenPortWiring(); await persist(); render(); };
row.append(sel, rm);
wrap.append(row);
});
const portOpts = dests.filter((d) => d.value.startsWith("port:"));
if (!pipelineLocked() || portOpts.length > 1 || (portOpts.length === 1 && !targets.some((t) => t.startsWith("port:")))) {
const add = el("button", "btn ghost tiny wire-add", "+ Add");
add.onclick = () => { targets.push(""); renderRows(); };
wrap.append(add);
}
}
renderRows();
return wrap;
}
// ── "+ New node": role dropdown → "Setup node" modal (search → values + wiring) ──
function openRoleMenu(anchor) {
document.querySelectorAll(".mn-role-pop").forEach((n) => n.remove());
const pop = el("div", "mn-role-pop");
const cats = {};
roles.forEach((r) => { (cats[r.category] = cats[r.category] || []).push(r); });
Object.keys(cats).forEach((cat) => {
pop.append(el("div", "mn-role-cat", cat));
cats[cat].forEach((r) => {
const it = el("div", "mn-role-item", r.label);
it.onclick = () => { pop.remove(); openNodeSetup(r.key); };
pop.append(it);
});
});
pop.append(el("div", "mn-role-cat", "Advanced"));
const any = el("div", "mn-role-item", "Any node…");
any.onclick = () => { pop.remove(); openNodeSetup("__any__"); };
pop.append(any);
const r = anchor.getBoundingClientRect();
pop.style.left = r.left + "px";
pop.style.top = (r.bottom + 4) + "px";
document.body.append(pop);
const away = (e) => { if (!pop.contains(e.target)) { pop.remove(); document.removeEventListener("mousedown", away, true); } };
document.addEventListener("mousedown", away, true);
}
// Setup-node modal: pick the node by search, then set widget values AND wire its
// outputs / input sources before it lands in the pipeline. On Add the new node's
// page opens (multi-wire, expose, bypass live there).
function openNodeSetup(roleKey) {
document.querySelectorAll(".ns-overlay").forEach((n) => n.remove());
const role = roleKey === "__any__" ? null : roles.find((r) => r.key === roleKey);
const roleFinal = roleKey === "__any__" ? "custom" : roleKey;
const overlay2 = el("div", "modal-overlay ns-overlay");
const modal = el("div", "modal ns-modal");
const head = el("div", "modal-head");
head.append(el("div", "modal-title", "Setup node — " + (role ? role.label : "Any node")));
const hr = el("div", "modal-head-right");
const closeBtn = el("button", "btn ghost tiny", "✕");
const closeSetup = () => overlay2.remove();
closeBtn.onclick = closeSetup;
hr.append(closeBtn); head.append(hr);
modal.append(head);
const content = el("div", "modal-content ns-content");
modal.append(content);
overlay2.append(modal);
overlay2.addEventListener("click", (e) => { if (e.target === overlay2) closeSetup(); });
document.body.append(overlay2);
const search = el("input", "ns-search");
search.type = "search"; search.placeholder = "Search nodes…"; search.autocomplete = "off";
const listBox = el("div", "ns-list");
const detail = el("div", "ns-detail");
content.append(search, listBox, detail);
setTimeout(() => search.focus(), 0);
let curList = [], curCand = null, draft = null;
// Collapsed once a node is picked — the full list is clutter once Values/Wiring are
// showing below it. Re-expands on "Change" or as soon as the user edits the search again.
let listOpen = true;
function renderList() {
clear(listBox);
if (curCand && !listOpen) {
const row = el("div", "ns-row ns-row-selected active");
row.append(el("span", "ns-row-name", curCand.display_name));
row.append(el("span", "ns-row-class", curCand.class));
const change = el("button", "btn ghost tiny ns-change", "Change");
change.type = "button";
change.onclick = (e) => { e.stopPropagation(); listOpen = true; renderList(); search.focus(); };
row.append(change);
listBox.append(row);
return;
}
const q = search.value.trim().toLowerCase();
const f = q ? curList.filter((c) => (c.display_name + " " + c.class + " " + (c.category || "")).toLowerCase().includes(q)) : curList;
if (!f.length) { listBox.append(el("div", "ns-empty", curList.length ? "No match." : "No matching nodes.")); return; }
f.slice(0, 200).forEach((c) => {
const row = el("div", "ns-row" + (curCand && curCand.class === c.class ? " active" : ""));
row.append(el("span", "ns-row-name", c.display_name));
row.append(el("span", "ns-row-class", c.class));
row.onclick = () => pick(c.class);
listBox.append(row);
});
if (f.length > 200) listBox.append(el("div", "ns-empty", `…${f.length - 200} more — refine the search.`));
}
search.oninput = () => { listOpen = true; renderList(); };
async function pick(cls) {
curCand = specByClass[cls];
if (!curCand) { try { curCand = await loadSpec(cls); } catch (_) { return; } }
const extras = defaultExtrasForRole(roleFinal, curCand);
draft = {
inputs: defaultsFor(curCand),
wires: { ...(extras.wires || {}) }, // outName -> single target
input_sources: { ...(extras.input_sources || {}) },
};
listOpen = false;
renderList(); renderDetail();
}
function renderDetail() {
clear(detail);
if (!curCand) return;
const draftSlot = { id: "__draft__", role: roleFinal, wires: {}, input_sources: {} };
if ((curCand.inputs || []).length) {
detail.append(el("div", "wire-title", "Values"));
const grid = el("div", "slot-fields");
curCand.inputs.forEach((spec) =>
grid.append(widgetField(spec, draft.inputs[spec.name], (v) => { draft.inputs[spec.name] = v; })));
detail.append(grid);
}
if ((curCand.outputs || []).length) {
const wbox = el("div", "wire-box");
wbox.append(el("div", "wire-title", "Wire outputs to"));
curCand.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", "→"));
const sel = el("select", "wire-select");
const cur = draft.wires[out.name] || "";
allowedDestinations(draftSlot, out).forEach((d) => {
const o = el("option", null, d.label); o.value = d.value; if (d.value === cur) o.selected = true; sel.append(o);
});
sel.onchange = () => { if (sel.value) draft.wires[out.name] = sel.value; else delete draft.wires[out.name]; };
row.append(sel);
wbox.append(row);
});
wbox.append(el("div", "links-hint", "One wire per output here — add more on the node's page after adding."));
detail.append(wbox);
}
if ((curCand.connection_inputs || []).length) {
const sbox = el("div", "wire-box");
sbox.append(el("div", "wire-title", "Input sources"));
curCand.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 sel = el("select", "wire-select");
const cur = draft.input_sources[ci.name] || "";
allowedSources(draftSlot, ci).forEach((s) => {
const o = el("option", null, s.label); o.value = s.value; if (s.value === cur) o.selected = true; sel.append(o);
});
sel.onchange = () => { if (sel.value) draft.input_sources[ci.name] = sel.value; else delete draft.input_sources[ci.name]; };
row.append(sel);
sbox.append(row);
});
detail.append(sbox);
}
const foot = el("div", "ns-foot");
const add = el("button", "btn primary", "Add node");
add.onclick = async () => {
const slot = {
id: uid(), role: roleFinal, node_class: curCand.class,
inputs: draft.inputs,
wires: {}, input_sources: {},
};
config.slots.push(slot);
Object.entries(draft.wires).forEach(([o, t]) => {
if (!t) return;
_addWire(slot.id, o, t);
_setWireTarget(slot, o, "", t); // mirror node: targets onto the destination's input source
});
Object.entries(draft.input_sources).forEach(([inp, v]) => { if (v) _setInputSource(slot, inp, v); });
reconcileOpenPortWiring();
await persist();
closeSetup();
setView("node:" + slot.id);
};
const cancel = el("button", "btn ghost", "Cancel");
cancel.onclick = closeSetup;
foot.append(cancel, add);
detail.append(foot);
}
listBox.append(el("div", "ns-empty", "Loading nodes…"));
(roleKey === "__any__" ? ensureAllNodes() : candidates(roleKey))
.then((list) => { curList = list || []; renderList(); })
.catch((e) => {
clear(listBox);
listBox.append(el("div", "ns-empty", "Node list unavailable: " + (e && e.message ? e.message : e)));
});
}
// 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");
if (config.disable_core) {
sec.append(el("div", "req-title", "Custom workflow"));
sec.append(el("div", "req-empty",
"Built-in FunPack pipeline is off. Wire your workflow nodes below; FunPack loader requirements do not apply."));
return sec;
}
if (pipelineLocked()) {
sec.append(el("div", "req-hint guided-hint",
"Guided wiring: LATENT → Studio · latent (core forwards to Concat · video_latent). "
+ "IMAGE → Studio · source_image (Input Image Processing defaults to Timeline scene image). "
+ "MODEL/CLIP may chain through LoRA. Audio latent → Concat · audio_latent only. "
+ "Enable Full control for manual rewiring."));
}
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 = () => openNodeSetup(req.role_hint);
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;
}
async function saveLinkSelection() {
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 };
if (first.kind === "int" || first.kind === "float") {
const w = s0 && inputSpecFor(s0, first.input);
if (w) Object.assign(link, numMeta(w));
}
ensureLinks().push(link);
applyLinkValue(link, val);
linkMode = false; linkSel = []; await persist(); setView("links");
}
// Persistent bar while picking link members — stays visible as the user moves
// between node pages, replacing the old everything-expanded scrollable list.
function linkModeBar() {
const bar = el("div", "models-linkbar");
bar.append(el("span", "models-linkbar-txt",
linkSel.length
? `Linking ${linkSel.length} input${linkSel.length > 1 ? "s" : ""}: ${linkSel.map((s) => s.label).join(", ")}`
: "Pick inputs to link: open a node in the sidebar and click + next to its inputs."));
const save = el("button", "btn primary tiny", `Save link (${linkSel.length})`);
save.disabled = linkSel.length < 1;
save.onclick = saveLinkSelection;
const cancel = el("button", "btn ghost tiny", "Cancel");
cancel.onclick = () => { linkMode = false; linkSel = []; render(); };
bar.append(save, cancel);
return bar;
}
function linksView() {
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", "+ New link");
b.onclick = () => { linkMode = true; linkSel = []; render(); };
right.append(b);
}
head.append(right);
sec.append(head);
sec.append(el("div", "links-hint",
"A link drives several node inputs from one control, or ties an input to a project value (e.g. a loader's FPS to Project · FPS)."));
(config.links || []).forEach((l) => sec.append(linkCard(l)));
if (!linkMode && !(config.links || []).length)
sec.append(el("div", "links-empty", "No links yet. Click + New link, then pick inputs on the node pages."));
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] || {};
const prev = config.core_overrides[cid][inp];
if (value) config.core_overrides[cid][inp] = value;
else delete config.core_overrides[cid][inp];
const parsed = _parseOutSource(value);
if (parsed) {
for (const entry of wiringRules.open_core_ports || []) {
if (entry.core_id === cid && entry.input === inp) {
_clearPortWires(entry.port, parsed.slotId, parsed.out);
_addWire(parsed.slotId, parsed.out, "port:" + entry.port);
break;
}
}
} else if (prev) {
const old = _parseOutSource(prev);
if (old) {
for (const entry of wiringRules.open_core_ports || []) {
if (entry.core_id === cid && entry.input === inp) {
_removeWire(old.slotId, old.out, "port:" + entry.port);
break;
}
}
}
}
}
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", "←"));
if (inp.locked) {
const lbl = el("span", "wire-locked", inp.detail || "Fixed built-in link");
lbl.title = "Guided wiring — enable Full control to override this input";
row.append(lbl);
} else {
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);
}
// Show every core output. In Full control these are tappable as slot input sources
// (pick "<node> → <output>" in a slot's Input sources), so the user can, e.g., feed a
// replacement sampler with Studio's conditioning. In guided mode they're informational.
const outs = n.outputs || [];
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", "→"));
const dest = (o.to || []).length
? o.to.join(", ")
: (config.full_control ? "available — pick in a slot's Input sources" : "—");
const destEl = el("span", "wire-dest", dest);
if (!(o.to || []).length && !config.full_control) destEl.classList.add("wire-muted");
row.append(destEl);
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`));
if (!disabled) {
const fc = el("button", "btn ghost tiny" + (config.full_control ? " active" : ""), config.full_control ? "Full control" : "Guided wiring");
fc.title = config.full_control
? "Full control ON — every core socket can be rewired manually (advanced)."
: "Guided wiring ON — loaders wire only to their built-in ports. Click for full manual control.";
fc.onclick = async () => {
config.full_control = !config.full_control;
if (!config.full_control) clearInternalCoreOverrides();
await persist();
try { coreNodes = (await API.coreGraph(window.Store?.get().project?.id)).nodes || coreNodes; } catch (_) {}
render();
};
head.append(fc);
}
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) {
const hint = pipelineLocked()
? "Fixed FunPack path (Studio → Conditioning → Chain Sampler → decode). "
+ "Wire video LATENT to Studio · latent (not Concat · video_latent — that link is internal). "
+ "Wire IMAGE via Input Image Processing → Studio · source_image (Timeline default). "
+ "MODEL/CLIP may chain through patchers. Enable Full control to override core links."
: "The fixed FunPack nodes and their wiring. Each input defaults to its built-in source — pick another to re-wire it.";
sec.append(el("div", "links-hint", hint));
coreNodes.forEach((n) => sec.append(coreCard(n)));
}
return sec;
}
// ── shell: inner sidebar (Links · Pipeline · + New node · nodes) + content pane ──
let view = "pipeline"; // "pipeline" | "links" | "node:<slotId>"
function setView(v) { view = v; render(); }
const mnItem = (opts) => window.SettingsWindow.navItem(opts);
function sideBar(v) {
const side = el("div", "models-side");
side.append(mnItem({
icon: "🔗", label: "Linked inputs",
badge: (config.links || []).length || null,
active: view === "links", onClick: () => setView("links"),
}));
const pipeBad = !config.disable_core && missingEssentials().length > 0;
side.append(mnItem({
icon: "▦", label: "Pipeline",
sub: config.disable_core ? "built-in off" : (pipeBad ? "missing loaders" : null),
active: view === "pipeline", onClick: () => setView("pipeline"),
}));
side.append(el("div", "mn-sep"));
const add = el("button", "mn-new", "+ New node");
add.onclick = (e) => openRoleMenu(e.currentTarget);
side.append(add);
config.slots.forEach((slot) => {
const issues = v.perSlot[slot.id] || [];
const errs = issues.some((i) => i.level === "error");
const warns = issues.some((i) => i.level === "warn");
const it = mnItem({
dot: errs ? "bad" : (warns || slot.bypassed ? "warn" : "ok"),
label: slotDisplayLabel(slot), sub: slotName(slot),
active: view === "node:" + slot.id, onClick: () => setView("node:" + slot.id),
});
const pen = el("button", "mn-pencil", "✎"); pen.title = "Rename";
pen.onclick = (e) => { e.stopPropagation(); renameSlot(slot); };
it.append(pen);
side.append(it);
});
if (!config.slots.length) side.append(el("div", "mn-empty", "No nodes yet."));
return side;
}
function paneContent(v) {
const pane = el("div", "models-pane");
if (view.startsWith("node:")) {
const slot = slotById(view.slice(5));
if (slot) { pane.append(nodePage(slot, v.perSlot[slot.id])); return pane; }
view = "pipeline"; // node was removed — fall back
}
if (view === "links") { pane.append(linksView()); return pane; }
if (config.workflow_import?.name) {
const banner = el("div", "wf-import-banner");
banner.textContent = `Imported workflow: ${config.workflow_import.name} (${config.workflow_import.node_count || config.slots.length} nodes) · built-in pipeline disabled`;
pane.append(banner);
}
pane.append(requirementsPanel());
pane.append(coreSection());
if (!config.slots.length)
pane.append(el("div", "empty-stage", "No models configured yet — use + New node in the sidebar to add loaders."));
return pane;
}
function body() {
const b = el("div", "models-shell");
if (linkMode) b.append(linkModeBar());
const v = validation();
const cols = el("div", "models-cols");
cols.append(sideBar(v), paneContent(v));
b.append(cols);
return b;
}
function render() {
if (!container) return;
// Preserve the content pane's scroll across the full re-render every edit triggers.
const prevPane = container.querySelector(".models-pane");
const scrollTop = prevPane ? prevPane.scrollTop : 0;
clear(container);
container.append(body());
const pane = container.querySelector(".models-pane");
if (pane) pane.scrollTop = scrollTop;
}
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() {
await doFullRefresh();
render();
}
async function loadAll() {
await ensureRoles();
try {
const pp = await API.pipelinePorts();
ports = pp.ports || [];
coreProducers = pp.core_producers || [];
requirements = pp.requirements || [];
wiringRules = pp.wiring || {};
} catch (_) { ports = []; coreProducers = []; requirements = []; wiringRules = {}; }
try { config = await API.getModels(window.Store?.get().project?.id); } catch (_) { config = { slots: [] }; }
reconcileOpenPortWiring();
try { coreNodes = (await API.coreGraph(window.Store?.get().project?.id)).nodes || []; } catch (_) { coreNodes = []; }
await prewarmSpecs();
// Backfill numeric bounds/step (and combo choices) onto controls exposed before this
// metadata was captured, so opening Models once upgrades existing projects. persist()
// dispatches funpack-models-changed → the store reloads and the inspector re-renders.
if (refreshExposedChoices()) { try { await persist(); } catch (_) {} }
}
function mount(body, ctx) {
container = el("div", "models-mount");
container.append(el("div", "pj-meta models-loading", "Loading models & pipeline…"));
body.append(container);
view = "pipeline";
linkMode = false; linkSel = [];
const imp = el("button", "btn ghost tiny", "⇪ Import workflow…");
imp.title = "Import a ComfyUI workflow (API format) as the pipeline";
imp.disabled = !window.Store?.get().project;
imp.onclick = () => window.WorkflowImportWizard?.open();
const refresh = el("button", "btn ghost tiny", "↻ Refresh model list");
refresh.onclick = refreshList;
ctx.setActions([imp, refresh]);
loadAll()
.then(() => { if (container && container.isConnected) render(); })
.catch(() => {
if (container && container.isConnected) {
clear(container);
container.append(el("div", "pj-meta models-loading", "Could not load models & pipeline."));
}
});
return () => {
container = null;
document.querySelectorAll(".mn-role-pop, .ns-overlay").forEach((n) => n.remove());
};
}
window.SettingsWindow.register({
id: "models", group: "Generation", order: 2, title: "Models & Pipeline", flush: true,
subtitle: "Loaders and custom nodes wired into the fixed FunPack pipeline.",
keywords: "models loaders unet vae clip lora nodes pipeline wiring workflow import "
+ "bypass disable core full control links",
iconBg: "linear-gradient(180deg,#b18cff,#7a4fd0)",
icon: '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="#fff" stroke-width="1.4" stroke-linejoin="round"><path d="M8 1.8 14 5v6l-6 3.2L2 11V5l6-3.2z"/><path d="M2 5l6 3 6-3M8 8v6.2"/></svg>',
mount,
});
window.ModelsModal = {
open: () => window.SettingsWindow.open("models"),
refresh: async () => {
await ensureRoles().catch(() => {});
await doFullRefresh().catch(() => {});
if (container) render();
},
};
})();