* feat(inspector): expose bounded physical file evidence * fix(inspector): distinguish ambiguous saved associations * feat(inspector): derive conservative generation facts * fix(inspector): guard fact ancestry and inspection state * feat(inspector): share complete file details and exports * fix(inspector): preserve fact states and touch targets * feat(viewer): navigate the loaded media collection Refs #216 * fix(viewer): refit after current stage geometry changes Ref #216. Observe the current media stage after readiness and wrapped title changes without rebuilding focused controls or resetting transforms at unchanged dimensions. * feat(viewer): add lifecycle-safe slideshow and media preferences * fix(viewer): resume gallery continuation after close (#216) * fix(images): guard source recovery and wrap viewer actions Clear stale root and folder intent, cancel old pages, and wait for current source discovery before loading a refreshed collection. Preserve safe retry through failed, superseded, and disposed discovery. Wrap narrow viewer footer actions and wait for measured gallery viewport settling in responsive checks. Refs #216; addresses PR #224 review. * test(viewer): await gallery layout after viewport changes Require actual inner, root client, and document widths to agree after each responsive-case viewport and CSS zoom transition. Keep the existing exact frame, overflow, focus, and coarse target assertions unchanged. Observed PR #224 CI initial mobile resize at537px before pending card relayout settled to356px. Refs #216.
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);
|
|
const entries = Object.entries(graph);
|
|
if (entries.length > MAX_VISITS) return factSet("unresolved", source, "projection-limited");
|
|
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 (record.value?.nodes?.length > MAX_VISITS) return factSet("unresolved", source, "projection-limited");
|
|
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);
|