Files
DumiFlex-ComfyUI-Wildcard-P…/wp_api/preview.py
T

167 lines
6.0 KiB
Python

"""POST /wp/api/preview/resolve — assembler-preview RNG-faithful resolve.
Frontend assembler widget calls this to get the same `(name → value)`
map a real WP_Context chain would produce at run time when every node
is seeded with the supplied seed (default 42). Returning ground truth
from the engine guarantees the preview matches what the user sees on
queue without porting Python's MT19937 to JS.
Body shape:
{
"chain": [<modules-list-step-1>, <modules-list-step-2>, ...],
"seed": <int, default 42>
}
Each `modules-list-step-N` is the same list shape ContextWidget
serialises into a node's `modules` widget value: list of module dicts
with `id`, `type`, `enabled`, `payload`, `entries`, `instance`, ...
Returns:
{"resolved": {<name>: <value>, ...}}
Empty chain or any malformed step → empty resolved map (never 500).
"""
from __future__ import annotations
import logging
from typing import Any
from aiohttp import web
from engine.context import strip_internals
from engine.db.connection import get_connection
from engine.db.migrations import migrate
from engine.db.repositories import ModuleNotFound, ModuleRepository
from engine.modules.snapshot import walk_transitive_refs
from engine.pipeline import PipelineEngine
from engine.syntax.types import ListVar
from wp_api._helpers import json_error, json_ok
logger = logging.getLogger(__name__)
def _build_catalog(modules: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""Wildcard subset → SnapshotEntry-shaped catalog keyed by uuid.
Mirrors `wp_nodes/types.py:deserialize_node_input` so the engine
sees the same shape it gets at run time.
"""
catalog: dict[str, dict[str, Any]] = {}
for m in modules:
if not isinstance(m, dict) or m.get("type") != "wildcard":
continue
mid = m.get("id")
if not isinstance(mid, str):
continue
payload = m.get("payload")
if not isinstance(payload, dict):
continue
meta = m.get("meta") if isinstance(m.get("meta"), dict) else {}
catalog[mid] = {
"snapshot_version": 1,
"uuid": mid,
"type": "wildcard",
"name": meta.get("name", "") if isinstance(meta, dict) else "",
"payload": payload,
"payload_hash": m.get("payload_hash") or "",
"source": {"kind": "user"},
}
return catalog
def _expand_with_db(catalog: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""Pull in nested `@{uuid}` refs from the live DB so preview can
resolve them — mirrors `wp_nodes/context_node.py:_expand_catalog_via_live_db`.
Failure-tolerant: any DB hiccup returns the embedded catalog
unchanged and the resolver falls back to the standard
"unknown ref" warning at expand time.
"""
if not catalog:
return catalog
try:
conn = get_connection()
migrate(conn)
repo = ModuleRepository(conn)
except Exception:
return catalog
def _fetch(uuid: str) -> dict[str, Any] | None:
embedded = catalog.get(uuid)
if embedded is not None:
return {
"id": uuid,
"type": embedded["type"],
"name": embedded.get("name", ""),
"payload": embedded["payload"],
"payload_hash": embedded.get("payload_hash", ""),
}
try:
return repo.get(uuid)
except ModuleNotFound:
return None
except Exception:
return None
walk = walk_transitive_refs(list(catalog.keys()), fetch_module=_fetch)
return walk.snapshots
def _jsonify_resolved(resolved: dict[str, Any]) -> dict[str, Any]:
"""Make a resolved ctx map JSON-serialisable. A multi-pick var is a
`ListVar` dataclass (engine/syntax/types.py) that aiohttp's JSON encoder
can't handle — without this the endpoint 500s. Emit it as
``{"items": [...], "sep": ...}`` so the TS preview can join (bare $name)
or index ($name.K) it, matching the engine's resolution exactly."""
out: dict[str, Any] = {}
for k, v in resolved.items():
out[k] = {"items": list(v.items), "sep": v.sep} if isinstance(v, ListVar) else v
return out
async def resolve_preview(request: web.Request) -> web.Response:
try:
body = await request.json()
except Exception:
return json_error("invalid JSON body", status=400)
if not isinstance(body, dict):
return json_error("body must be a JSON object", status=400)
chain = body.get("chain")
if not isinstance(chain, list):
return json_error("chain must be a list", status=400)
try:
seed = int(body.get("seed", 42))
except (TypeError, ValueError):
return json_error("seed must be an integer", status=400)
# Walk chain steps upstream → downstream; each step's modules run
# in their own PipelineEngine.run() with the supplied seed. Earlier
# ctx feeds the next step as upstream. Catalog accumulates wildcards
# from every step + their nested refs from the live DB.
ctx: dict[str, Any] = {}
accum_catalog: dict[str, dict[str, Any]] = {}
for step in chain:
if not isinstance(step, list):
continue
modules = [m for m in step if isinstance(m, dict)]
accum_catalog.update(_build_catalog(modules))
# Re-expand each step so newly-introduced wildcards pull their
# nested deps too. walk_transitive_refs is idempotent.
accum_catalog = _expand_with_db(accum_catalog)
ctx["__wp_catalog__"] = accum_catalog
try:
ctx = PipelineEngine().run(modules, ctx=ctx, seed=seed)
except Exception:
logger.exception("preview resolve: pipeline run failed")
# Don't return 500 — the user is just typing. Surface
# whatever we resolved up to this point.
break
return json_ok({"resolved": _jsonify_resolved(strip_internals(ctx))})
def register(router) -> None:
router.add_post("/wp/api/preview/resolve", resolve_preview)