R6 and R9 for nodes whose sources are installed beside ComfyUI, each entry's input names and output slots read from the node's code (the task report lists file and line): - detailer stages UltimateSDUpscale (image), UltimateSDUpscaleNoUpscale (upscaled_image) and DetailerForEach (image), judged with their upstream samplers like FaceDetailer; - pass-throughs ControlNetInpaintingAliMamaApply and LTXVConditioning (positive 0, negative 1) and FluxKontextMultiReferenceLatentMethod; - encoder Text to Conditioning, strings TextInput and DisplayText; - facts model pass-throughs Lora Loader (LoraManager), Power Lora Loader (rgthree), ModelPatchTorchSettings, CFGNorm, PerturbedAttentionGuidance. Impact Pack encodes a detailer's non-empty wildcard instead of its positive input, so FaceDetailer and DetailerForEach now list it as a prompt override: a literal wildcard is decided at run time and a linked one names its source, instead of resolving to the positive input. The overwritten PromptManager refusal (R1) still holds behind the new pass-throughs. The default profile does not read the table.
765 lines
42 KiB
JavaScript
765 lines
42 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 WORKFLOW_PASSTHROUGH = Symbol("inspection workflow passthrough");
|
|
// 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" },
|
|
DetailerForEach: { facts: "ksampler", image_stage: "image" },
|
|
UltimateSDUpscale: { facts: "ksampler", image_stage: "image" },
|
|
UltimateSDUpscaleNoUpscale: { facts: "ksampler", image_stage: "upscaled_image" },
|
|
});
|
|
// Stages merged for one result: the resolver's candidate cap. Together with
|
|
// MAX_VISITS per candidate walk these are deliberately tighter than the server's
|
|
// budgets (2,000 visits, depth 64), so the JS walk always fails closed first.
|
|
const MAX_STAGES = 32;
|
|
// Every class the semantics table lists (the resolver's `all_classes`): a stage walk
|
|
// passes these, and treats any other node shaped like a sampler as an unknown stage.
|
|
const TABLE_CLASSES = new Set([
|
|
"KSampler", "KSamplerAdvanced", "SamplerCustom", "SamplerCustomAdvanced", "FaceDetailer",
|
|
"CFGGuider", "BasicGuider", "easy ifElse", "ConditioningCombine", "ConditioningConcat",
|
|
"ConditioningAverage", "SaveImage", "SaveAnimatedWEBP", "SaveAnimatedPNG", "SaveVideo",
|
|
"SaveWEBM", "VHS_VideoCombine", "FluxGuidance", "ReferenceLatent", "ConditioningSetArea",
|
|
"ConditioningSetAreaPercentage", "ConditioningSetAreaStrength", "ConditioningSetMask", "ConditioningSetTimestepRange", "ControlNetApply",
|
|
"ControlNetApplyAdvanced", "ControlNetApplySD3", "InpaintModelConditioning", "WanImageToVideo", "ConditioningZeroOut",
|
|
"CLIPTextEncode", "CLIPTextEncodeSDXL", "CLIPTextEncodeFlux", "CLIPTextEncodeSD3", "TextEncodeQwenImageEdit",
|
|
"TextEncodeQwenImageEditPlus", "TextEncodeQwenImage21", "KikoTextEncode", "StringConstant", "StringConstantMultiline",
|
|
"JoinStringMulti", "PromptSearchList", "PreviewImage", "PreviewAny", "SaveImageWebsocket",
|
|
"Image Comparer (rgthree)", "DetailerForEach", "UltimateSDUpscale", "UltimateSDUpscaleNoUpscale",
|
|
"ControlNetInpaintingAliMamaApply", "LTXVConditioning", "FluxKontextMultiReferenceLatentMethod", "Text to Conditioning",
|
|
"TextInput", "DisplayText",
|
|
]);
|
|
// The table's `stage_inputs` (the resolver reads the same lists): inputs that make an
|
|
// unlisted node a possible stage, and those that do so beside a linked `model`.
|
|
const STAGE_INPUTS = Object.freeze({
|
|
shaped: ["latent_image", "guider", "positive", "negative", "pipe", "basic_pipe", "detailer_pipe", "sampler", "sigmas", "noise", "text_embeds", "hyvid_embeds"],
|
|
with_model: ["conditioning", "neg_conditioning", "samples", "latent"],
|
|
});
|
|
// 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", "ModelSamplingFlux", "ModelSamplingAuraFlow", "ModelSamplingSD3",
|
|
"Lora Loader (LoraManager)", "Power Lora Loader (rgthree)", "ModelPatchTorchSettings", "CFGNorm", "PerturbedAttentionGuidance",
|
|
]);
|
|
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"] },
|
|
});
|
|
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);
|
|
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);
|
|
}
|
|
|
|
const samplerEntry = (graph, id) => (Object.hasOwn(graph, id) && Object.hasOwn(SAMPLERS, graph[id].class_type) ? SAMPLERS[graph[id].class_type] : undefined);
|
|
|
|
// The resolver's `_link`: an integer or string id and a non-negative integer slot.
|
|
// Stage walks use it so they cross exactly the server's edges; the legacy `isLink` stays.
|
|
const stageLink = value => Array.isArray(value) && value.length === 2 &&
|
|
(typeof value[0] === "string" || Number.isInteger(value[0])) && Number.isInteger(value[1]) && value[1] >= 0;
|
|
const linked = (inputs, key) => Object.hasOwn(inputs, key) && stageLink(inputs[key]);
|
|
/** An unlisted node that may itself be a sampler stage: the walk cannot judge it. */
|
|
const stageShaped = ({ class_type, inputs }) => !TABLE_CLASSES.has(class_type) &&
|
|
(STAGE_INPUTS.shaped.some(key => linked(inputs, key)) || (linked(inputs, "model") && STAGE_INPUTS.with_model.some(key => linked(inputs, key))));
|
|
|
|
/** Samplers reached from a detailer's image input, breadth-first, stopping at listed samplers. */
|
|
function upstreamStages(graph, origin, value, budget) {
|
|
if (!stageLink(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; }
|
|
if (stageShaped(graph[id])) return null;
|
|
for (const input of Object.values(graph[id].inputs)) {
|
|
const next = stageLink(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.
|
|
*/
|
|
// `stages` is shared on purpose across one result's candidates: re-adding a stage is
|
|
// harmless, and MAX_STAGES caps the union, not each candidate.
|
|
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 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();
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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).
|
|
* Ambiguous without a named sampler (no candidates on the wire until PR3), and
|
|
* unresolved or absent over several listed samplers, merge the same way across
|
|
* every listed sampler and its stages. Otherwise (no resolution, or one listed
|
|
* sampler) exactly one listed sampler 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));
|
|
// Review I-1: an unresolved or absent prompt does not make the run's settings
|
|
// unknown, so several listed samplers merge the same way.
|
|
if (state === "ambiguous" || ((state === "unresolved" || state === "absent") && candidates.length > 1)) {
|
|
// The server's branches: every listed sampler, a detailer judged with its stages.
|
|
const stages = new Set();
|
|
if (!candidates.length || !candidates.every(([id]) => collectStages(graph, id, new Set(), stages, { visits: 0 }))) return factSet("unresolved", source);
|
|
return mergeFacts([...stages].map(id => samplerFacts(ctx, id, source)), source);
|
|
}
|
|
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();
|
|
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;
|
|
}
|
|
|
|
// The inspection resolver's `_footer` (Python): R5 also reads a bare `Steps: N`
|
|
// footer, and sd-dynamic-prompts' one-line template records after it.
|
|
const FOOTER_TRAILERS = ["Template:", "Negative Template:"];
|
|
function trailerLines(text) {
|
|
const lines = text.trim().split(/\r?\n|\r/).filter((line, index, all) => all.length > 1 || line);
|
|
const order = lines.map(line => FOOTER_TRAILERS.findIndex(name => line.startsWith(name)));
|
|
return !order.includes(-1) && order.every((value, index) => index === 0 || value > order[index - 1]);
|
|
}
|
|
function settingsFooter(parameters) {
|
|
let matches = [...parameters.matchAll(/^Steps:\s*\d+\s*,[^\n]*$/gm)];
|
|
if (!matches.length) matches = [...parameters.matchAll(/^Steps:\s*\d+\s*$/gm)];
|
|
if (matches.length !== 1) return null;
|
|
return trailerLines(parameters.slice(matches[0].index + matches[0][0].length)) ? matches[0] : null;
|
|
}
|
|
/** R10: split settings on commas outside A1111's quoted values; unbalanced quotes split as before. */
|
|
function settingsParts(line) {
|
|
const parts = [];
|
|
let start = 0, quoted = false;
|
|
for (let index = 0; index < line.length; index++) {
|
|
if (quoted && line[index] === "\\") index++;
|
|
else if (line[index] === '"') quoted = !quoted;
|
|
else if (line[index] === "," && !quoted) { parts.push(line.slice(start, index)); start = index + 1; }
|
|
}
|
|
if (quoted) return line.split(",");
|
|
parts.push(line.slice(start));
|
|
return parts;
|
|
}
|
|
function settingValue(value) {
|
|
if (!/^"(?:\\.|[^\\"])*"$/.test(value)) return value;
|
|
try { return JSON.parse(value); } catch { return value; }
|
|
}
|
|
|
|
function parameterFacts(parameters) {
|
|
const source = "embedded-parameters";
|
|
const footer = settingsFooter(parameters);
|
|
if (!footer) return factSet("unresolved", source);
|
|
// R4: like the resolver, text starting with the delimiter has an empty positive.
|
|
const body = "\n" + parameters.slice(0, footer.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 settingsParts(footer[0])) {
|
|
const match = part.match(/^\s*([^:]+):\s*(.*?)\s*$/);
|
|
if (!match) return factSet("unresolved", source);
|
|
const [, key, value] = match;
|
|
settings.set(key, settings.has(key) ? null : settingValue(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 };
|
|
// For the semantics sync test only; not part of the legacy page contract.
|
|
Object.defineProperty(api, "_factsTables", {
|
|
value: Object.freeze({
|
|
configuration_outputs: CONFIGURATION_OUTPUTS,
|
|
model_outputs: MODEL_OUTPUTS,
|
|
model_passthroughs: MODEL_PASSTHROUGHS,
|
|
scalar_outputs: SCALAR_OUTPUTS,
|
|
}),
|
|
});
|
|
Object.defineProperty(api, "_samplerTable", { value: SAMPLERS });
|
|
Object.defineProperty(api, "_tableClasses", { value: TABLE_CLASSES });
|
|
Object.defineProperty(api, "_stageInputs", { value: STAGE_INPUTS });
|
|
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
|
else root.ComfyMetadata = api;
|
|
})(typeof window !== "undefined" ? window : globalThis);
|