UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

658 lines • 36.2 kB
// Model-file extensions — kept in sync with workflow-validator.ts's isModel test. const MODEL_FILE_RE = /\.(safetensors|gguf|ckpt|pt|pth|bin|sft)$/i; // A latent produced from nothing — EmptyLatentImage, EmptySD3LatentImage, // EmptyHunyuanLatentVideo, EmptyLTXVLatentVideo, EmptyLatentAudio, and the // custom EmptyLatentImagePresets family. Name-matching alone is not enough to // call a node's output empty, so callers must ALSO require that the node consumes // no links — see `producesEmptyLatent`. const EMPTY_LATENT_RE = /^Empty[A-Za-z0-9_]*Latent/i; // ComfyUI's OWN full-denoise gate, comfy/samplers.py KSampler.set_steps. const FULL_DENOISE_THRESHOLD = 0.9999; /** * Does ComfyUI actually start this sampler below sigma_max? * * This is `KSampler.set_steps` (comfy/samplers.py) transcribed, NOT a proxy for it: * * if denoise is None or denoise > 0.9999: self.sigmas = calculate_sigmas(steps) * elif denoise <= 0.0: self.sigmas = FloatTensor([]) * else: sigmas = calculate_sigmas(int(steps / denoise)); self.sigmas = sigmas[-(steps + 1):] * * The last branch only DROPS leading entries when `int(steps / denoise) > steps`. * When it doesn't, the slice keeps the whole schedule, `sigmas[0]` is still * sigma_max, and an empty latent is exactly correct. That makes the threshold * steps-dependent, and a bare `denoise <= 0.9999` test is WRONG: at steps=50, * denoise=0.9999 gives int(50.005) = 50, i.e. the full schedule. Same for * steps=4 at denoise=0.9 (int(4.44) = 4). Flagging those would be a false alarm * on an ordinary txt2img graph. * * Returns true only for the two shapes that really are degenerate over zeros: * a truncated schedule, and the empty schedule at denoise <= 0 (zero sampling * steps, so the sampler hands back the latent it was given, untouched). */ function startsBelowSigmaMax(denoise, steps) { if (denoise <= 0) return true; if (denoise > FULL_DENOISE_THRESHOLD) return false; // `steps` decides the rest, so without a literal value there is no claim to make. if (typeof steps !== "number" || !Number.isFinite(steps) || steps <= 0) return false; // Python's int() truncates toward zero; both operands are positive here. return Math.trunc(steps / denoise) > steps; } // Scalar widget feeds. A link on one of these carries a NUMBER or a string into a // dimension/seed slot; it cannot put picture content into the generated latent. const SCALAR_INPUT_TYPES = new Set(["INT", "FLOAT", "STRING", "BOOLEAN"]); /** * Is this node's LATENT output empty — all zeros? * * The class-name test alone is not enough: an `EmptyLatentFromReference`-style * custom node derives its latent from an input and is not empty at all. But * "consumes no links" is too strict in the other direction, and would drop the * very common `PrimitiveInt → EmptyLatentImage.width` shape, whose output is * still zeros. * * So the question asked is specifically whether any CONNECTED input can carry * content, decided from the node's real `/object_info` slot types rather than * from its name. A width/height/batch_size feed is a scalar and changes only the * shape of the zeros; an IMAGE/LATENT/anything-else feed can carry a picture. * * A node missing from object_info, or a connected slot missing from its definition, * is NOT called empty — whether it is, is unknowable. That direction is the safe one: * it costs a warning we might have raised, never a false one. */ function producesEmptyLatent(node, objectInfo) { if (!node || !EMPTY_LATENT_RE.test(node.class_type)) return false; // An uninstalled node has no semantics here — its NAME is the only evidence, and a // name is not a fact. A custom `EmptyLatent…` could synthesise content from literal // configuration and never take a link at all, so a link-free unknown node must not // fall through to "empty". Nothing is lost by staying quiet: the validator already // reports an unknown class as a missing_node_type ERROR. const def = objectInfo[node.class_type]; if (!def) return false; const slots = { ...(def.input?.required ?? {}), ...(def.input?.optional ?? {}) }; for (const [name, value] of Object.entries(node.inputs ?? {})) { if (!isConnection(value)) continue; const declared = slots[name]?.[0]; if (declared === undefined) return false; // unknown slot — decline to claim empty if (Array.isArray(declared)) continue; // a combo (dropdown) is a value, not content if (SCALAR_INPUT_TYPES.has(declared)) continue; // INT/FLOAT/… — shape, not content return false; // IMAGE / LATENT / … — this latent may be derived } return true; } // --- Image-edit reference geometry --------------------------------------- // // ComfyUI's Qwen edit text encoders carry the source image on CONDITIONING as // `reference_latents`, at a HARD-CODED budget (comfy_extras/nodes_qwen.py, both // TextEncodeQwenImageEdit and …Plus): // // total = int(1024 * 1024) // scale_by = math.sqrt(total / (w * h)) // width = round(w * scale_by / 8.0) * 8 # height likewise // ref_latents.append(vae.encode(...)) // // so the reference is always ~1.05 MP at the SOURCE image's aspect ratio — a // geometry computed at run time from pixels a static check cannot see. // // The transformer then places the reference tokens and the sampled tokens on the // SAME centred RoPE grid (comfy/ldm/qwen_image/model.py `process_img`, called once // for `x` and once per ref): both get // `linspace(offset, len - 1 + offset) - (len // 2)` on the h and w axes, differing // only in the `index` (t) channel. Reference token (i, j) and output token (i, j) // therefore name the same coordinate ONLY when the two grids have the same shape. // // An `Empty*Latent*` node's dimensions are literals, so they can agree with a // run-time-derived reference only by coincidence. #2681 sampled a 1104x1472 canvas // (1.63 MP) against that 1.05 MP reference — 1.24x linear. Unlike #2678 this does // not depend on denoise: at denoise 1.0 the graph renders a plausible image, it just // re-synthesises the subject instead of copying it. // // Measured on the 533 workflow templates ComfyUI bundles // (comfyui_workflow_templates_json): 33 samplers take `positive` from a node that // sets reference_latents, 31 of them take `latent_image` from a `VAEEncode`, and NONE // takes it from an empty latent. The remaining 2 are the Qwen-Image-Layered templates, // whose `EmptyQwenImageLayeredLatentImage` genuinely is a different tensor rank from // its reference — they reach reference_latents through a generic `ReferenceLatent` // fed by a `CLIPTextEncode`, never through a Qwen edit encoder. That measurement is // why this rule keys on the two Qwen edit ENCODERS and not on `ReferenceLatent`: // including `ReferenceLatent` scores 2 false positives on the same corpus, and its // reference geometry is whatever latent it is handed, so the equal-shape invariant // is not universal for it. const QWEN_EDIT_ENCODERS = new Set(["TextEncodeQwenImageEdit", "TextEncodeQwenImageEditPlus"]); // `total = int(1024 * 1024)` in both encoders' execute(). const QWEN_EDIT_REFERENCE_PIXELS = 1024 * 1024; // `image` on TextEncodeQwenImageEdit, `image1`..`image3` on …Plus. The name alone is // not enough — a `TextEncodeQwenImageEdit` carrying an `image1` key is malformed, and // accepting it would classify a node as emitting references over a slot its own class // does not have. So the slot must ALSO be declared IMAGE in object_info. const EDIT_IMAGE_SLOT_RE = /^image[0-9]*$/; // Bound on the conditioning walk. Chains are a handful of nodes long in practice; // this only stops a pathological graph from costing real time. const CONDITIONING_WALK_LIMIT = 64; /** * Does this node actually append `reference_latents`? * * Both guards transcribe the encoder's own control flow, and both are * false-positive guards rather than defensive noise: * - `if vae is not None` — with no VAE the node never calls `vae.encode` and * appends nothing, so it is a plain (if VL-conditioned) text encoder and an * empty latent under it is ordinary txt2img. * - `if image is not None` — same, with no image there is no reference at all. */ function emitsEditReferenceLatents(node, objectInfo) { if (!node || !QWEN_EDIT_ENCODERS.has(node.class_type)) return false; // `vae` is the first half of ComfyUI's own condition, transcribed: with nothing wired // there the encoder never calls `vae.encode` and appends nothing. if (!isConnection(node.inputs?.vae)) return false; // The image slot is the second half, and its TYPE is checked as well as its name: a // key the class does not declare cannot stand in for one it does (a // `TextEncodeQwenImageEdit` carrying `image1` is malformed, not an edit graph). That // one test also settles the unknown-class case without a separate branch — a class // absent from object_info has no declared slots, so nothing is typed IMAGE and the // answer is "decline", the same direction `producesEmptyLatent` takes. Guards that // only restate this were removed after a mutation run showed no test could tell them // apart from their absence. const def = objectInfo[node.class_type]; const slots = { ...(def?.input?.required ?? {}), ...(def?.input?.optional ?? {}) }; return Object.entries(node.inputs ?? {}).some(([slot, value]) => EDIT_IMAGE_SLOT_RE.test(slot) && isConnection(value) && slots[slot]?.[0] === "IMAGE"); } /** * Walk a sampler's `positive` input upstream along CONDITIONING links, looking for * an edit encoder that really emits reference latents. Returns its node id, or null. * * KNOWN LIMIT (raised in review): the walk assumes a node that passes CONDITIONING * through also passes `reference_latents` through, and a transform that rebuilt the * entry from a fresh dict would break that — rule 7 would report a reference the sampler * never receives, and would suppress rule 6 while doing it. Measured against the * installed ComfyUI: the only conditioning entries built from a fresh `{}` are in * nodes_hunyuan3d.py and nodes_lotus.py, and neither node takes a CONDITIONING input, so * neither can sit mid-chain. Every stock transform (`ConditioningZeroOut`, * `conditioning_set_values`, …) does `t[1].copy()` and preserves the key. A custom node * could still do it; if one ever shows up, this is where to key off it. * * Only CONDITIONING-typed slots are followed, decided from real `/object_info` types * rather than slot names — so a `ControlNetApply`'s `image` input is not mistaken for * part of the conditioning chain. Three shapes end the walk, all in the same direction: * * - a node absent from object_info (an uninstalled class has no known slot types) * - any connected wildcard `*` slot, such as a Reroute's — it could be carrying * anything, including the conditioning that actually reaches the output, so it * counts as a fork arm even when a typed conditioning input sits beside it * - a node with MORE THAN ONE connected CONDITIONING input. That is a fork, not a * link in a chain, and which branch reaches the output is not knowable statically: * a switch/mux passes one through and discards the rest, `ConditioningAverage` * carries `reference_latents` from `conditioning_to` only, and * `ControlNetApplyAdvanced` takes a positive and a negative and emits both on * separate slots. Walking every branch would report a reference the sampler never * receives — and, worse, would suppress rule 6 while doing it, turning a false * positive into a LOST warning. (Raised in review; `ConditioningCombine` really * does merge both branches, so declining costs a warning there.) * * Each costs a warning that could have been raised and none can raise a false one, * which is the same direction `producesEmptyLatent` takes. These are real trades, not * oversights, and workflow-health.test.ts asserts each of them. */ function findEditReferenceEncoder(workflow, sampler, objectInfo) { const start = sampler.inputs?.positive; if (!isConnection(start)) return null; const seen = new Set(); const queue = [start[0]]; while (queue.length > 0 && seen.size < CONDITIONING_WALK_LIMIT) { const id = queue.shift(); if (seen.has(id)) continue; seen.add(id); const node = workflow[id]; if (!node) continue; if (emitsEditReferenceLatents(node, objectInfo)) return id; const def = objectInfo[node.class_type]; if (!def) continue; const slots = { ...(def.input?.required ?? {}), ...(def.input?.optional ?? {}) }; const upstream = []; let wildcardInput = false; for (const [slot, value] of Object.entries(node.inputs ?? {})) { if (!isConnection(value)) continue; const declared = slots[slot]?.[0]; // A connected `*` is a fork arm of unknown type — it may well be carrying the // conditioning that actually reaches the output. Counting only the TYPED inputs // would let a node with one typed conditioning and one wildcard look like a chain. if (declared === "*") wildcardInput = true; else if (declared === "CONDITIONING") upstream.push(value[0]); } // Exactly one inbound conditioning and nothing untyped beside it is a chain and can // be followed. Anything else is a fork whose surviving branch is a run-time // decision — stop rather than guess. if (!wildcardInput && upstream.length === 1) queue.push(upstream[0]); } return null; } // Hardcoded output classes, mirroring workflow-validator.ts step 4. const OUTPUT_CLASSES = new Set([ "SaveImage", "PreviewImage", "SaveAnimatedWEBP", "SaveAnimatedPNG", ]); /** * FL-MCP-style slot-name required heuristic, used ONLY for node classes absent * from object_info (uninstalled custom nodes). Returns the set of input-slot * names we *expect* a node of this class-name family to require, so a missing * key can be flagged. Returns null for families we can't reason about (a generic * unknown node), where guessing would only produce false positives. */ function heuristicRequiredSlots(classType) { if (/Sampler/i.test(classType)) { return ["model", "positive", "negative", "latent_image"]; } if (/VAE/i.test(classType)) { // Decode wants samples+vae; encode wants pixels/images+vae. `vae` is the // one slot common to the whole family, so that's all we assert. return ["vae"]; } // Loader / Load families are model-file consumers whose required input is a // widget (a *name*/*path*/*ckpt* value), not a link — handled separately below. return null; } /** Is a value an API-format connection tuple `[nodeId, outputIndex]`? */ function isConnection(value) { return (Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && typeof value[1] === "number"); } function isOutputNode(classType, objectInfo) { return OUTPUT_CLASSES.has(classType) || objectInfo[classType]?.output_node === true; } /** * Analyze a workflow's structural health. Pure — no I/O, no fetch. * * @param workflow API-format workflow JSON. * @param objectInfo Node catalog from `/object_info` (authoritative required/optional). */ export function analyzeGraphHealth(workflow, objectInfo) { const nodeIds = Object.keys(workflow); const findings = []; // --- Node-type histogram ------------------------------------------------- const histogram = {}; for (const id of nodeIds) { const ct = workflow[id].class_type; histogram[ct] = (histogram[ct] ?? 0) + 1; } // --- Adjacency (forward: source -> [targets]) ---------------------------- const consumers = new Map(); // node -> nodes that read its output const sources = new Map(); // node -> nodes it reads from for (const id of nodeIds) { consumers.set(id, new Set()); sources.set(id, new Set()); } for (const id of nodeIds) { for (const value of Object.values(workflow[id].inputs)) { if (isConnection(value)) { const [srcId] = value; if (workflow[srcId] && srcId !== id) { sources.get(id).add(srcId); consumers.get(srcId).add(id); } } } } // --- 1. Disconnected nodes (warning) ------------------------------------- // No inbound links, no consumers, and not itself an output node. for (const id of nodeIds) { const ct = workflow[id].class_type; if (sources.get(id).size === 0 && consumers.get(id).size === 0 && !isOutputNode(ct, objectInfo)) { findings.push({ kind: "disconnected", severity: "warning", node_ids: [id], node_type: ct, detail: `Node ${id} (${ct}) has no connections — it is isolated and does nothing.`, }); } } // --- 2. Missing required inputs ------------------------------------------ for (const id of nodeIds) { const node = workflow[id]; const ct = node.class_type; const def = objectInfo[ct]; if (def) { // Authoritative: object_info required list. const required = def.input?.required ?? {}; for (const inputName of Object.keys(required)) { if (!(inputName in node.inputs)) { findings.push({ kind: "missing_required_input", severity: "warning", node_ids: [id], node_type: ct, detail: `Node ${id} (${ct}) is missing required input "${inputName}".`, }); } } } else { // Fallback heuristic — only for classes absent from object_info. const expected = heuristicRequiredSlots(ct); if (expected) { for (const inputName of expected) { if (!(inputName in node.inputs)) { findings.push({ kind: "missing_required_input", severity: "warning", node_ids: [id], node_type: ct, detail: `Node ${id} (${ct}) appears to be missing required input "${inputName}" (heuristic — "${ct}" is not installed here).`, heuristic: true, }); } } } } } // --- 3. Duplicate model loads (warning) ---------------------------------- // Group Loader/Load-family nodes by (class_type, model-file value). NUL can't // appear in a class_type or filename, so it is a collision-proof separator. const DUP_SEP = String.fromCharCode(0); const loadGroups = new Map(); for (const id of nodeIds) { const ct = workflow[id].class_type; if (!/Loader|Load/i.test(ct)) continue; for (const value of Object.values(workflow[id].inputs)) { if (typeof value === "string" && MODEL_FILE_RE.test(value)) { const key = `${ct}${DUP_SEP}${value}`; const arr = loadGroups.get(key) ?? []; arr.push(id); loadGroups.set(key, arr); } } } for (const [key, ids] of loadGroups) { if (ids.length < 2) continue; const [ct, file] = key.split(DUP_SEP); findings.push({ kind: "duplicate_model_load", severity: "warning", node_ids: ids, node_type: ct, detail: `${ct} loads "${file}" in nodes ${ids.join(", ")} — merge into one loader to save VRAM.`, }); } // --- 4. Orphaned branches (warning) -------------------------------------- // Reverse-BFS from every output node; unreached non-output nodes are computed // but never saved. Report one finding per connected component of the unreached // set (over the undirected graph) to avoid many line items on big graphs. const outputNodes = nodeIds.filter((id) => isOutputNode(workflow[id].class_type, objectInfo)); if (nodeIds.length > 0 && outputNodes.length === 0) { // No output node at all → ComfyUI rejects the prompt outright with "Prompt // has no outputs" (field: small models hand-build a graph and forget the // sink). Surface it as the top finding so validate/analyze catches it BEFORE // a failed run, and the agent can add the missing node instead of retrying. findings.push({ kind: "no_output_reachable", severity: "warning", node_ids: [], detail: "No output node — the workflow will FAIL to run (ComfyUI: \"Prompt has no outputs\"). " + "Add a terminal SaveImage (or PreviewImage / SaveVideo / SaveAudio) node and connect the final IMAGE/LATENT/etc. into it before running.", }); } if (outputNodes.length > 0) { const reachable = new Set(); const queue = [...outputNodes]; while (queue.length > 0) { const cur = queue.shift(); if (reachable.has(cur)) continue; reachable.add(cur); for (const src of sources.get(cur)) { if (!reachable.has(src)) queue.push(src); } } const unreached = nodeIds.filter((id) => !reachable.has(id) && !isOutputNode(workflow[id].class_type, objectInfo)); // Exclude fully-disconnected nodes already reported in check 1 — an isolated // node is its own story, not an orphaned branch. const unreachedSet = new Set(unreached.filter((id) => sources.get(id).size > 0 || consumers.get(id).size > 0)); // Undirected connected components within the unreached set. const visited = new Set(); for (const start of unreachedSet) { if (visited.has(start)) continue; const component = []; const stack = [start]; while (stack.length > 0) { const cur = stack.pop(); if (visited.has(cur)) continue; visited.add(cur); component.push(cur); for (const nb of [...sources.get(cur), ...consumers.get(cur)]) { if (unreachedSet.has(nb) && !visited.has(nb)) stack.push(nb); } } component.sort((a, b) => a.localeCompare(b)); const types = component.map((id) => workflow[id].class_type); findings.push({ kind: "orphaned_branch", severity: "warning", node_ids: component, detail: `Orphaned branch: nodes ${component.join(", ")} (${types.join(" → ")}) never reach a save/preview node.`, }); } } // --- 5. Muted / bypassed nodes (info) ------------------------------------ // Surfaced via _meta.mode where the raw API JSON carries it. convertUiToApi // *drops* mode-2/4 nodes, so for library workflows this is usually absent and // silently skipped; raw API JSON handed to create_workflow (action:"validate") is checked here. for (const id of nodeIds) { const mode = workflow[id]._meta?.mode; if (mode === "muted" || mode === "bypassed") { const ct = workflow[id].class_type; findings.push({ kind: "muted_or_bypassed", severity: "info", node_ids: [id], node_type: ct, detail: `Node ${id} (${ct}) is ${mode} (_meta.mode) — its connections are silently dropped.`, }); } } // --- Empty latent under an image-edit reference (shared by rules 6 and 7) -- // Computed before rule 6 so that rule 6 can stand down where rule 7 speaks: both // findings would name the same sampler and prescribe the same fix, but rule 6 also // offers "to generate from scratch, set denoise to 1.0", which for an edit graph is // the wrong half of the advice — #2681 is exactly that graph at denoise 1.0. const editReferenceSamplers = new Map(); for (const id of nodeIds) { const node = workflow[id]; const latentInput = node.inputs?.latent_image; if (!isConnection(latentInput)) continue; if (!producesEmptyLatent(workflow[latentInput[0]], objectInfo)) continue; const encoderId = findEditReferenceEncoder(workflow, node, objectInfo); if (encoderId === null) continue; editReferenceSamplers.set(id, { encoderId, emptyId: latentInput[0] }); } // --- 6. Partial denoise fed by an empty latent (warning) ----------------- // A denoise low enough to truncate the schedule exists to PRESERVE part of the // incoming latent. If that latent is generated empty there is nothing to preserve: // the graph validates, executes without error, and cannot reproduce the source // image (#2678). Note this is specifically about the LATENT channel — a Qwen/Flux // edit encoder really does carry reference images on CONDITIONING, but conditioning // steers the denoising trajectory and never seeds its starting state, which is // built from `noise` and `latent_image` alone. // // From ComfyUI's own source, samplers.py KSAMPLER.sample: // noise = model_sampling.noise_scaling(sigmas[0], noise, latent_image, max_denoise) // and model_sampling.py: // CONST.noise_scaling -> sigma * noise + (1.0 - sigma) * latent_image (flow matching) // EPS.noise_scaling -> noise * sigma + latent_image (SD1.x/SDXL) // Either way `latent_image` is the ONLY channel carrying source content, and it is // zeros. The severity of the visible result differs by family and the message says // so rather than overclaiming: on flow matching (Qwen, Flux, SD3, WAN) the source // term is `(1 - sigma) * 0`, so the model's target collapses to the empty latent and // the output is a flat, near-uniform field — the reported symptom. On EPS the start // state `sigma * noise` is still a legitimate noisy latent, so an image does appear; // it simply has nothing to do with any source image. // // Scope is deliberately narrow in three ways, each of which is a false-positive // guard rather than an oversight: // - DIRECT connection only. A hires-fix chain (KSampler -> LatentUpscale -> // KSampler denoise 0.5) carries real content and must stay silent; tracing // through arbitrary latent ops to decide whether content survives would trade a // zero-false-positive check for a guess. // - LITERAL widget values only. A `denoise`/`steps` converted to an input is a // connection whose runtime value cannot be known statically. // - The schedule test is ComfyUI's own arithmetic, not a rounded threshold. for (const id of nodeIds) { if (editReferenceSamplers.has(id)) continue; // rule 7 says it, and says more const node = workflow[id]; const denoise = node.inputs?.denoise; if (typeof denoise !== "number" || !Number.isFinite(denoise)) continue; if (!startsBelowSigmaMax(denoise, node.inputs?.steps)) continue; const latentInput = node.inputs?.latent_image; if (!isConnection(latentInput)) continue; const sourceId = latentInput[0]; const sourceNode = workflow[sourceId]; if (!producesEmptyLatent(sourceNode, objectInfo)) continue; const sourceType = sourceNode.class_type; const steps = node.inputs?.steps; const schedule = typeof steps === "number" && denoise > 0 ? ` — ComfyUI builds int(${steps}/${denoise})=${Math.trunc(steps / denoise)} sigmas and keeps the last ${steps + 1}, so sampling starts BELOW sigma_max` : ""; findings.push({ kind: "partial_denoise_empty_latent", severity: "warning", node_ids: [id, sourceId], node_type: node.class_type, detail: `Node ${id} (${node.class_type}) runs denoise=${denoise}` + (typeof steps === "number" ? `, steps=${steps}` : "") + ` on a latent taken straight from node ${sourceId} (${sourceType}), which generates an ` + `EMPTY latent${schedule}. Starting below sigma_max is what PRESERVES part of the incoming ` + `latent — and an empty latent has nothing to preserve. Reference images carried on ` + `CONDITIONING (Qwen/Flux edit encoders) do not fill this in: latent_image is the only ` + `channel that seeds the sampler's starting state, and it is zeros. The graph runs without ` + `error; on flow-matching models (Qwen, Flux, SD3, WAN) the output collapses to a flat, ` + `near-uniform field. To edit an existing image, feed latent_image from an encode of it ` + `(VAEEncode; VAEEncodeAudio for audio). To generate from scratch, set denoise to 1.0.`, }); } // --- 7. Empty latent under an image-edit reference (warning) ------------- // See QWEN_EDIT_ENCODERS above for the mechanism and the corpus measurement. In // short: the encoder's reference latent is built from the SOURCE at run time and // the transformer aligns it with the sampled latent on a shared centred RoPE grid, // so a canvas whose size is a literal cannot be relied on to line up with it. // // Scope matches rule 6's: DIRECT latent connection only, and the encoder must be // reached through CONDITIONING slots typed by real object_info. Unlike rule 6 there // is no denoise condition — this one fires at denoise 1.0, which is the whole point. for (const [id, { encoderId, emptyId }] of editReferenceSamplers) { const node = workflow[id]; const emptyNode = workflow[emptyId]; const encoderType = workflow[encoderId].class_type; const width = emptyNode.inputs?.width; const height = emptyNode.inputs?.height; const area = typeof width === "number" && typeof height === "number" && width > 0 && height > 0 ? width * height : null; let geometry = ""; if (area !== null) { const ratio = area / QWEN_EDIT_REFERENCE_PIXELS; // Deliberately NOT phrased as proof. The encoder rounds each reference dimension // to a multiple of 8, so an extreme aspect ratio can move the reference's area a // few percent off the budget; an area gap is strong evidence, not a theorem. The // claim that always holds — a literal cannot track a run-time geometry — is in the // main sentence, and these clauses only add the arithmetic. geometry = ratio >= 0.95 && ratio <= 1.05 ? ` Its ${width}x${height} canvas does match that budget in AREA, but the reference also keeps the SOURCE image's aspect ratio, which is not knowable until the graph runs — equal area is not equal shape.` : ` Here ${width}x${height} = ${area} px is ${ratio.toFixed(2)}x that ${QWEN_EDIT_REFERENCE_PIXELS} px budget in area, ${Math.sqrt(ratio).toFixed(2)}x linear.`; } // Rule 6 stood down for this node, so say its part here rather than lose it. const denoise = node.inputs?.denoise; const steps = node.inputs?.steps; const alsoTruncated = typeof denoise === "number" && Number.isFinite(denoise) && startsBelowSigmaMax(denoise, steps); // `startsBelowSigmaMax` is true for two different reasons and they are not the same // sentence: denoise <= 0 gives an EMPTY sigma tensor (zero sampling steps, the latent // is handed straight back), while a positive denoise gives a schedule missing its // leading sigmas. Both end in a flat field over zeros; only the second is truncation. const truncation = !alsoTruncated ? "" : denoise <= 0 ? ` This sampler ALSO runs denoise=${denoise} over that empty latent, which builds an EMPTY sigma schedule — no sampling steps at all, so the empty latent is returned untouched and decodes to a flat field (#2678); feeding latent_image from the source fixes both.` : ` This sampler ALSO runs denoise=${denoise} over that empty latent, which truncates the sigma schedule and collapses the output to a flat field (#2678); feeding latent_image from the source fixes both.`; findings.push({ kind: "edit_reference_empty_latent", severity: "warning", node_ids: [id, emptyId, encoderId], node_type: node.class_type, detail: `Node ${id} (${node.class_type}) takes latent_image straight from node ${emptyId} ` + `(${emptyNode.class_type}), which generates an EMPTY latent, while its positive ` + `conditioning comes from node ${encoderId} (${encoderType}) with a VAE and a reference ` + `image connected — so that conditioning carries reference_latents. ComfyUI scales every ` + `such reference to int(1024*1024) px at the SOURCE's aspect ratio and then aligns the ` + `reference tokens with the sampled tokens on one shared, centred RoPE grid, so the model ` + `can only copy detail when the sampled latent has the reference's geometry. An ` + `Empty*Latent* node's size is a literal and the reference's is computed from the ` + `source when the graph runs, so the two line up only by coincidence.${geometry} ` + `Unlike a partial denoise this does not depend on denoise: where the grids differ the ` + `graph still runs clean at denoise 1.0 and returns a plausible image, but re-synthesises ` + `the subject instead of preserving it — materials read as plastic/CGI and printed detail ` + `comes back as generic shapes (#2681). Where they happen to coincide it is fine, which is ` + `the trap: nothing here tells you which one you got.${truncation} Feed latent_image from ` + `a VAEEncode of the same image you ` + `gave the encoder (ComfyUI's own Qwen edit templates use FluxKontextImageScale -> ` + `VAEEncode). If you deliberately want a canvas unrelated to the reference, this warning ` + `is expected.`, }); } // --- Summary ------------------------------------------------------------- const typeCount = Object.keys(histogram).length; const top = Object.entries(histogram) .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) .slice(0, 4) .map(([ct, n]) => `${ct} x${n}`) .join(", "); const warnCount = findings.filter((f) => f.severity === "warning").length; const infoCount = findings.filter((f) => f.severity === "info").length; const summary = `${nodeIds.length} nodes, ${typeCount} types` + (top ? ` (top: ${top})` : "") + ` — ${warnCount} warning(s), ${infoCount} info.`; return { total_nodes: nodeIds.length, node_type_histogram: histogram, findings, summary, }; } //# sourceMappingURL=workflow-health.js.map