Every node's own OUTPUTS were invisible on its own panel in the pipeline editor -- wiring an input already let you pick a source, but opening the node that PRODUCES something told you nothing about what it makes or whether anything reads it. Found live, by a real user just looking at a node, after two full review rounds that only ever checked the consuming side. Fixed with a read-only "Outputs" section per node, naming each output, its type, and who (if anyone) currently wires from it. Also: model family detection. core/probe.py reads a safetensors header (tensor names only, no weights) and asks every installed model module, through the same capability-lookup mechanism already used for empty_latent/decode, whether the file is its own -- core never names a family itself. A real, adversarially-found bug in the ported signature: one of MiniMax H3's three candidate prefixes doesn't correspond to any prefix ComfyUI's own loader actually resolves to, traced through comfy/model_detection.py and comfy/sd.py -- the probe could have called a file H3 that ComfyUI's real loader would refuse. Removed, and pinned with a dedicated test module. A second finding narrowed the folder search to only the folders FunPack's own loaders actually read from. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
814 lines
31 KiB
JavaScript
814 lines
31 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,
|
|
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: [] };
|
|
|
|
const body = composer.region.stack({ gap: "md", label: "Pipeline", fill: true });
|
|
|
|
// ---------------------------------------------------------------- server
|
|
|
|
async function refresh() {
|
|
const answer = await load();
|
|
slots = answer.slots;
|
|
notes = { refused: [], incomplete: answer.incomplete || [] };
|
|
await learn(slots.map((slot) => slot.node));
|
|
}
|
|
|
|
/** Descriptions for classes we have not seen. Asked once per class. */
|
|
async function learn(classes) {
|
|
const unknown = [...new Set(classes)].filter((c) => c && !nodes.has(c));
|
|
if (!unknown.length) return;
|
|
const described = await describe(unknown);
|
|
// 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 unknown) 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;
|
|
let answer;
|
|
try {
|
|
answer = await check({ slots: next, ...action });
|
|
} catch (err) {
|
|
if (mine !== edits) return false;
|
|
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.
|
|
if (answer.refused.length) {
|
|
notes = { refused: answer.refused, incomplete: notes.incomplete };
|
|
draw();
|
|
return false;
|
|
}
|
|
forgetDraftsFor(slots, answer.slots);
|
|
slots = answer.slots;
|
|
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 })),
|
|
// 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
|
|
|
|
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),
|
|
})),
|
|
onActivate: (item) => enter(item.id),
|
|
}),
|
|
composer.toolbar.default({ label: "Groups", items: [newGroupButton()] }),
|
|
];
|
|
}
|
|
|
|
function newGroupButton() {
|
|
return composer.button.md({
|
|
label: "New group",
|
|
onClick: async () => {
|
|
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
|
|
|
|
function enter(name) {
|
|
editing = name;
|
|
draft = new Map();
|
|
selected = (groupsOf(slots, extraGroups).byGroup.get(name) || [])[0]?.id ?? null;
|
|
draw();
|
|
}
|
|
|
|
function leave() {
|
|
editing = null;
|
|
draft = null;
|
|
selected = null;
|
|
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),
|
|
}),
|
|
] }),
|
|
] });
|
|
|
|
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} |