// 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 " - " 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;
}