* test(inspect): add synthetic inspection parity fixtures and legacy characterization * test(inspect): pin test-time parity vectors and keep fixtures fully synthetic * fix(inspect): bound resolver work instead of graph size * fix(inspect): bound shared-node and stripped-text work in the inspection profile Keep one memo per resolution whose entries carry their unfolded visit cost; a memo hit charges that cost to the current candidate's allowance only. Cache node validation and charge input keys, stripped characters and copied provenance entries to a whole-resolution work budget before doing the work. The default profile is unchanged. * fix(inspect): keep memoized depth honest across inspection candidates A memo entry now records its height below the reusing depth; a hit raises depth-limit exactly where a fresh walk would, so each candidate's outcome matches its anchored result and does not depend on trace or node order. The default profile is unchanged. * test(inspect): pin memo height propagation Three samplers share one chain, each later one hanging below a memo hit. The third candidate must report depth-limit in both trace orders and when anchored; removing the memo-hit height propagation fails this test. The provenance test now asserts a plain subset. * fix(inspect): tolerate ComfyUI node metadata quirks in projections Projections now take a kind. Each prompt node keeps exactly class_type and inputs; _meta, is_changed and other node keys are validated and dropped, so "is_changed": [NaN] no longer marks the whole prompt graph malformed. A nonfinite value inside inputs, anywhere in a workflow, or in a retained carrier member projects to null and is never reported as a number. The carrier applies the prompt/workflow kinds to its members. Exact numbers are unchanged (Decimal spelling, unsafe integers as strings) and duplicate keys are still malformed. The 500-node gate is replaced by a 20,000-node backstop (workflow count includes subgraph definitions). Raw records are untouched. The f14 inspection vector's facts become known; default vectors are unchanged. * fix(inspect): budget metadata per record so large workflows never hide prompts PNG inspection no longer counts every text chunk against one shared 2 MiB budget. Role records (prompt, parameters, the pair execution record) keep a 2 MiB budget together, encoded and decoded, and exceeding it still fails the whole read. A workflow has its own 4 MiB budget: a chunk over it is skipped by seek, and a compressed one stops inflating at budget + 1. Only the workflow is then reported as limited (record format "limited", no raw text, workflow_graph limited); prompts, facts and the prompt graph are unaffected. Each text chunk's keyword is read first (at most 80 bytes). Chunks that are not retained are skipped by length without being read or checksummed, so memory stays bounded. Byte-identical duplicates of a retained record are accepted and do not consume budget; differing duplicates still raise "Duplicate prompt metadata", and a duplicate of a limited workflow stays limited. The chunk-count limit is unchanged. InspectionRead gains limited_records (empty by default). The reader is only used by the inspection routes, so ingestion, scan and import are unchanged. Vectors: f04-inspection (2.5 MB workflow, within the new workflow budget) and f15-inspection (identical duplicate chunks) now read and resolve, with the same outcome and facts as f05-inspection. Default vectors are unchanged. * fix(inspect): keep prompt evidence longest under the response budget * fix(inspect): bound identical duplicate chunk reads * fix(inspect): fail closed when role duplicates exceed their budget * fix(inspect): use the bounded inspection profile end to end Inspection routes resolve with INSPECTION_PROFILE and expose reason_code and node on the resolution; scan, live monitor and admin import keep the default profile (pinned by spies over the real resolver on f03). JS facts drop the entries and workflow node-count gates; per-lookup visit sets and the server's node ceiling bound the work. The validator accepts the textless `limited` workflow format, follows the workflow-first response omission order, and checks the closed reason-code vocabulary. The file inspector labels limited records and disables their workflow download. vectors.json: 12 inspection vectors gain their reason codes; f03 facts become known and f01 facts ambiguous. Default vectors are unchanged. A new parity test asserts vectors.json facts through the workspace JS. * chore(tools): add read-only inspection corpus tool tools/inspection_corpus.py runs the inspection route pipeline and the workspace JS facts of a chosen code tree over a media folder, read-only, and reports the #240 invariants H1 to H8 with aggregate counts. Rows hold statuses and SHA-256 digests of resolved role texts, never file names or prompt text unless --with-paths is given. Outputs inside the root or a code tree are refused. The facts-over-vectors test now fails instead of skipping when CI is set and Node.js is missing. * docs(testing): document bounded inspection work * fix(tools): fail the corpus run when facts or inspection did not run Rows whose facts were never computed (no or exiting Node.js) now fail H3, and a new harness line fails on any tool exception or missing facts, so a run where nothing was measured can no longer exit 0. Unreadable files stay inspector outcomes. A missing --node binary is refused, an exited harness is reaped, H8 has a 50 ms floor for tiny baselines, and JSONL rows are sorted by id. * docs(inspect): correct ceiling wording and bandit annotations Describe the two 20,000-node ceilings (prompt entries, workflow UI plus subgraph nodes) in TESTING.md, and use bare nosec ids so Bandit stops warning about unparsed test names. * test(inspect): make parity observation tuples explicit
581 lines
31 KiB
JavaScript
581 lines
31 KiB
JavaScript
/**
|
|
* Generation-parameter extraction from ComfyUI PNG metadata (#75).
|
|
*
|
|
* Works on the API-format `prompt` graph ({id: {class_type, inputs}}) by
|
|
* tracing links from the sampler, instead of guessing by node type or by
|
|
* keywords in the text. Falls back to the UI `workflow` graph only for
|
|
* fields the API graph could not provide.
|
|
*
|
|
* Shared by admin.js, gallery.js and metadata.html; also loadable in Node
|
|
* for tests.
|
|
*/
|
|
(function (root) {
|
|
"use strict";
|
|
|
|
const PLACEHOLDERS = Object.freeze({
|
|
positivePrompt: "No prompt found",
|
|
negativePrompt: "No negative prompt found",
|
|
checkpoint: "Unknown",
|
|
steps: "Unknown",
|
|
cfgScale: "Unknown",
|
|
sampler: "Unknown",
|
|
seed: "Unknown",
|
|
});
|
|
|
|
// Upper bound on nodes expanded per lookup; crafted PNGs can contain any graph
|
|
const MAX_VISITS = 500;
|
|
const MODEL_NAME_KEYS = ["ckpt_name", "unet_name", "model_name", "gguf_name"];
|
|
const TEXT_KEYS = /^(text|string|prompt|value)(_?[a-z0-9]+)?$/i;
|
|
const SEED_CONTROL_VALUES = new Set(["fixed", "increment", "decrement", "randomize"]);
|
|
|
|
const isLink = (v) =>
|
|
Array.isArray(v) &&
|
|
v.length === 2 &&
|
|
(typeof v[0] === "string" || typeof v[0] === "number") &&
|
|
typeof v[1] === "number";
|
|
|
|
// KSampler or a guider (CFGGuider); the model link excludes ControlNet-style pass-throughs
|
|
const isSampler = (node) =>
|
|
node &&
|
|
node.inputs &&
|
|
isLink(node.inputs.positive) &&
|
|
isLink(node.inputs.negative) &&
|
|
isLink(node.inputs.model);
|
|
|
|
// SamplerCustomAdvanced-style nodes split settings across noise/sampler/sigmas nodes
|
|
const isCustomSampler = (node) =>
|
|
node && node.inputs && isLink(node.inputs.guider) && isLink(node.inputs.sigmas);
|
|
|
|
const workflowNodes = (workflow) => (Array.isArray(workflow && workflow.nodes) ? workflow.nodes : []);
|
|
const widgetsOf = (n) => (Array.isArray(n.widgets_values) ? n.widgets_values : []);
|
|
|
|
/**
|
|
* Tracing context: the API graph plus recovered text for PromptManager nodes
|
|
* whose saved `text` was corrupted.
|
|
*
|
|
* Before 3.2.3 a SaveImage patch overwrote every PromptManager node's `text`
|
|
* with the last-run node's prompt. The signature is two or more PromptManager
|
|
* nodes with identical API text whose workflow widgets differ; only those get
|
|
* their workflow widget text back. Otherwise the API graph is what actually ran
|
|
* (expanded dynamic prompts, linked text) and always wins.
|
|
*/
|
|
function createContext(graph, workflow, conservative = false) {
|
|
const widgets = new Map();
|
|
for (const n of workflowNodes(workflow)) {
|
|
const text = widgetsOf(n)[0];
|
|
if (n.type !== "PromptManagerPair" && /promptmanager/i.test(n.type || "") && typeof text === "string") {
|
|
widgets.set(String(n.id), text);
|
|
}
|
|
}
|
|
|
|
const byApiText = new Map();
|
|
for (const id of widgets.keys()) {
|
|
const apiText = graph[id] && graph[id].inputs && graph[id].inputs.text;
|
|
if (typeof apiText !== "string") continue;
|
|
byApiText.set(apiText, [...(byApiText.get(apiText) || []), id]);
|
|
}
|
|
|
|
const widgetText = new Map();
|
|
for (const ids of byApiText.values()) {
|
|
const distinctWidgets = new Set(ids.map((id) => widgets.get(id)));
|
|
if (ids.length > 1 && distinctWidgets.size > 1) {
|
|
ids.forEach((id) => widgetText.set(id, widgets.get(id)));
|
|
}
|
|
}
|
|
return { graph, widgetText, conservative };
|
|
}
|
|
|
|
/**
|
|
* Returns [nodeId, node] for a link, or [] when it does not resolve or the node
|
|
* was already expanded in this lookup. The shared `seen` set keeps every lookup
|
|
* linear in graph size, even for cyclic or heavily fanned-out graphs.
|
|
*/
|
|
function enter(ctx, link, seen) {
|
|
if (!isLink(link) || seen.size >= MAX_VISITS) return [];
|
|
if (ctx.conservative && (!Number.isSafeInteger(link[1]) || link[1] < 0 || seen.size >= 64)) return [];
|
|
const id = String(link[0]);
|
|
const node = !ctx.conservative || Object.hasOwn(ctx.graph, id) ? ctx.graph[id] : undefined;
|
|
const key = `${id}:${link[1]}`;
|
|
if (!node || !node.inputs || seen.has(key)) return [];
|
|
seen.add(key);
|
|
return [id, node];
|
|
}
|
|
|
|
/**
|
|
* Resolve a scalar input, following a link to a primitive node if needed.
|
|
* Multi-output settings nodes (e.g. SamplerCombo) expose several values, so an
|
|
* input with the same `name` as the requested field wins over the first scalar.
|
|
*/
|
|
function resolveScalar(ctx, value, name, seen = new Set()) {
|
|
if (!isLink(value)) return value;
|
|
const [, node] = enter(ctx, value, seen);
|
|
if (!node) return undefined;
|
|
if (ctx.conservative) {
|
|
if (node[WORKFLOW_PASSTHROUGH]) return node[WORKFLOW_PASSTHROUGH] === "value" && value[1] === 0 ? resolveScalar(ctx, node.inputs.value, name, seen) : undefined;
|
|
const kind = ["seed", "noise_seed", "steps", "cfg"].includes(name) ? "number" : "string";
|
|
return value[1] === 0 && SCALAR_OUTPUTS[node.class_type] === kind && Object.hasOwn(node.inputs, "value") ?
|
|
resolveScalar(ctx, node.inputs.value, name, seen) : undefined;
|
|
}
|
|
if (name && Object.prototype.hasOwnProperty.call(node.inputs, name)) {
|
|
return resolveScalar(ctx, node.inputs[name], name, seen);
|
|
}
|
|
for (const v of Object.values(node.inputs)) {
|
|
const resolved = resolveScalar(ctx, v, name, seen);
|
|
if (resolved !== undefined && typeof resolved !== "object") return resolved;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** Read the first of `keys` from the node a link points to, resolving further links. */
|
|
function resolveLinkedInput(ctx, link, keys) {
|
|
const seen = new Set();
|
|
const [, node] = enter(ctx, link, seen);
|
|
if (!node) return undefined;
|
|
const key = keys.find((k) => k in node.inputs);
|
|
return key === undefined ? undefined : resolveScalar(ctx, node.inputs[key], key, seen);
|
|
}
|
|
|
|
/** Resolve a string input; text-producing nodes may be chained (concat, primitives). */
|
|
function resolveString(ctx, value, seen = new Set()) {
|
|
if (typeof value === "string") return value;
|
|
const [id, node] = enter(ctx, value, seen);
|
|
if (!node) return "";
|
|
if (node.class_type === "PromptManagerPair") {
|
|
if (value[1] === 3) return resolveString(ctx, node.inputs.negative_text, seen);
|
|
return value[1] === 2 ? composeNodeText(ctx, id, node, seen) : "";
|
|
}
|
|
if ("text" in node.inputs) return composeNodeText(ctx, id, node, seen);
|
|
const delimiter = typeof node.inputs.delimiter === "string" ? node.inputs.delimiter : " ";
|
|
return Object.entries(node.inputs)
|
|
.filter(([key]) => TEXT_KEYS.test(key))
|
|
.map(([, v]) => resolveString(ctx, v, seen))
|
|
.filter((s) => s.trim())
|
|
.join(delimiter);
|
|
}
|
|
|
|
/** Text of an encoder node, matching PromptManager's prepend + text + append join. */
|
|
function composeNodeText(ctx, id, node, seen) {
|
|
const { inputs } = node;
|
|
const raw = ctx.widgetText.has(id) ? ctx.widgetText.get(id) : resolveString(ctx, inputs.text, seen);
|
|
const text = raw.trim();
|
|
if (!text) return "";
|
|
// One shared set even across siblings: copying it per branch reintroduces
|
|
// exponential work on crafted chains
|
|
const prepend = resolveString(ctx, inputs.prepend_text, seen).trim();
|
|
const append = resolveString(ctx, inputs.append_text, seen).trim();
|
|
// Older saves stored the already-combined prompt in `text`
|
|
return [
|
|
prepend && !text.startsWith(prepend) ? prepend : "",
|
|
text,
|
|
append && !text.endsWith(append) ? append : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
}
|
|
|
|
/** Follow a conditioning link upstream to the encoder that produced it. */
|
|
function traceConditioning(ctx, link, role, seen = new Set()) {
|
|
const [id, node] = enter(ctx, link, seen);
|
|
if (!node) return "";
|
|
if (node.class_type === "PromptManagerPair") {
|
|
if (link[1] === 1) return resolveString(ctx, node.inputs.negative_text, seen);
|
|
return link[1] === 0 ? composeNodeText(ctx, id, node, seen) : "";
|
|
}
|
|
if ("text" in node.inputs) return composeNodeText(ctx, id, node, seen);
|
|
// Pass-through nodes (ControlNet apply, conditioning combine/set area, ...)
|
|
const { inputs } = node;
|
|
const next = inputs[role] || inputs.conditioning || inputs.conditioning_to || inputs.conditioning_1;
|
|
return traceConditioning(ctx, next, role, seen);
|
|
}
|
|
|
|
/** Follow the sampler's model link upstream (through LoRA loaders etc.) to a loader. */
|
|
function traceModelName(ctx, link, seen = new Set()) {
|
|
const [, node] = enter(ctx, link, seen);
|
|
if (!node) return undefined;
|
|
if (ctx.conservative) {
|
|
if (link[1] !== 0) return undefined;
|
|
if (node[WORKFLOW_PASSTHROUGH]) return traceModelName(ctx, node.inputs.value, seen);
|
|
const key = Object.hasOwn(MODEL_OUTPUTS, node.class_type) ? MODEL_OUTPUTS[node.class_type] : undefined;
|
|
if (key) return resolveScalar(ctx, node.inputs[key], key, seen);
|
|
return MODEL_PASSTHROUGHS.has(node.class_type) ? traceModelName(ctx, node.inputs.model, seen) : undefined;
|
|
}
|
|
const key = MODEL_NAME_KEYS.find((k) => typeof node.inputs[k] === "string");
|
|
return key ? node.inputs[key] : traceModelName(ctx, node.inputs.model, seen);
|
|
}
|
|
|
|
function findAnyModelName(nodes) {
|
|
for (const node of nodes) {
|
|
const inputs = (node && node.inputs) || {};
|
|
const key = MODEL_NAME_KEYS.find((k) => typeof inputs[k] === "string");
|
|
if (key) return inputs[key];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function fromPromptGraph(graph, workflow) {
|
|
const ctx = createContext(graph, workflow);
|
|
const nodes = Object.values(graph);
|
|
const custom = nodes.find(isCustomSampler);
|
|
const [, guider] = custom ? enter(ctx, custom.inputs.guider, new Set()) : [];
|
|
const sampler = isSampler(guider) ? guider : nodes.find(isSampler);
|
|
if (!sampler) {
|
|
const loose = nodes.find((n) => n && n.inputs && /ksampler/i.test(n.class_type || ""));
|
|
const inputs = (loose && loose.inputs) || {};
|
|
return {
|
|
checkpoint: findAnyModelName(nodes),
|
|
seed: resolveScalar(ctx, inputs.seed ?? inputs.noise_seed, "seed"),
|
|
steps: resolveScalar(ctx, inputs.steps, "steps"),
|
|
cfgScale: resolveScalar(ctx, inputs.cfg, "cfg"),
|
|
sampler: resolveScalar(ctx, inputs.sampler_name, "sampler_name"),
|
|
};
|
|
}
|
|
|
|
const { inputs } = sampler;
|
|
const fromCustom = (link, keys) => (custom ? resolveLinkedInput(ctx, link, keys) : undefined);
|
|
return {
|
|
positivePrompt: traceConditioning(ctx, inputs.positive, "positive"),
|
|
negativePrompt: traceConditioning(ctx, inputs.negative, "negative"),
|
|
checkpoint: traceModelName(ctx, inputs.model) || findAnyModelName(nodes),
|
|
seed:
|
|
resolveScalar(ctx, inputs.seed ?? inputs.noise_seed, "seed" in inputs ? "seed" : "noise_seed") ??
|
|
fromCustom(custom && custom.inputs.noise, ["noise_seed", "seed"]),
|
|
steps: resolveScalar(ctx, inputs.steps, "steps") ?? fromCustom(custom && custom.inputs.sigmas, ["steps"]),
|
|
cfgScale: resolveScalar(ctx, inputs.cfg, "cfg"),
|
|
sampler:
|
|
resolveScalar(ctx, inputs.sampler_name, "sampler_name") ??
|
|
fromCustom(custom && custom.inputs.sampler, ["sampler_name"]),
|
|
};
|
|
}
|
|
|
|
// Widget order for core nodes, used when a workflow carries no widget names
|
|
const PROMPT_MANAGER_WIDGETS = ["text", "category", "tags", "search_text", "prepend_text", "append_text"];
|
|
const WIDGET_POSITIONS = {
|
|
KSampler: ["seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"],
|
|
KSamplerAdvanced: [
|
|
"add_noise", "noise_seed", "steps", "cfg", "sampler_name", "scheduler",
|
|
"start_at_step", "end_at_step", "return_with_leftover_noise",
|
|
],
|
|
CLIPTextEncode: ["text"],
|
|
PromptManager: PROMPT_MANAGER_WIDGETS,
|
|
PromptManagerText: PROMPT_MANAGER_WIDGETS,
|
|
PromptManagerFocused: PROMPT_MANAGER_WIDGETS,
|
|
PromptManagerSplit: PROMPT_MANAGER_WIDGETS,
|
|
PromptManagerPair: ["text", "negative_text", "category", "tags", "search_text", "prepend_text", "append_text"],
|
|
CheckpointLoader: ["config_name", "ckpt_name"],
|
|
UNETLoader: ["unet_name", "weight_dtype"],
|
|
};
|
|
const PASS_THROUGH_TYPES = new Set(["Reroute", "GetNode", "SetNode"]);
|
|
const MODE_MUTED = 2;
|
|
const MODE_BYPASSED = 4;
|
|
|
|
function widgetNames(n) {
|
|
const fromInputs = (n.inputs || []).filter((i) => i && i.widget && i.widget.name).map((i) => i.widget.name);
|
|
if (fromInputs.length) return fromInputs;
|
|
if (WIDGET_POSITIONS[n.type]) return WIDGET_POSITIONS[n.type];
|
|
if (/checkpointloader/i.test(n.type || "")) return ["ckpt_name"];
|
|
if (/unet/i.test(n.type || "")) return ["unet_name"];
|
|
return [];
|
|
}
|
|
|
|
/** Map a workflow node's widget values to input names, skipping control_after_generate. */
|
|
function namedWidgets(n) {
|
|
const named = n.widgets_values_named;
|
|
if (named && typeof named === "object" && !Array.isArray(named)) return { ...named };
|
|
const values = widgetsOf(n);
|
|
const out = {};
|
|
let v = 0;
|
|
for (const name of widgetNames(n)) {
|
|
if (v >= values.length) break;
|
|
out[name] = values[v++];
|
|
if (/seed$/.test(name) && SEED_CONTROL_VALUES.has(values[v])) v++;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function factWorkflowGraph(workflow) {
|
|
const nodes = workflowNodes(workflow), links = new Map(), setters = new Map();
|
|
for (const link of workflow.links || []) {
|
|
if (Array.isArray(link)) links.set(link[0], { from: link[1], slot: link[2] });
|
|
else links.set(link.id, { from: link.origin_id, slot: link.origin_slot });
|
|
}
|
|
const source = id => {
|
|
const link = links.get(id);
|
|
if (!link || !["string", "number"].includes(typeof link.from) || !Number.isSafeInteger(link.slot) || link.slot < 0) return undefined;
|
|
return [String(link.from), link.slot];
|
|
};
|
|
for (const node of nodes) {
|
|
const name = widgetsOf(node)[0];
|
|
if (node.type === "SetNode" && node.mode !== MODE_MUTED && typeof name === "string") setters.set(name, node.inputs?.length === 1 ? node.inputs[0].link : undefined);
|
|
}
|
|
const graph = Object.create(null);
|
|
for (const node of nodes) {
|
|
if (node.mode === MODE_MUTED) continue;
|
|
let pass = PASS_THROUGH_TYPES.has(node.type), link, kind = "value";
|
|
if (pass) {
|
|
const name = widgetsOf(node)[0];
|
|
if (node.type === "GetNode") link = typeof name === "string" ? setters.get(name) : undefined;
|
|
else if (node.inputs?.length === 1) link = node.inputs[0].link;
|
|
}
|
|
else if (node.mode === MODE_BYPASSED) {
|
|
if (!MODEL_PASSTHROUGHS.has(node.type)) continue;
|
|
const models = (node.inputs || []).filter(input => input.name === "model");
|
|
pass = true; kind = "model"; link = models.length === 1 ? models[0].link : undefined;
|
|
}
|
|
if (pass) {
|
|
// Retain the actual alias hop so a fact's full original ancestry shares one depth/visit set.
|
|
graph[String(node.id)] = { class_type: node.type, inputs: { value: source(link) }, [WORKFLOW_PASSTHROUGH]: kind };
|
|
continue;
|
|
}
|
|
const inputs = namedWidgets(node);
|
|
for (const input of node.inputs || []) {
|
|
if (input.link !== null && input.link !== undefined) inputs[input.name] = source(input.link);
|
|
}
|
|
graph[String(node.id)] = { class_type: node.type, inputs };
|
|
}
|
|
return graph;
|
|
}
|
|
|
|
/**
|
|
* Convert a UI workflow ({nodes, links}) into the API graph shape so images
|
|
* without a `prompt` chunk are traced the same way. Set/Get pairs, Reroutes and
|
|
* bypassed nodes are resolved to their real source; muted nodes are dropped.
|
|
*/
|
|
function workflowToGraph(workflow, conservative = false) {
|
|
if (conservative) return factWorkflowGraph(workflow);
|
|
const nodes = workflowNodes(workflow).filter((n) => n && n.id !== undefined && n.id !== null);
|
|
const byId = new Map(nodes.map((n) => [String(n.id), n]));
|
|
const links = new Map();
|
|
for (const l of Array.isArray(workflow && workflow.links) ? workflow.links : []) {
|
|
if (Array.isArray(l)) links.set(l[0], { from: l[1], slot: l[2], type: l[5] });
|
|
else if (l && typeof l === "object") links.set(l.id, { from: l.origin_id, slot: l.origin_slot, type: l.type });
|
|
}
|
|
const setters = new Map();
|
|
for (const n of nodes) {
|
|
if (n.type === "SetNode") setters.set(widgetsOf(n)[0], n.inputs && n.inputs[0] && n.inputs[0].link);
|
|
}
|
|
|
|
const source = (linkId, hops = 0) => {
|
|
const link = links.get(linkId);
|
|
const node = link && byId.get(String(link.from));
|
|
if (!node || node.mode === MODE_MUTED || hops > MAX_VISITS) return undefined;
|
|
const next = (id) => source(id, hops + 1);
|
|
if (node.type === "Reroute") return next(node.inputs && node.inputs[0] && node.inputs[0].link);
|
|
if (node.type === "GetNode") return next(setters.get(widgetsOf(node)[0]));
|
|
if (node.mode === MODE_BYPASSED) {
|
|
const same = (node.inputs || []).find(
|
|
(i) => i && i.link !== null && i.link !== undefined && (!link.type || link.type === "*" || i.type === link.type),
|
|
);
|
|
return same ? next(same.link) : undefined;
|
|
}
|
|
return [String(node.id), link.slot];
|
|
};
|
|
|
|
const graph = {};
|
|
for (const n of nodes) {
|
|
if (n.mode === MODE_MUTED || n.mode === MODE_BYPASSED || PASS_THROUGH_TYPES.has(n.type)) continue;
|
|
const inputs = namedWidgets(n);
|
|
for (const i of n.inputs || []) {
|
|
const src = i && i.link !== null && i.link !== undefined ? source(i.link) : undefined;
|
|
if (src) inputs[i.name] = src;
|
|
}
|
|
graph[String(n.id)] = { class_type: n.type, inputs };
|
|
}
|
|
return graph;
|
|
}
|
|
|
|
const hasValue = (v) => v !== undefined && v !== null && v !== "";
|
|
|
|
/**
|
|
* @param {{prompt?: Object, workflow?: Object}} comfyData - Parsed PNG metadata
|
|
* @returns {{positivePrompt, negativePrompt, checkpoint, steps, cfgScale, sampler, seed}}
|
|
*/
|
|
function extractGenerationParams(comfyData) {
|
|
const data = comfyData || {};
|
|
const primary =
|
|
data.prompt && typeof data.prompt === "object" ? fromPromptGraph(data.prompt, data.workflow) : {};
|
|
const fallback = data.workflow ? fromPromptGraph(workflowToGraph(data.workflow), null) : {};
|
|
const merged = {};
|
|
for (const key of Object.keys(PLACEHOLDERS)) {
|
|
const value = hasValue(primary[key]) ? primary[key] : fallback[key];
|
|
merged[key] = hasValue(value) ? value : PLACEHOLDERS[key];
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
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");
|
|
// Fixed output0 contracts from the pinned core node schemas, not input-name inference.
|
|
const SCALAR_OUTPUTS = Object.freeze({
|
|
PrimitiveInt: "number", PrimitiveFloat: "number",
|
|
PrimitiveString: "string", PrimitiveStringMultiline: "string",
|
|
});
|
|
const MODEL_OUTPUTS = Object.freeze({
|
|
CheckpointLoader: "ckpt_name", CheckpointLoaderSimple: "ckpt_name",
|
|
unCLIPCheckpointLoader: "ckpt_name", UNETLoader: "unet_name",
|
|
});
|
|
const MODEL_PASSTHROUGHS = new Set(["LoraLoader", "LoraLoaderModelOnly"]);
|
|
const CONFIGURATION_OUTPUTS = Object.freeze({
|
|
RandomNoise: { kind: "noise", fields: ["noise_seed"] },
|
|
KSamplerSelect: { kind: "sampler", fields: ["sampler_name"] },
|
|
BasicScheduler: { kind: "sigmas", fields: ["steps", "scheduler"] },
|
|
CFGGuider: { kind: "guider", fields: ["cfg", "model"] },
|
|
BasicGuider: { kind: "guider", fields: ["model"] },
|
|
});
|
|
const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
|
|
function fact(status, source = null, samplerId = null, value = null, reason = null) {
|
|
return { status, value, source, evidence: source === "embedded-parameters" ? source : source ? "configuration" : null, reason, sampler_id: samplerId };
|
|
}
|
|
|
|
function factSet(status, source = null, reason = null) {
|
|
return Object.fromEntries(FACT_FIELDS.map(name => [name, fact(status, source, null, null, reason)]));
|
|
}
|
|
|
|
function exactFactValue(value, field) {
|
|
if (typeof value === "number") {
|
|
// The projection carries decimals as strings; a float here cannot prove exactness.
|
|
if (!["seed", "steps", "cfg"].includes(field) || !Number.isFinite(value) || !Number.isSafeInteger(value)) return null;
|
|
value = String(value);
|
|
}
|
|
if (typeof value !== "string" || !value.trim()) return null;
|
|
if (field === "seed" || field === "steps") {
|
|
return /^\d+$/.test(value) && !/[\r\n]/.test(value) && (field === "seed" || /[1-9]/.test(value)) ? value : null;
|
|
}
|
|
if (field === "cfg") return /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(value) && value.trim() === value ? value : null;
|
|
return value === "Unknown" ? null : value;
|
|
}
|
|
|
|
function scalarFact(ctx, inputs, key, field, source, samplerId, seen = new Set()) {
|
|
if (!Object.hasOwn(inputs, key)) return fact("not-recorded", source, samplerId);
|
|
const value = exactFactValue(resolveScalar(ctx, inputs[key], key, seen), field);
|
|
return fact(value === null ? "unresolved" : "known", source, samplerId, value);
|
|
}
|
|
|
|
function configurationOutput(ctx, link, kind, seen) {
|
|
const [id, node] = enter(ctx, link, seen);
|
|
if (node?.[WORKFLOW_PASSTHROUGH]) return node[WORKFLOW_PASSTHROUGH] === "value" && link[1] === 0 ? configurationOutput(ctx, node.inputs.value, kind, seen) : [];
|
|
const adapter = node && CONFIGURATION_OUTPUTS[node.class_type];
|
|
return node && link[1] === 0 && adapter?.kind === kind ? [id, node, adapter] : [];
|
|
}
|
|
|
|
function providerFact(ctx, link, kind, keys, field, source, samplerId) {
|
|
const seen = new Set();
|
|
const [, node, adapter] = configurationOutput(ctx, link, kind, seen);
|
|
if (!node) return fact("unresolved", source, samplerId);
|
|
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 inputs = sampler.inputs;
|
|
const advanced = sampler.class_type === "SamplerCustomAdvanced";
|
|
const custom = advanced || sampler.class_type === "SamplerCustom";
|
|
let settings = inputs;
|
|
let settingsAdapter;
|
|
const settingsPath = new Set();
|
|
if (advanced) {
|
|
const [, guider, adapter] = configurationOutput(ctx, inputs.guider, "guider", settingsPath);
|
|
settings = guider ? guider.inputs : null;
|
|
settingsAdapter = adapter;
|
|
}
|
|
const fromSettings = (key, field) => {
|
|
if (!settings) return fact("unresolved", source, samplerId);
|
|
if (settingsAdapter && !settingsAdapter.fields.includes(key)) return fact(Object.hasOwn(settings, key) ? "unresolved" : "not-recorded", source, samplerId);
|
|
return scalarFact(ctx, settings, key, field, source, samplerId, new Set(settingsPath));
|
|
};
|
|
const seedKey = Object.hasOwn(inputs, "seed") ? "seed" : "noise_seed";
|
|
const facts = {
|
|
seed: advanced ? providerFact(ctx, inputs.noise, "noise", ["noise_seed"], "seed", source, samplerId) : scalarFact(ctx, inputs, seedKey, "seed", source, samplerId),
|
|
steps: custom ? providerFact(ctx, inputs.sigmas, "sigmas", ["steps"], "steps", source, samplerId) : scalarFact(ctx, inputs, "steps", "steps", source, samplerId),
|
|
cfg: fromSettings("cfg", "cfg"),
|
|
sampler: custom ? providerFact(ctx, inputs.sampler, "sampler", ["sampler_name"], "sampler", source, samplerId) : scalarFact(ctx, inputs, "sampler_name", "sampler", source, samplerId),
|
|
scheduler: Object.hasOwn(inputs, "scheduler") || !custom ? scalarFact(ctx, inputs, "scheduler", "scheduler", source, samplerId) : providerFact(ctx, inputs.sigmas, "sigmas", ["scheduler"], "scheduler", source, samplerId),
|
|
model: fact(settings && !Object.hasOwn(settings, "model") ? "not-recorded" : "unresolved", source, samplerId),
|
|
};
|
|
if (settings && Object.hasOwn(settings, "model")) {
|
|
const model = exactFactValue(traceModelName(ctx, settings.model, new Set(settingsPath)), "model");
|
|
facts.model = fact(model === null ? "unresolved" : "known", source, samplerId, model);
|
|
}
|
|
return facts;
|
|
}
|
|
|
|
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();
|
|
for (const node of workflow.nodes) {
|
|
if (!object(node) || !["string", "number"].includes(typeof node.id) || typeof node.type !== "string" ||
|
|
(node.inputs !== undefined && (!Array.isArray(node.inputs) || node.inputs.some(input => !object(input) || typeof input.name !== "string"))) || ids.has(String(node.id))) return false;
|
|
ids.add(String(node.id));
|
|
if (node.type === "SetNode") {
|
|
const name = widgetsOf(node)[0];
|
|
if (setters.has(name)) return false;
|
|
setters.add(name);
|
|
}
|
|
}
|
|
for (const link of workflow.links || []) {
|
|
const id = Array.isArray(link) ? link[0] : link?.id;
|
|
if (id === undefined || links.has(id)) return false;
|
|
links.add(id);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function parameterFacts(parameters) {
|
|
const source = "embedded-parameters";
|
|
const matches = [...parameters.matchAll(/^Steps:\s*\d+\s*,[^\n]*$/gm)];
|
|
if (matches.length !== 1 || parameters.slice(matches[0].index + matches[0][0].length).trim()) return factSet("unresolved", source);
|
|
const body = parameters.slice(0, matches[0].index).replace(/[\r\n]+$/, "");
|
|
if (!body.split("\nNegative prompt:")[0].trim() || body.split("\nNegative prompt:").length > 2) return factSet("unresolved", source);
|
|
const settings = new Map();
|
|
for (const part of matches[0][0].split(",")) {
|
|
const match = part.match(/^\s*([^:]+):\s*(.*?)\s*$/);
|
|
if (!match) return factSet("unresolved", source);
|
|
const [, key, value] = match;
|
|
settings.set(key, settings.has(key) ? null : value);
|
|
}
|
|
const keys = { seed: "Seed", steps: "Steps", cfg: "CFG scale", sampler: "Sampler", scheduler: "Schedule type", model: "Model" };
|
|
return Object.fromEntries(FACT_FIELDS.map(field => {
|
|
const key = keys[field];
|
|
if (!settings.has(key)) return [field, fact("not-recorded", source)];
|
|
let value = exactFactValue(settings.get(key), field);
|
|
if (field === "scheduler" && value !== null && !/^(automatic|normal|karras|exponential|sgm[ _]uniform|simple|ddim[ _]uniform|beta|uniform|linear[ _]quadratic|kl[ _]optimal)$/i.test(value)) value = null;
|
|
return [field, fact(value === null ? "unresolved" : "known", source, null, value)];
|
|
}));
|
|
}
|
|
|
|
/** Consume only the exact, bounded inspection projection; never reparse raw copies. */
|
|
function extractGenerationFacts({ prompt_graph, workflow_graph, parameters, recordFormats = {}, metadataStatus, resolution } = {}) {
|
|
for (const [record, source] of [[prompt_graph, "api-graph"], [workflow_graph, "workflow"]]) {
|
|
if (!record || record.status === "absent") continue;
|
|
if (record.status === "limited") return factSet("unresolved", source, "projection-limited");
|
|
if (record.status !== "parsed") return factSet("unresolved", source);
|
|
if (source === "workflow") {
|
|
if (!validFactWorkflow(record.value)) return factSet("unresolved", source);
|
|
return graphFacts(workflowToGraph(record.value, true), source, null);
|
|
}
|
|
return graphFacts(record.value, source, resolution);
|
|
}
|
|
if (metadataStatus === "limited") return factSet("unresolved", null, "projection-limited");
|
|
if (metadataStatus === "unreadable") return factSet("unresolved", null, "metadata-unreadable");
|
|
if (parameters !== undefined && parameters !== null || Object.hasOwn(recordFormats, "parameters")) {
|
|
return recordFormats.parameters === "original-text" && typeof parameters === "string" ? parameterFacts(parameters) : factSet("unresolved");
|
|
}
|
|
return factSet(metadataStatus === "available" || metadataStatus === "absent" ? "not-recorded" : "unresolved");
|
|
}
|
|
|
|
const api = { extractGenerationParams, extractGenerationFacts, PLACEHOLDERS };
|
|
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
|
else root.ComfyMetadata = api;
|
|
})(typeof window !== "undefined" ? window : globalThis);
|