feat(inspect): compute facts from the resolved sampler

This commit is contained in:
Vito Sansevero
2026-10-19 15:00:33 -07:00
parent f0c25b9ef3
commit 8cd8ca55ac
7 changed files with 300 additions and 36 deletions
+15 -15
View File
@@ -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",
+119
View File
@@ -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;
@@ -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);
}
});
+32 -1
View File
@@ -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):
+7 -1
View File
@@ -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"]
+103 -18
View File
@@ -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);
+8 -1
View File
@@ -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",