Files
olivv-cs-ComfyUI-FunPack/app/ui/features/settings/models.js
T

158 lines
12 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 & Pipeline: every node in the live pipeline by group, its values editable, and its shape: swap, add, remove, rewire, regroup.
// The server refuses what would not build (a type that does not fit, a loop) and says why; the reason is shown and nothing changes.
import { composer as c } from "../../composer/composer.js";
import { pickNode } from "./nodepick.js";
import { labelOf, shownValues, sourcesFor } from "./wiring.js";
const SEP = "\0", NEW_GROUP = "\0new";
const fed = (v) => Array.isArray(v);
const widgetControl = (w, current, set) => {
const label = w.name;
if (w.type === "COMBO") {
const choices = [...(w.choices || [])];
if (current != null && !choices.includes(current)) choices.unshift(current); // a file gone from disk still shows, so saving something else cannot swap it out
return choices.length ? c.select.md({ label, value: current ?? choices[0], options: choices.map((v) => ({ value: v, label: String(v) })), onChange: set })
: c.hint.default({ text: "No choices available — nothing found in the models folder." });
}
if (w.type === "INT" || w.type === "FLOAT") {
return c.number.md({ label, value: current ?? w.default ?? 0, min: w.min, max: w.max, step: w.step ?? (w.type === "INT" ? 1 : undefined),
precision: w.type === "INT" ? 0 : undefined, onChange: set });
}
return w.multiline ? c.textarea.md({ label, value: current ?? "", rows: 4, onCommit: set }) : c.input.md({ label, value: current ?? "", onCommit: set });
};
export const models = (app) => function mount() {
const ps = app.pipeline, api = app.api;
const page = c.region.stack({ gap: "md" });
let specs = {}, note = "", group = "", off = null, opened = null, gone = false;
const slotsNow = () => ps.slots() || [];
const say = (text) => { note = text; draw(); };
const label = (slot) => labelOf(slot, specs, slotsNow());
async function describe() {
const missing = [...new Set(slotsNow().map((s) => s.node))].filter((n) => !(n in specs));
if (!missing.length) return;
try { Object.assign(specs, (await api.describeNodes(missing)).nodes || {}); } catch (err) { note = err.message; }
missing.forEach((n) => { if (!(n in specs)) specs[n] = null; });
}
const setInput = (slot, name) => async (value) => {
if (JSON.stringify((slot.inputs || {})[name]) === JSON.stringify(value)) return; // a blur that changed nothing saves nothing
await ps.save({ inputs: { [slot.id]: { [name]: value } } });
say([...(ps.refused() || []), ...ps.saveNotes()].join(" "));
};
async function structural(body) {
const res = await ps.edit(body);
await describe();
if (!res.refused.length) {
const target = body.action === "add" ? slotsNow().slice(-1)[0] : body.action === "replace" || body.action === "unwire" ? slotsNow().find((s) => s.id === body.slot) : null;
const values = target && shownValues(target, specs[target.node], body.action === "unwire" ? body.input : undefined);
if (values && Object.keys(values).length) await ps.save({ inputs: { [target.id]: values } });
}
say(res.refused.length ? `Not changed: ${res.refused.join(" ")}` : "");
return !res.refused.length;
}
const confirm = (title, message, tone) => c.modal.dialogue({ title, message, tone, confirmLabel: title.split(" ")[0] }).result;
// A select over what could feed an input: "a value" (or not connected), or an output of another node.
const wiring = (slot, input, type, current, valueLabel) => {
const options = [{ value: "", label: valueLabel }];
const sources = sourcesFor(slot, type, slotsNow(), specs), now = fed(current) ? `${current[0]}${SEP}${current[1]}` : "";
if (now && !sources.some((s) => s.value === now)) options.push({ value: now, label: `${current[0]} · output ${current[1]}` });
sources.forEach((s) => options.push({ value: s.value, label: `Fed by ${s.label}` }));
return c.select.md({ label: input, value: now, options, onChange: (v) => {
if (!v) structural({ action: "unwire", slot: slot.id, input });
else { const [from, idx] = v.split(SEP); structural({ action: "wire", slot: slot.id, input, from_slot: from, from_output: Number(idx) }); }
} });
};
const groups = () => [...new Set(slotsNow().map((s) => s.group || "Other"))];
const structure = (slot) => {
const here = slot.group || "Other";
return c.field.default({ label: "Group", hint: "Cards in the sidebar. Any node can live in any group.", control: c.toolbar.default({
items: [c.select.md({ label: "Group", value: here, options: [...groups().map((g) => ({ value: g, label: g })), { value: NEW_GROUP, label: "New group…" }], onChange: async (v) => {
let name = v;
if (v === NEW_GROUP) { name = ((await c.modal.prompt({ title: "New group", label: "Name", confirmLabel: "Create" }).result) || "").trim(); if (!name) return draw(); }
await ps.setGroup(slot.id, name); group = name; draw();
} })],
trailing: [c.button.sm({ label: "Swap node…", tone: "ghost", title: "Use a different node here. It must still produce what the nodes after it read.", onClick: async () => {
const cls = await pickNode(api, `Swap ${label(slot)} for…`);
if (!cls) return;
const driven = (slot.roles || []).map((r) => r.label || r.input).filter(Boolean);
if (await confirm("Swap node", `Swap ${label(slot)} for ${cls}? Everything set on this node (files, values, connections into it) is cleared, and the new node starts empty.${driven.length ? ` The main window drives this node's ${driven.join(", ")}: those controls keep working only for inputs the new node also has, by the same name.` : ""}`, "danger")) structural({ action: "replace", slot: slot.id, node: cls });
} }), c.button.sm({ label: "Remove", tone: "danger", title: "Take this node out; what it fed is rewired to what fed it, when that is unambiguous.", onClick: async () => {
if (await confirm("Remove node", `Remove ${label(slot)} from the pipeline?`, "danger")) structural({ action: "remove", slot: slot.id });
} })] }) });
};
const slotBlock = (slot) => {
const spec = specs[slot.node];
if (spec === undefined) return [c.hint.default({ text: "Loading…" })];
const head = c.header.sm({ text: label(slot) });
if (spec === null) return [head, structure(slot), c.hint.default({ text: `${slot.node} is not installed — this slot can't be edited or run. Swap it for another node, or remove it.` })];
const sockets = (spec.sockets || []).map((s) => c.field.default({ label: s.name, hint: s.required ? `${s.type} · needed` : s.type, control: wiring(slot, s.name, s.type, slot.inputs && slot.inputs[s.name], "Not connected") }));
const rows = (spec.widgets || []).flatMap((w) => {
const cur = slot.inputs && slot.inputs[w.name];
if (fed(cur)) return [c.field.default({ label: w.name, hint: "Fed by another node: change it there, or pick “A value” to type one here.", control: wiring(slot, w.name, w.type, cur, "A value (typed here)") })];
const value = cur !== undefined ? cur : w.default;
const row = w.type === "BOOLEAN" ? c.toggle.default({ label: w.name, hint: w.tooltip || "", checked: Boolean(value), onChange: setInput(slot, w.name) })
: c.settingsRow.default({ label: w.name, hint: w.tooltip || "", control: widgetControl(w, value, setInput(slot, w.name)) });
const appOwned = (slot.roles || []).some((r) => r.input === w.name && r.at !== "project.video"); // the app writes it: wiring it elsewhere would make every Generate refuse
const canTake = !appOwned && (w.type !== "COMBO" || (w.choices || []).length) && sourcesFor(slot, w.type, slotsNow(), specs).length;
return canTake ? [row, c.field.default({ label: "↳ or take it from", hint: "Another node's output can drive this value; one Primitive can drive several inputs.", control: wiring(slot, w.name, w.type, null, "A value (typed here)") })] : [row];
});
return [head, structure(slot), ...sockets, ...rows, ...(!rows.length && !sockets.length ? [c.hint.default({ text: "Nothing to configure on this node." })] : [])];
};
function status() {
const incomplete = ps.incomplete(), refused = ps.refused(), notes = ps.saveNotes();
return [refused.length ? c.banner.warn({ text: `Could not save: ${refused.join(" ")}` }) : null, notes.length ? c.banner.info({ text: notes.join(" ") }) : null,
incomplete.length ? c.banner.warn({ text: `Not ready to generate yet: ${incomplete.join(" ")}` }) : ps.queueable() ? c.hint.default({ text: "Every slot is filled — this pipeline is ready to generate." }) : null];
}
const tools = () => c.toolbar.default({ items: [c.button.sm({ label: "Start from…", tone: "ghost", title: "Replace this pipeline with a model's own starting point", onClick: presets }),
c.button.sm({ label: "Revert", tone: "ghost", disabled: !opened, title: "Put back what was here when this page was opened", onClick: async () => { const r = await ps.restore(opened); say(r.refused.length ? r.refused[0] : ""); } })],
trailing: [c.button.sm({ label: "🖼 Export settings…", tone: "ghost", title: "Render this pipeline as a PNG: loaders, typed-in values, torch / CUDA / attention", onClick: card })] });
async function presets() {
let found;
try { found = (await api.pipelinePresets()).presets || []; } catch (err) { return say(err.message); }
if (!found.length) return say("No module offers a starting point.");
const id = await c.modal.choice({ title: "Start from…", items: found.map((p) => ({ id: p.id, label: p.title, hint: p.module })) }).result;
const pick = found.find((p) => p.id === id);
if (!pick || !(await confirm("Replace pipeline", `Replace the whole pipeline with “${pick.title}”? Every file and value you set here is cleared. Revert puts it back until you leave this page.`, "danger"))) return;
const r = await ps.restore({ slots: pick.slots, removed: [], unwired: {} });
specs = {}; await describe(); say(r.refused.length ? `Not changed: ${r.refused[0]}` : "");
}
async function card() {
let url;
try { url = URL.createObjectURL(await api.settingsCard(slotsNow(), app.project.project && app.project.project.name, app.theme && app.theme.get ? app.theme.get() : "dark")); } catch (err) { return say(`Could not render the card: ${err.message}`); }
const img = Object.assign(document.createElement("img"), { src: url, alt: "Pipeline settings", style: "max-width:100%" });
const win = c.modal.generic({ title: "Pipeline settings", size: "lg", body: { node: img }, onClose: () => URL.revokeObjectURL(url) });
win.setFooter({ actions: [c.button.sm({ label: "Save PNG", tone: "primary", onClick: () => Object.assign(document.createElement("a"), { href: url, download: "funpack-settings.png" }).click() })] });
}
function draw() {
if (gone) return;
if (ps.loading()) return page.set([c.hint.default({ text: "Loading…" })]);
if (ps.loadError()) return page.set([c.banner.warn({ text: `Could not load models & pipeline: ${ps.loadError()}` })]);
const all = groups(), slots = slotsNow();
if (!all.includes(group)) group = all[0] || "";
page.set([tools(), note ? c.banner.warn({ text: note }) : null, ...status(),
!slots.length ? c.emptyState.default({ icon: "⬡", title: "No pipeline loaded", hint: "Is ComfyUI reachable? Or start from a model's starting point above." }) : null,
all.length ? c.tabs.underline({ label: "Group", tabs: all.map((g) => ({ value: g, label: g })), value: group, onChange: (g) => { group = g; draw(); } }) : null,
group ? c.button.sm({ label: `+ Add a node to ${group}`, tone: "ghost", title: "Put any installed node in this group, then wire it to the nodes around it.", onClick: async () => {
const cls = await pickNode(api, `Add a node to ${group}`);
if (cls) structural({ action: "add", node: cls, group });
} }) : null,
...slots.filter((s) => (s.group || "Other") === group).flatMap(slotBlock)].filter(Boolean));
}
// A redraw while a box has focus would eat what is being typed: it waits for the focus to leave.
const typing = () => page.node.contains(document.activeElement) && /^(input|textarea)$/i.test(document.activeElement.tagName);
page.node.addEventListener("focusout", () => setTimeout(() => { if (!typing()) draw(); }));
draw();
ps.ensureLoaded().then(async () => { opened = ps.snapshot(); await describe(); draw(); });
off = ps.subscribe(() => { if (!typing()) { describe().then(draw); } });
return { node: page.node, destroy: () => { gone = true; off && off(); page.node.remove(); } };
};