ComfyUI refuses the WHOLE graph over one wrong value. v4 hit that with a LoRA file that had been deleted: a feature that was switched off still stopped every generation, and the message was "Prompt outputs failed validation" over a dozen loaders. build() checked wiring and required-ness and never looked at a literal, so check() called such a pipeline queueable. A combo value that is not among the node choices, and a number outside the bounds it declares, are now refused here -- naming the slot and the input. No bounds means unknown, and unknown is not a refusal. And when the queue does refuse one, its per-node reasons were captured by run.js and never read: the status bar showed only the top-level string, because describe() read `node` and the refusal path sets `nodes`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
162 lines
6.1 KiB
JavaScript
162 lines
6.1 KiB
JavaScript
// Generate, and everything that happens after it.
|
|
//
|
|
// One row that owns the whole lifecycle, because the alternative is a button
|
|
// here and a progress bar there and a status somewhere else, each with its own
|
|
// idea of what is happening. This reads ONE state object and draws it -- so
|
|
// "queued" cannot appear next to a finished result, whatever order the messages
|
|
// arrive in.
|
|
//
|
|
// Warnings live here too, beside Generate, rather than in the panel that would
|
|
// have caused them: a setting that will do nothing has to be said where the run
|
|
// is started, not where it was switched on.
|
|
|
|
import { composer } from "../composer/composer.js";
|
|
import { IDLE, QUEUED, RUNNING, DONE, FAILED, CANCELLED } from "./run.js";
|
|
|
|
const WORKING = new Set([QUEUED, RUNNING]);
|
|
|
|
export function createTransport({ onGenerate, onCancel } = {}) {
|
|
const generate = composer.button.lg({
|
|
label: "Generate", tone: "primary",
|
|
onClick: () => { if (onGenerate) onGenerate(); },
|
|
});
|
|
const cancel = composer.button.md({
|
|
label: "Cancel", tone: "ghost",
|
|
onClick: () => { if (onCancel) onCancel(); },
|
|
});
|
|
const progress = composer.progress.bar({ value: 0, max: 100, label: "Generating" });
|
|
const status = composer.text.sm({ text: "Ready" });
|
|
|
|
const bar = composer.actionBar.sticky({
|
|
actions: [status, progress, cancel, generate],
|
|
});
|
|
|
|
// Something to say that is not about a run in progress: why the last attempt
|
|
// did not become one. It has to survive the next draw(), because a refused
|
|
// attempt leaves the run exactly where it was -- idle -- and drawing an idle
|
|
// run says "Ready", which wiped the reason a moment after it appeared.
|
|
let note = null;
|
|
|
|
// Held for a reason that is not a run: the page is still working out whether
|
|
// it already has one. Kept as a flag rather than left to whoever disabled the
|
|
// button, because draw() runs on every state change and each of those would
|
|
// otherwise hand the button back -- which is exactly what a subscription
|
|
// delivering the current state did, one line after the button was disabled.
|
|
let held = false;
|
|
|
|
// Hidden by attribute rather than removed: the row's shape then does not jump
|
|
// as a run starts and finishes, and there is one node to find in a test.
|
|
const hide = (handle, yes) => {
|
|
if (yes) handle.node.setAttribute("hidden", "");
|
|
else handle.node.removeAttribute("hidden");
|
|
};
|
|
hide(progress, true);
|
|
hide(cancel, true);
|
|
|
|
function draw(state) {
|
|
// Anything other than idle means a run answered for itself, and what it
|
|
// says outranks a note about an attempt that never started.
|
|
if (state.phase !== IDLE) note = null;
|
|
const working = WORKING.has(state.phase);
|
|
generate.setDisabled(working || held);
|
|
generate.setBusy(working);
|
|
hide(cancel, !working);
|
|
|
|
const measured = state.progress && state.progress.max;
|
|
hide(progress, !(working && measured));
|
|
if (measured) {
|
|
progress.node.setAttribute("aria-valuemax", String(state.progress.max));
|
|
progress.setValue(state.progress.value);
|
|
}
|
|
|
|
status.setText(note || describe(state));
|
|
}
|
|
|
|
/** Say why nothing was started, or -- with null -- stop saying it. Cleared by
|
|
* the next run that says otherwise. */
|
|
function say(text, state) {
|
|
note = text || null;
|
|
status.setText(note || describe(state || { phase: IDLE, images: [] }));
|
|
}
|
|
|
|
/** Stop offering Generate for a reason of the page's own, and say why. */
|
|
function hold(reason) {
|
|
held = Boolean(reason);
|
|
if (reason) say(reason);
|
|
generate.setDisabled(true);
|
|
}
|
|
|
|
/** Offer it again. The run's own state decides from here. */
|
|
function release(state) {
|
|
held = false;
|
|
say(null, state);
|
|
draw(state);
|
|
}
|
|
|
|
return { node: bar.node, draw, say, hold, release, generate, cancel, progress, status };
|
|
}
|
|
|
|
/**
|
|
* Why a run failed, in one line, from whichever of the two shapes it arrived in.
|
|
*
|
|
* A crash during execution names one node -- `node`. A graph the queue refused
|
|
* names as many as were wrong -- `nodes`, an object keyed by node id, each with
|
|
* its own reasons. Only the first was ever read, so the whole of the second was
|
|
* captured and thrown away: the bar showed ComfyUI's top-level string, which is
|
|
* "Prompt outputs failed validation" and says nothing about which of a dozen
|
|
* loaders to go and fix.
|
|
*/
|
|
export function failure(error) {
|
|
if (!error) return "the run failed";
|
|
|
|
const first = firstNodeError(error.nodes);
|
|
if (first) return first;
|
|
const where = error.node ? `${error.node}: ` : "";
|
|
return `${where}${error.message || "the run failed"}`;
|
|
}
|
|
|
|
function firstNodeError(nodes) {
|
|
if (!nodes || typeof nodes !== "object") return null;
|
|
for (const [id, entry] of Object.entries(nodes)) {
|
|
const reasons = entry && Array.isArray(entry.errors) ? entry.errors : [];
|
|
for (const reason of reasons) {
|
|
const what = reason && (reason.message || reason.type);
|
|
if (!what) continue;
|
|
// The class type as well as the id: a node id is a number in ComfyUI's
|
|
// own graphs and a slot name in ours, and neither on its own is much to
|
|
// go on when the message is "Value not in list".
|
|
const which = entry.class_type ? `${entry.class_type} (${id})` : id;
|
|
const detail = reason.details ? ` — ${reason.details}` : "";
|
|
return `${which}: ${what}${detail}`;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** One line saying where the run is. The only place these words are decided. */
|
|
export function describe(state) {
|
|
switch (state.phase) {
|
|
case QUEUED:
|
|
return "Queued";
|
|
case RUNNING: {
|
|
const where = state.node ? `node ${state.node}` : "working";
|
|
if (state.progress && state.progress.max) {
|
|
return `${where} — ${state.progress.value} of ${state.progress.max}`;
|
|
}
|
|
return where.charAt(0).toUpperCase() + where.slice(1);
|
|
}
|
|
case DONE:
|
|
// The count, because a graph that saved nothing finished successfully and
|
|
// produced no picture, and those must not read the same.
|
|
return state.images.length
|
|
? `Done — ${state.images.length} result${state.images.length === 1 ? "" : "s"}`
|
|
: "Done, but nothing was saved";
|
|
case FAILED:
|
|
return failure(state.error);
|
|
case CANCELLED:
|
|
return "Cancelled";
|
|
default:
|
|
return "Ready";
|
|
}
|
|
}
|