Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
585 lines
26 KiB
JavaScript
585 lines
26 KiB
JavaScript
// Models & Pipeline: a renderer over the real, live pipeline slots (GET/POST
|
||
// /api/pipeline) instead of v4's Studio-shaped "roles" and "candidates per
|
||
// role" (`nodeRoles`/`nodeCandidates` -- neither exists in v5: the pipeline is
|
||
// a flat, swappable slot list, not a fixed set of named roles). One slot per
|
||
// row, grouped the way the pipeline itself groups them (Loaders/Preparation/
|
||
// Sampling/Render, or whatever a module's default_pipeline declares); each
|
||
// slot's node inputs are read live from GET /api/nodes and edited through the
|
||
// same /api/pipeline POST Engine Settings uses (shared state -- see
|
||
// pipeline_state.js). Adding/removing/swapping a node or wiring a slot to
|
||
// another is NOT built here yet: v4's ~2700 lines of that machinery assumed
|
||
// Studio/Chain Sampler's own node set, which no longer exists, and a real
|
||
// graph editor is its own piece of work -- this establishes the real,
|
||
// working file-picker/settings surface first ("port the looks, leave
|
||
// placeholders for features"), per the port plan.
|
||
(function () {
|
||
const { el, clear } = window.dom;
|
||
const API = window.MovieEditorAPI;
|
||
const PS = window.PipelineState;
|
||
|
||
let _mounted = null;
|
||
let group = null; // selected sidebar group, chosen once slots load
|
||
let nodesByClass = {}; // class name -> widget spec (core/widgets.py's describe())
|
||
let nodesLoading = false;
|
||
let nodesError = null;
|
||
let openNodeId = null; // slot id a pinned button asked to jump straight to
|
||
|
||
function field(labelText, control, hint) {
|
||
const row = el("div", "sw-row eng-field");
|
||
const main = el("div", "sw-row-main");
|
||
main.append(el("div", "sw-row-title", labelText));
|
||
if (hint) main.append(el("div", "sw-row-hint", hint));
|
||
row.append(main, control);
|
||
return row;
|
||
}
|
||
function toggleField(labelText, checkbox) {
|
||
checkbox.style.width = "auto";
|
||
return field(labelText, checkbox);
|
||
}
|
||
function rowsGroup(parent, label) {
|
||
if (label) parent.append(el("div", "sw-rows-label", label));
|
||
const g = el("div", "sw-rows");
|
||
parent.append(g);
|
||
return g;
|
||
}
|
||
function hintEl(text) { return el("div", "sw-hint", text); }
|
||
|
||
function slotDisplayLabel(slot) {
|
||
const spec = nodesByClass[slot.node];
|
||
const title = (spec && spec.title) || slot.node || slot.id;
|
||
// Two slots of one kind (the positive and negative prompt, two Primitives) are told apart by id.
|
||
const twin = (PS.slots() || []).some((o) => o.id !== slot.id && ((nodesByClass[o.node] && nodesByClass[o.node].title) || o.node) === title);
|
||
return twin ? `${title} (${slot.id})` : title;
|
||
}
|
||
|
||
function setInput(slotId, name, value) {
|
||
lastRefusal = [];
|
||
PS.save({ inputs: { [slotId]: { [name]: value } } }).then(render);
|
||
}
|
||
|
||
// ── the pipeline's shape: groups, swapping, removing, wiring, adding ────────────
|
||
// Every structural change goes to the server, which refuses what would not build (a type
|
||
// that does not fit, a loop, a node whose output something still reads) and says why; the
|
||
// reason is shown here and nothing changes.
|
||
let lastRefusal = [];
|
||
|
||
// A node that is new (added, swapped in) or whose input was just unwired has nothing SET on it, while
|
||
// its form shows the node's defaults: send what is shown, so what runs is what is seen.
|
||
async function fillShown(slotId, onlyInput) {
|
||
const slot = (PS.slots() || []).find((s) => s.id === slotId);
|
||
const spec = slot && nodesByClass[slot.node];
|
||
if (!spec || !spec.widgets) return;
|
||
const values = {};
|
||
spec.widgets.forEach((w) => {
|
||
if (onlyInput && w.name !== onlyInput) return;
|
||
if (slot.inputs && slot.inputs[w.name] !== undefined) return;
|
||
const v = w.default !== undefined ? w.default
|
||
: w.type === "COMBO" ? (w.choices || [])[0] : w.type === "BOOLEAN" ? false : undefined;
|
||
if (v !== undefined) values[w.name] = v;
|
||
});
|
||
if (Object.keys(values).length) await PS.save({ inputs: { [slotId]: values } });
|
||
}
|
||
|
||
async function structural(body) {
|
||
const res = await PS.edit(body);
|
||
lastRefusal = res.refused || [];
|
||
await ensureNodesLoaded();
|
||
if (!lastRefusal.length) {
|
||
if (body.action === "replace") await fillShown(body.slot);
|
||
else if (body.action === "unwire") await fillShown(body.slot, body.input);
|
||
else if (body.action === "add") { const s = (PS.slots() || []).slice(-1)[0]; if (s) await fillShown(s.id); }
|
||
}
|
||
render();
|
||
return !lastRefusal.length;
|
||
}
|
||
|
||
function otherGroups(slot) {
|
||
return groupsWithSlots().filter((g) => g !== (slot.group || "Other"));
|
||
}
|
||
|
||
function groupSelect(slot) {
|
||
const sel = el("select", "eng-select");
|
||
const here = slot.group || "Other";
|
||
[here, ...otherGroups(slot)].forEach((g) => { const o = el("option", "", g); o.value = g; sel.append(o); });
|
||
const add = el("option", "", "New group…"); add.value = "\u0000new"; sel.append(add);
|
||
sel.onchange = () => {
|
||
if (sel.value === "\u0000new") {
|
||
const name = (prompt("Name of the new group:", "") || "").trim();
|
||
if (!name) { sel.value = here; return; }
|
||
group = name;
|
||
PS.setGroup(slot.id, name).then(render);
|
||
} else {
|
||
lastRefusal = [];
|
||
PS.setGroup(slot.id, sel.value).then(render);
|
||
}
|
||
};
|
||
return sel;
|
||
}
|
||
|
||
// A search box over every installed node; `onPick(className)` gets the choice.
|
||
function pickNode(title, onPick) {
|
||
const overlay = el("div", "modal-overlay slot-picker-overlay");
|
||
const box = el("div", "modal slot-picker-modal");
|
||
box.append(el("div", "modal-title", title));
|
||
const input = el("input", "slot-picker-input");
|
||
input.type = "text"; input.placeholder = "Search installed nodes by name or category…";
|
||
const list = el("div", "slot-picker-list");
|
||
const note = el("div", "sw-row-hint", "");
|
||
let timer = null, ticket = 0;
|
||
const close = () => overlay.remove();
|
||
async function run() {
|
||
const mine = ++ticket;
|
||
let found;
|
||
try { found = await API.searchNodes(input.value, 40); }
|
||
catch (e) { clear(list); note.textContent = `Could not search: ${e.message || e}`; return; }
|
||
if (mine !== ticket) return;
|
||
clear(list);
|
||
(found.nodes || []).forEach((n) => {
|
||
const row = el("button", "slot-picker-row", n.title || n.node);
|
||
row.append(el("span", "slot-picker-hint", `${n.category || ""}${n.outputs && n.outputs.length ? " → " + n.outputs.join(", ") : ""}`));
|
||
row.onclick = () => { close(); onPick(n.node); };
|
||
list.append(row);
|
||
});
|
||
note.textContent = found.total > (found.nodes || []).length
|
||
? `${found.nodes.length} of ${found.total} shown: type more to narrow it.` : (found.nodes || []).length ? "" : "Nothing matches.";
|
||
}
|
||
input.oninput = () => { clearTimeout(timer); timer = setTimeout(run, 200); };
|
||
const cancel = el("button", "btn ghost", "Cancel");
|
||
cancel.onclick = close;
|
||
box.append(input, note, list, cancel);
|
||
overlay.append(box);
|
||
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
|
||
overlay.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.stopPropagation(); close(); } });
|
||
document.body.append(overlay);
|
||
input.focus();
|
||
run();
|
||
}
|
||
|
||
function structureRow(slot) {
|
||
const wrap = el("div", "eng-structure");
|
||
wrap.append(field("Group", groupSelect(slot), "Cards in the sidebar. Any node can live in any group."));
|
||
const actions = el("div", "eng-structure-actions");
|
||
const swap = el("button", "btn ghost tiny", "Swap node…");
|
||
swap.title = "Use a different node here. It must still produce what the nodes after it read.";
|
||
swap.onclick = () => pickNode(`Swap ${slotDisplayLabel(slot)} for…`, (cls) => {
|
||
const roles = (slot.roles || []).map((r) => r.label || r.input).filter(Boolean);
|
||
const note = roles.length
|
||
? `\n\nThe main window drives this node's ${roles.join(", ")}: those controls keep working only for inputs the new node also has, by the same name.` : "";
|
||
if (!confirm(`Swap ${slotDisplayLabel(slot)} for ${cls}?\n\nEverything set on this node (files, values, connections into it) is cleared, and the new node starts empty.${note}`)) return;
|
||
structural({ action: "replace", slot: slot.id, node: cls });
|
||
});
|
||
const rm = el("button", "btn ghost tiny danger", "Remove");
|
||
rm.title = "Take this node out; what it fed is rewired to what fed it, when that is unambiguous.";
|
||
rm.onclick = () => { if (confirm(`Remove ${slotDisplayLabel(slot)} from the pipeline?`)) structural({ action: "remove", slot: slot.id }); };
|
||
actions.append(swap, rm);
|
||
wrap.append(actions);
|
||
return wrap;
|
||
}
|
||
|
||
// Outputs of other slots that could feed an input of type `type`, as {value, label}.
|
||
function sourcesFor(slot, type) {
|
||
const out = [];
|
||
(PS.slots() || []).forEach((other) => {
|
||
if (other.id === slot.id) return;
|
||
const spec = nodesByClass[other.node];
|
||
if (!spec || !spec.outputs) return;
|
||
spec.outputs.forEach((kind, i) => {
|
||
if (kind !== type && kind !== "*" && type !== "*") return;
|
||
const name = (spec.output_names && spec.output_names[i]) || kind;
|
||
out.push({ value: `${other.id}\u0000${i}`, label: `${slotDisplayLabel(other)} · ${name}` });
|
||
});
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function wiringControl(slot, input, type, current, allowValue) {
|
||
const sel = el("select", "eng-select");
|
||
const wired = Array.isArray(current);
|
||
const value = el("option", "", allowValue ? "A value (typed here)" : "Not connected"); value.value = "";
|
||
sel.append(value);
|
||
if (wired) {
|
||
const known = sourcesFor(slot, type).some((s) => s.value === `${current[0]}\u0000${current[1]}`);
|
||
if (!known) { const o = el("option", "", `${current[0]} · output ${current[1]}`); o.value = `${current[0]}\u0000${current[1]}`; sel.append(o); }
|
||
}
|
||
sourcesFor(slot, type).forEach((s) => { const o = el("option", "", `Fed by ${s.label}`); o.value = s.value; sel.append(o); });
|
||
sel.value = wired ? `${current[0]}\u0000${current[1]}` : "";
|
||
sel.onchange = () => {
|
||
if (!sel.value) { structural({ action: "unwire", slot: slot.id, input }); return; }
|
||
const [from, idx] = sel.value.split("\u0000");
|
||
structural({ action: "wire", slot: slot.id, input, from_slot: from, from_output: Number(idx) });
|
||
};
|
||
return sel;
|
||
}
|
||
|
||
function controlForWidget(slot, widget) {
|
||
if (Array.isArray(slot.inputs && slot.inputs[widget.name])) {
|
||
return field(widget.name, wiringControl(slot, widget.name, widget.type, slot.inputs[widget.name], true),
|
||
"Fed by another node: change it there, or pick “A value” to type one here.");
|
||
}
|
||
const row = controlForPlainWidget(slot, widget);
|
||
// An input the app itself writes (the prompt box, the source picture, the length) is the app's:
|
||
// wiring it elsewhere would make every Generate refuse.
|
||
// (Width, height and length are the exception: Generate skips them when they are wired.)
|
||
if ((slot.roles || []).some((r) => r.input === widget.name && r.at !== "project.video")) return row;
|
||
// Another node can drive this value (one Primitive shared by several inputs is how a "link" is made).
|
||
if (widget.type !== "COMBO" || (widget.choices || []).length) {
|
||
const sources = sourcesFor(slot, widget.type);
|
||
if (sources.length) {
|
||
// Own row beneath the value: squeezed beside a text box it left the box a few pixels wide.
|
||
const both = document.createDocumentFragment();
|
||
both.append(row, field("↳ or take it from", wiringControl(slot, widget.name, widget.type, null, true),
|
||
"Another node's output can drive this value; one Primitive can drive several inputs."));
|
||
return both;
|
||
}
|
||
}
|
||
return row;
|
||
}
|
||
|
||
function controlForPlainWidget(slot, widget) {
|
||
const current = slot.inputs && slot.inputs[widget.name] !== undefined
|
||
? slot.inputs[widget.name] : widget.default;
|
||
const hint = widget.tooltip || "";
|
||
|
||
if (widget.type === "BOOLEAN") {
|
||
const cb = el("input", "");
|
||
cb.type = "checkbox";
|
||
cb.checked = !!current;
|
||
cb.onchange = () => setInput(slot.id, widget.name, cb.checked);
|
||
return toggleField(widget.name, cb);
|
||
}
|
||
if (widget.type === "COMBO") {
|
||
const select = el("select", "eng-select");
|
||
const choices = widget.choices || [];
|
||
if (!choices.length) {
|
||
return field(widget.name, hintEl("No choices available — nothing found in the models folder."));
|
||
}
|
||
// A file that was picked before but has since been removed from disk
|
||
// must still show AS the current value, or saving anything else on
|
||
// this slot would silently swap it out from under the user.
|
||
if (current != null && !choices.includes(current)) choices.unshift(current);
|
||
choices.forEach((choice) => {
|
||
const o = el("option", "", String(choice));
|
||
o.value = choice;
|
||
if (choice === current) o.selected = true;
|
||
select.append(o);
|
||
});
|
||
select.onchange = () => setInput(slot.id, widget.name, select.value);
|
||
return field(widget.name, select, hint);
|
||
}
|
||
if (widget.type === "INT" || widget.type === "FLOAT") {
|
||
const input = el("input", "eng-num-input");
|
||
input.type = "number";
|
||
if (widget.min != null) input.min = widget.min;
|
||
if (widget.max != null) input.max = widget.max;
|
||
input.step = widget.step != null ? widget.step : (widget.type === "INT" ? 1 : "any");
|
||
input.value = current != null ? current : "";
|
||
input.onchange = () => {
|
||
const n = widget.type === "INT" ? parseInt(input.value, 10) : parseFloat(input.value);
|
||
if (!Number.isNaN(n)) setInput(slot.id, widget.name, n);
|
||
};
|
||
return field(widget.name, input, hint);
|
||
}
|
||
// STRING
|
||
const input = el(widget.multiline ? "textarea" : "input", "eng-text-input");
|
||
if (!widget.multiline) input.type = "text";
|
||
input.value = current || "";
|
||
input.onblur = () => setInput(slot.id, widget.name, input.value);
|
||
return field(widget.name, input, hint);
|
||
}
|
||
|
||
// ── export the pipeline as a picture ──────────────────────────────────────
|
||
// "Which model was that?" outlives the session that could answer it. The
|
||
// card is built on the SERVER (it knows torch/CUDA/GPU; the browser only
|
||
// knows the laptop showing the page) from the pipeline the CLIENT holds --
|
||
// v5's pipeline has no server-side copy of its own (see api.js's own note
|
||
// on /api/pipeline being stateless), so this sends PS.slots() up rather
|
||
// than a project id the way v4's version did.
|
||
function openSettingsCard() {
|
||
// A prior overlay reached only via re-clicking "Export settings…" (never
|
||
// through its own ✕/Close, which is the only path that revokes its blob
|
||
// URL) would otherwise leak that blob every reopen -- found in this
|
||
// feature's own extensive_testing round, carried over unfixed from v4.
|
||
document.querySelectorAll(".sc-overlay").forEach((n) => {
|
||
if (n._scObjectUrl) URL.revokeObjectURL(n._scObjectUrl);
|
||
n.remove();
|
||
});
|
||
const overlay = el("div", "modal-overlay sc-overlay");
|
||
const modal = el("div", "modal sc-modal");
|
||
const head = el("div", "modal-head");
|
||
head.append(el("div", "modal-title", "Export settings"));
|
||
const hr = el("div", "modal-head-right");
|
||
const x = el("button", "btn ghost tiny", "✕");
|
||
const close = () => { if (url) URL.revokeObjectURL(url); overlay._scObjectUrl = null; overlay.remove(); };
|
||
x.onclick = close;
|
||
hr.append(x); head.append(hr);
|
||
const content = el("div", "modal-content sc-content");
|
||
const foot = el("div", "modal-foot sc-foot");
|
||
modal.append(head, content, foot);
|
||
overlay.append(modal);
|
||
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
|
||
document.body.append(overlay);
|
||
|
||
let url = null, blob = null;
|
||
content.append(el("div", "pj-meta", "Rendering…"));
|
||
|
||
const status = el("div", "sc-status");
|
||
const dl = el("button", "btn primary tiny", "⤓ Download");
|
||
const cp = el("button", "btn ghost tiny", "⧉ Copy image");
|
||
const cl = el("button", "btn ghost tiny", "Close");
|
||
cl.onclick = close;
|
||
dl.disabled = true; cp.disabled = true;
|
||
foot.append(status, dl, cp, cl);
|
||
|
||
// The card is rendered in whatever theme the app is showing, because it
|
||
// is a document about this install and it should look like this install.
|
||
const theme = window.FunPackTheme?.resolved?.()
|
||
|| document.documentElement.getAttribute("data-theme") || "dark";
|
||
const project = window.Store?.get().project;
|
||
API.settingsCard(PS.slots() || [], project?.name, theme)
|
||
.then((b) => {
|
||
if (!overlay.isConnected) return;
|
||
blob = b; url = URL.createObjectURL(b);
|
||
overlay._scObjectUrl = url;
|
||
clear(content);
|
||
const img = el("img", "sc-img");
|
||
img.src = url;
|
||
img.alt = "FunPack settings card";
|
||
content.append(img);
|
||
dl.disabled = false; cp.disabled = false;
|
||
})
|
||
.catch((e) => {
|
||
if (!overlay.isConnected) return;
|
||
clear(content);
|
||
content.append(el("div", "pj-meta", "Could not render the card: " + (e?.message || e)));
|
||
});
|
||
|
||
dl.onclick = () => {
|
||
if (!url) return;
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
const base = (project?.name || "funpack-settings").replace(/[^\w.-]+/g, "_");
|
||
a.download = `${base}-settings.png`;
|
||
document.body.append(a); a.click(); a.remove();
|
||
};
|
||
|
||
cp.onclick = async () => {
|
||
// Clipboard image writes need a secure context, which a rental reached
|
||
// over plain http://<ip> is not. Say that, rather than failing
|
||
// silently -- Download still works.
|
||
status.textContent = "";
|
||
try {
|
||
if (!navigator.clipboard || typeof window.ClipboardItem !== "function") {
|
||
throw new Error("this browser/connection has no image clipboard");
|
||
}
|
||
await navigator.clipboard.write([new window.ClipboardItem({ "image/png": blob })]);
|
||
status.textContent = "Copied.";
|
||
} catch (e) {
|
||
status.textContent = "Could not copy (" + (e?.message || e) + "). Use Download.";
|
||
}
|
||
};
|
||
}
|
||
|
||
function groupsWithSlots() {
|
||
const order = [];
|
||
const seen = new Set();
|
||
(PS.slots() || []).forEach((slot) => {
|
||
const g = slot.group || "Other";
|
||
if (!seen.has(g)) { seen.add(g); order.push(g); }
|
||
});
|
||
return order;
|
||
}
|
||
|
||
async function ensureNodesLoaded() {
|
||
const classes = Array.from(new Set((PS.slots() || []).map((s) => s.node).filter(Boolean)));
|
||
const missing = classes.filter((c) => !(c in nodesByClass));
|
||
if (!missing.length) return;
|
||
nodesLoading = true; nodesError = null; render();
|
||
try {
|
||
const body = await API.describeNodes(missing);
|
||
nodesByClass = { ...nodesByClass, ...(body.nodes || {}) };
|
||
} catch (e) {
|
||
nodesError = e && e.message ? e.message : String(e);
|
||
}
|
||
nodesLoading = false;
|
||
render();
|
||
}
|
||
|
||
function renderSlot(pane, slot) {
|
||
const spec = nodesByClass[slot.node];
|
||
const g = rowsGroup(pane, slotDisplayLabel(slot));
|
||
if (spec === undefined) { g.append(hintEl("Loading…")); return; }
|
||
g.append(structureRow(slot));
|
||
if (spec === null) {
|
||
g.append(hintEl(`${slot.node} is not installed — this slot can't be edited or run. Swap it for another node, or remove it.`));
|
||
return;
|
||
}
|
||
(spec.sockets || []).forEach((socket) => {
|
||
g.append(field(socket.name, wiringControl(slot, socket.name, socket.type, slot.inputs && slot.inputs[socket.name], false),
|
||
socket.required ? `${socket.type} · needed` : socket.type));
|
||
});
|
||
if (!spec.widgets || !spec.widgets.length) {
|
||
if (!(spec.sockets || []).length) g.append(hintEl("Nothing to configure on this node."));
|
||
return;
|
||
}
|
||
spec.widgets.forEach((widget) => g.append(controlForWidget(slot, widget)));
|
||
}
|
||
|
||
function renderStatus(pane) {
|
||
const incomplete = PS.incomplete();
|
||
const refused = PS.refused();
|
||
const notes = PS.saveNotes();
|
||
if (lastRefusal.length) pane.append(hintEl(`Not changed: ${lastRefusal.join(" ")}`));
|
||
else if (refused.length) pane.append(hintEl(`Could not save: ${refused.join(" ")}`));
|
||
if (notes.length) pane.append(hintEl(notes.join(" ")));
|
||
if (incomplete.length) {
|
||
const box = el("div", "sw-hint eng-detail");
|
||
box.append(el("div", "sw-rows-label", "Not ready to generate yet"));
|
||
incomplete.forEach((why) => box.append(el("div", "sw-row-hint", why)));
|
||
pane.append(box);
|
||
} else if (PS.queueable()) {
|
||
pane.append(hintEl("Every slot is filled — this pipeline is ready to generate."));
|
||
}
|
||
}
|
||
|
||
function renderPane(pane) {
|
||
if (PS.loading()) { pane.append(hintEl("Loading…")); return; }
|
||
if (PS.loadError()) {
|
||
pane.append(hintEl(`Could not load models & pipeline: ${PS.loadError()}`));
|
||
return;
|
||
}
|
||
renderStatus(pane);
|
||
if (nodesError) pane.append(hintEl(`Could not describe some nodes: ${nodesError}`));
|
||
|
||
const groups = groupsWithSlots();
|
||
if (!groups.length) { pane.append(hintEl("This pipeline has no slots.")); return; }
|
||
if (!group || !groups.includes(group)) group = groups[0];
|
||
|
||
const addRow = el("div", "eng-structure-actions");
|
||
const addBtn = el("button", "btn ghost tiny", `+ Add a node to ${group}`);
|
||
addBtn.title = "Put any installed node in this group, then wire it to the nodes around it.";
|
||
addBtn.onclick = () => pickNode(`Add a node to ${group}`, (cls) => structural({ action: "add", node: cls, group }));
|
||
addRow.append(addBtn);
|
||
pane.append(addRow);
|
||
|
||
(PS.slots() || [])
|
||
.filter((s) => (s.group || "Other") === group)
|
||
.forEach((slot) => renderSlot(pane, slot));
|
||
}
|
||
|
||
function renderContent(container) {
|
||
if (PS.loading() || PS.loadError()) {
|
||
const solo = el("div", "models-pane");
|
||
renderPane(solo);
|
||
container.append(solo);
|
||
return;
|
||
}
|
||
const groups = groupsWithSlots();
|
||
if (!groups.length) {
|
||
const solo = el("div", "models-pane");
|
||
renderPane(solo);
|
||
container.append(solo);
|
||
return;
|
||
}
|
||
const cols = el("div", "models-cols");
|
||
const side = el("div", "models-side");
|
||
groups.forEach((g) => {
|
||
side.append(window.SettingsWindow.navItem({
|
||
label: g, active: group === g, onClick: () => { group = g; render(); },
|
||
}));
|
||
});
|
||
const pane = el("div", "models-pane eng-pane");
|
||
renderPane(pane);
|
||
cols.append(side, pane);
|
||
container.append(cols);
|
||
}
|
||
|
||
function render() {
|
||
if (!_mounted) return;
|
||
const { content } = _mounted;
|
||
const prevPane = content.querySelector(".models-pane");
|
||
const scrollTop = prevPane ? prevPane.scrollTop : 0;
|
||
clear(content);
|
||
renderContent(content);
|
||
const pane = content.querySelector(".models-pane");
|
||
if (pane) pane.scrollTop = scrollTop;
|
||
}
|
||
|
||
function mount(_body, ctx) {
|
||
const content = el("div", "models-mount eng-mount");
|
||
_body.append(content);
|
||
_mounted = { content };
|
||
if (ctx && ctx.sub && ctx.sub.startsWith("node:")) openNodeId = ctx.sub.slice(5);
|
||
let snap = null; // the pipeline as it was when this window opened
|
||
if (ctx && ctx.setActions) {
|
||
const card = el("button", "btn ghost tiny", "🖼 Export settings…");
|
||
card.title = "Render this pipeline as a PNG — loaders, typed-in node values, "
|
||
+ "and the host's torch / CUDA / attention";
|
||
card.onclick = openSettingsCard;
|
||
// Changes apply as they are made (the pipeline is the live one); Cancel puts back what was here
|
||
// when the window opened, Save keeps it and closes.
|
||
const cancel = el("button", "btn ghost tiny", "Cancel");
|
||
cancel.title = "Undo everything changed in this window since it opened";
|
||
cancel.onclick = async () => {
|
||
if (snap) {
|
||
const res = await PS.restore(snap);
|
||
if (res.refused.length) { alert(res.refused[0]); return; }
|
||
}
|
||
window.SettingsWindow.close();
|
||
};
|
||
const save = el("button", "btn primary tiny", "Save");
|
||
save.title = "Keep the changes and close";
|
||
save.onclick = () => window.SettingsWindow.close();
|
||
ctx.setActions([card, cancel, save]);
|
||
}
|
||
render();
|
||
PS.ensureLoaded().then(() => {
|
||
snap = PS.snapshot();
|
||
if (openNodeId) {
|
||
const slot = (PS.slots() || []).find((s) => s.id === openNodeId);
|
||
if (slot) group = slot.group || "Other";
|
||
openNodeId = null;
|
||
}
|
||
render();
|
||
ensureNodesLoaded();
|
||
});
|
||
return () => { _mounted = null; };
|
||
}
|
||
|
||
window.SettingsWindow.register({
|
||
id: "models", group: "Generation", order: 2, title: "Models & Pipeline", flush: true,
|
||
subtitle: "Loaders and nodes wired into the live pipeline.",
|
||
keywords: "models loaders unet vae clip lora nodes pipeline wiring",
|
||
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,
|
||
// Group-level pinning only, deliberately -- v4 could pin ONE node (settings_window.js's
|
||
// own comment: "the node page is the thing that takes several clicks to reach"), which
|
||
// needed a concept of "the currently open node" this flat slot list doesn't have yet
|
||
// (nothing here drills into a single slot the way the old per-role node page did).
|
||
// `openNodeId`/`ModelsModal.openNode` below still work if something hands them a real
|
||
// slot id -- pinned_buttons.js still calls them -- but nothing on this screen currently
|
||
// PRODUCES a node-level pin to begin with, so a per-node pin from before this rewrite
|
||
// is the only way this path is reached today. Group-level is the honest current target.
|
||
pinTarget: () => (group
|
||
? { kind: "section", id: "models", sub: group, label: `Models ▸ ${group}` }
|
||
: null),
|
||
});
|
||
|
||
window.ModelsModal = {
|
||
open: () => window.SettingsWindow.open("models"),
|
||
openNode: (slotId) => {
|
||
if (!slotId) return;
|
||
openNodeId = slotId;
|
||
window.SettingsWindow.open("models");
|
||
},
|
||
// Node choices (a checkpoint folder's contents, say) are read live on
|
||
// every describe() call -- nothing here caches them -- so "refresh" only
|
||
// needs to re-ask for the currently visible nodes' specs, never the
|
||
// pipeline's own slot structure.
|
||
refresh: async () => {
|
||
nodesByClass = {};
|
||
await ensureNodesLoaded();
|
||
},
|
||
};
|
||
})();
|