Groups the pipeline window into a card grid with a "+ Add group/node" card beside it -- matching v4's card-per-loader layout instead of a separate toolbar button. Picking it offers a new empty group or a node outside any group (landed in the existing "Other" bucket). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
989 lines
40 KiB
JavaScript
989 lines
40 KiB
JavaScript
// The pipeline, opened up and edited.
|
|
//
|
|
// One wide modal, a card per group, and a group that opens into a node list on
|
|
// the left with that node's parameters on the right, saved a group at a time.
|
|
//
|
|
// Two kinds of edit, and they behave differently on purpose:
|
|
//
|
|
// * A VALUE is drafted. Changing steps from 20 to 30 cannot make a graph
|
|
// illegal, so it waits in the draft until Save, and Cancel puts it back.
|
|
// * A STRUCTURE change -- adding a node, removing one, pointing a slot at a
|
|
// different node, moving it to another group -- goes to the server as it
|
|
// happens, because only the server can say whether the result still wires up,
|
|
// and a refusal shown ten edits later is a refusal nobody can act on.
|
|
//
|
|
// Nothing here composes a graph, and nothing here makes an element. Both would
|
|
// be a second answer to a question that already has one -- what runs, and what a
|
|
// control looks like.
|
|
|
|
import { composer } from "../composer/composer.js";
|
|
import { rendererFor, rendererNameFor, SELF_LABELLING } from "../composer/panel.js";
|
|
import { settingFor, label as humanLabel, whyNotEditable } from "./widgets.js";
|
|
|
|
const UNGROUPED = "Other";
|
|
|
|
// The value of a list nobody has chosen from. Not "" -- an empty string is a
|
|
// value a combo could legitimately offer.
|
|
const NOT_SET = "\u0000not-set";
|
|
|
|
/** Groups in the order they first appear, plus any the user has just made. */
|
|
export function groupsOf(slots, extra = []) {
|
|
const order = [];
|
|
const byGroup = new Map();
|
|
const put = (name) => {
|
|
if (!byGroup.has(name)) { byGroup.set(name, []); order.push(name); }
|
|
return byGroup.get(name);
|
|
};
|
|
for (const slot of slots) put(slot.group || UNGROUPED).push(slot);
|
|
for (const name of extra) put(name);
|
|
return { order, byGroup };
|
|
}
|
|
|
|
const isLink = (value) => Array.isArray(value) && value.length === 2
|
|
&& typeof value[0] === "string" && typeof value[1] === "number";
|
|
|
|
/**
|
|
* Whether an output of type `given` could feed an input of type `wanted`.
|
|
*
|
|
* Mirrors core/comfy_types.py's accepts() -- a union on either side is a set
|
|
* of members, not one string, so "IMAGE,MASK" takes a MASK. Kept as its own
|
|
* copy rather than shared with the server: this is a picker's FILTER, not the
|
|
* refusal that matters -- the server checks the same thing again, for real,
|
|
* when the wire is actually sent, and is what a bug here would be caught by.
|
|
*/
|
|
function typeAccepts(wanted, given) {
|
|
if (wanted === "*" || given === "*") return true;
|
|
const left = new Set(String(wanted).split(",").map((s) => s.trim()).filter(Boolean));
|
|
const right = new Set(String(given).split(",").map((s) => s.trim()).filter(Boolean));
|
|
if (!left.size || !right.size) return wanted === given;
|
|
for (const t of left) if (right.has(t)) return true;
|
|
return false;
|
|
}
|
|
|
|
const countOf = (n) => (n === 0 ? "empty" : `${n} node${n === 1 ? "" : "s"}`);
|
|
|
|
// The one that is open, if one is.
|
|
//
|
|
// There is a single pipeline, so there is a single window onto it. Without this
|
|
// the button opened another one on every press, and two windows over the same
|
|
// pipeline are two drafts of it: whichever was saved last wins, and the other
|
|
// was edited against a pipeline that had already moved.
|
|
let current = null;
|
|
|
|
/**
|
|
* mount({ load, describe, check, search, onApply, setFooter, close }) -> handle
|
|
*
|
|
* Every server call is injected. The window is then testable without a server
|
|
* and -- more to the point -- cannot reach for one that is not there.
|
|
*
|
|
* `setFooter`/`close` are the two things a modal gives this that a container
|
|
* does not have for free -- see tempfiles.js's mount() for the general shape.
|
|
* This one additionally exposes its live state (slots/editing/selected/
|
|
* pending) and a few structural actions, because `open()` below and the
|
|
* jsdom suite both drive it without reading the DOM back; `open()` forwards
|
|
* every one of these rather than wrapping a second copy of this state.
|
|
*/
|
|
export function mount({ load, describe, check, search, onApply, presets,
|
|
setFooter = () => {}, close = () => {} } = {}) {
|
|
let slots = [];
|
|
const nodes = new Map(); // class name -> description | null
|
|
const extraGroups = []; // made in this window, no slot in them yet
|
|
let draft = null; // slot id -> { input: value }, while editing
|
|
let editing = null; // group name
|
|
let selected = null; // slot id
|
|
let notes = { refused: [], incomplete: [] };
|
|
// Kept separate from `notes.refused`: that field is about an EDIT the
|
|
// server rejected (commit()), and stays on screen until the user does
|
|
// something about it. A rescan() failure is unrelated to any edit -- and
|
|
// sharing one field meant a rescan that happened to succeed a moment later
|
|
// could silently wipe out a real, still-unaddressed commit() refusal the
|
|
// user had not yet seen or acted on.
|
|
let scanFailed = null;
|
|
let learnGen = 0; // guards against two learn() calls landing out of order
|
|
// Bumped by leave() only: every enter() is reached through the index,
|
|
// which only ever renders after a leave(), so leave() alone sees every
|
|
// group-to-group transition. Which VIEW an in-flight commit() belongs to
|
|
// -- a commit started in one group can still answer after the user has
|
|
// backed out to the index. Its DATA (slots) must still apply either way,
|
|
// an edit that happened does not un-happen because someone stopped
|
|
// looking, but its refusal banner belongs to a group that is no longer on
|
|
// screen, and painting it on the index is the same misattribution
|
|
// rescanGen/scanFailed already exist to prevent for a scan failure.
|
|
let viewGen = 0;
|
|
|
|
const body = composer.region.stack({ gap: "md", label: "Pipeline", fill: true });
|
|
|
|
// ---------------------------------------------------------------- server
|
|
|
|
async function refresh() {
|
|
const answer = await load();
|
|
slots = answer.slots;
|
|
// `load` can be backed by a plain read (always `refused: []`) or, when a
|
|
// previously-saved pipeline is being re-validated on open, by the same
|
|
// check() a structural edit uses -- which can refuse. Reading it here
|
|
// rather than hardcoding `[]` is what keeps that case from replacing the
|
|
// screen with an unexplained empty pipeline.
|
|
notes = { refused: answer.refused || [], incomplete: answer.incomplete || [] };
|
|
await learn(slots.map((slot) => slot.node));
|
|
}
|
|
|
|
/**
|
|
* Descriptions for classes we have not seen -- or, with `force`, for
|
|
* classes we have. A class's combo choices are a file listing (model
|
|
* names, LoRAs), and this window's `nodes` cache lives for as long as the
|
|
* window is open: without forcing a re-ask on entry, a file added to disk
|
|
* mid-session stayed invisible in an already-opened group until the whole
|
|
* page reloaded, which throws the in-progress edit away with it.
|
|
*/
|
|
async function learn(classes, { force = false } = {}) {
|
|
const wanted = [...new Set(classes)].filter(Boolean);
|
|
const ask = force ? wanted : wanted.filter((c) => !nodes.has(c));
|
|
if (!ask.length) return;
|
|
const mine = ++learnGen;
|
|
const described = await describe(ask);
|
|
// A newer learn() (another force-refresh, or the group being left and
|
|
// re-entered) answered first. Applying this older one now would put its
|
|
// stale choices back over the fresh ones just drawn -- the exact bug this
|
|
// whole mechanism exists to fix, reintroduced by its own race.
|
|
if (mine !== learnGen) return;
|
|
// Every class asked about gets an entry, `null` included: without it an
|
|
// absent node is asked for again on every redraw, and reads on screen as a
|
|
// node still being looked up rather than one that is not installed.
|
|
for (const name of ask) nodes.set(name, described[name] ?? null);
|
|
}
|
|
|
|
// Which edit is the current one. Two structural edits can be in flight at
|
|
// once -- moving a node carries the whole slot list and a remove carries one
|
|
// id, so the second can easily answer first -- and the older answer was built
|
|
// from the pipeline as it stood BEFORE the newer edit. Taking it put the
|
|
// removed node back with no refusal and nothing said. The picker's search
|
|
// already guards this; the path that decides what gets QUEUED did not.
|
|
let edits = 0;
|
|
|
|
/** Send the pipeline as it now stands and keep what the server says of it. */
|
|
async function commit(next, action = {}) {
|
|
const mine = ++edits;
|
|
const myView = viewGen;
|
|
let answer;
|
|
try {
|
|
answer = await check({ slots: next, ...action });
|
|
} catch (err) {
|
|
if (mine !== edits) return false;
|
|
if (myView === viewGen) notes = { refused: [err.message], incomplete: notes.incomplete };
|
|
draw();
|
|
return false;
|
|
}
|
|
// A newer edit has been sent since. Its answer is the one that describes
|
|
// the pipeline; this one describes a pipeline that no longer exists.
|
|
if (mine !== edits) return false;
|
|
// A refused edit did not happen, so what is on screen must stay what it
|
|
// was: showing the refusal beside the change it refused is how someone
|
|
// comes to believe an edit landed when it did not. But the refusal is
|
|
// only shown if the user is still looking at the view that made the
|
|
// edit -- otherwise it paints a group's own rejection onto whatever the
|
|
// user has since navigated to, the same misattribution scanFailed exists
|
|
// to prevent for a failed rescan.
|
|
if (answer.refused.length) {
|
|
// `incomplete` describes the WHOLE pipeline, not this one edit, so it
|
|
// stays live regardless of view -- only `refused`, which is about
|
|
// THIS edit specifically, is view-scoped.
|
|
notes = { refused: myView === viewGen ? answer.refused : notes.refused, incomplete: notes.incomplete };
|
|
draw();
|
|
return false;
|
|
}
|
|
// The edit happened server-side regardless of where the user is looking
|
|
// now, so the data applies unconditionally -- only the refusal notes are
|
|
// view-scoped, same reasoning as the branch above.
|
|
forgetDraftsFor(slots, answer.slots);
|
|
slots = answer.slots;
|
|
notes = { refused: myView === viewGen ? [] : notes.refused, incomplete: answer.incomplete };
|
|
await learn(slots.map((slot) => slot.node));
|
|
if (onApply) onApply(slots);
|
|
draw();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Drop edits that were for a node this slot no longer holds.
|
|
*
|
|
* A draft is keyed by slot id, and a slot's id survives being pointed at a
|
|
* different node -- so an unsaved `steps` for one sampler stayed attached
|
|
* when the slot was changed to another. Most samplers declare `steps`, and
|
|
* the server only refuses an input the new node does NOT declare, so a
|
|
* same-named one is written on the next Save with nothing anywhere saying it
|
|
* was never chosen for that node.
|
|
*
|
|
* Keyed on the node CHANGING, not on the slots having come back from the
|
|
* server: saving goes through this same path, and a rule that fired on every
|
|
* answer would throw the edit away in the act of saving it.
|
|
*/
|
|
function forgetDraftsFor(before, after) {
|
|
if (!draft || !draft.size) return;
|
|
const was = new Map(before.map((slot) => [slot.id, slot.node]));
|
|
const now = new Map(after.map((slot) => [slot.id, slot.node]));
|
|
for (const id of [...draft.keys()]) {
|
|
if (!now.has(id) || now.get(id) !== was.get(id)) draft.delete(id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drop one input's own drafted value after it was wired or unwired.
|
|
*
|
|
* Wiring is a STRUCTURE change and commits immediately; a value typed into
|
|
* that same box earlier is still sitting in the draft, waiting for Save --
|
|
* and saveDraft() spreads the draft OVER the slot's current inputs, so an
|
|
* untouched stale value would silently overwrite the wire the user just
|
|
* watched get confirmed on screen. forgetDraftsFor() does not catch this:
|
|
* it only clears a slot's whole draft when the slot's NODE changes, never
|
|
* when one of its inputs gets wired on its own.
|
|
*/
|
|
function forgetDraftedInput(slotId, input) {
|
|
const edited = draft && draft.get(slotId);
|
|
if (!edited || !Object.prototype.hasOwnProperty.call(edited, input)) return;
|
|
const rest = { ...edited };
|
|
delete rest[input];
|
|
if (Object.keys(rest).length) draft.set(slotId, rest);
|
|
else draft.delete(slotId);
|
|
setFooter(footer());
|
|
}
|
|
|
|
// ----------------------------------------------------------------- draw
|
|
|
|
function draw() {
|
|
if (editing === null) {
|
|
body.set(index());
|
|
// Nothing to save from the index: the cards are a way in, not an edit.
|
|
setFooter({});
|
|
return;
|
|
}
|
|
body.set(group(editing));
|
|
setFooter(footer());
|
|
}
|
|
|
|
/**
|
|
* What to say, and where.
|
|
*
|
|
* A refusal is about the edit just attempted, so it belongs wherever that
|
|
* edit was made. What is still unfilled is a property of the WHOLE pipeline,
|
|
* and repeating all of it above every group put three warnings about the
|
|
* loaders over the sampler's settings, where none of them were about anything
|
|
* on screen. Inside a group the unfilled control says so itself -- it reads
|
|
* "not set" -- which is the same information in the place it can be acted on.
|
|
*/
|
|
function messages({ everything = false } = {}) {
|
|
return [
|
|
...notes.refused.map((text) => composer.banner.danger({ text })),
|
|
...(scanFailed ? [composer.banner.danger({ text: scanFailed })] : []),
|
|
// Not an error: a fresh install has no model picked, and colouring that
|
|
// red says something went wrong when nothing has yet.
|
|
...(everything ? notes.incomplete.map((text) => composer.banner.warn({ text })) : []),
|
|
];
|
|
}
|
|
|
|
// ---- the index: one card per group
|
|
|
|
const ADD_CARD = "__add__";
|
|
|
|
function index() {
|
|
const { order, byGroup } = groupsOf(slots, extraGroups);
|
|
return [
|
|
...messages({ everything: true }),
|
|
composer.gallery.cards({
|
|
items: [
|
|
...order.map((name) => ({
|
|
id: name, label: name, hint: countOf(byGroup.get(name).length),
|
|
})),
|
|
// A card, not a button below the list -- a group and a node outside
|
|
// any group are both things this window can hold, and "+" sits
|
|
// beside what it adds to rather than under all of it.
|
|
{ id: ADD_CARD, icon: "+", label: "Add group/node" },
|
|
],
|
|
onActivate: (item) => (item.id === ADD_CARD ? addCard() : enter(item.id)),
|
|
}),
|
|
...(presets ? [composer.toolbar.default({ label: "Groups", items: [presetsButton()] })] : []),
|
|
];
|
|
}
|
|
|
|
/** What the "+ Add group/node" card offers: a new empty group, or a node
|
|
* that belongs to none -- the "Other" bucket groupsOf() already gives one. */
|
|
function addCard() {
|
|
const window_ = composer.modal.generic({
|
|
title: "Add",
|
|
body: composer.gallery.cards({
|
|
items: [
|
|
{ id: "group", icon: "▧", label: "New group…",
|
|
hint: "A named place to put nodes in, like Loaders or Sampling." },
|
|
{ id: "node", icon: "▦", label: "Add a node…",
|
|
hint: "Not in any group -- appears under “Other”." },
|
|
],
|
|
onActivate: (item) => {
|
|
window_.close("picked");
|
|
if (item.id === "group") newGroup();
|
|
else pickNode(UNGROUPED);
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* A starting point offered by a module, loaded the same way any structural
|
|
* edit lands -- through commit(), which is what makes it real: the server
|
|
* checks it the same as a hand-drawn wire would be, and a preset that does
|
|
* not actually build is refused here rather than shown as if it had loaded.
|
|
*/
|
|
function presetsButton() {
|
|
return composer.button.md({
|
|
label: "Load preset…",
|
|
onClick: async () => {
|
|
let offered;
|
|
try {
|
|
offered = await presets();
|
|
} catch (err) {
|
|
notes = { refused: [`Presets could not be read: ${err.message}`], incomplete: notes.incomplete };
|
|
draw();
|
|
return;
|
|
}
|
|
if (!offered.length) {
|
|
notes = { refused: ["No module offers a preset pipeline."], incomplete: notes.incomplete };
|
|
draw();
|
|
return;
|
|
}
|
|
const window_ = composer.modal.generic({
|
|
title: "Load a preset",
|
|
subtitle: "Replaces the pipeline you are looking at with this one.",
|
|
body: composer.gallery.cards({
|
|
items: offered.map((p) => ({ id: p.id, label: p.title })),
|
|
onActivate: async (item) => {
|
|
const picked = offered.find((p) => p.id === item.id);
|
|
window_.close("picked");
|
|
if (picked) await commit(picked.slots);
|
|
},
|
|
}),
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
async function newGroup() {
|
|
const picked = await composer.modal.prompt({
|
|
title: "New group",
|
|
label: "Name",
|
|
placeholder: "Upscaling",
|
|
validate: (name) => {
|
|
if (!name.trim()) return "A group needs a name.";
|
|
if (groupsOf(slots, extraGroups).byGroup.has(name.trim())) {
|
|
return "There is already a group called that.";
|
|
}
|
|
return null;
|
|
},
|
|
}).result;
|
|
if (picked === null) return;
|
|
// Held only for as long as this window is open. A group with no node in
|
|
// it is not part of the pipeline, so there is nothing to save it on --
|
|
// said in the card rather than left to be discovered after a reload.
|
|
extraGroups.push(picked.trim());
|
|
draw();
|
|
}
|
|
|
|
// ---- one group: nodes on the left, the selected node's parameters right
|
|
|
|
let rescanGen = 0;
|
|
|
|
/**
|
|
* Force-refresh a group's node descriptions and report if the server
|
|
* could not be reached -- describe() is a real fetch and a network hiccup
|
|
* or a restarted server leaves it rejecting, same as every other server
|
|
* call this window makes. Silently doing nothing here would make a refresh
|
|
* failure look identical to "nothing new to show", which defeats the point.
|
|
*
|
|
* `learnGen` inside learn() only guards which answer gets WRITTEN to the
|
|
* `nodes` cache; it says nothing about which rescan() call is allowed to
|
|
* touch `scanFailed`. Without its own token, an older rescan's late
|
|
* rejection could still slap a stale error banner over a newer rescan's
|
|
* fresh, successful result.
|
|
*/
|
|
async function rescan(classes) {
|
|
const mine = ++rescanGen;
|
|
try {
|
|
await learn(classes, { force: true });
|
|
if (mine !== rescanGen) return;
|
|
scanFailed = null;
|
|
} catch (err) {
|
|
if (mine !== rescanGen) return;
|
|
scanFailed = err.message;
|
|
}
|
|
draw();
|
|
}
|
|
|
|
async function enter(name) {
|
|
editing = name;
|
|
draft = new Map();
|
|
const mine = groupsOf(slots, extraGroups).byGroup.get(name) || [];
|
|
selected = mine[0]?.id ?? null;
|
|
// A scan failure belongs to the group whose rescan raised it. Carrying
|
|
// it into a different group -- or the index, via leave() -- points
|
|
// someone at the wrong place to investigate; this group's own rescan()
|
|
// below sets its own, current answer moments later.
|
|
scanFailed = null;
|
|
// Same reasoning as rescanGen above, for an in-flight commit() started
|
|
// in whatever group was open before this one: its refusal belongs there,
|
|
// not here.
|
|
viewGen += 1;
|
|
draw();
|
|
// Refetched fresh rather than served from `nodes`: a combo's choices are
|
|
// a file listing, and a file added to disk since this window opened must
|
|
// show up without forcing a full page reload, which would drop the whole
|
|
// in-progress edit along with the stale cache.
|
|
await rescan(mine.map((slot) => slot.node));
|
|
}
|
|
|
|
function leave() {
|
|
editing = null;
|
|
draft = null;
|
|
selected = null;
|
|
scanFailed = null;
|
|
// Invalidates any rescan() still in flight for the group being left --
|
|
// without this, its answer lands after leave() (with editing now null)
|
|
// and a LATE failure writes scanFailed straight onto the index, since
|
|
// rescan()'s own guard only checks whether a NEWER rescan() has started,
|
|
// never whether the one it belongs to has since been left.
|
|
rescanGen += 1;
|
|
// Same for an in-flight commit(): its DATA still applies when it lands
|
|
// (an edit that happened does not un-happen because someone stopped
|
|
// looking), but its refusal banner belongs to the group just left, not
|
|
// to the index this draw() is about to show.
|
|
viewGen += 1;
|
|
draw();
|
|
}
|
|
|
|
function group(name) {
|
|
const mine = groupsOf(slots, extraGroups).byGroup.get(name) || [];
|
|
if (selected && !mine.some((slot) => slot.id === selected)) selected = mine[0]?.id ?? null;
|
|
|
|
const left = composer.region.stack({ gap: "sm", label: "Nodes", children: [
|
|
composer.filterList.md({
|
|
items: mine.map((slot) => ({ id: slot.id, label: slot.id, hint: titleOf(slot.node) })),
|
|
value: selected,
|
|
placeholder: "Find a node",
|
|
empty: "No nodes in this group yet",
|
|
onChange: (id) => { selected = id; draw(); },
|
|
}),
|
|
composer.toolbar.default({ label: "Nodes", items: [
|
|
composer.button.sm({ label: "Add node", onClick: () => pickNode(name) }),
|
|
composer.button.sm({
|
|
label: "Remove", tone: "ghost", disabled: !selected,
|
|
onClick: () => remove(selected),
|
|
}),
|
|
composer.button.sm({
|
|
label: "Refresh models", tone: "ghost",
|
|
// Same file listing a re-entry already forces -- exposed here too,
|
|
// because a file dropped in AFTER this group was opened has no
|
|
// other way to appear short of reloading the page, which would
|
|
// throw away every edit made in this session.
|
|
onClick: () => rescan(mine.map((slot) => slot.node)),
|
|
}),
|
|
] }),
|
|
] });
|
|
|
|
const right = composer.region.stack({ gap: "sm", label: "Parameters",
|
|
children: selected ? parameters(slotBy(selected)) : [
|
|
composer.emptyState.default({
|
|
icon: "◇", title: "Nothing selected",
|
|
hint: "Pick a node on the left, or add one.",
|
|
}),
|
|
] });
|
|
|
|
return [
|
|
composer.toolbar.default({ label: "Group", items: [
|
|
composer.button.sm({ label: "← All groups", tone: "ghost", onClick: leave }),
|
|
composer.text.sm({ text: name }),
|
|
] }),
|
|
...messages(),
|
|
composer.splitPane.h({ panes: [left, right], size: 32, label: "Nodes and parameters" }),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Save and Cancel, in the modal's own footer rather than in the body.
|
|
*
|
|
* The footer is the one row outside the scrolling body. A bar built inside it
|
|
* sticks to the bottom of the SCROLLER, which is above the card's own edge by
|
|
* the body's padding -- so a strip of the settings showed through underneath
|
|
* the buttons and scrolled as the settings scrolled.
|
|
*/
|
|
function footer() {
|
|
const pending = draft ? draft.size : 0;
|
|
return {
|
|
note: pending
|
|
? `${pending} node${pending === 1 ? "" : "s"} edited — not saved yet`
|
|
: "No changes to save",
|
|
actions: [
|
|
composer.button.lg({ label: "Cancel", tone: "neutral", onClick: leave }),
|
|
composer.button.lg({
|
|
label: "Save", tone: "primary",
|
|
onClick: async () => { if (await saveDraft()) leave(); },
|
|
}),
|
|
],
|
|
};
|
|
}
|
|
|
|
const slotBy = (id) => slots.find((slot) => slot.id === id) || null;
|
|
const titleOf = (className) => (nodes.get(className) || {}).title || className;
|
|
|
|
// ---- the parameters of one node
|
|
|
|
function parameters(slot) {
|
|
if (!slot) return [];
|
|
const described = nodes.get(slot.node);
|
|
|
|
const rows = [
|
|
composer.settingsRow.default({
|
|
label: titleOf(slot.node),
|
|
hint: slot.node,
|
|
control: composer.button.sm({
|
|
label: "Change node",
|
|
onClick: () => pickNode(slot.group || UNGROUPED, slot.id),
|
|
}),
|
|
}),
|
|
composer.settingsRow.default({
|
|
label: "Group",
|
|
hint: "Which card this node appears under.",
|
|
control: composer.select.md({
|
|
options: groupsOf(slots, extraGroups).order.map((g) => ({ value: g, label: g })),
|
|
value: slot.group || UNGROUPED,
|
|
onChange: (to) => moveTo(slot.id, to),
|
|
}),
|
|
}),
|
|
];
|
|
|
|
if (!described) {
|
|
// The slot names a node this install does not have. Said here, where its
|
|
// parameters would be: an empty pane reads as "no settings".
|
|
rows.push(composer.banner.danger({
|
|
text: `${slot.node} is not installed here, so there is nothing to edit and the `
|
|
+ "pipeline will not run until this slot points somewhere else.",
|
|
}));
|
|
return rows;
|
|
}
|
|
|
|
for (const widget of described.widgets) {
|
|
const wired = (slot.inputs || {})[widget.name];
|
|
// A widget input can hold a link too -- that is the whole of what a
|
|
// "linked input" is: a Primitive node's output feeding this box instead
|
|
// of a value typed into it. Shown the same way a wired socket is, not
|
|
// as an editable box quietly holding the widget's OWN default while the
|
|
// real value comes from somewhere this window never says.
|
|
if (isLink(wired)) {
|
|
const from = slotBy(wired[0]);
|
|
rows.push(composer.settingsRow.default({
|
|
label: humanLabel(widget.name),
|
|
hint: `${widget.type} — fed by ${wired[0]}${from ? ` (${titleOf(from.node)})` : ""}`,
|
|
control: composer.toolbar.default({ label: widget.name, items: [
|
|
composer.button.sm({ label: "Change", onClick: () => pickSource(slot, widget) }),
|
|
composer.button.sm({
|
|
label: "Unwire", tone: "ghost",
|
|
onClick: () => unwireInput(slot.id, widget.name),
|
|
}),
|
|
] }),
|
|
}));
|
|
continue;
|
|
}
|
|
|
|
let setting = settingFor(widget);
|
|
if (setting && unset(slot, widget)) setting = asUnset(setting);
|
|
if (!setting) {
|
|
rows.push(composer.settingsRow.default({
|
|
label: humanLabel(widget.name), hint: whyNotEditable(widget),
|
|
}));
|
|
continue;
|
|
}
|
|
const current = valueOf(slot, widget.name, setting.default);
|
|
const editor = rendererFor(setting)(setting, current,
|
|
(next) => { if (next !== NOT_SET) edit(slot.id, widget.name, next); });
|
|
// Every widget can be wired instead of typed into -- ComfyUI's own
|
|
// "convert to input" idea, and the only way a value ever gets shared
|
|
// across several nodes as one linked input in the first place.
|
|
const control = composer.toolbar.default({ label: widget.name, items: [
|
|
editor,
|
|
composer.iconButton.sm({
|
|
icon: "⛓", label: `Wire ${humanLabel(widget.name)} from another node`,
|
|
onClick: () => pickSource(slot, widget),
|
|
}),
|
|
] });
|
|
// A checkbox row draws its own label and hint. Wrapping it in a settings
|
|
// row printed both of them twice, one above the other.
|
|
rows.push(SELF_LABELLING.has(rendererNameFor(setting))
|
|
? control
|
|
: composer.settingsRow.default({
|
|
label: setting.label, hint: setting.hint, control,
|
|
}));
|
|
}
|
|
|
|
for (const socket of described.sockets) {
|
|
const wired = (slot.inputs || {})[socket.name];
|
|
const from = isLink(wired) ? slotBy(wired[0]) : null;
|
|
rows.push(composer.settingsRow.default({
|
|
label: humanLabel(socket.name),
|
|
hint: isLink(wired)
|
|
? `${socket.type} — fed by ${wired[0]}${from ? ` (${titleOf(from.node)})` : ""}`
|
|
: `${socket.type} — nothing feeds it`,
|
|
control: composer.toolbar.default({ label: socket.name, items: [
|
|
composer.button.sm({
|
|
label: isLink(wired) ? "Change" : "Wire…",
|
|
onClick: () => pickSource(slot, socket),
|
|
}),
|
|
isLink(wired)
|
|
? composer.button.sm({
|
|
label: "Unwire", tone: "ghost",
|
|
onClick: () => unwireInput(slot.id, socket.name),
|
|
})
|
|
: null,
|
|
].filter(Boolean) }),
|
|
}));
|
|
}
|
|
|
|
// Outputs are never wired from HERE -- wiring is always asked for by the
|
|
// input that wants one, through its own Wire button, so the same link
|
|
// is never described two different ways depending on which end you
|
|
// opened. But an output that is never SHOWN anywhere on its own node is
|
|
// a real blind spot, not just a different workflow: open a node that
|
|
// exists to produce things and there was nothing here saying what it
|
|
// produces, or whether anything downstream already reads it.
|
|
if (described.outputs && described.outputs.length) {
|
|
rows.push(composer.label.section({ text: "Outputs" }));
|
|
described.outputs.forEach((type, index) => {
|
|
const name = (described.output_names || [])[index] || type;
|
|
const consumers = consumersOf(slot.id, index);
|
|
rows.push(composer.settingsRow.default({
|
|
label: name,
|
|
hint: consumers.length
|
|
? `${type} — feeds ${consumers.join(", ")}`
|
|
: `${type} — nothing reads this yet`,
|
|
}));
|
|
});
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
/** Every (consumerId.inputName) currently wired to this slot's output. */
|
|
function consumersOf(slotId, index) {
|
|
const found = [];
|
|
for (const other of slots) {
|
|
for (const [name, value] of Object.entries(other.inputs || {})) {
|
|
if (isLink(value) && value[0] === slotId && value[1] === index) {
|
|
found.push(`${other.id}.${name}`);
|
|
}
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/** Every OTHER slot's output that could legally feed this socket. */
|
|
function sourcesFor(slot, socket) {
|
|
const found = [];
|
|
for (const candidate of slots) {
|
|
if (candidate.id === slot.id) continue; // a self-wire is always a cycle
|
|
const described = nodes.get(candidate.node);
|
|
if (!described) continue;
|
|
described.outputs.forEach((type, index) => {
|
|
if (!typeAccepts(socket.type, type)) return;
|
|
const name = (described.output_names || [])[index] || type;
|
|
found.push({
|
|
id: `${candidate.id} |