diff --git a/tests/fixtures/inspection_parity/vectors.json b/tests/fixtures/inspection_parity/vectors.json index eb2d84c..18d6fb7 100644 --- a/tests/fixtures/inspection_parity/vectors.json +++ b/tests/fixtures/inspection_parity/vectors.json @@ -57,28 +57,28 @@ "candidate_count": 0, "facts": { "seed": { - "status": "known", - "value": "1000" + "status": "ambiguous", + "value": null }, "steps": { - "status": "unresolved", + "status": "ambiguous", "value": null }, "cfg": { - "status": "not-recorded", + "status": "ambiguous", "value": null }, "sampler": { - "status": "known", - "value": "euler" + "status": "ambiguous", + "value": null }, "scheduler": { - "status": "unresolved", + "status": "ambiguous", "value": null }, "model": { - "status": "known", - "value": "flux-2-klein-9b-fp8.safetensors" + "status": "ambiguous", + "value": null } } } @@ -939,16 +939,16 @@ "candidate_count": 0, "facts": { "seed": { - "status": "known", - "value": "42" + "status": "ambiguous", + "value": null }, "steps": { - "status": "known", - "value": "25" + "status": "ambiguous", + "value": null }, "cfg": { - "status": "known", - "value": "6.5" + "status": "ambiguous", + "value": null }, "sampler": { "status": "known", diff --git a/tests/js/comfy-metadata.test.js b/tests/js/comfy-metadata.test.js index 7d64f08..402b977 100644 --- a/tests/js/comfy-metadata.test.js +++ b/tests/js/comfy-metadata.test.js @@ -164,6 +164,125 @@ test("facts require model output ancestry with supported loaders and passthrough assert.equal(factsFor(numericModel).model.status, "unresolved", "model names require string evidence"); }); +// D19: a FaceDetailer re-samples the KSampler's decoded image with its own settings. +function detailerFactGraph(detailer = {}) { + const graph = factGraph({ seed: "42", steps: 25, cfg: "6.5", sampler_name: "euler", scheduler: "normal" }); + graph.decode = { class_type: "VAEDecode", inputs: { samples: ["s", 0], vae: ["model", 2] } }; + graph.d = { class_type: "FaceDetailer", inputs: { image: ["decode", 0], model: ["model", 0], seed: "7", steps: 12, cfg: "6.5", sampler_name: "euler", scheduler: "normal", denoise: "0.4", ...detailer } }; + return graph; +} +const statusValues = facts => Object.fromEntries(factFields.map(field => [field, [facts[field].status, facts[field].value]])); + +test("facts use resolution.sampler_id for detailer-stage nodes", () => { + // Resolved (the stages' prompts agree): the final stage the server named supplies every fact. + const resolved = factsFor(detailerFactGraph(), { resolution: { state: "resolved", sampler_id: "d" } }); + assert.deepEqual(statusValues(resolved), { + seed: ["known", "7"], steps: ["known", "12"], cfg: ["known", "6.5"], + sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"], + }); + assert.ok(factFields.every(field => resolved[field].sampler_id === "d" && resolved[field].evidence === "configuration")); + // The upstream KSampler is also accepted when it is the one resolved. + assert.equal(factsFor(detailerFactGraph(), { resolution: { state: "resolved", sampler_id: "s" } }).seed.value, "42"); + // Ambiguous stages: merged per field, never the detailer's or the upstream value alone. + const ambiguous = factsFor(detailerFactGraph(), { resolution: { state: "ambiguous", sampler_id: "d", reason_code: "multistage-differs" } }); + assert.deepEqual(statusValues(ambiguous), { + seed: ["ambiguous", null], steps: ["ambiguous", null], cfg: ["known", "6.5"], + sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"], + }); + assert.ok(factFields.every(field => ambiguous[field].sampler_id === null), "a merged fact names no single sampler"); + // A field one stage cannot read and the others agree on stays unresolved; not-recorded is not agreement. + const unreadable = factsFor(detailerFactGraph({ cfg: ["missing", 0] }), { resolution: { state: "ambiguous", sampler_id: "d" } }); + assert.deepEqual([unreadable.cfg.status, unreadable.cfg.value], ["unresolved", null]); + const unrecorded = detailerFactGraph(); delete unrecorded.d.inputs.scheduler; + assert.equal(factsFor(unrecorded, { resolution: { state: "ambiguous", sampler_id: "d" } }).scheduler.status, "unresolved"); + // Two detailer stages over one KSampler: three stages merge. + const chained = detailerFactGraph({ seed: "42" }); + chained.d2 = { class_type: "FaceDetailer", inputs: { image: ["d", 0], model: ["model", 0], seed: "42", steps: 25, cfg: "3", sampler_name: "euler", scheduler: "normal" } }; + assert.deepEqual(statusValues(factsFor(chained, { resolution: { state: "ambiguous", sampler_id: "d2" } })), { + seed: ["known", "42"], steps: ["ambiguous", null], cfg: ["ambiguous", null], + sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"], + }); + // Without a named sampler, two samplers stay ambiguous (today's rules, now over the samplers table). + for (const resolution of [undefined, { state: "ambiguous", sampler_id: null }, { state: "unresolved", sampler_id: "d" }]) { + assert.ok(Object.values(factsFor(detailerFactGraph(), { resolution })).every(fact => fact.status === "ambiguous"), JSON.stringify(resolution)); + } + // A sampler_id the graph or the samplers table does not hold never falls back to a guess. + for (const sampler_id of ["missing", "decode", "model", "__proto__"]) { + for (const state of ["resolved", "ambiguous"]) { + const facts = factsFor(detailerFactGraph(), { resolution: { state, sampler_id } }); + assert.ok(Object.values(facts).every(fact => fact.status === "unresolved" && fact.value === null), `${state} ${sampler_id}`); + } + } + // Stage ancestry that cannot be walked is unresolved, not a partial merge. + const cyclic = detailerFactGraph(); cyclic.decode.inputs.samples = ["d", 0]; + const unlinked = detailerFactGraph({ image: "not a link" }); + for (const graph of [cyclic, unlinked]) { + assert.ok(Object.values(factsFor(graph, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved")); + } + const wide = detailerFactGraph(); + let previous = ["s", 0]; + for (let index = 0; index < 600; index++) { wide[`hop${index}`] = { class_type: "ImageScale", inputs: { image: previous } }; previous = [`hop${index}`, 0]; } + wide.decode.inputs.samples = ["s", 0]; wide.d.inputs.image = previous; + assert.ok(Object.values(factsFor(wide, { resolution: { state: "ambiguous", sampler_id: "d" } })).every(fact => fact.status === "unresolved"), "the stage walk is bounded by MAX_VISITS"); +}); + +test("Flux2Scheduler provides steps and scheduler is its node type", () => { + const graph = customFactGraph(); + graph.sigmas = { class_type: "Flux2Scheduler", inputs: { steps: 4, width: 1024, height: 1024 } }; + const facts = factsFor(graph); + assert.deepEqual([facts.steps.status, facts.steps.value], ["known", "4"]); + assert.deepEqual([facts.scheduler.status, facts.scheduler.value, facts.scheduler.evidence], ["known", "Flux2Scheduler", "configuration"]); + for (const slot of [1, -1]) { + const linked = customFactGraph(); linked.sigmas = graph.sigmas; linked.s.inputs.sigmas = ["sigmas", slot]; + assert.equal(factsFor(linked).scheduler.status, "unresolved", `output${slot}`); + } + // Its own inputs cannot replace the node type, and steps keep exact-integer rules. + const named = customFactGraph(); named.sigmas = { class_type: "Flux2Scheduler", inputs: { steps: 0, scheduler: "karras" } }; + assert.equal(factsFor(named).scheduler.value, "Flux2Scheduler"); + assert.equal(factsFor(named).steps.status, "unresolved"); +}); + +test("ModelSampling passthroughs reach UNETLoader", () => { + const graph = customFactGraph(); + graph.g.inputs.model = ["flux", 0]; + graph.flux = { class_type: "ModelSamplingFlux", inputs: { model: ["aura", 0], max_shift: "1.15", base_shift: "0.5" } }; + graph.aura = { class_type: "ModelSamplingAuraFlow", inputs: { model: ["sd3", 0], shift: "1.73" } }; + graph.sd3 = { class_type: "ModelSamplingSD3", inputs: { model: ["lora", 0], shift: "3" } }; + graph.lora = { class_type: "LoraLoaderModelOnly", inputs: { model: ["m", 0], lora_name: "style", strength_model: "1" } }; + assert.deepEqual([factsFor(graph).model.status, factsFor(graph).model.value], ["known", "flux.safetensors"]); + for (const node of ["flux", "aura", "sd3"]) { + const wrongSlot = structuredClone(graph); + const parent = { flux: "g", aura: "flux", sd3: "aura" }[node]; + wrongSlot[parent].inputs.model = [node, 1]; + assert.equal(factsFor(wrongSlot).model.status, "unresolved", `${node} output1`); + const unlisted = structuredClone(graph); unlisted[node].class_type = "ModelSamplingUnknown"; + assert.equal(factsFor(unlisted).model.status, "unresolved", `${node} unlisted`); + } +}); + +test("vectors.json pins full facts for every inspection fixture", () => { + const fs = require("node:fs"); + const path = require("node:path"); + const vectors = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "fixtures", "inspection_parity", "vectors.json"), "utf8")); + const inspection = vectors.filter(vector => vector.profile === "inspection"); + assert.ok(inspection.length > 0); + const statuses = new Set(["known", "ambiguous", "unresolved", "not-recorded"]); + for (const vector of inspection) { + const facts = vector.expect.facts; + assert.deepEqual(Object.keys(facts), factFields, vector.name); + for (const field of factFields) { + assert.ok(statuses.has(facts[field].status), `${vector.name} ${field}`); + assert.equal(facts[field].value === null, facts[field].status !== "known", `${vector.name} ${field} value`); + } + } + // f19: KSampler seed 42 / steps 25 / cfg 6.5 then FaceDetailer seed 7 / steps 12 / cfg 4.5, different prompts. + const f19 = inspection.find(vector => vector.fixture === "f19"); + assert.equal(f19.expect.state, "ambiguous"); + assert.deepEqual(Object.fromEntries(factFields.map(field => [field, f19.expect.facts[field].status])), { + seed: "ambiguous", steps: "ambiguous", cfg: "ambiguous", sampler: "known", scheduler: "known", model: "known", + }); +}); + test("wide workflow lookups retain selected core sampler facts", () => { const nodes = [{ id: "text", type: "PrimitiveString", widgets_values_named: { value: "unrelated text" } }], links = []; let linkId = 1; diff --git a/tests/js/inspection-semantics-sync.test.js b/tests/js/inspection-semantics-sync.test.js index 0a6ee52..d59a5b8 100644 --- a/tests/js/inspection-semantics-sync.test.js +++ b/tests/js/inspection-semantics-sync.test.js @@ -33,3 +33,19 @@ test("the facts tables are not part of the legacy page contract", () => { assert.deepStrictEqual(Object.keys(metadata).filter(key => !key.startsWith("_")).sort(), ["PLACEHOLDERS", "extractGenerationFacts", "extractGenerationParams"]); assert.ok(Object.isFrozen(metadata._factsTables)); }); + +// Task 2.1 checked only `facts`; the facts code also reads the samplers table +// (which classes are samplers, their facts shape and a detailer's image input). +test("sampler facts table equals semantics.samplers", () => { + assert.equal(typeof metadata._samplerTable, "object", "comfy-metadata exposes its sampler table for this test"); + const expected = Object.fromEntries(Object.entries(semantics.samplers).map(([name, entry]) => [name, + entry.image_stage === undefined ? { facts: entry.facts } : { facts: entry.facts, image_stage: entry.image_stage }])); + assert.deepStrictEqual(plain(metadata._samplerTable), expected); + assert.ok(Object.isFrozen(metadata._samplerTable)); +}); + +test("detailer-stage samplers, and only they, name an image stage", () => { + for (const [name, entry] of Object.entries(semantics.samplers)) { + assert.equal(Object.hasOwn(metadata._samplerTable[name], "image_stage"), entry.strategy === "detailer-stage", name); + } +}); diff --git a/tests/test_inspection_semantics.py b/tests/test_inspection_semantics.py index c59057d..56a7ed4 100644 --- a/tests/test_inspection_semantics.py +++ b/tests/test_inspection_semantics.py @@ -317,6 +317,12 @@ class SpecEntriesTest(unittest.TestCase): "RandomNoise": {"kind": "noise", "fields": ("noise_seed",)}, "KSamplerSelect": {"kind": "sampler", "fields": ("sampler_name",)}, "BasicScheduler": {"kind": "sigmas", "fields": ("steps", "scheduler")}, + # D8: Flux2Scheduler has no scheduler input; its type names it. + "Flux2Scheduler": { + "kind": "sigmas", + "fields": ("steps",), + "type_fields": ("scheduler",), + }, "CFGGuider": {"kind": "guider", "fields": ("cfg", "model")}, "BasicGuider": {"kind": "guider", "fields": ("model",)}, }, @@ -331,7 +337,14 @@ class SpecEntriesTest(unittest.TestCase): }, ) self.assertEqual( - facts["model_passthroughs"], ("LoraLoader", "LoraLoaderModelOnly") + facts["model_passthroughs"], + ( + "LoraLoader", + "LoraLoaderModelOnly", + "ModelSamplingFlux", + "ModelSamplingAuraFlow", + "ModelSamplingSD3", + ), ) self.assertEqual( facts["scalar_outputs"], @@ -479,6 +492,24 @@ class ValidationTest(_TableCase): lambda d: d["facts"]["scalar_outputs"].update(PrimitiveInt="int"), "facts", ), + ( + lambda d: d["facts"]["configuration_outputs"].update( + X={"kind": "sigmas", "fields": ["steps"], "type_fields": []} + ), + "input name", + ), + ( + lambda d: d["facts"]["configuration_outputs"].update( + X={"kind": "sigmas", "fields": ["steps"], "type_fields": ["steps"]} + ), + "type_fields", + ), + ( + lambda d: d["facts"]["configuration_outputs"].update( + X={"kind": "sigmas", "fields": ["steps"], "names": ["x"]} + ), + "field", + ), ) for index, (mutate, fragment) in enumerate(cases): with self.subTest(case=index): diff --git a/utils/inspection_semantics.py b/utils/inspection_semantics.py index cbce11f..45dff31 100644 --- a/utils/inspection_semantics.py +++ b/utils/inspection_semantics.py @@ -56,6 +56,7 @@ SAVER_PATTERNS = MappingProxyType( ) _CONFIGURATION_KINDS = frozenset({"noise", "sampler", "sigmas", "guider"}) _SCALAR_KINDS = frozenset({"number", "string"}) +_TYPE_FIELDS = frozenset({"type_fields"}) _CLASS_CATEGORIES = ("samplers", "guiders", "selectors", "composites", "savers") _SLOT_CATEGORIES = ("conditioning", "encoders", "strings") _SET_CATEGORIES = ("runtime", "non_file_outputs") @@ -360,10 +361,15 @@ def _facts(doc: dict) -> Mapping[str, Any]: for name, adapter in configuration.items(): where = f"facts.configuration_outputs[{name!r}]" _name(name, where, "class") - _fields(adapter, where, frozenset({"kind", "fields"}), frozenset()) + _fields(adapter, where, frozenset({"kind", "fields"}), _TYPE_FIELDS) if adapter["kind"] not in _CONFIGURATION_KINDS: raise SemanticsError(f"{where}: unknown kind {adapter['kind']!r}") _names(adapter["fields"], where) + if "type_fields" in adapter: + # A fact whose value is the provider's node type (D8: Flux2Scheduler). + _names(adapter["type_fields"], where) + if set(adapter["type_fields"]) & set(adapter["fields"]): + raise SemanticsError(f"{where}: type_fields repeat an input field") _string_map(facts["model_outputs"], "facts.model_outputs", None) _string_map(facts["scalar_outputs"], "facts.scalar_outputs", _SCALAR_KINDS) passthroughs = facts["model_passthroughs"] diff --git a/web/js/comfy-metadata.js b/web/js/comfy-metadata.js index 2166e5e..47105c3 100644 --- a/web/js/comfy-metadata.js +++ b/web/js/comfy-metadata.js @@ -403,11 +403,20 @@ } const FACT_FIELDS = ["seed", "steps", "cfg", "sampler", "scheduler", "model"]; - const GENERATION_SAMPLERS = new Set(["KSampler", "KSamplerAdvanced", "SamplerCustom", "SamplerCustomAdvanced"]); const WORKFLOW_PASSTHROUGH = Symbol("inspection workflow passthrough"); - // These four tables must equal the `facts` section of inspection-semantics.json - // (tests/js/inspection-semantics-sync.test.js). They stay inline literals because the - // legacy pages load this file as a classic script. + // These tables must equal inspection-semantics.json: SAMPLERS its `samplers` section + // (facts shape, and a detailer stage's image input), the other four its `facts` + // section (tests/js/inspection-semantics-sync.test.js). They stay inline literals + // because the legacy pages load this file as a classic script. + const SAMPLERS = Object.freeze({ + KSampler: { facts: "ksampler" }, + KSamplerAdvanced: { facts: "ksampler" }, + SamplerCustom: { facts: "custom" }, + SamplerCustomAdvanced: { facts: "custom-advanced" }, + FaceDetailer: { facts: "ksampler", image_stage: "image" }, + }); + // Stages merged for one ambiguous multistage result: the resolver's candidate cap. + const MAX_STAGES = 32; // Fixed output0 contracts from the pinned core node schemas, not input-name inference. const SCALAR_OUTPUTS = Object.freeze({ PrimitiveInt: "number", PrimitiveFloat: "number", @@ -417,11 +426,13 @@ CheckpointLoader: "ckpt_name", CheckpointLoaderSimple: "ckpt_name", unCLIPCheckpointLoader: "ckpt_name", UNETLoader: "unet_name", }); - const MODEL_PASSTHROUGHS = new Set(["LoraLoader", "LoraLoaderModelOnly"]); + const MODEL_PASSTHROUGHS = new Set(["LoraLoader", "LoraLoaderModelOnly", "ModelSamplingFlux", "ModelSamplingAuraFlow", "ModelSamplingSD3"]); const CONFIGURATION_OUTPUTS = Object.freeze({ RandomNoise: { kind: "noise", fields: ["noise_seed"] }, KSamplerSelect: { kind: "sampler", fields: ["sampler_name"] }, BasicScheduler: { kind: "sigmas", fields: ["steps", "scheduler"] }, + // D8: no scheduler input; the node type is the scheduler configuration. + Flux2Scheduler: { kind: "sigmas", fields: ["steps"], type_fields: ["scheduler"] }, CFGGuider: { kind: "guider", fields: ["cfg", "model"] }, BasicGuider: { kind: "guider", fields: ["model"] }, }); @@ -466,24 +477,69 @@ const seen = new Set(); const [, node, adapter] = configurationOutput(ctx, link, kind, seen); if (!node) return fact("unresolved", source, samplerId); + if (keys.some(name => adapter.type_fields?.includes(name))) { + const value = exactFactValue(node.class_type, field); + return fact(value === null ? "unresolved" : "known", source, samplerId, value); + } const key = keys.find(name => adapter.fields.includes(name) && Object.hasOwn(node.inputs, name)); return key === undefined ? fact("not-recorded", source, samplerId) : scalarFact(ctx, node.inputs, key, field, source, samplerId, seen); } - function graphFacts(graph, source, resolution) { - if (!object(graph)) return factSet("unresolved", source); - // Graph size is not a bound: each lookup is bounded by its MAX_VISITS seen-set, - // and the server's node ceiling arrives as a `limited` projection. - const entries = Object.entries(graph); - if (entries.some(([, node]) => !object(node) || !object(node.inputs) || typeof node.class_type !== "string")) return factSet("unresolved", source); - const candidates = entries.filter(([, node]) => GENERATION_SAMPLERS.has(node.class_type)); - const selected = candidates.find(([id]) => typeof resolution?.sampler_id === "string" && id === resolution.sampler_id); - if (!selected && candidates.length !== 1) return factSet(candidates.length > 1 ? "ambiguous" : "unresolved", source); - const [samplerId, sampler] = selected || candidates[0]; - const ctx = createContext(graph, null, true); + const samplerEntry = (graph, id) => (Object.hasOwn(graph, id) && Object.hasOwn(SAMPLERS, graph[id].class_type) ? SAMPLERS[graph[id].class_type] : undefined); + + /** Samplers reached from a detailer's image input, breadth-first, stopping at listed samplers. */ + function upstreamStages(graph, origin, value, budget) { + if (!isLink(value)) return null; + const found = [], queue = [String(value[0])], queued = new Set(queue); + for (let head = 0; head < queue.length; head++) { + const id = queue[head]; + if (id === origin || ++budget.visits > MAX_VISITS || !Object.hasOwn(graph, id)) return null; + if (samplerEntry(graph, id)) { found.push(id); continue; } + for (const input of Object.values(graph[id].inputs)) { + const next = isLink(input) ? String(input[0]) : null; + if (next !== null && !queued.has(next)) { queued.add(next); queue.push(next); } + } + } + return found; + } + + /** + * Collect a final stage and every stage in its image ancestry into `stages`, as the + * resolver judges a detailer chain. `later` holds the detailers already being judged + * below this one, so a cyclic chain fails instead of recursing. False when the chain + * cannot be walked within the bounds. + */ + function collectStages(graph, id, later, stages, budget) { + stages.add(id); + if (stages.size > MAX_STAGES || later.size >= MAX_STAGES) return false; + const entry = samplerEntry(graph, id); + if (entry.image_stage === undefined) return true; + const inputs = graph[id].inputs; + const upstream = upstreamStages(graph, id, Object.hasOwn(inputs, entry.image_stage) ? inputs[entry.image_stage] : undefined, budget); + if (upstream === null || upstream.some(stage => later.has(stage))) return false; + const below = new Set(later).add(id); + return upstream.every(stage => collectStages(graph, stage, below, stages, budget)); + } + + /** Spec section 10 merge: equal known values stay known, differing ones are ambiguous. */ + function mergeFacts(sets, source) { + if (sets.length === 1) return sets[0]; + return Object.fromEntries(FACT_FIELDS.map(field => { + const facts = sets.map(set => set[field]); + if (new Set(facts.filter(item => item.status === "known").map(item => item.value)).size > 1) return [field, fact("ambiguous", source)]; + const [first] = facts; + const agreed = facts.every(item => item.status === first.status && item.value === first.value); + // A merged fact names no single sampler: it is every stage's value. + return [field, agreed ? fact(first.status, source, null, first.value, first.reason) : fact("unresolved", source)]; + })); + } + + function samplerFacts(ctx, samplerId, source) { + const sampler = ctx.graph[samplerId]; const inputs = sampler.inputs; - const advanced = sampler.class_type === "SamplerCustomAdvanced"; - const custom = advanced || sampler.class_type === "SamplerCustom"; + const shape = SAMPLERS[sampler.class_type].facts; + const advanced = shape === "custom-advanced"; + const custom = advanced || shape === "custom"; let settings = inputs; let settingsAdapter; const settingsPath = new Set(); @@ -513,6 +569,34 @@ return facts; } + /** + * Facts follow the server's resolution. Its `sampler_id` drives them only when + * resolved, or for an ambiguous result naming the final stage of a multistage + * chain (merged per field across the stages, independent of any selection: D5). + * Otherwise (no candidates on the wire until PR3) exactly one listed sampler in the + * graph supplies them; several leave every field ambiguous. + */ + function graphFacts(graph, source, resolution) { + if (!object(graph)) return factSet("unresolved", source); + // Graph size is not a bound: each lookup is bounded by its MAX_VISITS seen-set, + // and the server's node ceiling arrives as a `limited` projection. + const entries = Object.entries(graph); + if (entries.some(([, node]) => !object(node) || !object(node.inputs) || typeof node.class_type !== "string")) return factSet("unresolved", source); + const ctx = createContext(graph, null, true); + const state = object(resolution) ? resolution.state : undefined; + const named = typeof resolution?.sampler_id === "string" ? resolution.sampler_id : null; + if ((state === "resolved" || state === "ambiguous") && named !== null) { + if (!samplerEntry(graph, named)) return factSet("unresolved", source); + if (state === "resolved") return samplerFacts(ctx, named, source); + const stages = new Set(); + if (!collectStages(graph, named, new Set(), stages, { visits: 0 })) return factSet("unresolved", source); + return mergeFacts([...stages].map(id => samplerFacts(ctx, id, source)), source); + } + const candidates = entries.filter(([id]) => samplerEntry(graph, id)); + if (candidates.length !== 1) return factSet(candidates.length > 1 ? "ambiguous" : "unresolved", source); + return samplerFacts(ctx, candidates[0][0], source); + } + function validFactWorkflow(workflow) { if (!object(workflow) || !Array.isArray(workflow.nodes) || (workflow.links !== undefined && !Array.isArray(workflow.links))) return false; const ids = new Set(), links = new Set(), setters = new Set(); @@ -587,6 +671,7 @@ scalar_outputs: SCALAR_OUTPUTS, }), }); + Object.defineProperty(api, "_samplerTable", { value: SAMPLERS }); if (typeof module !== "undefined" && module.exports) module.exports = api; else root.ComfyMetadata = api; })(typeof window !== "undefined" ? window : globalThis); diff --git a/web/js/inspection-semantics.json b/web/js/inspection-semantics.json index b8f538a..38102b7 100644 --- a/web/js/inspection-semantics.json +++ b/web/js/inspection-semantics.json @@ -149,6 +149,7 @@ "RandomNoise": { "kind": "noise", "fields": ["noise_seed"] }, "KSamplerSelect": { "kind": "sampler", "fields": ["sampler_name"] }, "BasicScheduler": { "kind": "sigmas", "fields": ["steps", "scheduler"] }, + "Flux2Scheduler": { "kind": "sigmas", "fields": ["steps"], "type_fields": ["scheduler"] }, "CFGGuider": { "kind": "guider", "fields": ["cfg", "model"] }, "BasicGuider": { "kind": "guider", "fields": ["model"] } }, @@ -158,7 +159,13 @@ "unCLIPCheckpointLoader": "ckpt_name", "UNETLoader": "unet_name" }, - "model_passthroughs": ["LoraLoader", "LoraLoaderModelOnly"], + "model_passthroughs": [ + "LoraLoader", + "LoraLoaderModelOnly", + "ModelSamplingFlux", + "ModelSamplingAuraFlow", + "ModelSamplingSD3" + ], "scalar_outputs": { "PrimitiveInt": "number", "PrimitiveFloat": "number",