feat(inspect): compute facts from the resolved sampler
This commit is contained in:
+15
-15
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user