A MiniMax H3 run that keeps a clip's sound stops with KeyError: 'latent' when the Muse MiniMax Director pack is installed, and an older ComfyUI stops on a w6a8 model file. Neither message tells a person what to do, so the run check now shows one toast for each with the cause and the fix.
240 lines
12 KiB
JavaScript
240 lines
12 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
collapseRepeatedErrors,
|
|
emptySourceMessage,
|
|
explainRunError,
|
|
findEmptySources,
|
|
nodeLacksSource,
|
|
nodesThatRun,
|
|
} from "../js/shared/run_check.mjs";
|
|
|
|
// Node definitions as the frontend holds them: only output_node and the
|
|
// lazy flag matter to the check.
|
|
const DEFINITIONS = {
|
|
SaveImage: { output_node: true, input: { required: { images: ["IMAGE"] } } },
|
|
PreviewImage: { output_node: true, input: { required: { images: ["IMAGE"] } } },
|
|
AUSBOSS_NODES_SaveVideo: { output_node: true, input: { optional: { frames: ["IMAGE"] } } },
|
|
PickOne: { input: { required: { first: ["IMAGE", { lazy: true }], second: ["IMAGE"] } } },
|
|
};
|
|
const definitionOf = (classType) => DEFINITIONS[classType] ?? null;
|
|
|
|
const node = (class_type, inputs = {}, title = class_type) => ({ class_type, inputs, _meta: { title } });
|
|
|
|
test("a blank Load Image that feeds a save is found, by its own title", () => {
|
|
const prompt = {
|
|
1: node("LoadImage", { image: "" }, "Your image"),
|
|
2: node("SaveImage", { images: ["1", 0] }),
|
|
};
|
|
assert.deepEqual(findEmptySources(prompt, definitionOf), [{ id: "1", title: "Your image", input: "image", kind: "picture" }]);
|
|
assert.equal(emptySourceMessage(findEmptySources(prompt, definitionOf)), "Load a picture first: Your image");
|
|
});
|
|
|
|
test("a picked picture, a wired name or a missing widget value are told apart", () => {
|
|
const run = (image) => findEmptySources({ 1: node("LoadImage", { image }), 2: node("SaveImage", { images: ["1", 0] }) }, definitionOf);
|
|
assert.deepEqual(run("pier.png"), []);
|
|
assert.deepEqual(run(["7", 0]), [], "a name fed by another node is set at run time");
|
|
assert.equal(run(undefined).length, 1);
|
|
assert.equal(run(" ").length, 1);
|
|
});
|
|
|
|
test("a loader no output needs never blocks the run", () => {
|
|
const prompt = {
|
|
1: node("LoadImage", { image: "" }),
|
|
2: node("LoadImage", { image: "pier.png" }),
|
|
3: node("SaveImage", { images: ["2", 0] }),
|
|
};
|
|
assert.deepEqual(findEmptySources(prompt, definitionOf), []);
|
|
});
|
|
|
|
test("bypassed and muted loaders are not in the prompt, so they are never found", () => {
|
|
// The frontend leaves them out of the prompt and reroutes a bypassed one.
|
|
const prompt = { 2: node("LoadImage", { image: "pier.png" }), 3: node("SaveImage", { images: ["2", 0] }) };
|
|
assert.deepEqual(findEmptySources(prompt, definitionOf), []);
|
|
});
|
|
|
|
test("a loader only behind a lazy input may never be read, so it is not found", () => {
|
|
const prompt = {
|
|
1: node("LoadImage", { image: "" }),
|
|
2: node("LoadImage", { image: "pier.png" }),
|
|
3: node("PickOne", { first: ["1", 0], second: ["2", 0] }),
|
|
4: node("SaveImage", { images: ["3", 0] }),
|
|
};
|
|
assert.deepEqual(findEmptySources(prompt, definitionOf), []);
|
|
prompt[3].inputs = { first: ["2", 0], second: ["1", 0] };
|
|
assert.deepEqual(findEmptySources(prompt, definitionOf).map((found) => found.id), ["1"]);
|
|
});
|
|
|
|
test("queueing selected outputs only checks what those outputs read", () => {
|
|
const prompt = {
|
|
1: node("LoadImage", { image: "" }),
|
|
2: node("SaveImage", { images: ["1", 0] }),
|
|
3: node("LoadImage", { image: "pier.png" }),
|
|
4: node("PreviewImage", { images: ["3", 0] }),
|
|
};
|
|
assert.deepEqual(findEmptySources(prompt, definitionOf, ["4"]), []);
|
|
assert.deepEqual(findEmptySources(prompt, definitionOf, ["2"]).map((found) => found.id), ["1"]);
|
|
assert.deepEqual([...nodesThatRun(prompt, definitionOf, [])].sort(), ["1", "2", "3", "4"], "no targets: every output");
|
|
});
|
|
|
|
test("nodes inside subgraphs keep their execution ids", () => {
|
|
const prompt = {
|
|
"12:5": node("LoadImage", { image: "" }, "Inner image"),
|
|
"12:6": node("SaveImage", { images: ["12:5", 0] }),
|
|
};
|
|
assert.deepEqual(findEmptySources(prompt, definitionOf).map((found) => found.id), ["12:5"]);
|
|
});
|
|
|
|
test("Load Image + Pad needs no file while a picture is wired in", () => {
|
|
const run = (inputs) => findEmptySources({ 1: node("AUSBOSS_NODES_LoadImagePad", inputs), 2: node("SaveImage", { images: ["1", 0] }) }, definitionOf);
|
|
assert.equal(run({ image: "" }).length, 1);
|
|
assert.deepEqual(run({ image: "", source_image: ["9", 0] }), []);
|
|
});
|
|
|
|
test("the video nodes check the file or the local path, whichever the mode reads", () => {
|
|
const run = (class_type, inputs) =>
|
|
findEmptySources({ 1: node(class_type, inputs, "Clip"), 2: node("AUSBOSS_NODES_SaveVideo", { frames: ["1", 0] }) }, definitionOf);
|
|
for (const class_type of ["AUSBOSS_NODES_VideoCropRotatePad", "AUSBOSS_NODES_VideoCropRotatePadClip"]) {
|
|
assert.deepEqual(run(class_type, { video: "", source_mode: "input folder", local_path: "" }).map((f) => f.input), ["video"]);
|
|
assert.deepEqual(run(class_type, { video: "pier.mp4", source_mode: "input folder", local_path: "" }), []);
|
|
assert.deepEqual(run(class_type, { video: "", source_mode: "local path", local_path: "" }).map((f) => f.input), ["local_path"]);
|
|
assert.deepEqual(run(class_type, { video: "", source_mode: "local path", local_path: "/comfy/output/a.mp4" }), []);
|
|
assert.deepEqual(run(class_type, { video: "", source_mode: ["4", 0], local_path: "" }), [], "a wired mode is known at run time");
|
|
}
|
|
const found = run("AUSBOSS_NODES_LoadVideo", { video: "" });
|
|
assert.equal(emptySourceMessage(found), "Load a video first: Clip");
|
|
assert.deepEqual(run("LoadVideo", { file: "" }).map((f) => f.input), ["file"]);
|
|
});
|
|
|
|
test("the message names the first loader and counts the rest", () => {
|
|
const found = [
|
|
{ id: "1", title: "Your image", kind: "picture" },
|
|
{ id: "2", title: "Load Video 🆎", kind: "video" },
|
|
{ id: "3", title: "Mask", kind: "picture" },
|
|
];
|
|
assert.equal(emptySourceMessage(found.slice(0, 2)), "Load a picture first: Your image (and 1 more node)");
|
|
assert.equal(emptySourceMessage(found), "Load a picture first: Your image (and 2 more nodes)");
|
|
assert.equal(emptySourceMessage([]), "");
|
|
});
|
|
|
|
test("the widget scan reads the live node the same way", () => {
|
|
const widget = (name, value) => ({ name, value });
|
|
assert.equal(nodeLacksSource({ comfyClass: "LoadImage", widgets: [widget("image", "")] }), true);
|
|
assert.equal(nodeLacksSource({ comfyClass: "LoadImage", widgets: [widget("image", "pier.png")] }), false);
|
|
assert.equal(
|
|
nodeLacksSource({ comfyClass: "LoadImage", widgets: [widget("image", "")], inputs: [{ name: "image", widget: { name: "image" }, link: 4 }] }),
|
|
false,
|
|
"a widget fed by a link is set",
|
|
);
|
|
assert.equal(
|
|
nodeLacksSource({ comfyClass: "AUSBOSS_NODES_LoadImagePad", widgets: [widget("image", "")], inputs: [{ name: "source_image", link: 9 }] }),
|
|
false,
|
|
);
|
|
assert.equal(nodeLacksSource({ type: "KSampler", widgets: [widget("seed", 0)] }), false);
|
|
assert.equal(nodeLacksSource(null), false);
|
|
});
|
|
|
|
// ---------------------------------------------------------------- collapse
|
|
|
|
const failed = (input_name, message) => ({
|
|
type: "custom_validation_failed",
|
|
message: "Custom validation failed for node",
|
|
details: `${input_name} - ${message}`,
|
|
extra_info: { input_name },
|
|
});
|
|
|
|
test("a failed check repeated on every input it read comes back once", () => {
|
|
const message = "Video Crop + Rotate + Pad: Select or upload a source file first.";
|
|
const errors = {
|
|
7: {
|
|
class_type: "AUSBOSS_NODES_VideoCropRotatePadClip",
|
|
errors: [failed("video", message), failed("source_mode", message), failed("local_path", message)],
|
|
dependent_outputs: ["9"],
|
|
},
|
|
};
|
|
collapseRepeatedErrors(errors);
|
|
assert.deepEqual(errors[7].errors.map((error) => error.extra_info.input_name), ["video"]);
|
|
assert.deepEqual(errors[7].dependent_outputs, ["9"]);
|
|
});
|
|
|
|
test("the error stays on the input its message is about", () => {
|
|
const nameMessage = "Save Image: exact_name may not contain '..'.";
|
|
const pathMessage = "Video Crop + Rotate + Pad: Local path mode requires a video path.";
|
|
const errors = {
|
|
1: { class_type: "AUSBOSS_NODES_SaveImage", errors: ["filename_prefix", "exact_name", "output_dir"].map((name) => failed(name, nameMessage)) },
|
|
2: { class_type: "AUSBOSS_NODES_VideoCropRotatePad", errors: ["video", "source_mode", "local_path"].map((name) => failed(name, pathMessage)) },
|
|
};
|
|
collapseRepeatedErrors(errors);
|
|
assert.deepEqual(errors[1].errors.map((error) => error.extra_info.input_name), ["exact_name"]);
|
|
assert.deepEqual(errors[2].errors.map((error) => error.extra_info.input_name), ["local_path"]);
|
|
});
|
|
|
|
test("different problems, other kinds of error and other packs' nodes are left alone", () => {
|
|
const range = { type: "value_smaller_than_min", message: "Value -5 smaller than min of 0", details: "crop_x", extra_info: { input_name: "crop_x" } };
|
|
const errors = {
|
|
1: { class_type: "AUSBOSS_NODES_LoraLoader", errors: [failed("loras", "LoRA Loader: a"), failed("on_missing", "LoRA Loader: a"), failed("loras", "LoRA Loader: b"), range] },
|
|
2: { class_type: "SomePackLoader", errors: [failed("x", "same"), failed("y", "same")] },
|
|
};
|
|
collapseRepeatedErrors(errors);
|
|
assert.deepEqual(errors[1].errors.map((error) => [error.extra_info.input_name, error.details]), [
|
|
["loras", "loras - LoRA Loader: a"],
|
|
["loras", "loras - LoRA Loader: b"],
|
|
["crop_x", "crop_x"],
|
|
]);
|
|
assert.equal(errors[2].errors.length, 2);
|
|
assert.equal(collapseRepeatedErrors(null), null);
|
|
});
|
|
|
|
test("an empty Load Audio asks for a sound file", () => {
|
|
const outputs = (classType) => (classType === "SaveAudio"
|
|
? { output_node: true, input: { required: { audio: ["AUDIO"] } } }
|
|
: definitionOf(classType));
|
|
const prompt = {
|
|
1: node("AUSBOSS_NODES_LoadAudio", { audio: "" }, "Song"),
|
|
2: node("SaveAudio", { audio: ["1", 0] }),
|
|
};
|
|
const found = findEmptySources(prompt, outputs);
|
|
assert.deepEqual(found.map((entry) => entry.input), ["audio"]);
|
|
assert.equal(emptySourceMessage(found), "Load a sound file first: Song");
|
|
prompt[1].inputs.audio = "song.mp3";
|
|
assert.deepEqual(findEmptySources(prompt, outputs), []);
|
|
});
|
|
|
|
// ------------------------------------------------------------ known run errors
|
|
|
|
// The report a user posted: ComfyUI 0.44.0, H3 keeping the clip's sound, with
|
|
// Muse MiniMax Director installed.
|
|
const MUSE_REPORT = {
|
|
node_type: "SamplerCustomAdvanced",
|
|
exception_type: "KeyError",
|
|
exception_message: "'latent'",
|
|
traceback: [
|
|
' File "G:\\ComfyUI\\ComfyUI\\comfy\\samplers.py", line 953, in encode_model_conds\n out = model_function(**params)\n',
|
|
' File "G:\\ComfyUI\\ComfyUI\\custom_nodes\\muse-minimax-director-v1-2\\muse_minimax_hybrid_patch.py", line 30, in extra_conds_with_hybrid\n visual_latents.extend(item["latent"] for item in keyframes)\n',
|
|
],
|
|
};
|
|
|
|
test("the Muse MiniMax Director error is explained and names the pack", () => {
|
|
const known = explainRunError(MUSE_REPORT);
|
|
assert.match(known.detail, /Muse MiniMax Director/);
|
|
assert.match(known.detail, /Turn that pack off/);
|
|
});
|
|
|
|
test("the same KeyError from anywhere else is left alone", () => {
|
|
assert.equal(explainRunError({ ...MUSE_REPORT, traceback: [' File "comfy/samplers.py", line 953\n'] }), null);
|
|
assert.equal(explainRunError({ ...MUSE_REPORT, exception_message: "'samples'" }), null);
|
|
});
|
|
|
|
test("an old ComfyUI loading a w6a8 model is told to update", () => {
|
|
const known = explainRunError({ exception_type: "KeyError", exception_message: "'w6a8_int8'", traceback: [] });
|
|
assert.match(known.detail, /0\.38/);
|
|
});
|
|
|
|
test("an unknown error, or no report at all, gives nothing", () => {
|
|
assert.equal(explainRunError({ exception_type: "RuntimeError", exception_message: "CUDA out of memory", traceback: [] }), null);
|
|
assert.equal(explainRunError(null), null);
|
|
assert.equal(explainRunError({}), null);
|
|
assert.equal(explainRunError({ exception_type: "KeyError", exception_message: "'latent'", traceback: "muse_minimax_hybrid_patch" }) !== null, true);
|
|
});
|