* feat(nodes): per-node Save to library switch (#90) Every PromptManager node (Classic, Text, Focused, Split, Pair) gets an optional save_to_library BOOLEAN, default on and declared last. Off, the node still returns its normal outputs but PromptManager ignores the run: - the node saves, counts, tracks and records nothing; - the queue hook reads the switch from the queued graph (literal, or a linked PrimitiveBoolean), so a cached node is not counted either; a switch wired from an unreadable node is left to the node itself; - an opted-out node is no run prompt, so no tracker entry, run key, embedded-metadata match or history attribution links its images, and a run with any opted-out positive node links none of its files; - the scan reports such files as not_saved (never stored or queued for Needs review), and a file whose only opted-out node is its negative is stored positive-only, without the pair; - execution history records no prompt text for an opted-out role. PromptSearchList only reads the library and gets no switch. The switch takes the widget index older workflows used for the editor's own DOM slot (''), so a configure step turns any non-boolean value on: workflows saved before the switch load with it on and no earlier widget value moves. The Focused, Split and Pair editors draw it as a "Save to library" checkbox in their toolbar; Classic and Text show the native toggle. * fix(nodes): Save to library review fixes (#90) - A switch wired from a node the graph cannot read and off at run time marks the node's run key, so an earlier run's tracker entry never links this run's images. - The scan reports such files as not_saved_wired ("only the run itself can link it") instead of not_saved. - Workflow-only metadata ignores muted and bypassed PromptManager nodes and reads nodes inside subgraph definitions. - The Focused, Split and Pair footer says when the switch comes from a link. * fix(usage): count cached re-runs of runtime-linked prompt text (#347) A PromptManager-family node whose text comes from a runtime link (PreviewAny, an LLM, a list node) counted only the job that executed it; later jobs served from ComfyUI's output cache counted nothing. Each execution now records a receipt (digests of its STRING outputs and hashes of the text it stores) per run key and queue item. On execution_cached the receipt whose digests equal the cache entry's text is replayed once for the job, with the node's role and what the queue hook already saw recomputed from the job's own graph. The executing node and the replay count through one shared own_counts. Unmatched cache entries count nothing, as before; a node that executes after its replay in the same job skips its own count. No schema change, no backfill. * fix(usage): never replay a node twice in one job; dedupe receipts once complete (#347) A repeated execution_cached id or message for the same job now counts the node once. Receipts replace equal older ones only when their job finishes (or the next job starts), so a list still recording [A] of [A, B] no longer drops an older [A] receipt. Node-level tests check the receipt each execution records. * test(coverage): baseline for the Save to library module (#90) * test(usage): disabled node then cached jobs counts 0 (#90, #347) A PromptManager node with Save to library off records no cached-run receipt and is no run prompt in the replayed job's graph, so ComfyUI serving it from its cache on later jobs counts nothing. The prompt is saved first by an enabled run, so a replayed count would show. Covers PromptManager, Focused, Split, Text and the Pair. * fix(nodes): hide the editor's native widgets through one helper (#90) The Save to library switch copied the text fallbacks' zero-height computeSize arrow, and neither copy was ever called, so focused-node.js function coverage fell to 87.5 (floor 90). One hideNativeWidget helper now hides both, and the spec checks that the hidden switch and a text fallback take no node height.
500 lines
19 KiB
Python
500 lines
19 KiB
Python
"""Find which PromptManager nodes feed a sampler's positive input in a ComfyUI graph.
|
|
|
|
Works on the API-format prompt graph ({node_id: {"class_type", "inputs"}}) that
|
|
ComfyUI queues and embeds in saved images. Usage counting and image linking use it
|
|
so that negative-prompt PromptManager nodes never count as "the prompt" of a run.
|
|
|
|
Pure functions with no ComfyUI imports, so they are unit-testable. Mirrors the
|
|
link-tracing rules of web/js/comfy-metadata.js.
|
|
"""
|
|
|
|
import re
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
PROMPT_MANAGER_TYPES = frozenset(
|
|
{
|
|
"PromptManager",
|
|
"PromptManagerText",
|
|
"PromptManagerFocused",
|
|
"PromptManagerSplit",
|
|
"PromptManagerPair",
|
|
}
|
|
)
|
|
|
|
# A node's switch (#90): False means PromptManager ignores that node's runs
|
|
SAVE_INPUT = "save_to_library"
|
|
# Core nodes whose output is a known boolean: (class_type) -> input holding it
|
|
_BOOLEAN_SOURCES = {"PrimitiveBoolean": "value"}
|
|
|
|
# Upper bound on nodes expanded per graph; queued and embedded graphs are untrusted
|
|
MAX_VISITS = 500
|
|
# Longest text resolve_text will build; StringConcatenate chains can double
|
|
# the text at every level, so anything larger is treated as unknowable
|
|
MAX_TEXT_LENGTH = 100_000
|
|
# Deepest chain of string nodes resolve_text follows (recursion stays bounded)
|
|
MAX_DEPTH = 64
|
|
|
|
# Inputs that carry positive conditioning or prompt text towards a sampler
|
|
_CONDITIONING_KEY = re.compile(r"^conditioning(_\w+)?$")
|
|
_TEXT_KEY = re.compile(r"^(text|string|prompt)(_?[a-z0-9]+)?$", re.IGNORECASE)
|
|
# Input names that carry a negative prompt (text_negative, neg_text, negative_prompt)
|
|
_NEGATIVE_KEY = re.compile(r"(^|_)neg(ative)?($|_)", re.IGNORECASE)
|
|
|
|
|
|
def follows_input(key: Any) -> bool:
|
|
"""Whether an input name carries positive conditioning or prompt text."""
|
|
if not isinstance(key, str) or _NEGATIVE_KEY.search(key):
|
|
return False
|
|
return key == "positive" or bool(
|
|
_CONDITIONING_KEY.match(key) or _TEXT_KEY.match(key)
|
|
)
|
|
|
|
|
|
def _is_link(value: Any) -> bool:
|
|
return (
|
|
isinstance(value, (list, tuple))
|
|
and len(value) == 2
|
|
and isinstance(value[0], (str, int))
|
|
and isinstance(value[1], int)
|
|
)
|
|
|
|
|
|
def _inputs(node: Any) -> Dict[str, Any]:
|
|
inputs = node.get("inputs") if isinstance(node, dict) else None
|
|
return inputs if isinstance(inputs, dict) else {}
|
|
|
|
|
|
def _as_boolean(value: Any) -> bool:
|
|
"""ComfyUI's coercion of a BOOLEAN input before the node runs."""
|
|
if isinstance(value, dict) and "__value__" in value:
|
|
value = value["__value__"]
|
|
return bool(value)
|
|
|
|
|
|
def save_setting(graph: Any, node_id: Any) -> Optional[bool]:
|
|
"""A PromptManager node's ``save_to_library`` as the node will receive it.
|
|
|
|
True when the input is missing (every workflow and image from before the
|
|
switch), the literal coerced like ComfyUI does, the value of a linked core
|
|
``PrimitiveBoolean``, and None when the switch is wired from a node whose
|
|
output cannot be known from the graph.
|
|
"""
|
|
node = graph.get(str(node_id)) if isinstance(graph, dict) else None
|
|
inputs = _inputs(node)
|
|
if SAVE_INPUT not in inputs:
|
|
return True
|
|
value = inputs[SAVE_INPUT]
|
|
if not _is_link(value):
|
|
return _as_boolean(value)
|
|
source = graph.get(str(value[0]))
|
|
if not isinstance(source, dict) or value[1] != 0:
|
|
return None
|
|
key = _BOOLEAN_SOURCES.get(str(source.get("class_type", "")))
|
|
source_inputs = _inputs(source)
|
|
if key is None or key not in source_inputs or _is_link(source_inputs[key]):
|
|
return None
|
|
return _as_boolean(source_inputs[key])
|
|
|
|
|
|
def opted_out(graph: Any, node_id: Any) -> bool:
|
|
"""Whether a PromptManager node's switch is off: PromptManager ignores it."""
|
|
return save_setting(graph, node_id) is False
|
|
|
|
|
|
def side_opted_out(graph: Any, side: Any) -> bool:
|
|
"""Whether a resolved side's path passes a PromptManager node that is off.
|
|
|
|
``side`` is a resolver side (``positive`` or ``negative`` of a resolved
|
|
pair): its text must not be stored when any PromptManager node on its
|
|
provenance path has its switch off or wired from an unreadable node (#90).
|
|
"""
|
|
if not isinstance(graph, dict) or not isinstance(side, dict):
|
|
return False
|
|
provenance = side.get("provenance")
|
|
path = provenance.get("path") if isinstance(provenance, dict) else None
|
|
for step in path if isinstance(path, list) else []:
|
|
node_id = step.get("node_id") if isinstance(step, dict) else None
|
|
node = graph.get(str(node_id)) if node_id is not None else None
|
|
if (
|
|
isinstance(node, dict)
|
|
and node.get("class_type") in PROMPT_MANAGER_TYPES
|
|
and save_setting(graph, node_id) is not True
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _workflow_node_opted_out(node: Any) -> bool:
|
|
"""A saved-workflow PromptManager node whose widget values hold ``False``.
|
|
|
|
No other PromptManager widget holds a boolean, so the value is the switch.
|
|
"""
|
|
if not isinstance(node, dict):
|
|
return False
|
|
if (node.get("type") or node.get("class_type")) not in PROMPT_MANAGER_TYPES:
|
|
return False
|
|
if node.get("mode") in (2, 4):
|
|
return False # muted or bypassed: it did not run
|
|
values = node.get("widgets_values")
|
|
return isinstance(values, list) and any(value is False for value in values)
|
|
|
|
|
|
def metadata_opted_out(metadata: Any, *, unreadable: bool = False) -> bool:
|
|
"""Whether a file's metadata says its run's prompt does not save (#90).
|
|
|
|
The executed graph (``prompt``) decides when present: a node of
|
|
``positive_opt_outs``, or with ``unreadable`` also a run prompt whose
|
|
switch is wired from a node the graph cannot read. Without one, the
|
|
saved workflow (``workflow`` nodes or the extractor's
|
|
``text_encoder_nodes``) is opted out when a PromptManager node stores
|
|
``False``.
|
|
"""
|
|
if not isinstance(metadata, dict):
|
|
return False
|
|
graph = metadata.get("prompt")
|
|
if isinstance(graph, dict):
|
|
if positive_opt_outs(graph):
|
|
return True
|
|
return unreadable and any(
|
|
save_setting(graph, node_id) is None for node_id in run_prompt_nodes(graph)
|
|
)
|
|
workflow = metadata.get("workflow")
|
|
nodes = list(metadata.get("text_encoder_nodes") or [])
|
|
if isinstance(workflow, dict) and isinstance(workflow.get("nodes"), list):
|
|
nodes.extend(workflow["nodes"])
|
|
definitions = workflow.get("definitions")
|
|
subgraphs = (
|
|
definitions.get("subgraphs") if isinstance(definitions, dict) else None
|
|
)
|
|
for subgraph in subgraphs if isinstance(subgraphs, list) else []:
|
|
inner = subgraph.get("nodes") if isinstance(subgraph, dict) else None
|
|
nodes.extend(inner if isinstance(inner, list) else [])
|
|
# Without the executed graph the node's role is unknown: any opted-out
|
|
# PromptManager node opts the file out (never a guessed link).
|
|
return any(_workflow_node_opted_out(node) for node in nodes[:MAX_VISITS])
|
|
|
|
|
|
def _positive_roots(graph: Dict[str, Any]) -> List[Any]:
|
|
"""Links into samplers' positive inputs (KSampler, CFGGuider, BasicGuider, ...).
|
|
|
|
A sampler is a node taking a model plus positive/negative conditioning, or a
|
|
guider taking a model plus conditioning. Nodes named like a sampler also count
|
|
without a model link, so a negative-only graph is never mistaken for one with
|
|
no sampler at all.
|
|
"""
|
|
roots = []
|
|
for node in graph.values():
|
|
if not isinstance(node, dict):
|
|
continue
|
|
inputs = _inputs(node)
|
|
class_name = str(node.get("class_type", "")).lower()
|
|
has_model = _is_link(inputs.get("model"))
|
|
if _is_link(inputs.get("positive")) and _is_link(inputs.get("negative")):
|
|
if has_model or "sampler" in class_name:
|
|
roots.append(inputs["positive"])
|
|
elif has_model and _is_link(inputs.get("conditioning")):
|
|
if "guider" in class_name:
|
|
roots.append(inputs["conditioning"])
|
|
return roots
|
|
|
|
|
|
def _upstream_links(inputs: Dict[str, Any]) -> List[Any]:
|
|
"""Inputs to follow from a pass-through or encoder node, never `negative`."""
|
|
links = []
|
|
for key, value in inputs.items():
|
|
if not _is_link(value):
|
|
continue
|
|
if follows_input(key):
|
|
links.append(value)
|
|
return links
|
|
|
|
|
|
def has_sampler(graph: Any) -> bool:
|
|
"""Whether the graph contains a node recognised as a sampler or guider."""
|
|
return isinstance(graph, dict) and bool(_positive_roots(graph))
|
|
|
|
|
|
def positive_prompt_components(graph: Any) -> List[Tuple[str, str]]:
|
|
"""Node/input identities reaching actual sampler positive wires.
|
|
|
|
A node whose switch is off is not one, and nothing upstream of it is
|
|
followed (#90): see ``positive_opt_outs``.
|
|
"""
|
|
return _walk_positive(graph)[0]
|
|
|
|
|
|
def _walk_positive(graph: Any) -> Tuple[List[Tuple[str, str]], List[str]]:
|
|
"""(components of enabled nodes, opted-out nodes) on sampler positive wires."""
|
|
if not isinstance(graph, dict):
|
|
return [], []
|
|
|
|
found: List[Tuple[str, str]] = []
|
|
skipped: List[str] = []
|
|
seen = set()
|
|
stack = list(reversed(_positive_roots(graph)))
|
|
while stack and len(seen) < MAX_VISITS:
|
|
link = stack.pop()
|
|
node_id, slot = str(link[0]), link[1]
|
|
if (node_id, slot) in seen:
|
|
continue
|
|
seen.add((node_id, slot))
|
|
node = graph.get(node_id)
|
|
if not isinstance(node, dict):
|
|
continue
|
|
if node.get("class_type") in PROMPT_MANAGER_TYPES:
|
|
if opted_out(graph, node_id):
|
|
if node_id not in skipped:
|
|
skipped.append(node_id)
|
|
continue
|
|
field = "text"
|
|
if node.get("class_type") == "PromptManagerPair":
|
|
field = {
|
|
0: "text",
|
|
1: "negative_text",
|
|
2: "text",
|
|
3: "negative_text",
|
|
}.get(slot)
|
|
if field is None:
|
|
continue
|
|
identity = (node_id, field)
|
|
if identity not in found:
|
|
found.append(identity)
|
|
continue
|
|
stack.extend(reversed(_upstream_links(_inputs(node))))
|
|
return found, skipped
|
|
|
|
|
|
def _sampler_less_nodes(graph: Dict[str, Any]) -> List[str]:
|
|
"""Every PromptManager node of a graph without a recognisable sampler."""
|
|
return [
|
|
str(node_id)
|
|
for node_id, node in graph.items()
|
|
if isinstance(node, dict) and node.get("class_type") in PROMPT_MANAGER_TYPES
|
|
]
|
|
|
|
|
|
def positive_opt_outs(graph: Any) -> List[str]:
|
|
"""PromptManager nodes with their switch off that would be a run's prompt.
|
|
|
|
Those on a sampler's positive wires, or, in a graph without a recognisable
|
|
sampler, any. A run with one of them links no file to any prompt (#90):
|
|
the file's graph cannot always say which sampler wrote it.
|
|
"""
|
|
if not isinstance(graph, dict):
|
|
return []
|
|
found, skipped = _walk_positive(graph)
|
|
if found or skipped or has_sampler(graph):
|
|
return skipped
|
|
return [
|
|
node_id for node_id in _sampler_less_nodes(graph) if opted_out(graph, node_id)
|
|
]
|
|
|
|
|
|
def positive_prompt_nodes(graph: Any) -> List[str]:
|
|
"""Nodes whose main editor reaches a sampler's positive input."""
|
|
return list(
|
|
dict.fromkeys(
|
|
node_id
|
|
for node_id, field in positive_prompt_components(graph)
|
|
if field == "text"
|
|
)
|
|
)
|
|
|
|
|
|
def run_prompt_components(graph: Any) -> List[Tuple[str, str]]:
|
|
"""Actual positive components, retaining standalone main-editor fallback."""
|
|
components, skipped = _walk_positive(graph)
|
|
if components or skipped or not isinstance(graph, dict) or has_sampler(graph):
|
|
return components
|
|
return [
|
|
(node_id, "text")
|
|
for node_id in _sampler_less_nodes(graph)
|
|
if not opted_out(graph, node_id)
|
|
]
|
|
|
|
|
|
def run_prompt_nodes(graph: Any) -> List[str]:
|
|
"""Prompt nodes that represent a run: the positive ones, if any can be found.
|
|
|
|
When the graph has no recognisable sampler (an unrecognised custom sampler,
|
|
say), every PromptManager node counts, as before 3.2.4, so an exotic workflow
|
|
never silently loses usage counting or image linking. When a sampler exists
|
|
and no PromptManager node feeds its positive input, nothing counts: the only
|
|
PromptManager nodes are negative prompts. A node whose switch is off never
|
|
counts (#90).
|
|
"""
|
|
return list(
|
|
dict.fromkeys(
|
|
node_id
|
|
for node_id, field in run_prompt_components(graph)
|
|
if field == "text"
|
|
)
|
|
)
|
|
|
|
|
|
def _join_text_inputs(
|
|
inputs: Dict[str, Any], keys: List[str], resolve
|
|
) -> Optional[str]:
|
|
"""WAS-style join: resolve each key, optionally strip, skip empty, join by
|
|
delimiter."""
|
|
delimiter = inputs.get("delimiter", " ")
|
|
if not isinstance(delimiter, str):
|
|
return None
|
|
clean = str(inputs.get("clean_whitespace", "true")).lower() == "true"
|
|
parts = []
|
|
for key in keys:
|
|
value = resolve(inputs.get(key, ""))
|
|
if value is None:
|
|
return None
|
|
value = value.strip() if clean else value
|
|
if value:
|
|
parts.append(value)
|
|
return delimiter.join(parts)
|
|
|
|
|
|
def _resolve_string_node(
|
|
class_type: str, inputs: Dict[str, Any], resolve
|
|
) -> Optional[str]:
|
|
"""Output of a known pure string node, or None when the type isn't known."""
|
|
if class_type in ("PrimitiveString", "PrimitiveStringMultiline"):
|
|
return resolve(inputs.get("value"))
|
|
if class_type in ("ShowText|pysssss", "Text Multiline", "String Literal"):
|
|
return resolve(inputs.get("text", inputs.get("string")))
|
|
if class_type == "StringConcatenate":
|
|
a, b = resolve(inputs.get("string_a", "")), resolve(inputs.get("string_b", ""))
|
|
delimiter = inputs.get("delimiter", "")
|
|
if a is None or b is None or not isinstance(delimiter, str):
|
|
return None
|
|
return f"{a}{delimiter}{b}"
|
|
if class_type == "Text Concatenate":
|
|
keys = sorted(k for k in inputs if re.fullmatch(r"text_[a-z]", k))
|
|
return _join_text_inputs(inputs, keys, resolve)
|
|
return None
|
|
|
|
|
|
def resolve_text(graph: Any, node_id: str) -> Optional[str]:
|
|
"""A prompt node's `text` as it will be at run time, if knowable from the graph.
|
|
|
|
Typed text, or text produced by known pure string nodes (core primitives and
|
|
StringConcatenate, WAS Text Concatenate/Multiline, pysssss ShowText). Anything
|
|
else, including PromptSearchList batches, returns None rather than a guess.
|
|
Callers compare the result with the text the node actually received.
|
|
"""
|
|
if not isinstance(graph, dict):
|
|
return None
|
|
# Cycle detection is scoped to the current path so a node read by two
|
|
# branches (a diamond, or StringConcatenate(string_a=X, string_b=X))
|
|
# resolves in both. Each node's text is computed once and memoised, so a
|
|
# chain of diamonds costs one expansion per node rather than one per path,
|
|
# and the shared visit budget bounds total work whatever the shape. A node
|
|
# that fails because of a cycle fails from every path (the cycle is in the
|
|
# graph, not in the walk), so memoising None is sound.
|
|
path = {str(node_id)}
|
|
memo: Dict[str, Optional[str]] = {}
|
|
budget = [MAX_VISITS]
|
|
|
|
def resolve(value: Any) -> Optional[str]:
|
|
if isinstance(value, str):
|
|
return value if len(value) <= MAX_TEXT_LENGTH else None
|
|
if not _is_link(value):
|
|
return None
|
|
source_id = str(value[0])
|
|
if source_id in memo:
|
|
return memo[source_id]
|
|
if source_id in path or budget[0] <= 0 or len(path) > MAX_DEPTH:
|
|
return None
|
|
budget[0] -= 1
|
|
source = graph.get(source_id)
|
|
if not isinstance(source, dict):
|
|
memo[source_id] = None
|
|
return None
|
|
path.add(source_id)
|
|
try:
|
|
text = _resolve_string_node(
|
|
str(source.get("class_type", "")), _inputs(source), resolve
|
|
)
|
|
finally:
|
|
path.discard(source_id)
|
|
if isinstance(text, str) and len(text) > MAX_TEXT_LENGTH:
|
|
text = None
|
|
memo[source_id] = text
|
|
return text
|
|
|
|
text = resolve(_inputs(graph.get(str(node_id))).get("text"))
|
|
return text.strip() if isinstance(text, str) and text.strip() else None
|
|
|
|
|
|
def intrinsic_pair_nodes(graph: Any) -> List[str]:
|
|
"""Paired nodes whose matching outputs feed the same sampler, or standalone.
|
|
|
|
A pair whose switch is off is never one (#90).
|
|
"""
|
|
return [
|
|
node_id
|
|
for node_id in _intrinsic_pair_nodes(graph)
|
|
if not opted_out(graph, node_id)
|
|
]
|
|
|
|
|
|
def _intrinsic_pair_nodes(graph: Any) -> List[str]:
|
|
if not isinstance(graph, dict) or len(graph) > MAX_VISITS:
|
|
return []
|
|
if not has_sampler(graph):
|
|
return [
|
|
str(key)
|
|
for key, value in graph.items()
|
|
if isinstance(value, dict)
|
|
and value.get("class_type") == "PromptManagerPair"
|
|
]
|
|
|
|
def origin(link: Any, role: str, kind: str = "conditioning") -> Optional[str]:
|
|
seen = set()
|
|
while _is_link(link) and len(seen) < MAX_DEPTH:
|
|
node_id, slot = str(link[0]), link[1]
|
|
if (node_id, slot, kind) in seen:
|
|
return None
|
|
seen.add((node_id, slot, kind))
|
|
node = graph.get(node_id)
|
|
if not isinstance(node, dict):
|
|
return None
|
|
name, inputs = node.get("class_type"), _inputs(node)
|
|
if name == "PromptManagerPair":
|
|
expected = {
|
|
("positive", "conditioning"): 0,
|
|
("negative", "conditioning"): 1,
|
|
("positive", "string"): 2,
|
|
("negative", "string"): 3,
|
|
}
|
|
return node_id if slot == expected[(role, kind)] else None
|
|
if name == "CLIPTextEncode" and slot == 0 and kind == "conditioning":
|
|
link, kind = inputs.get("text"), "string"
|
|
elif name == "Reroute" and slot == 0:
|
|
keys = [key for key in ("value", "input", "in") if key in inputs]
|
|
if len(keys) != 1:
|
|
return None
|
|
link = inputs[keys[0]]
|
|
else:
|
|
return None
|
|
return None
|
|
|
|
found = []
|
|
for node in graph.values():
|
|
if not isinstance(node, dict):
|
|
continue
|
|
inputs = _inputs(node)
|
|
if not _is_link(inputs.get("positive")) or not _is_link(inputs.get("negative")):
|
|
continue
|
|
if (
|
|
not _is_link(inputs.get("model"))
|
|
and "sampler" not in str(node.get("class_type", "")).lower()
|
|
):
|
|
continue
|
|
positive = origin(inputs["positive"], "positive")
|
|
if (
|
|
positive
|
|
and positive == origin(inputs["negative"], "negative")
|
|
and positive not in found
|
|
):
|
|
found.append(positive)
|
|
return found
|