Many shared workflows save their loaders blank so people load their own picture. Pressing Run first started the run, and core Load Image failed inside it with "[Errno 21] Is a directory". Now the run stops before it is sent: one message, "Load a picture first:" and the node's name, and the loader gets a red outline (on the classic canvas and in Nodes 2.0). It covers core Load Image, Load Image (as Mask), Load Image (from Outputs) and Load Video, and the pack's Load Image + Pad, Image Crop + Rotate + Pad, Load Video and both Video Crop + Rotate + Pad nodes (the file or the local path, whichever the mode reads). It looks at the prompt the frontend is about to send and walks it the way ComfyUI does, so bypassed and muted loaders, loaders fed by a link, loaders no output needs, loaders behind a lazy input and outputs left out of "Queue selected output nodes" never stop a run. Prompts sent straight to the API are not checked. The frontend has no extension hook that can stop a queue, so app.queuePrompt is wrapped; it only reads, and builds the prompt a second time only when a blank loader is on the canvas. Anything unexpected lets the run through unchanged. When ComfyUI does reject a prompt, a check that reads several inputs comes back once per input. api.queuePrompt's answer is tidied so each AusBoss node shows one entry per message, on the input the message is about. Other packs' nodes are left as ComfyUI sends them.
160 lines
7.1 KiB
JavaScript
160 lines
7.1 KiB
JavaScript
// Checks on a run, before it goes to ComfyUI and after it comes back.
|
|
//
|
|
// 1. A picture or video loader with nothing picked. Core Load Image takes a
|
|
// blank name and only fails once the run starts, with "[Errno 21] Is a
|
|
// directory". Many shared workflows save their loaders blank on purpose,
|
|
// so a stranger who presses Run first gets that error. findEmptySources
|
|
// finds such loaders in the prompt the frontend is about to send, so the
|
|
// run can stop with one plain message instead.
|
|
// 2. One failure, filed once per input. ComfyUI repeats a failed
|
|
// VALIDATE_INPUTS message on every input the check reads: a missing video
|
|
// on Video Crop + Rotate + Pad comes back three times.
|
|
// collapseRepeatedErrors keeps one per message on AusBoss nodes.
|
|
//
|
|
// No /scripts imports here, so node:test covers it (tests/run_check.test.mjs).
|
|
|
|
const blank = (value) => value === undefined || value === null || (typeof value === "string" && value.trim() === "");
|
|
// In a prompt, an input fed by another node holds [node id, output slot].
|
|
const linked = (value) => Array.isArray(value) && value.length === 2;
|
|
const emptyWidget = (inputs, name) => !linked(inputs?.[name]) && blank(inputs?.[name]);
|
|
|
|
const picture = (name) => (inputs) => (emptyWidget(inputs, name) ? { input: name, kind: "picture" } : null);
|
|
const video = (name) => (inputs) => (emptyWidget(inputs, name) ? { input: name, kind: "video" } : null);
|
|
|
|
function videoSource(inputs) {
|
|
const mode = inputs?.source_mode;
|
|
if (linked(mode)) return null;
|
|
return mode === "local path" ? video("local_path")(inputs) : video("video")(inputs);
|
|
}
|
|
|
|
// The loaders a run needs a picked file for, by class type. Each rule gets
|
|
// the node's prompt inputs and returns the empty input, or null when the
|
|
// node has its source (a file, a path, or a link).
|
|
export const SOURCE_LOADERS = Object.freeze({
|
|
LoadImage: picture("image"),
|
|
LoadImageMask: picture("image"),
|
|
LoadImageOutput: picture("image"),
|
|
LoadVideo: video("file"),
|
|
AUSBOSS_NODES_ImageCropRotatePad: picture("image"),
|
|
// A wired source_image replaces the file, so the file may stay blank.
|
|
AUSBOSS_NODES_LoadImagePad: (inputs) => (linked(inputs?.source_image) ? null : picture("image")(inputs)),
|
|
AUSBOSS_NODES_LoadVideo: video("video"),
|
|
AUSBOSS_NODES_VideoCropRotatePad: videoSource,
|
|
AUSBOSS_NODES_VideoCropRotatePadClip: videoSource,
|
|
});
|
|
|
|
// The same rule on a live graph node, from its widgets: whether the full
|
|
// check is worth building a prompt for. A widget fed by a link counts as set.
|
|
export function nodeLacksSource(node) {
|
|
const rule = SOURCE_LOADERS[String(node?.comfyClass ?? node?.type ?? "")];
|
|
if (!rule) return false;
|
|
const inputs = {};
|
|
for (const widget of node.widgets ?? []) {
|
|
if (widget?.name) inputs[widget.name] = widget.value;
|
|
}
|
|
(node.inputs ?? []).forEach((slot, index) => {
|
|
const name = slot?.widget?.name ?? slot?.name;
|
|
const connected = typeof node.isInputConnected === "function" ? node.isInputConnected(index) : slot?.link != null;
|
|
if (name && connected) inputs[name] = ["link", 0];
|
|
});
|
|
return Boolean(rule(inputs));
|
|
}
|
|
|
|
function isLazy(definition, name) {
|
|
const spec = definition?.input?.required?.[name] ?? definition?.input?.optional?.[name];
|
|
return Boolean(Array.isArray(spec) && spec[1]?.lazy);
|
|
}
|
|
|
|
// The node ids a run executes, walked the way ComfyUI's server walks a
|
|
// prompt: from every output node (or only the chosen ones, for "Queue
|
|
// selected output nodes") back through everything they read. A lazy input
|
|
// may never be read, so the walk does not follow one; a loader behind it
|
|
// never blocks a run. definitionOf(classType) gives the node definition.
|
|
export function nodesThatRun(output, definitionOf, targets = null) {
|
|
const prompt = output ?? {};
|
|
const wanted = Array.isArray(targets) && targets.length ? new Set(targets.map(String)) : null;
|
|
const pending = Object.keys(prompt).filter(
|
|
(id) => definitionOf(prompt[id]?.class_type)?.output_node === true && (!wanted || wanted.has(id)),
|
|
);
|
|
const running = new Set();
|
|
while (pending.length) {
|
|
const id = pending.pop();
|
|
if (running.has(id)) continue;
|
|
running.add(id);
|
|
const entry = prompt[id];
|
|
const definition = definitionOf(entry?.class_type);
|
|
for (const [name, value] of Object.entries(entry?.inputs ?? {})) {
|
|
if (!linked(value) || isLazy(definition, name)) continue;
|
|
const source = String(value[0]);
|
|
if (source in prompt && !running.has(source)) pending.push(source);
|
|
}
|
|
}
|
|
return running;
|
|
}
|
|
|
|
// Loaders the run would execute with no picture or video picked, in prompt
|
|
// order: [{ id, title, input, kind }].
|
|
export function findEmptySources(output, definitionOf, targets = null) {
|
|
const running = nodesThatRun(output, definitionOf, targets);
|
|
const found = [];
|
|
for (const [id, entry] of Object.entries(output ?? {})) {
|
|
if (!running.has(id)) continue;
|
|
const empty = SOURCE_LOADERS[entry?.class_type]?.(entry.inputs ?? {});
|
|
if (empty) found.push({ id, title: String(entry?._meta?.title || entry.class_type), ...empty });
|
|
}
|
|
return found;
|
|
}
|
|
|
|
export function emptySourceMessage(found) {
|
|
const [first] = found ?? [];
|
|
if (!first) return "";
|
|
const more = found.length - 1;
|
|
const tail = more > 0 ? ` (and ${more} more node${more === 1 ? "" : "s"})` : "";
|
|
return `Load a ${first.kind === "video" ? "video" : "picture"} first: ${first.title}${tail}`;
|
|
}
|
|
|
|
// "Save Image: exact_name may not ..." names the input it is about, after
|
|
// the node's own label; so does "... Local path mode ..." for local_path.
|
|
function namesInput(text, inputName) {
|
|
if (!inputName) return false;
|
|
const colon = text.indexOf(": ");
|
|
const body = (colon >= 0 ? text.slice(colon + 2) : text).toLowerCase();
|
|
const name = String(inputName).toLowerCase();
|
|
return body.startsWith(name) || body.startsWith(name.replaceAll("_", " "));
|
|
}
|
|
|
|
// ComfyUI's details read "<input> - <message>" for a failed custom check.
|
|
function messageOf(error) {
|
|
const details = String(error?.details ?? "");
|
|
const prefix = `${error?.extra_info?.input_name ?? ""} - `;
|
|
return details.startsWith(prefix) ? details.slice(prefix.length) : details;
|
|
}
|
|
|
|
// One entry per distinct failed check on each AusBoss node, kept on the
|
|
// input the message is about, else the first input ComfyUI listed. Other
|
|
// packs' nodes and other kinds of error are left exactly as they came.
|
|
export function collapseRepeatedErrors(nodeErrors) {
|
|
for (const entry of Object.values(nodeErrors ?? {})) {
|
|
if (!String(entry?.class_type ?? "").startsWith("AUSBOSS_NODES_") || !Array.isArray(entry.errors)) continue;
|
|
const kept = [];
|
|
const slotOf = new Map();
|
|
for (const error of entry.errors) {
|
|
if (error?.type !== "custom_validation_failed") {
|
|
kept.push(error);
|
|
continue;
|
|
}
|
|
const text = messageOf(error);
|
|
if (!slotOf.has(text)) {
|
|
slotOf.set(text, kept.length);
|
|
kept.push(error);
|
|
continue;
|
|
}
|
|
const slot = slotOf.get(text);
|
|
const current = kept[slot]?.extra_info?.input_name;
|
|
if (!namesInput(text, current) && namesInput(text, error?.extra_info?.input_name)) kept[slot] = error;
|
|
}
|
|
entry.errors = kept;
|
|
}
|
|
return nodeErrors;
|
|
}
|