Files
ComfyAssets-ComfyUI_PromptM…/web/js/comfy-metadata.js
T
Vito aa768f57dd fix: release 3.2.3 — metadata parsing, null-safe saves, auto-hide, tag search (#151)
* fix: release 3.2.3 — metadata parsing, null-safe saves, auto-hide, tag search

- fix(#75): stop SaveImage patch from writing the last-run prompt into every
  PromptManager node (negative prompts were saved as copies of the positive)
- fix(#75): replace four duplicated metadata parsers with a shared
  web/js/comfy-metadata.js that traces sampler links (KSampler, CFGGuider /
  SamplerCustomAdvanced, LoRA chains, ControlNet pass-through, any checkpoint
  loader) and recovers per-node text for images saved by older versions
- fix(#143): treat null JSON body fields as empty in prompt API handlers
- fix(#149): keep auto-hide timeout 0 (disabled) instead of falling back to
  the default; validate result_timeout on save
- feat(#150): support tag:name filters in the node search box
- docs(#142): scan dialog mentions A1111/Forge metadata support
- docs(#144): fix ComfyUI-Lora-Manager links
- ci: run JS tests with node --test

* fix: bound metadata graph traversal to prevent tab freeze on crafted PNGs

Link resolution tried every linked input recursively with only a depth cap,
so a PNG whose prompt graph has cycles and fan-out could force ~inputs^25
paths and hang the gallery/metadata page. Each lookup now shares a visited
set (each node expanded once) with a hard cap of 500 visits.

* fix: read the matching output of multi-output settings nodes

KSampler settings linked to a node like SamplerCombo all resolved to its
first value, so steps, cfg and sampler showed the sampler name. Scalar
resolution now prefers the linked node's input with the same field name.

* fix: address review — trust API text, restore workflow-only parsing

- Workflow widget text now only replaces API text for metadata showing the
  pre-3.2.3 corruption signature (2+ PromptManager nodes with identical API
  text but different widgets). Otherwise the API graph wins, so expanded
  dynamic prompts and linked text display what actually ran.
- Images with only a UI workflow are converted to the API graph shape and
  traced the same way: prompts, KSamplerAdvanced, UNET/checkpoint loaders,
  KJNodes Set/Get, Reroute, bypassed and muted nodes.
2026-10-07 11:27:40 -07:00

339 lines
15 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) {
const widgets = new Map();
for (const n of workflowNodes(workflow)) {
const text = widgetsOf(n)[0];
if (/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 };
}
/**
* 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 [];
const id = String(link[0]);
const node = ctx.graph[id];
if (!node || !node.inputs || seen.has(id)) return [];
seen.add(id);
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 (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 ("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 ("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;
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,
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;
}
/**
* 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) {
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 api = { extractGenerationParams, PLACEHOLDERS };
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.ComfyMetadata = api;
})(typeof window !== "undefined" ? window : globalThis);