From c8f0cabb18b26562e1b64ffebb90fa8ff1cedd32 Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Wed, 4 Nov 2026 23:59:58 +0330 Subject: [PATCH 1/2] Record ComfyUI's object_info and test every preset against it Beta gate G1 (roadmap U2). Every preset had only been checked against ComfyUI 0.37.0; the newest ComfyUI is 0.44.0, and node or input renames were found only when an artist's Workflow Health went red. npm run object-info records tests/fixtures/comfyui-object-info.json from a live ComfyUI: only the 80 node classes OpenLayer uses, each input's type, fixed choices such as samplers (never model file lists, which differ per machine) and the names nested inputs allow (ComfyUI's dynamic combo and autogrow inputs, written "parent.child" in a workflow). npm run object-info -- --check diffs a live ComfyUI against it without writing, and fails only on something a preset uses. objectInfoSnapshot.test.ts checks every node, input, nested input and fixed choice in all 39 bundled workflows, and every requiredNodes entry, against the snapshot. Recorded from ComfyUI 0.44.0: everything matches. Removing KSampler.denoise and the "euler" choice from the snapshot makes it name every preset that uses them. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 + package.json | 1 + scripts/object-info-snapshot.mjs | 171 +++ tests/comfy/objectInfoSnapshot.test.ts | 119 ++ tests/fixtures/comfyui-object-info.json | 1577 +++++++++++++++++++++++ 5 files changed, 1870 insertions(+) create mode 100644 scripts/object-info-snapshot.mjs create mode 100644 tests/comfy/objectInfoSnapshot.test.ts create mode 100644 tests/fixtures/comfyui-object-info.json diff --git a/CLAUDE.md b/CLAUDE.md index fc08ab0..597604d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,8 @@ npm run build tsc + vite build + scripts/copy-uxp-assets.mjs -> dist/ npm run package build + zip + .ccx into packages/ (incl. openlayer-latest.*) npm run setup-pack GENERATED from presetRegistry; never hand-edit its output npm run audit-css measures CSS redeclarations +npm run object-info record tests/fixtures/comfyui-object-info.json from a live ComfyUI + (`-- --check` diffs a live ComfyUI against it; read-only) cd bridge && npm ci the MCP bridge is a separate package with its own lockfile ``` diff --git a/package.json b/package.json index dfe9648..9ea4ca5 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "package": "npm run build && node scripts/package.mjs", "setup-pack": "node scripts/build-setup-pack.mjs", "audit-css": "node scripts/audit-css.mjs", + "object-info": "node scripts/object-info-snapshot.mjs", "test": "vitest run --config vitest.config.ts", "test:e2e": "vitest run --config vitest.e2e.config.ts" }, diff --git a/scripts/object-info-snapshot.mjs b/scripts/object-info-snapshot.mjs new file mode 100644 index 0000000..88844d4 --- /dev/null +++ b/scripts/object-info-snapshot.mjs @@ -0,0 +1,171 @@ +import { readFile, readdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Records, or checks against, the parts of ComfyUI's /object_info that OpenLayer + * depends on. Beta gate G1 (roadmap U2). + * + * node scripts/object-info-snapshot.mjs [serverUrl] record tests/fixtures/comfyui-object-info.json + * node scripts/object-info-snapshot.mjs --check [serverUrl] diff a live ComfyUI against the recording + * + * Only the node classes OpenLayer uses are kept: every class_type in the bundled + * API workflows, every classType the preset registry requires, and every + * class_type the TypeScript builds itself (Live Painting, the LoRA splice). For + * each input the snapshot keeps its type and, for a fixed list of choices such + * as a sampler or scheduler, the choices -- but never a list of model files, + * which differs on every machine. Nested inputs (ComfyUI's dynamic combo and + * autogrow inputs, written "parent.child" in a workflow) keep the names they + * allow. tests/comfy/objectInfoSnapshot.test.ts checks the presets against it. + * + * Read-only against ComfyUI: one GET of /object_info and one of /system_stats. + */ + +const root = fileURLToPath(new URL("..", import.meta.url)); +const snapshotPath = resolve(root, "tests/fixtures/comfyui-object-info.json"); +const args = process.argv.slice(2); +const checkMode = args.includes("--check"); +const serverUrl = (args.find((arg) => !arg.startsWith("--")) ?? "http://127.0.0.1:8188").replace(/\/+$/, ""); + +const FILE_LIKE = /\.(safetensors|pt|pth|gguf|ckpt|bin|onnx|png|jpe?g|webp|json|ya?ml)$/i; + +// "Class.input" -> the literal values the bundled workflows set on it, so a +// choice removed upstream only fails the check when a preset uses it. +const usedChoices = new Map(); + +async function usedClasses() { + const classes = new Set(); + const apiDir = resolve(root, "src/workflows/api"); + + for (const name of await readdir(apiDir)) { + if (!name.endsWith(".json")) continue; + const workflow = JSON.parse(await readFile(join(apiDir, name), "utf8")); + for (const node of Object.values(workflow)) { + if (!node || typeof node !== "object" || typeof node.class_type !== "string") continue; + classes.add(node.class_type); + for (const [input, value] of Object.entries(node.inputs ?? {})) { + if (typeof value !== "string" || value === "") continue; + const key = `${node.class_type}.${input}`; + if (!usedChoices.has(key)) usedChoices.set(key, new Set()); + usedChoices.get(key).add(value); + } + } + } + + const comfyDir = resolve(root, "src/comfy"); + for (const name of await readdir(comfyDir)) { + if (!name.endsWith(".ts")) continue; + const source = await readFile(join(comfyDir, name), "utf8"); + for (const match of source.matchAll(/class(?:_t|T)ype: "([^"]+)"/g)) classes.add(match[1]); + } + + return [...classes].sort(); +} + +function choicesOf(spec) { + if (Array.isArray(spec) && Array.isArray(spec[0])) return spec[0]; + if (Array.isArray(spec) && spec[0] === "COMBO" && Array.isArray(spec[1]?.options)) return spec[1].options; + return null; +} + +function summarizeInput(spec) { + const type = Array.isArray(spec) ? spec[0] : spec; + + if (type === "COMFY_DYNAMICCOMBO_V3") { + const nested = {}; + for (const option of spec[1]?.options ?? []) { + nested[option.key] = Object.keys({ ...(option.inputs?.required ?? {}), ...(option.inputs?.optional ?? {}) }).sort(); + } + return { type, nested }; + } + + if (type === "COMFY_AUTOGROW_V3") { + // ComfyUI 0.44 keeps the slot names ("image_1", ...) under "template". + return { type, names: spec[1]?.template?.names ?? spec[1]?.names ?? [] }; + } + + const choices = choicesOf(spec); + if (choices) { + const fixed = choices.length > 0 && choices.every((choice) => typeof choice === "string" && !FILE_LIKE.test(choice) && !choice.includes("/") && !choice.includes("\\")); + return fixed ? { type: "COMBO", choices } : { type: "COMBO" }; + } + + return { type: typeof type === "string" ? type : "UNKNOWN" }; +} + +function summarizeNode(info) { + const inputs = {}; + for (const [name, spec] of Object.entries({ ...(info.input?.required ?? {}), ...(info.input?.optional ?? {}) })) { + inputs[name] = summarizeInput(spec); + } + return { inputs }; +} + +async function fetchJson(path) { + const response = await fetch(`${serverUrl}${path}`); + if (!response.ok) throw new Error(`${serverUrl}${path} answered HTTP ${response.status}`); + return response.json(); +} + +const classes = await usedClasses(); +const [objectInfo, systemStats] = await Promise.all([fetchJson("/object_info"), fetchJson("/system_stats")]); +const live = { + comfyuiVersion: systemStats.system?.comfyui_version ?? "unknown", + recordedAt: new Date().toISOString().slice(0, 10), + missingClasses: classes.filter((name) => !objectInfo[name]), + nodes: Object.fromEntries(classes.filter((name) => objectInfo[name]).map((name) => [name, summarizeNode(objectInfo[name])])) +}; + +// process.exitCode rather than process.exit(): exiting while fetch's sockets +// are still closing trips a libuv assertion on Windows. +if (!checkMode) { + await writeFile(snapshotPath, `${JSON.stringify(live, null, 2)}\n`); + console.log(`Recorded ${Object.keys(live.nodes).length} node classes from ComfyUI ${live.comfyuiVersion} at ${serverUrl}.`); + if (live.missingClasses.length > 0) console.log(`Not installed there: ${live.missingClasses.join(", ")}.`); +} else { + process.exitCode = checkAgainstSnapshot(JSON.parse(await readFile(snapshotPath, "utf8"))) ? 0 : 1; +} + +function checkAgainstSnapshot(recorded) { + const problems = []; + const notes = []; + let unusedChoicesGone = 0; + + for (const missing of recorded.missingClasses ?? []) { + notes.push(`${missing}: was not installed when the snapshot was recorded`); + } + + for (const [name, node] of Object.entries(recorded.nodes)) { + const now = live.nodes[name]; + if (!now) { + problems.push(`${name}: class is not there (a core node renamed, or its node pack is not installed)`); + continue; + } + for (const [input, spec] of Object.entries(node.inputs)) { + const current = now.inputs[input]; + if (!current) { + problems.push(`${name}.${input}: input is gone`); + continue; + } + for (const choice of spec.choices ?? []) { + if (!current.choices || current.choices.includes(choice)) continue; + if (usedChoices.get(`${name}.${input}`)?.has(choice)) problems.push(`${name}.${input}: choice "${choice}" is gone, and a preset uses it`); + else unusedChoicesGone += 1; + } + for (const [key, names] of Object.entries(spec.nested ?? {})) { + const nowNames = current.nested?.[key]; + if (!nowNames) problems.push(`${name}.${input}: option "${key}" is gone`); + else for (const child of names) if (!nowNames.includes(child)) problems.push(`${name}.${input}.${child}: nested input is gone`); + } + } + for (const input of Object.keys(now.inputs)) { + if (!node.inputs[input]) notes.push(`${name}.${input}: new input`); + } + } + + console.log(`Snapshot: ComfyUI ${recorded.comfyuiVersion} (${recorded.recordedAt}). Live: ComfyUI ${live.comfyuiVersion} at ${serverUrl}.`); + console.log(problems.length === 0 ? "Nothing OpenLayer uses is missing." : `Missing, and OpenLayer uses it:\n ${problems.join("\n ")}`); + if (unusedChoicesGone > 0) console.log(`${unusedChoicesGone} choice(s) no preset uses are also missing there (harmless).`); + if (notes.length > 0) console.log(`Notes:\n ${notes.join("\n ")}`); + return problems.length === 0; +} diff --git a/tests/comfy/objectInfoSnapshot.test.ts b/tests/comfy/objectInfoSnapshot.test.ts new file mode 100644 index 0000000..0d19d92 --- /dev/null +++ b/tests/comfy/objectInfoSnapshot.test.ts @@ -0,0 +1,119 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { listWorkflowPresets } from "../../src/comfy/presetRegistry"; + +/** + * Every preset against a recorded ComfyUI /object_info. Beta gate G1 (roadmap U2). + * + * The snapshot is written by `node scripts/object-info-snapshot.mjs` from a live + * ComfyUI and holds only the node classes OpenLayer uses. When ComfyUI renames + * a node or an input, re-recording the snapshot makes this test name every + * preset it breaks -- before an artist finds it in Workflow Health. When a + * preset starts using a new node, this test asks for the snapshot to be + * recorded again. + * + * Re-record after updating ComfyUI: node scripts/object-info-snapshot.mjs + * Check a ComfyUI without recording: node scripts/object-info-snapshot.mjs --check + */ + +type InputSummary = { + type: string; + choices?: string[]; + nested?: Record; + names?: string[]; +}; + +type Snapshot = { + comfyuiVersion: string; + recordedAt: string; + missingClasses: string[]; + nodes: Record }>; +}; + +const root = resolve(__dirname, "../.."); +const snapshot = JSON.parse(readFileSync(join(root, "tests/fixtures/comfyui-object-info.json"), "utf8")) as Snapshot; +const apiDir = join(root, "src/workflows/api"); +const workflows = readdirSync(apiDir) + .filter((name) => name.endsWith(".json")) + .map((name) => ({ name, graph: JSON.parse(readFileSync(join(apiDir, name), "utf8")) as Record }> })); + +/** Whether `input` (possibly "parent.child") exists on the recorded node. */ +function hasInput(inputs: Record, input: string): boolean { + if (inputs[input]) return true; + + const dot = input.indexOf("."); + if (dot < 0) return false; + + const parent = inputs[input.slice(0, dot)]; + const child = input.slice(dot + 1); + + if (parent?.nested) return Object.values(parent.nested).some((names) => names.includes(child)); + if (parent?.names) return parent.names.includes(child); + return false; +} + +describe("ComfyUI object_info snapshot", () => { + it("was recorded with every node class OpenLayer uses installed", () => { + expect(snapshot.missingClasses, `record it again from a ComfyUI with every node pack installed`).toEqual([]); + expect(Object.keys(snapshot.nodes).length).toBeGreaterThan(0); + }); + + it("knows every node class in every bundled workflow", () => { + const unknown = workflows.flatMap(({ name, graph }) => + Object.values(graph) + .filter((node) => typeof node?.class_type === "string" && !snapshot.nodes[node.class_type]) + .map((node) => `${name}: ${node.class_type}`) + ); + + expect(unknown, "classes the snapshot lacks: record it again with node scripts/object-info-snapshot.mjs").toEqual([]); + }); + + it("has every input every bundled workflow sets, nested inputs included", () => { + const missing = workflows.flatMap(({ name, graph }) => + Object.values(graph).flatMap((node) => { + const recorded = node?.class_type ? snapshot.nodes[node.class_type] : undefined; + if (!recorded) return []; + return Object.keys(node.inputs ?? {}) + .filter((input) => !hasInput(recorded.inputs, input)) + .map((input) => `${name}: ${node.class_type}.${input}`); + }) + ); + + expect(missing).toEqual([]); + }); + + it("offers every fixed choice a bundled workflow uses, such as a sampler or scheduler", () => { + // Model file names are left out of the snapshot on purpose (they differ per + // machine), so only inputs with a recorded fixed list are checked. An empty + // string is a placeholder the panel fills in at run time. + const missing = workflows.flatMap(({ name, graph }) => + Object.values(graph).flatMap((node) => { + const recorded = node?.class_type ? snapshot.nodes[node.class_type] : undefined; + if (!recorded) return []; + return Object.entries(node.inputs ?? {}) + .filter(([input, value]) => { + const choices = recorded.inputs[input]?.choices; + return choices && typeof value === "string" && value !== "" && !choices.includes(value); + }) + .map(([input, value]) => `${name}: ${node.class_type}.${input} = "${String(value)}"`); + }) + ); + + expect(missing).toEqual([]); + }); + + it("has every node and input the preset registry requires", () => { + const missing = listWorkflowPresets().flatMap((preset) => + preset.requiredNodes.flatMap((required) => { + const recorded = snapshot.nodes[required.classType]; + if (!recorded) return [`${preset.id}: ${required.classType}`]; + return required.requiredInputs + .filter((input) => !hasInput(recorded.inputs, input)) + .map((input) => `${preset.id}: ${required.classType}.${input}`); + }) + ); + + expect(missing).toEqual([]); + }); +}); diff --git a/tests/fixtures/comfyui-object-info.json b/tests/fixtures/comfyui-object-info.json new file mode 100644 index 0000000..1d85333 --- /dev/null +++ b/tests/fixtures/comfyui-object-info.json @@ -0,0 +1,1577 @@ +{ + "comfyuiVersion": "0.44.0", + "recordedAt": "2026-11-04", + "missingClasses": [], + "nodes": { + "AnyLineArtPreprocessor_aux": { + "inputs": { + "image": { + "type": "IMAGE" + }, + "merge_with_lineart": { + "type": "COMBO", + "choices": [ + "lineart_standard", + "lineart_realisitic", + "lineart_anime", + "manga_line" + ] + }, + "resolution": { + "type": "INT" + }, + "lineart_lower_bound": { + "type": "FLOAT" + }, + "lineart_upper_bound": { + "type": "FLOAT" + }, + "object_min_size": { + "type": "INT" + }, + "object_connectivity": { + "type": "INT" + } + } + }, + "BAE-NormalMapPreprocessor": { + "inputs": { + "image": { + "type": "IMAGE" + }, + "resolution": { + "type": "INT" + } + } + }, + "BasicGuider": { + "inputs": { + "model": { + "type": "MODEL" + }, + "conditioning": { + "type": "CONDITIONING" + } + } + }, + "CFGNorm": { + "inputs": { + "model": { + "type": "MODEL" + }, + "strength": { + "type": "FLOAT" + }, + "pre_cfg": { + "type": "BOOLEAN" + } + } + }, + "CLIPLoader": { + "inputs": { + "clip_name": { + "type": "COMBO" + }, + "type": { + "type": "COMBO", + "choices": [ + "stable_diffusion", + "stable_cascade", + "sd3", + "stable_audio", + "mochi", + "ltxv", + "pixart", + "cosmos", + "lumina2", + "wan", + "hidream", + "chroma", + "ace", + "omnigen2", + "qwen_image", + "hunyuan_image", + "flux2", + "ovis", + "longcat_image", + "cogvideox", + "lens", + "pixeldit", + "ideogram4", + "boogu", + "krea2", + "joyimage", + "mage", + "minimax", + "yue2", + "flux3", + "moss_image", + "fibo2" + ] + }, + "device": { + "type": "COMBO", + "choices": [ + "default", + "cpu" + ] + } + } + }, + "CLIPTextEncode": { + "inputs": { + "text": { + "type": "STRING" + }, + "clip": { + "type": "CLIP" + } + } + }, + "CLIPVisionLoader": { + "inputs": { + "clip_name": { + "type": "COMBO" + } + } + }, + "CheckpointLoaderSimple": { + "inputs": { + "ckpt_name": { + "type": "COMBO" + } + } + }, + "ConditioningZeroOut": { + "inputs": { + "conditioning": { + "type": "CONDITIONING" + } + } + }, + "ControlNetApplyAdvanced": { + "inputs": { + "positive": { + "type": "CONDITIONING" + }, + "negative": { + "type": "CONDITIONING" + }, + "control_net": { + "type": "CONTROL_NET" + }, + "image": { + "type": "IMAGE" + }, + "strength": { + "type": "FLOAT" + }, + "start_percent": { + "type": "FLOAT" + }, + "end_percent": { + "type": "FLOAT" + }, + "vae": { + "type": "VAE" + } + } + }, + "ControlNetLoader": { + "inputs": { + "control_net_name": { + "type": "COMBO" + } + } + }, + "DepthAnythingV2Preprocessor": { + "inputs": { + "image": { + "type": "IMAGE" + }, + "ckpt_name": { + "type": "COMBO" + }, + "resolution": { + "type": "INT" + } + } + }, + "DifferentialDiffusion": { + "inputs": { + "model": { + "type": "MODEL" + }, + "strength": { + "type": "FLOAT" + } + } + }, + "DualCLIPLoader": { + "inputs": { + "clip_name1": { + "type": "COMBO" + }, + "clip_name2": { + "type": "COMBO" + }, + "type": { + "type": "COMBO", + "choices": [ + "sdxl", + "sd3", + "flux", + "hunyuan_video", + "hidream", + "hunyuan_image", + "hunyuan_video_15", + "kandinsky5", + "kandinsky5_image", + "ltxv", + "newbie", + "ace" + ] + }, + "device": { + "type": "COMBO", + "choices": [ + "default", + "cpu" + ] + } + } + }, + "EmptyFlux2LatentImage": { + "inputs": { + "width": { + "type": "INT" + }, + "height": { + "type": "INT" + }, + "batch_size": { + "type": "INT" + } + } + }, + "EmptyLatentImage": { + "inputs": { + "width": { + "type": "INT" + }, + "height": { + "type": "INT" + }, + "batch_size": { + "type": "INT" + } + } + }, + "EmptyQwenImageLayeredLatentImage": { + "inputs": { + "width": { + "type": "INT" + }, + "height": { + "type": "INT" + }, + "layers": { + "type": "INT" + }, + "batch_size": { + "type": "INT" + } + } + }, + "EmptySD3LatentImage": { + "inputs": { + "width": { + "type": "INT" + }, + "height": { + "type": "INT" + }, + "batch_size": { + "type": "INT" + } + } + }, + "Florence2ModelLoader": { + "inputs": { + "model": { + "type": "COMBO", + "choices": [ + "Florence-2-base", + "Florence-2-base-ft", + "Florence-2-base-PromptGen-v2.0" + ] + }, + "precision": { + "type": "COMBO", + "choices": [ + "fp16", + "bf16", + "fp32" + ] + }, + "lora": { + "type": "PEFTLORA" + }, + "convert_to_safetensors": { + "type": "BOOLEAN" + } + } + }, + "Florence2Run": { + "inputs": { + "image": { + "type": "IMAGE" + }, + "florence2_model": { + "type": "FL2MODEL" + }, + "text_input": { + "type": "STRING" + }, + "task": { + "type": "COMBO", + "choices": [ + "region_caption", + "dense_region_caption", + "region_proposal", + "caption", + "detailed_caption", + "more_detailed_caption", + "caption_to_phrase_grounding", + "referring_expression_segmentation", + "ocr", + "ocr_with_region", + "docvqa", + "prompt_gen_tags", + "prompt_gen_mixed_caption", + "prompt_gen_analyze", + "prompt_gen_mixed_caption_plus" + ] + }, + "fill_mask": { + "type": "BOOLEAN" + }, + "keep_model_loaded": { + "type": "BOOLEAN" + }, + "max_new_tokens": { + "type": "INT" + }, + "num_beams": { + "type": "INT" + }, + "do_sample": { + "type": "BOOLEAN" + }, + "output_mask_select": { + "type": "STRING" + }, + "seed": { + "type": "INT" + } + } + }, + "Flux2Scheduler": { + "inputs": { + "steps": { + "type": "INT" + }, + "width": { + "type": "INT" + }, + "height": { + "type": "INT" + } + } + }, + "FluxGuidance": { + "inputs": { + "conditioning": { + "type": "CONDITIONING" + }, + "guidance": { + "type": "FLOAT" + } + } + }, + "FluxKontextImageScale": { + "inputs": { + "image": { + "type": "IMAGE" + } + } + }, + "FluxKontextMultiReferenceLatentMethod": { + "inputs": { + "conditioning": { + "type": "CONDITIONING" + }, + "reference_latents_method": { + "type": "COMBO" + } + } + }, + "GetImageSize": { + "inputs": { + "image": { + "type": "IMAGE" + } + } + }, + "IPAdapterAdvanced": { + "inputs": { + "model": { + "type": "MODEL" + }, + "ipadapter": { + "type": "IPADAPTER" + }, + "image": { + "type": "IMAGE" + }, + "weight": { + "type": "FLOAT" + }, + "weight_type": { + "type": "COMBO", + "choices": [ + "linear", + "ease in", + "ease out", + "ease in-out", + "reverse in-out", + "weak input", + "weak output", + "weak middle", + "strong middle", + "style transfer", + "composition", + "strong style transfer", + "style and composition", + "style transfer precise", + "composition precise" + ] + }, + "combine_embeds": { + "type": "COMBO", + "choices": [ + "concat", + "add", + "subtract", + "average", + "norm average" + ] + }, + "start_at": { + "type": "FLOAT" + }, + "end_at": { + "type": "FLOAT" + }, + "embeds_scaling": { + "type": "COMBO" + }, + "image_negative": { + "type": "IMAGE" + }, + "attn_mask": { + "type": "MASK" + }, + "clip_vision": { + "type": "CLIP_VISION" + } + } + }, + "IPAdapterModelLoader": { + "inputs": { + "ipadapter_file": { + "type": "COMBO" + } + } + }, + "ImageCompositeMasked": { + "inputs": { + "destination": { + "type": "IMAGE" + }, + "source": { + "type": "IMAGE" + }, + "x": { + "type": "INT" + }, + "y": { + "type": "INT" + }, + "resize_source": { + "type": "BOOLEAN" + }, + "mask": { + "type": "MASK" + } + } + }, + "ImageInvert": { + "inputs": { + "image": { + "type": "IMAGE" + } + } + }, + "ImagePadForOutpaint": { + "inputs": { + "image": { + "type": "IMAGE" + }, + "left": { + "type": "INT" + }, + "top": { + "type": "INT" + }, + "right": { + "type": "INT" + }, + "bottom": { + "type": "INT" + }, + "feathering": { + "type": "INT" + } + } + }, + "ImageScale": { + "inputs": { + "image": { + "type": "IMAGE" + }, + "upscale_method": { + "type": "COMBO", + "choices": [ + "nearest-exact", + "bilinear", + "area", + "bicubic", + "lanczos" + ] + }, + "width": { + "type": "INT" + }, + "height": { + "type": "INT" + }, + "crop": { + "type": "COMBO", + "choices": [ + "disabled", + "center" + ] + } + } + }, + "ImageScaleToMaxDimension": { + "inputs": { + "image": { + "type": "IMAGE" + }, + "upscale_method": { + "type": "COMBO", + "choices": [ + "area", + "lanczos", + "bilinear", + "nearest-exact", + "bilinear", + "bicubic" + ] + }, + "largest_size": { + "type": "INT" + } + } + }, + "ImageScaleToTotalPixels": { + "inputs": { + "image": { + "type": "IMAGE" + }, + "upscale_method": { + "type": "COMBO", + "choices": [ + "nearest-exact", + "bilinear", + "area", + "bicubic", + "lanczos" + ] + }, + "megapixels": { + "type": "FLOAT" + }, + "resolution_steps": { + "type": "INT" + } + } + }, + "ImageToMask": { + "inputs": { + "image": { + "type": "IMAGE" + }, + "channel": { + "type": "COMBO", + "choices": [ + "red", + "green", + "blue", + "alpha" + ] + } + } + }, + "ImageUpscaleWithModel": { + "inputs": { + "upscale_model": { + "type": "UPSCALE_MODEL" + }, + "image": { + "type": "IMAGE" + } + } + }, + "InpaintCropImproved": { + "inputs": { + "image": { + "type": "IMAGE" + }, + "downscale_algorithm": { + "type": "COMBO", + "choices": [ + "nearest", + "bilinear", + "bicubic", + "lanczos", + "box", + "hamming" + ] + }, + "upscale_algorithm": { + "type": "COMBO", + "choices": [ + "nearest", + "bilinear", + "bicubic", + "lanczos", + "box", + "hamming" + ] + }, + "preresize": { + "type": "BOOLEAN" + }, + "preresize_mode": { + "type": "COMBO", + "choices": [ + "ensure minimum resolution", + "ensure maximum resolution", + "ensure minimum and maximum resolution" + ] + }, + "preresize_min_width": { + "type": "INT" + }, + "preresize_min_height": { + "type": "INT" + }, + "preresize_max_width": { + "type": "INT" + }, + "preresize_max_height": { + "type": "INT" + }, + "mask_fill_holes": { + "type": "BOOLEAN" + }, + "mask_expand_pixels": { + "type": "INT" + }, + "mask_invert": { + "type": "BOOLEAN" + }, + "mask_blend_pixels": { + "type": "INT" + }, + "mask_hipass_filter": { + "type": "FLOAT" + }, + "extend_for_outpainting": { + "type": "BOOLEAN" + }, + "extend_up_factor": { + "type": "FLOAT" + }, + "extend_down_factor": { + "type": "FLOAT" + }, + "extend_left_factor": { + "type": "FLOAT" + }, + "extend_right_factor": { + "type": "FLOAT" + }, + "context_from_mask_extend_factor": { + "type": "FLOAT" + }, + "output_resize_to_target_size": { + "type": "BOOLEAN" + }, + "output_target_width": { + "type": "INT" + }, + "output_target_height": { + "type": "INT" + }, + "output_padding": { + "type": "COMBO", + "choices": [ + "0", + "8", + "16", + "32", + "64", + "128", + "256", + "512" + ] + }, + "device_mode": { + "type": "COMBO", + "choices": [ + "cpu (compatible)", + "gpu (much faster)" + ] + }, + "mask": { + "type": "MASK" + }, + "optional_context_mask": { + "type": "MASK" + } + } + }, + "InpaintModelConditioning": { + "inputs": { + "positive": { + "type": "CONDITIONING" + }, + "negative": { + "type": "CONDITIONING" + }, + "vae": { + "type": "VAE" + }, + "pixels": { + "type": "IMAGE" + }, + "mask": { + "type": "MASK" + }, + "noise_mask": { + "type": "BOOLEAN" + } + } + }, + "InpaintStitchImproved": { + "inputs": { + "stitcher": { + "type": "STITCHER" + }, + "inpainted_image": { + "type": "IMAGE" + } + } + }, + "InvertMask": { + "inputs": { + "mask": { + "type": "MASK" + } + } + }, + "JoinImageWithAlpha": { + "inputs": { + "image": { + "type": "IMAGE" + }, + "alpha": { + "type": "MASK" + } + } + }, + "KSampler": { + "inputs": { + "model": { + "type": "MODEL" + }, + "seed": { + "type": "INT" + }, + "steps": { + "type": "INT" + }, + "cfg": { + "type": "FLOAT" + }, + "sampler_name": { + "type": "COMBO", + "choices": [ + "euler", + "euler_cfg_pp", + "euler_ancestral", + "euler_ancestral_cfg_pp", + "heun", + "heunpp2", + "exp_heun_2_x0", + "exp_heun_2_x0_sde", + "dpm_2", + "dpm_2_ancestral", + "lms", + "dpm_fast", + "dpm_adaptive", + "dpmpp_2s_ancestral", + "dpmpp_2s_ancestral_cfg_pp", + "dpmpp_sde", + "dpmpp_sde_gpu", + "dpmpp_2m", + "dpmpp_2m_cfg_pp", + "dpmpp_2m_sde", + "dpmpp_2m_sde_gpu", + "dpmpp_2m_sde_heun", + "dpmpp_2m_sde_heun_gpu", + "dpmpp_3m_sde", + "dpmpp_3m_sde_gpu", + "ddpm", + "lcm", + "ipndm", + "ipndm_v", + "deis", + "cfgpp_ud10_ab", + "res_multistep", + "res_multistep_cfg_pp", + "res_multistep_ancestral", + "res_multistep_ancestral_cfg_pp", + "gradient_estimation", + "gradient_estimation_cfg_pp", + "er_sde", + "seeds_2", + "seeds_3", + "sa_solver", + "sa_solver_pece", + "ddim", + "uni_pc", + "uni_pc_bh2", + "legacy_rk", + "rk", + "rk_beta", + "deis_3m_ode", + "deis_2m_ode", + "deis_3m", + "deis_2m", + "res_6s_ode", + "res_5s_ode", + "res_3s_ode", + "res_2s_ode", + "res_3m_ode", + "res_2m_ode", + "res_6s", + "res_5s", + "res_3s", + "res_2s", + "res_3m", + "res_2m" + ] + }, + "scheduler": { + "type": "COMBO", + "choices": [ + "simple", + "sgm_uniform", + "karras", + "exponential", + "ddim_uniform", + "beta", + "normal", + "linear_quadratic", + "kl_optimal", + "bong_tangent", + "beta57" + ] + }, + "positive": { + "type": "CONDITIONING" + }, + "negative": { + "type": "CONDITIONING" + }, + "latent_image": { + "type": "LATENT" + }, + "denoise": { + "type": "FLOAT" + } + } + }, + "KSamplerSelect": { + "inputs": { + "sampler_name": { + "type": "COMBO", + "choices": [ + "euler", + "euler_cfg_pp", + "euler_ancestral", + "euler_ancestral_cfg_pp", + "heun", + "heunpp2", + "exp_heun_2_x0", + "exp_heun_2_x0_sde", + "dpm_2", + "dpm_2_ancestral", + "lms", + "dpm_fast", + "dpm_adaptive", + "dpmpp_2s_ancestral", + "dpmpp_2s_ancestral_cfg_pp", + "dpmpp_sde", + "dpmpp_sde_gpu", + "dpmpp_2m", + "dpmpp_2m_cfg_pp", + "dpmpp_2m_sde", + "dpmpp_2m_sde_gpu", + "dpmpp_2m_sde_heun", + "dpmpp_2m_sde_heun_gpu", + "dpmpp_3m_sde", + "dpmpp_3m_sde_gpu", + "ddpm", + "lcm", + "ipndm", + "ipndm_v", + "deis", + "cfgpp_ud10_ab", + "res_multistep", + "res_multistep_cfg_pp", + "res_multistep_ancestral", + "res_multistep_ancestral_cfg_pp", + "gradient_estimation", + "gradient_estimation_cfg_pp", + "er_sde", + "seeds_2", + "seeds_3", + "sa_solver", + "sa_solver_pece", + "ddim", + "uni_pc", + "uni_pc_bh2", + "legacy_rk", + "rk", + "rk_beta", + "deis_3m_ode", + "deis_2m_ode", + "deis_3m", + "deis_2m", + "res_6s_ode", + "res_5s_ode", + "res_3s_ode", + "res_2s_ode", + "res_3m_ode", + "res_2m_ode", + "res_6s", + "res_5s", + "res_3s", + "res_2s", + "res_3m", + "res_2m" + ] + } + } + }, + "LaMaInpaint": { + "inputs": { + "image": { + "type": "IMAGE" + }, + "mask": { + "type": "MASK" + }, + "device_mode": { + "type": "COMBO", + "choices": [ + "AUTO", + "Prefer GPU", + "CPU" + ] + }, + "lama_model": { + "type": "LAMA" + } + } + }, + "LatentCutToBatch": { + "inputs": { + "samples": { + "type": "LATENT" + }, + "dim": { + "type": "COMBO", + "choices": [ + "t", + "x", + "y" + ] + }, + "slice_size": { + "type": "INT" + } + } + }, + "LineArtPreprocessor": { + "inputs": { + "image": { + "type": "IMAGE" + }, + "coarse": { + "type": "COMBO", + "choices": [ + "disable", + "enable" + ] + }, + "resolution": { + "type": "INT" + } + } + }, + "LoadBackgroundRemovalModel": { + "inputs": { + "bg_removal_name": { + "type": "COMBO" + } + } + }, + "LoadImage": { + "inputs": { + "image": { + "type": "COMBO" + } + } + }, + "LoadLaMaModel": { + "inputs": { + "model_name": { + "type": "COMBO" + } + } + }, + "LoraLoader": { + "inputs": { + "model": { + "type": "MODEL" + }, + "clip": { + "type": "CLIP" + }, + "lora_name": { + "type": "COMBO" + }, + "strength_model": { + "type": "FLOAT" + }, + "strength_clip": { + "type": "FLOAT" + } + } + }, + "LoraLoaderModelOnly": { + "inputs": { + "model": { + "type": "MODEL" + }, + "lora_name": { + "type": "COMBO" + }, + "strength_model": { + "type": "FLOAT" + } + } + }, + "MaskToImage": { + "inputs": { + "mask": { + "type": "MASK" + } + } + }, + "ModelPatchLoader": { + "inputs": { + "name": { + "type": "COMBO" + } + } + }, + "ModelSamplingAuraFlow": { + "inputs": { + "model": { + "type": "MODEL" + }, + "shift": { + "type": "FLOAT" + }, + "sampling": { + "type": "COMBO", + "choices": [ + "flow", + "img_to_img_velocity" + ] + } + } + }, + "PreviewAny": { + "inputs": { + "source": { + "type": "*" + } + } + }, + "RandomNoise": { + "inputs": { + "noise_seed": { + "type": "INT" + } + } + }, + "ReferenceLatent": { + "inputs": { + "conditioning": { + "type": "CONDITIONING" + }, + "latent": { + "type": "LATENT" + } + } + }, + "RemoveBackground": { + "inputs": { + "bg_removal_model": { + "type": "BACKGROUND_REMOVAL" + }, + "image": { + "type": "IMAGE" + } + } + }, + "ResizeImageMaskNode": { + "inputs": { + "input": { + "type": "COMFY_MATCHTYPE_V3" + }, + "resize_type": { + "type": "COMFY_DYNAMICCOMBO_V3", + "nested": { + "scale dimensions": [ + "crop", + "height", + "width" + ], + "scale by multiplier": [ + "multiplier" + ], + "scale longer dimension": [ + "longer_size" + ], + "scale shorter dimension": [ + "shorter_size" + ], + "scale width": [ + "width" + ], + "scale height": [ + "height" + ], + "scale total pixels": [ + "megapixels" + ], + "match size": [ + "crop", + "match" + ], + "scale to multiple": [ + "multiple" + ] + } + }, + "scale_method": { + "type": "COMBO", + "choices": [ + "nearest-exact", + "bilinear", + "area", + "bicubic", + "lanczos" + ] + } + } + }, + "SAM3_Detect": { + "inputs": { + "model": { + "type": "MODEL" + }, + "image": { + "type": "IMAGE" + }, + "threshold": { + "type": "FLOAT" + }, + "refine_iterations": { + "type": "INT" + }, + "individual_masks": { + "type": "BOOLEAN" + }, + "conditioning": { + "type": "CONDITIONING" + }, + "bboxes": { + "type": "BOUNDING_BOX" + }, + "positive_coords": { + "type": "STRING" + }, + "negative_coords": { + "type": "STRING" + } + } + }, + "SamplerCustomAdvanced": { + "inputs": { + "noise": { + "type": "NOISE" + }, + "guider": { + "type": "GUIDER" + }, + "sampler": { + "type": "SAMPLER" + }, + "sigmas": { + "type": "SIGMAS" + }, + "latent_image": { + "type": "LATENT" + } + } + }, + "SaveImage": { + "inputs": { + "images": { + "type": "IMAGE" + }, + "filename_prefix": { + "type": "STRING" + } + } + }, + "Scribble_PiDiNet_Preprocessor": { + "inputs": { + "image": { + "type": "IMAGE" + }, + "safe": { + "type": "COMBO", + "choices": [ + "enable", + "disable" + ] + }, + "resolution": { + "type": "INT" + } + } + }, + "SeedVR2Conditioning": { + "inputs": { + "model": { + "type": "MODEL" + }, + "vae_conditioning": { + "type": "LATENT" + } + } + }, + "SeedVR2PostProcessing": { + "inputs": { + "images": { + "type": "IMAGE" + }, + "original_resized_images": { + "type": "IMAGE" + }, + "color_correction_method": { + "type": "COMBO", + "choices": [ + "lab", + "wavelet", + "adain", + "none" + ] + } + } + }, + "SeedVR2Preprocess": { + "inputs": { + "resized_images": { + "type": "IMAGE" + } + } + }, + "SetLatentNoiseMask": { + "inputs": { + "samples": { + "type": "LATENT" + }, + "mask": { + "type": "MASK" + } + } + }, + "SplitImageWithAlpha": { + "inputs": { + "image": { + "type": "IMAGE" + } + } + }, + "StringConstantMultiline": { + "inputs": { + "string": { + "type": "STRING" + }, + "strip_newlines": { + "type": "BOOLEAN" + } + } + }, + "Superprompt": { + "inputs": { + "instruction_prompt": { + "type": "STRING" + }, + "prompt": { + "type": "STRING" + }, + "max_new_tokens": { + "type": "INT" + } + } + }, + "TextEncodeQwenImage21": { + "inputs": { + "clip": { + "type": "CLIP" + }, + "prompt": { + "type": "STRING" + }, + "negative_prompt": { + "type": "STRING" + }, + "resolution": { + "type": "INT" + }, + "images": { + "type": "COMFY_AUTOGROW_V3", + "names": [ + "image_1", + "image_2", + "image_3", + "image_4", + "image_5", + "image_6", + "image_7", + "image_8", + "image_9", + "image_10", + "image_11", + "image_12", + "image_13", + "image_14", + "image_15", + "image_16" + ] + }, + "vae": { + "type": "VAE" + } + } + }, + "TextEncodeQwenImageEditPlus": { + "inputs": { + "clip": { + "type": "CLIP" + }, + "prompt": { + "type": "STRING" + }, + "vae": { + "type": "VAE" + }, + "image1": { + "type": "IMAGE" + }, + "image2": { + "type": "IMAGE" + }, + "image3": { + "type": "IMAGE" + } + } + }, + "UNETLoader": { + "inputs": { + "unet_name": { + "type": "COMBO" + }, + "weight_dtype": { + "type": "COMBO", + "choices": [ + "default", + "fp8_e4m3fn", + "fp8_e4m3fn_fast", + "fp8_e5m2" + ] + } + } + }, + "UnetLoaderGGUF": { + "inputs": { + "unet_name": { + "type": "COMBO" + } + } + }, + "UpscaleModelLoader": { + "inputs": { + "model_name": { + "type": "COMBO" + } + } + }, + "VAEDecode": { + "inputs": { + "samples": { + "type": "LATENT" + }, + "vae": { + "type": "VAE" + } + } + }, + "VAEDecodeTiled": { + "inputs": { + "samples": { + "type": "LATENT" + }, + "vae": { + "type": "VAE" + }, + "tile_size": { + "type": "INT" + }, + "overlap": { + "type": "INT" + }, + "temporal_size": { + "type": "INT" + }, + "temporal_overlap": { + "type": "INT" + } + } + }, + "VAEEncode": { + "inputs": { + "pixels": { + "type": "IMAGE" + }, + "vae": { + "type": "VAE" + } + } + }, + "VAEEncodeTiled": { + "inputs": { + "pixels": { + "type": "IMAGE" + }, + "vae": { + "type": "VAE" + }, + "tile_size": { + "type": "INT" + }, + "overlap": { + "type": "INT" + }, + "temporal_size": { + "type": "INT" + }, + "temporal_overlap": { + "type": "INT" + } + } + }, + "VAELoader": { + "inputs": { + "vae_name": { + "type": "COMBO" + } + } + }, + "ZImageFunControlnet": { + "inputs": { + "model": { + "type": "MODEL" + }, + "model_patch": { + "type": "MODEL_PATCH" + }, + "vae": { + "type": "VAE" + }, + "strength": { + "type": "FLOAT" + }, + "image": { + "type": "IMAGE" + }, + "inpaint_image": { + "type": "IMAGE" + }, + "mask": { + "type": "MASK" + }, + "start_percent": { + "type": "FLOAT" + }, + "end_percent": { + "type": "FLOAT" + } + } + } + } +} From 0cae735d9358ee0209cdc98d7b0e3f0acb1e8364 Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Wed, 4 Nov 2026 23:59:59 +0330 Subject: [PATCH 2/2] Let Workflow Health see optional node inputs Run against ComfyUI 0.44, Workflow Health marked 13 working presets "Missing ComfyUI node": every crop-and-stitch Inpaint preset (InpaintCropImproved's mask), both Remove object presets (LaMaInpaint's lama_model), Select by Text (SAM3_Detect's conditioning), the Klein and Qwen edit presets (ReferenceLatent's latent and others), Layer Maps, Camera Angle, Unflatten and Multi-Reference. Each input it named is there; ComfyUI lists it as optional, and the node list Health and Setup read kept only required inputs. Generate itself was never affected: validatePresetSetup already reads both. getWorkflowNodeAvailability, which Health and Setup use, now records every declared input. The custom workflow check keeps required-only, because it asks the other question: whether a pasted graph supplies every input ComfyUI requires. On the test machine Health now reads 28 ready, 10 experimental and one model in the wrong folder, instead of 21, 4 and 14. Co-Authored-By: Claude Opus 5.5 --- docs/beta-gate.md | 2 +- src/comfy/comfyClient.ts | 31 +++++++++++++--- src/comfy/workflowCompatibility.ts | 5 +++ tests/comfy/nodeAvailability.test.ts | 55 ++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 tests/comfy/nodeAvailability.test.ts diff --git a/docs/beta-gate.md b/docs/beta-gate.md index 6f6b2b8..7f2e441 100644 --- a/docs/beta-gate.md +++ b/docs/beta-gate.md @@ -15,7 +15,7 @@ the same change. | # | Gate | How it is checked | Status | |---|---|---|---| -| G1 | **Runs on a current ComfyUI.** Every runnable preset's nodes and inputs match a recorded `/object_info` from the newest ComfyUI release, and a test fails when they drift. | Roadmap U2: a snapshot in the repo, a test of every preset's `requiredNodes` against it, and a script that diffs a live ComfyUI against the snapshot. Its output against a current ComfyUI is pasted in the PR. | **Open.** Every preset has been checked only against ComfyUI 0.37.0, the version on the test machine. ComfyUI 0.40 was out by 2026-10-13. | +| G1 | **Runs on a current ComfyUI.** Every runnable preset's nodes and inputs match a recorded `/object_info` from the newest ComfyUI release, and a test fails when they drift. | Roadmap U2: a snapshot in the repo, a test of every preset's `requiredNodes` against it, and a script that diffs a live ComfyUI against the snapshot. Its output against a current ComfyUI is pasted in the PR. | **Done in v0.41.** `tests/fixtures/comfyui-object-info.json` is recorded from ComfyUI 0.44.0 (80 node classes) by `npm run object-info`, and `tests/comfy/objectInfoSnapshot.test.ts` checks every node, input, nested input and fixed choice of all 39 presets against it. All match. Running Workflow Health against 0.44 found a real fault: it read only required inputs and marked 13 working presets "Missing ComfyUI node"; fixed, it now reports 28 ready, 10 experimental and 1 model in the wrong folder on the test machine. `npm run object-info -- --check` against a fresh Comfy Desktop 0.38.1 lists only the custom node packs it has not installed. | | G2 | **Finds Comfy Desktop.** The port scan includes Comfy Desktop's default port. | A test on `COMFY_PORT_CANDIDATES`, plus Settings › Find ComfyUI Active Port against a ComfyUI started on that port. | **Done in v0.41.** 8000 is scanned second, right after 8188, with a test. Not yet run against a real Comfy Desktop: on the test machine a ComfyUI on 8188 always answers first. A clean-install tester (G6) using Comfy Desktop checks it for real. | | G3 | **Says so when there is no GPU.** When ComfyUI reports only a CPU device, the panel says which tools will be too slow to use, rather than failing or hanging. | A unit test with a CPU-only `/system_stats` fixture, plus one run against ComfyUI started with `--cpu`. | **Done in v0.41.** Check ComfyUI says "ComfyUI is online, but on CPU only." with the reason underneath, and Detect Hardware reports "Processor only, no graphics card", VRAM none, tier No GPU. A CPU device no longer counts its system RAM as VRAM. Checked in Photoshop 2025 on 2026-11-04 against a real Comfy Desktop instance started with `--cpu` (ComfyUI 0.38.1, port 8189), and against a GPU ComfyUI 0.44.0 on 8188, which still reports normally. | | G4 | **Passes the full smoke checklist on the release build.** All blocks of [the UXP smoke checklist](uxp-smoke-checklist.md) on the build that will be tagged, with a row in its Results table. | The Results table. | **Open.** The only recorded full run is from v0.37.1. v0.38 to v0.40 were checked per feature, in their own PRs. | diff --git a/src/comfy/comfyClient.ts b/src/comfy/comfyClient.ts index 3356d08..4cddc86 100644 --- a/src/comfy/comfyClient.ts +++ b/src/comfy/comfyClient.ts @@ -256,6 +256,15 @@ export class ComfyClient { return inventory; } + /** + * For Workflow Health and Setup: every input each preset node *declares*, + * required and optional, because the question is whether the input OpenLayer + * wires exists -- the same reading validatePresetSetup uses before Generate. + * Reading required inputs only marked 13 working presets "Missing ComfyUI + * node" on ComfyUI 0.44, among them every crop-and-stitch Inpaint preset + * (InpaintCropImproved's mask), both Remove object presets (LaMaInpaint's + * lama_model) and Select by Text (SAM3_Detect's conditioning). Beta gate G1. + */ async getWorkflowNodeAvailability( presets: readonly WorkflowPresetDefinition[] ): Promise { @@ -267,23 +276,29 @@ export class ComfyClient { } } - return this.getNodeAvailability([...nodeClasses]); + return this.getNodeAvailability([...nodeClasses], "declared"); } /** * The same question for an arbitrary list of class types, so a workflow * OpenLayer did not author can be checked against this server. * - * Records only the inputs ComfyUI marks *required*. A caller that treats the - * absence of an optional input as a fault repeats the mistake documented on - * the Klein edit preset, where it reported a working graph as broken. + * By default records only the inputs ComfyUI marks *required*: the custom + * workflow check asks whether a graph supplies every one of those, and an + * optional input left out is not a fault there. "declared" records required + * and optional together, for a caller asking whether an input exists at all + * (see getWorkflowNodeAvailability). Mixing the two up is how Workflow + * Health came to report working presets as broken. * * A class that does not exist is left out rather than recorded as empty. * That matters more than it looks: `GET /object_info/` answers 200 * with an empty body for an unknown class, so presence of a *response* proves * nothing and only the presence of the schema does. */ - async getNodeAvailability(classTypes: readonly string[]): Promise { + async getNodeAvailability( + classTypes: readonly string[], + inputs: "required" | "declared" = "required" + ): Promise { const availability: WorkflowNodeAvailability = {}; for (const classType of classTypes) { @@ -292,7 +307,11 @@ export class ComfyClient { const schema = objectInfo[classType]; if (schema) { - availability[classType] = Object.keys(schema.input?.required ?? {}); + availability[classType] = Object.keys( + inputs === "declared" + ? { ...(schema.input?.required ?? {}), ...(schema.input?.optional ?? {}) } + : schema.input?.required ?? {} + ); } } catch { // Missing node classes are reported by the caller, not here. diff --git a/src/comfy/workflowCompatibility.ts b/src/comfy/workflowCompatibility.ts index 5bdd3d9..adbd480 100644 --- a/src/comfy/workflowCompatibility.ts +++ b/src/comfy/workflowCompatibility.ts @@ -47,6 +47,11 @@ export type WorkflowCompatibilityIssue = { link?: WorkflowCompatibilityLink; }; +/** + * Node class -> input names. Which inputs depends on who asked: the preset + * checks (Workflow Health, Setup) get every declared input, the custom + * workflow check gets the required ones. See ComfyClient.getNodeAvailability. + */ export type WorkflowNodeAvailability = Record; export type WorkflowPhotoshopInputAvailability = Partial>; diff --git a/tests/comfy/nodeAvailability.test.ts b/tests/comfy/nodeAvailability.test.ts new file mode 100644 index 0000000..81737f4 --- /dev/null +++ b/tests/comfy/nodeAvailability.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ComfyClient } from "../../src/comfy/comfyClient"; +import { getWorkflowPreset } from "../../src/comfy/presetRegistry"; +import { createWorkflowHealthItem } from "../../src/comfy/workflowHealth"; + +/** + * Workflow Health asked ComfyUI which inputs each preset node has, but kept + * only the required ones. On ComfyUI 0.44, 13 working presets were marked + * "Missing ComfyUI node" because the input they wire is optional there: + * InpaintCropImproved's mask, LaMaInpaint's lama_model, SAM3_Detect's + * conditioning, ReferenceLatent's latent and others. Beta gate G1. + */ +describe("node availability", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + // InpaintCropImproved as ComfyUI 0.44 reports it: `mask` is optional. + function stubInpaintCrop() { + vi.stubGlobal("fetch", async (url: string) => { + const classType = decodeURIComponent(String(url).split("/object_info/")[1] ?? ""); + const body = + classType === "InpaintCropImproved" + ? { InpaintCropImproved: { input: { required: { image: ["IMAGE"] }, optional: { mask: ["MASK"] } } } } + : {}; + return new Response(JSON.stringify(body), { status: 200 }); + }); + } + + it("gives the preset checks every declared input, optional ones included", async () => { + stubInpaintCrop(); + const nodes = await new ComfyClient("http://127.0.0.1:8188").getWorkflowNodeAvailability([ + getWorkflowPreset("inpaint-flux2-klein") + ]); + + expect(nodes.InpaintCropImproved).toEqual(["image", "mask"]); + }); + + it("keeps the custom workflow check on required inputs only", async () => { + // A pasted graph that leaves out an optional input is not broken. + stubInpaintCrop(); + const nodes = await new ComfyClient("http://127.0.0.1:8188").getNodeAvailability(["InpaintCropImproved"]); + + expect(nodes.InpaintCropImproved).toEqual(["image"]); + }); + + it("no longer reports a preset as missing a node when its input is optional", () => { + const preset = getWorkflowPreset("inpaint-flux2-klein"); + const crop = preset.requiredNodes.find((node) => node.classType === "InpaintCropImproved"); + const availableNodes = Object.fromEntries(preset.requiredNodes.map((node) => [node.classType, node.requiredInputs])); + + expect(crop?.requiredInputs).toContain("mask"); + expect(createWorkflowHealthItem(preset, { availableNodes }).state).not.toBe("missing-node"); + }); +});