ComfyUI/app/prompt_metadata.py
Deep Mehta 74cfcaa318 feat(server): per-prompt metadata envelope on websocket events
Replaces the workflow_id-on-every-event approach (#13684, reverted in
#13901) with a generic metadata envelope captured at submission and
injected at the server-side send chokepoint.

- POST /prompt accepts an opaque ``extra_data.metadata`` dict (falls
  back to synthesizing ``{"workflow_id": <id>}`` from
  ``extra_pnginfo.workflow.id`` so existing frontends keep working).
- ``PromptServer`` owns a ``prompt_id -> metadata`` map populated at
  submission, drained when the prompt finishes. ``send_sync`` injects
  the envelope into any outbound payload that carries a ``prompt_id``,
  including the ``(preview_image, metadata_dict)`` tuple used by
  ``PREVIEW_IMAGE_WITH_METADATA``. WS reconnect path carries it too.
- Pure helpers live in ``app/prompt_metadata.py`` so the execution
  layer never depends on workflow concepts and the helpers can be
  unit-tested without torch.

Execution layer (``execution.py``, ``comfy_execution/*``) and the jobs
API are unchanged. Backward compatible: existing fields and shapes are
preserved, only an additional ``metadata`` field is attached when
present.
2026-05-14 20:47:00 -07:00

97 lines
3.5 KiB
Python

"""Per-prompt metadata envelope shared between submission and outbound events.
The metadata envelope is an opaque dict (e.g. ``{"workflow_id": ...}``)
attached to a prompt at submission and injected by the server into every
outbound execution event that carries a ``prompt_id``. It lets consumers
scope state by tags they care about (workflow, trace, tenant) without the
execution layer ever needing to know those tags exist.
Two pure functions live here; ``PromptServer`` owns the per-prompt map and
wires them into the submission and send paths.
"""
from __future__ import annotations
from typing import Any, Callable, Optional
def extract_envelope_from_extra_data(extra_data: Any) -> Optional[dict]:
"""Pull the per-prompt metadata envelope out of a submitted prompt's
``extra_data``.
Two sources, in order:
1. Explicit ``extra_data["metadata"]`` dict — preferred path, accepted
as-is (copied so later mutations on the caller's dict don't leak).
2. ``extra_data["extra_pnginfo"]["workflow"]["id"]`` — backward-
compatibility fallback. Frontends that already stamp the workflow
id into ``extra_pnginfo`` keep working without changes; the
synthesized envelope is ``{"workflow_id": <id>}``.
Returns ``None`` when neither source yields a usable envelope.
"""
if not isinstance(extra_data, dict):
return None
metadata = extra_data.get("metadata")
if isinstance(metadata, dict) and metadata:
return dict(metadata)
extra_pnginfo = extra_data.get("extra_pnginfo")
if isinstance(extra_pnginfo, dict):
workflow = extra_pnginfo.get("workflow")
if isinstance(workflow, dict):
workflow_id = workflow.get("id")
if isinstance(workflow_id, str) and workflow_id:
return {"workflow_id": workflow_id}
return None
def inject_envelope(
data: Any,
envelope_lookup: Callable[[str], Optional[dict]],
) -> Any:
"""Return ``data`` with a per-prompt ``metadata`` envelope attached.
``envelope_lookup`` is called with the payload's ``prompt_id`` and is
expected to return the registered envelope or ``None``. This indirection
keeps the function pure and avoids depending on any specific storage.
Two payload shapes are handled:
- **dict** carrying ``prompt_id``. A shallow copy is returned with a
``metadata`` key set to the envelope. The caller's dict is not
mutated.
- **(preview_image, metadata_dict) tuple** — the format used by
``PREVIEW_IMAGE_WITH_METADATA``. Only the inner dict is augmented;
the binary preview is passed through by reference.
The function is a no-op for:
- payloads without a ``prompt_id``,
- payloads already declaring their own ``metadata`` field
(callers can opt out by setting it explicitly),
- prompts with no registered envelope,
- any other payload shape (raw bytes, ``None``, etc.).
"""
def inject(d: dict) -> dict:
if not isinstance(d, dict) or "metadata" in d:
return d
prompt_id = d.get("prompt_id")
if not prompt_id:
return d
envelope = envelope_lookup(prompt_id)
if envelope is None:
return d
return {**d, "metadata": envelope}
if isinstance(data, dict):
return inject(data)
if isinstance(data, tuple) and len(data) == 2 and isinstance(data[1], dict):
injected = inject(data[1])
if injected is data[1]:
return data
return (data[0], injected)
return data