Files
comfy-adapter/app/injector.py
T

105 lines
3.8 KiB
Python

"""Build a concrete workflow graph from a template + request values.
Values are injected through placeholder tokens written directly inside the
workflow graph (e.g. "{prompt}", "{seed}"), so the graph itself declares which
node input receives which request field. The set of recognised tokens is fixed
and owned by the adapter, not by the model.
"""
import copy
import re
import secrets
from typing import Any
from .errors import InvalidRequest
SEED_MAX = 2**63 - 1
# Fixed token vocabulary owned by the adapter. A model's workflow may only use
# these tokens; anything else is rejected. Types drive typed vs string injection.
TOKEN_TYPES: dict[str, str] = {
"prompt": "str",
"negative": "str",
"seed": "int",
"width": "int",
"height": "int",
"image": "str",
}
KNOWN_TOKENS = frozenset(TOKEN_TYPES)
_TOKEN = re.compile(r"\{(\w+)\}")
_WHOLE = re.compile(r"^\{(\w+)\}$")
def parse_size(size: str) -> tuple[int, int]:
"""Parse OpenAI `WxH` size strings (e.g. '1024x1024')."""
try:
w_str, h_str = size.lower().split("x", 1)
width, height = int(w_str), int(h_str)
except ValueError as e:
raise InvalidRequest(f"invalid size '{size}', expected format 'WIDTHxHEIGHT'") from e
if width <= 0 or height <= 0:
raise InvalidRequest(f"invalid size '{size}', dimensions must be positive")
return width, height
def new_seed(seed: int | None) -> int:
if seed is None:
return secrets.randbelow(SEED_MAX) + 1
if not 0 <= seed <= SEED_MAX:
raise InvalidRequest(f"seed must be between 0 and {SEED_MAX}")
return seed
def extract_tokens(graph: dict[str, Any]) -> tuple[set[str], list[str]]:
"""Token names referenced by the graph.
Returns (known_tokens, unknown_token_errors). A token is any `{name}` inside
a string node input. Unknown names are reported so startup can reject the
workflow before it ever reaches a request.
"""
declared: set[str] = set()
unknown: set[str] = set()
for node_id, node in graph.items():
for field, value in node.get("inputs", {}).items():
if isinstance(value, str):
for name in _TOKEN.findall(value):
if name in KNOWN_TOKENS:
declared.add(name)
else:
unknown.add(f"node {node_id}: input '{field}' uses unknown token '{{{name}}}'")
return declared, sorted(unknown)
def build_workflow(template: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]:
"""Deep-copy the template and substitute placeholder tokens with values.
Injection is type-preserving: a value that is exactly one token (e.g.
"{seed}") is replaced by the typed object (int for seed/width/height, str
otherwise); a token embedded in surrounding text is string-interpolated.
A referenced token that is unrecognized or missing from ``values`` rejects
the build, so a malformed graph never reaches ComfyUI.
"""
graph = copy.deepcopy(template)
for node in graph.values():
inputs = node.get("inputs", {})
for field, value in inputs.items():
if isinstance(value, str):
inputs[field] = _substitute(value, values)
return graph
def _substitute(value: str, values: dict[str, Any]) -> Any:
for name in _TOKEN.findall(value):
if name not in KNOWN_TOKENS:
raise InvalidRequest(f"workflow uses unrecognized token '{{{name}}}'")
if name not in values:
raise InvalidRequest(f"no value supplied for token '{{{name}}}'")
whole = _WHOLE.fullmatch(value)
if whole:
return _coerce(whole.group(1), values[whole.group(1)])
return _TOKEN.sub(lambda m: str(values[m.group(1)]), value)
def _coerce(name: str, value: Any) -> Any:
return int(value) if TOKEN_TYPES[name] == "int" else str(value)