Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37b77f837a | ||
|
|
1113a80229 | ||
|
|
58d93daf2a | ||
|
|
97fbfd892c |
@@ -82,6 +82,15 @@ CLIENT_CAPABILITIES = [
|
||||
# about IO.Custom fallthrough get filtered out before they try to
|
||||
# parse a descriptor whose io_type strings they wouldn't recognise.
|
||||
Capability.IO_OPAQUE,
|
||||
# 3D MODEL envelope (binary glTF 2.0 — single-mesh GLB). The
|
||||
# decoder in ``serialization.decode_model3d_envelope`` hands raw
|
||||
# GLB bytes to ``comfy_api.latest._util.File3D`` so downstream
|
||||
# nodes (preview3d / SaveGLB / Load3D) see the same value shape
|
||||
# they would from a local Tripo / Rodin / Meshy node. Servers
|
||||
# gate ``MODEL_3D`` provider descriptors on this token so a
|
||||
# legacy client without the decoder surfaces NEGOTIATION_FAILED
|
||||
# at descriptor-load time rather than crashing at execute.
|
||||
Capability.MODEL_3D_GLB_INLINE,
|
||||
]
|
||||
|
||||
# Reported in the X-RNP-Client-Version header.
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
"""Smoke test for the DYNAMIC_COMBO + AUTOGROW parser extension.
|
||||
|
||||
Verifies the proxy_node parser now constructs real V3
|
||||
``IO.DynamicCombo.Input`` and ``IO.Autogrow.Input`` objects from the
|
||||
flattened RNP wire shapes that the server already emits, instead of
|
||||
falling through to the opaque ``IO.Custom`` bucket.
|
||||
|
||||
Checks:
|
||||
|
||||
1. Bria-shaped 2-branch DYNAMIC_COMBO (the "false" branch has empty
|
||||
``inputs``; the "true" branch has 3 BOOLEAN sub-widgets) parses
|
||||
into an ``IO.DynamicCombo.Input`` whose two ``Option``s carry the
|
||||
right keys + sub-input objects. Proves empty branches are kept and
|
||||
that the outer id is whatever the descriptor key was (here
|
||||
``"moderation"``, *not* ``"model"``).
|
||||
2. Grok-V2-shaped DYNAMIC_COMBO with three branches, each containing
|
||||
a nested AUTOGROW(IMAGE) plus a COMBO + INT (and an aspect_ratio
|
||||
COMBO on 2 of the 3 branches). Asserts the AUTOGROW becomes a real
|
||||
``IO.Autogrow.Input`` with a ``TemplatePrefix`` whose underlying
|
||||
input is ``IO.Image.Input``, and that prefix / min / max survive.
|
||||
3. ElevenLabs-shaped DYNAMIC_COMBO whose branches mix
|
||||
``IO.Custom(<opaque_io_type>).Input`` (voiceN custom-IO) with
|
||||
primitive STRING sub-widgets — proves the recursive opaque
|
||||
fallback path inside a branch still works for partner helper
|
||||
types.
|
||||
4. Synthetic nested DYNAMIC_COMBO-inside-DYNAMIC_COMBO — no provider
|
||||
uses this today, but defensive coverage so the recursive entry
|
||||
point keeps working.
|
||||
5. Malformed DYNAMIC_COMBO / AUTOGROW shapes return ``None`` (so the
|
||||
caller skips the descriptor entirely) instead of silently
|
||||
falling through to the opaque IO.Custom bucket.
|
||||
6. Regression: ``_collect_local_validate`` output is byte-identical
|
||||
before and after the parser change (the rules-extraction path is
|
||||
independent of input construction and must not regress).
|
||||
7. AUTOGROW template that is itself a dynamic input is rejected
|
||||
(upstream Autogrow asserts this).
|
||||
8. Source-level check: the proxy_node docstring + dispatch covers
|
||||
DYNAMIC_COMBO and AUTOGROW (not just the existing primitive set).
|
||||
|
||||
Run with the ComfyUI venv (needs torch / comfy_api on PYTHONPATH):
|
||||
|
||||
python notes/run_dynamic_combo_parser.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Locate the worktree under test.
|
||||
# ---------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_CLIENT_DIR = os.path.dirname(_HERE)
|
||||
_COMFYUI_DIR = os.path.dirname(os.path.dirname(_CLIENT_DIR))
|
||||
|
||||
|
||||
def _load_module(name: str, path: str) -> types.ModuleType:
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub all the heavy deps proxy_node + comfy_api/latest/_io.py pull in.
|
||||
#
|
||||
# We only need three things from the world:
|
||||
#
|
||||
# * the real V3 IO classes (DynamicCombo / Autogrow / Image / Combo / String /
|
||||
# Int / Boolean / Custom) — load ``_io.py`` standalone so we don't pay the
|
||||
# full ``comfy_api.latest`` package init (PIL, numpy, comfy.cli_args,
|
||||
# comfy_execution, the sync-class generator etc.).
|
||||
# * the real proxy_node parser helpers — load ``proxy_node.py`` after the
|
||||
# ``comfy_api.latest`` shim is in place.
|
||||
# * the real protocol module (lightweight; for the ErrorCode / Header / etc.
|
||||
# imports proxy_node does up top).
|
||||
#
|
||||
# Everything else (server.PromptServer, aiohttp, comfy_api_nodes.*) is
|
||||
# stubbed because proxy_node only references those at request-time, not at
|
||||
# import-time-after-trivial-stub.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 1) Stub ``torch`` — _io.py uses ``torch.Tensor`` only in type annotations
|
||||
# (``Type = torch.Tensor`` class-level field), so a bare-class stub works.
|
||||
_torch_stub = types.ModuleType("torch")
|
||||
class _Tensor: # noqa: D401
|
||||
pass
|
||||
_torch_stub.Tensor = _Tensor
|
||||
sys.modules.setdefault("torch", _torch_stub)
|
||||
|
||||
# 2) Stub ``comfy_execution.graph_utils.ExecutionBlocker`` — only used as a
|
||||
# class reference inside Output construction; never instantiated here.
|
||||
_comfy_execution = types.ModuleType("comfy_execution")
|
||||
_graph_utils = types.ModuleType("comfy_execution.graph_utils")
|
||||
class _ExecutionBlocker: # noqa: D401
|
||||
pass
|
||||
_graph_utils.ExecutionBlocker = _ExecutionBlocker
|
||||
sys.modules.setdefault("comfy_execution", _comfy_execution)
|
||||
sys.modules.setdefault("comfy_execution.graph_utils", _graph_utils)
|
||||
|
||||
# 3) Stub ``comfy_api.internal`` — _io.py imports a handful of helper names.
|
||||
# The parser code paths exercised here don't actually invoke any of these;
|
||||
# bare-function / bare-class stubs are sufficient to satisfy the import.
|
||||
_comfy_api = types.ModuleType("comfy_api")
|
||||
_comfy_api.__path__ = []
|
||||
_comfy_api_internal = types.ModuleType("comfy_api.internal")
|
||||
class _ComfyNodeInternal: # noqa: D401
|
||||
pass
|
||||
class _NodeOutputInternal: # noqa: D401
|
||||
pass
|
||||
class _classproperty: # noqa: D401
|
||||
def __init__(self, f):
|
||||
self.f = f
|
||||
def __get__(self, instance, owner):
|
||||
return self.f(owner)
|
||||
def _identity(x, *_args, **_kw):
|
||||
return x
|
||||
def _first_real_override(*_a, **_kw):
|
||||
return None
|
||||
def _is_class(x):
|
||||
return isinstance(x, type)
|
||||
def _prune_dict(d):
|
||||
return {k: v for k, v in (d or {}).items() if v is not None}
|
||||
_comfy_api_internal._ComfyNodeInternal = _ComfyNodeInternal
|
||||
_comfy_api_internal._NodeOutputInternal = _NodeOutputInternal
|
||||
_comfy_api_internal.classproperty = _classproperty
|
||||
_comfy_api_internal.copy_class = _identity
|
||||
_comfy_api_internal.first_real_override = _first_real_override
|
||||
_comfy_api_internal.is_class = _is_class
|
||||
_comfy_api_internal.prune_dict = _prune_dict
|
||||
_comfy_api_internal.shallow_clone_class = _identity
|
||||
sys.modules.setdefault("comfy_api", _comfy_api)
|
||||
sys.modules.setdefault("comfy_api.internal", _comfy_api_internal)
|
||||
|
||||
# 3b) Stub ``comfy_api.input`` for the lazy imports inside _io.py's
|
||||
# Curve / RangeInput class bodies — the parser code never touches
|
||||
# those types, but the *class body* runs at module load.
|
||||
_comfy_api_input = types.ModuleType("comfy_api.input")
|
||||
class _CurvePoint: pass # noqa: E701,D401
|
||||
class _RangeInput: pass # noqa: E701,D401
|
||||
_comfy_api_input.CurvePoint = _CurvePoint
|
||||
_comfy_api_input.RangeInput = _RangeInput
|
||||
sys.modules.setdefault("comfy_api.input", _comfy_api_input)
|
||||
|
||||
# 4) Stub ``comfy_api.latest._util`` — _io.py imports MESH / VOXEL / SVG /
|
||||
# File3D as type-token classes. We don't construct any here.
|
||||
_comfy_api_latest = types.ModuleType("comfy_api.latest")
|
||||
_comfy_api_latest.__path__ = []
|
||||
_comfy_api_latest_util = types.ModuleType("comfy_api.latest._util")
|
||||
class _MESH: pass # noqa: E701,D401
|
||||
class _VOXEL: pass # noqa: E701,D401
|
||||
class _SVG: pass # noqa: E701,D401
|
||||
class _File3D: pass # noqa: E701,D401
|
||||
_comfy_api_latest_util.MESH = _MESH
|
||||
_comfy_api_latest_util.VOXEL = _VOXEL
|
||||
_comfy_api_latest_util.SVG = _SVG
|
||||
_comfy_api_latest_util.File3D = _File3D
|
||||
sys.modules.setdefault("comfy_api.latest", _comfy_api_latest)
|
||||
sys.modules.setdefault("comfy_api.latest._util", _comfy_api_latest_util)
|
||||
|
||||
# 5) Load the real ``_io.py`` as ``comfy_api.latest._io`` — its
|
||||
# relative ``from ._util import ...`` requires the parent-package
|
||||
# setup we just did.
|
||||
_io_path = os.path.join(_COMFYUI_DIR, "comfy_api", "latest", "_io.py")
|
||||
_io_mod = _load_module("comfy_api.latest._io", _io_path)
|
||||
# Expose ``IO`` exactly like the real package does
|
||||
# (``from . import _io_public as io; IO = io`` where _io_public is just
|
||||
# ``from ._io import *``).
|
||||
_comfy_api_latest.IO = _io_mod
|
||||
_comfy_api_latest.io = _io_mod
|
||||
IO = _io_mod # local alias for the assertions below
|
||||
|
||||
# 6) Stub proxy_node's other heavyweight imports.
|
||||
for missing in (
|
||||
"server",
|
||||
"aiohttp",
|
||||
"comfy_api_nodes",
|
||||
"comfy_api_nodes.util",
|
||||
"comfy_api_nodes.util.client",
|
||||
"comfy_api_nodes.util.common_exceptions",
|
||||
):
|
||||
if missing not in sys.modules:
|
||||
m = types.ModuleType(missing)
|
||||
sys.modules[missing] = m
|
||||
m.__path__ = [] # type: ignore[attr-defined]
|
||||
sys.modules["server"].PromptServer = type("_StubPS", (), {})
|
||||
sys.modules["comfy_api_nodes.util.client"].ApiEndpoint = type("_StubAE", (), {})
|
||||
sys.modules["comfy_api_nodes.util.client"].poll_op_raw = lambda *a, **k: None
|
||||
sys.modules["comfy_api_nodes.util.common_exceptions"].ProcessingInterrupted = type(
|
||||
"_StubPI", (Exception,), {},
|
||||
)
|
||||
|
||||
# 7) Emulate the package layout for the client so its relative imports
|
||||
# work (``from .protocol import ...`` etc.).
|
||||
pkg = types.ModuleType("comfy_remote_nodes_test")
|
||||
pkg.__path__ = [_CLIENT_DIR]
|
||||
sys.modules["comfy_remote_nodes_test"] = pkg
|
||||
sys.modules["comfy_remote_nodes_test.client"] = types.ModuleType(
|
||||
"comfy_remote_nodes_test.client",
|
||||
)
|
||||
sys.modules["comfy_remote_nodes_test.serialization"] = types.ModuleType(
|
||||
"comfy_remote_nodes_test.serialization",
|
||||
)
|
||||
_load_module(
|
||||
"comfy_remote_nodes_test.protocol",
|
||||
os.path.join(_CLIENT_DIR, "protocol.py"),
|
||||
)
|
||||
|
||||
proxy_node = _load_module(
|
||||
"comfy_remote_nodes_test.proxy_node",
|
||||
os.path.join(_CLIENT_DIR, "proxy_node.py"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Bria-shaped 2-branch DYNAMIC_COMBO.
|
||||
# ---------------------------------------------------------------------------
|
||||
bria_spec = [
|
||||
"DYNAMIC_COMBO",
|
||||
{
|
||||
"options": [
|
||||
{"key": "false", "inputs": []},
|
||||
{
|
||||
"key": "true",
|
||||
"inputs": [
|
||||
["prompt_content_moderation", "BOOLEAN", {"default": False}],
|
||||
["visual_input_moderation", "BOOLEAN", {"default": False}],
|
||||
["visual_output_moderation", "BOOLEAN", {"default": True}],
|
||||
],
|
||||
},
|
||||
],
|
||||
"tooltip": "Moderation settings",
|
||||
},
|
||||
]
|
||||
inp = proxy_node._parse_input_spec("moderation", bria_spec, optional=False)
|
||||
assert inp is not None, "Bria DYNAMIC_COMBO returned None"
|
||||
assert isinstance(inp, IO.DynamicCombo.Input), (
|
||||
f"Expected IO.DynamicCombo.Input, got {type(inp).__name__}"
|
||||
)
|
||||
assert inp.id == "moderation", f"outer id should track descriptor key, got {inp.id!r}"
|
||||
assert len(inp.options) == 2, f"expected 2 branches, got {len(inp.options)}"
|
||||
assert [o.key for o in inp.options] == ["false", "true"], (
|
||||
f"branch keys wrong: {[o.key for o in inp.options]}"
|
||||
)
|
||||
# Empty branch preserved (UX needs the dropdown entry).
|
||||
assert inp.options[0].inputs == [], "empty 'false' branch dropped"
|
||||
assert len(inp.options[1].inputs) == 3, (
|
||||
f"'true' branch should have 3 sub-inputs, got {len(inp.options[1].inputs)}"
|
||||
)
|
||||
for sub in inp.options[1].inputs:
|
||||
assert isinstance(sub, IO.Boolean.Input), (
|
||||
f"branch sub-input should be IO.Boolean.Input, got {type(sub).__name__}"
|
||||
)
|
||||
assert [s.id for s in inp.options[1].inputs] == [
|
||||
"prompt_content_moderation",
|
||||
"visual_input_moderation",
|
||||
"visual_output_moderation",
|
||||
], "branch sub-input names lost"
|
||||
print("PASS: Bria DYNAMIC_COMBO parses with empty + populated branches.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Grok-V2-shaped DYNAMIC_COMBO with nested AUTOGROW(IMAGE) per branch.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _grok_v2_subinputs(*, max_ref_images: int, with_aspect_ratio: bool) -> list:
|
||||
inputs: list = [
|
||||
[
|
||||
"images",
|
||||
"AUTOGROW",
|
||||
{
|
||||
"template": [
|
||||
"IMAGE",
|
||||
{"local_validate": {"image_max_batch": max_ref_images}},
|
||||
],
|
||||
"prefix": "image",
|
||||
"min": 1,
|
||||
"max": max_ref_images,
|
||||
"tooltip": (
|
||||
"Reference image to edit."
|
||||
if max_ref_images == 1
|
||||
else f"Reference image(s) to edit. Up to {max_ref_images} images."
|
||||
),
|
||||
},
|
||||
],
|
||||
["resolution", "COMBO", {"options": ["768p", "1k"]}],
|
||||
["number_of_images", "INT", {
|
||||
"default": 1, "min": 1, "max": 10, "step": 1,
|
||||
}],
|
||||
]
|
||||
if with_aspect_ratio:
|
||||
inputs.append(["aspect_ratio", "COMBO", {
|
||||
"options": ["auto", "1:1", "16:9"], "default": "auto",
|
||||
}])
|
||||
return inputs
|
||||
|
||||
grok_spec = [
|
||||
"DYNAMIC_COMBO",
|
||||
{
|
||||
"options": [
|
||||
{
|
||||
"key": "grok-imagine-image-quality",
|
||||
"inputs": _grok_v2_subinputs(max_ref_images=3, with_aspect_ratio=True),
|
||||
},
|
||||
{
|
||||
"key": "grok-imagine-image-pro",
|
||||
"inputs": _grok_v2_subinputs(max_ref_images=1, with_aspect_ratio=False),
|
||||
},
|
||||
{
|
||||
"key": "grok-imagine-image",
|
||||
"inputs": _grok_v2_subinputs(max_ref_images=3, with_aspect_ratio=True),
|
||||
},
|
||||
],
|
||||
"tooltip": "The model to use for editing.",
|
||||
},
|
||||
]
|
||||
grok_inp = proxy_node._parse_input_spec("model", grok_spec, optional=False)
|
||||
assert grok_inp is not None, "Grok V2 DYNAMIC_COMBO returned None"
|
||||
assert isinstance(grok_inp, IO.DynamicCombo.Input), (
|
||||
f"Expected IO.DynamicCombo.Input, got {type(grok_inp).__name__}"
|
||||
)
|
||||
assert len(grok_inp.options) == 3
|
||||
# Pro branch (max_ref_images=1, no aspect_ratio).
|
||||
pro_branch = next(o for o in grok_inp.options if o.key == "grok-imagine-image-pro")
|
||||
assert len(pro_branch.inputs) == 3, f"pro branch should have 3 sub-inputs, got {len(pro_branch.inputs)}"
|
||||
images_inp = pro_branch.inputs[0]
|
||||
assert isinstance(images_inp, IO.Autogrow.Input), (
|
||||
f"Expected nested AUTOGROW to parse as IO.Autogrow.Input, got "
|
||||
f"{type(images_inp).__name__}"
|
||||
)
|
||||
assert images_inp.id == "images"
|
||||
assert isinstance(images_inp.template, IO.Autogrow.TemplatePrefix), (
|
||||
f"Expected TemplatePrefix template, got {type(images_inp.template).__name__}"
|
||||
)
|
||||
assert images_inp.template.prefix == "image"
|
||||
assert images_inp.template.min == 1
|
||||
assert images_inp.template.max == 1, f"pro branch max should be 1, got {images_inp.template.max}"
|
||||
assert isinstance(images_inp.template.input, IO.Image.Input), (
|
||||
f"AUTOGROW template input should be IO.Image.Input, got "
|
||||
f"{type(images_inp.template.input).__name__}"
|
||||
)
|
||||
# Quality branch (max_ref_images=3 + aspect_ratio).
|
||||
qual_branch = next(o for o in grok_inp.options if o.key == "grok-imagine-image-quality")
|
||||
assert len(qual_branch.inputs) == 4, f"quality branch should have 4 sub-inputs, got {len(qual_branch.inputs)}"
|
||||
qual_images = qual_branch.inputs[0]
|
||||
assert isinstance(qual_images, IO.Autogrow.Input)
|
||||
assert qual_images.template.max == 3, f"quality branch max should be 3, got {qual_images.template.max}"
|
||||
# Primitive widgets after the autogrow.
|
||||
assert isinstance(qual_branch.inputs[1], IO.Combo.Input)
|
||||
assert isinstance(qual_branch.inputs[2], IO.Int.Input)
|
||||
assert isinstance(qual_branch.inputs[3], IO.Combo.Input)
|
||||
print("PASS: Grok V2 DYNAMIC_COMBO + nested AUTOGROW parses correctly.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. ElevenLabs-shaped branch with custom-IO sub-inputs (voiceN).
|
||||
# ---------------------------------------------------------------------------
|
||||
el_spec = [
|
||||
"DYNAMIC_COMBO",
|
||||
{
|
||||
"options": [
|
||||
{
|
||||
"key": "2",
|
||||
"inputs": [
|
||||
["voice0", "ELEVENLABS_VOICE", {}],
|
||||
["text0", "STRING", {"multiline": True, "default": ""}],
|
||||
["voice1", "ELEVENLABS_VOICE", {}],
|
||||
["text1", "STRING", {"multiline": True, "default": ""}],
|
||||
],
|
||||
},
|
||||
],
|
||||
"tooltip": "Number of dialogue turns.",
|
||||
},
|
||||
]
|
||||
el_inp = proxy_node._parse_input_spec("inputs", el_spec, optional=False)
|
||||
assert el_inp is not None, "ElevenLabs DYNAMIC_COMBO returned None"
|
||||
assert isinstance(el_inp, IO.DynamicCombo.Input)
|
||||
assert el_inp.id == "inputs", (
|
||||
f"outer id should be 'inputs' (not always 'model'), got {el_inp.id!r}"
|
||||
)
|
||||
branch = el_inp.options[0]
|
||||
# IO.Custom("FOO") returns a synthesized ComfyTypeIO subclass — each call
|
||||
# creates a *new* class, so isinstance-against-a-fresh-Custom doesn't
|
||||
# work. The @comfytype decorator stamps ``io_type`` onto the Input class
|
||||
# instead; check that the synthesized custom Input carries the right
|
||||
# string (this is how the frontend's connection-validity check chains
|
||||
# sockets).
|
||||
voice0 = branch.inputs[0]
|
||||
assert voice0.get_io_type() == "ELEVENLABS_VOICE", (
|
||||
f"voice0 should be opaque ELEVENLABS_VOICE Input, got "
|
||||
f"{voice0.get_io_type()!r} on {type(voice0).__name__}"
|
||||
)
|
||||
assert isinstance(branch.inputs[1], IO.String.Input)
|
||||
assert isinstance(branch.inputs[3], IO.String.Input)
|
||||
print("PASS: ElevenLabs DYNAMIC_COMBO branch mixes custom-IO + primitive.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Synthetic nested DYNAMIC_COMBO inside DYNAMIC_COMBO.
|
||||
# ---------------------------------------------------------------------------
|
||||
nested = [
|
||||
"DYNAMIC_COMBO",
|
||||
{
|
||||
"options": [
|
||||
{
|
||||
"key": "outer_a",
|
||||
"inputs": [
|
||||
[
|
||||
"submodel",
|
||||
"DYNAMIC_COMBO",
|
||||
{
|
||||
"options": [
|
||||
{"key": "inner_x", "inputs": [
|
||||
["x", "INT", {"default": 1, "min": 0, "max": 10}],
|
||||
]},
|
||||
{"key": "inner_y", "inputs": []},
|
||||
],
|
||||
"tooltip": "Inner branch.",
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
"tooltip": "Outer branch.",
|
||||
},
|
||||
]
|
||||
nest_inp = proxy_node._parse_input_spec("model", nested, optional=False)
|
||||
assert isinstance(nest_inp, IO.DynamicCombo.Input)
|
||||
inner = nest_inp.options[0].inputs[0]
|
||||
assert isinstance(inner, IO.DynamicCombo.Input), (
|
||||
f"nested DynamicCombo should be IO.DynamicCombo.Input, got {type(inner).__name__}"
|
||||
)
|
||||
assert [o.key for o in inner.options] == ["inner_x", "inner_y"]
|
||||
assert isinstance(inner.options[0].inputs[0], IO.Int.Input)
|
||||
print("PASS: nested DYNAMIC_COMBO-in-DYNAMIC_COMBO parses recursively.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Malformed dynamic specs return None (no opaque fallthrough).
|
||||
# ---------------------------------------------------------------------------
|
||||
# Missing options list on DYNAMIC_COMBO.
|
||||
assert proxy_node._parse_input_spec("x", ["DYNAMIC_COMBO", {}], optional=False) is None
|
||||
# Option missing key.
|
||||
bad = ["DYNAMIC_COMBO", {"options": [{"inputs": []}]}]
|
||||
assert proxy_node._parse_input_spec("x", bad, optional=False) is None
|
||||
# Option's inputs entry not a 3-tuple.
|
||||
bad2 = ["DYNAMIC_COMBO", {"options": [{"key": "a", "inputs": [["x"]]}]}]
|
||||
assert proxy_node._parse_input_spec("x", bad2, optional=False) is None
|
||||
# AUTOGROW without template.
|
||||
assert proxy_node._parse_input_spec("x", ["AUTOGROW", {"prefix": "p"}], optional=False) is None
|
||||
# AUTOGROW without prefix/names.
|
||||
assert (
|
||||
proxy_node._parse_input_spec("x", ["AUTOGROW", {"template": ["IMAGE", {}]}], optional=False)
|
||||
is None
|
||||
)
|
||||
print("PASS: malformed DYNAMIC_COMBO / AUTOGROW specs return None.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Regression: _collect_local_validate output unchanged for these inputs.
|
||||
# ---------------------------------------------------------------------------
|
||||
desc_grok = {
|
||||
"input": {
|
||||
"required": {"model": grok_spec},
|
||||
"optional": {},
|
||||
},
|
||||
}
|
||||
rules = proxy_node._collect_local_validate(desc_grok)
|
||||
# rules['model'] should carry __branches__ with per-branch maps that each
|
||||
# carry an __template__ entry under "images" with image_max_batch.
|
||||
assert "model" in rules, f"_collect_local_validate dropped 'model': {rules!r}"
|
||||
br = rules["model"].get("__branches__")
|
||||
assert isinstance(br, dict) and set(br) == {
|
||||
"grok-imagine-image-quality",
|
||||
"grok-imagine-image-pro",
|
||||
"grok-imagine-image",
|
||||
}, f"branches mismatch: {br!r}"
|
||||
pro = br["grok-imagine-image-pro"]
|
||||
assert "images" in pro, f"pro branch should carry 'images' rule: {pro!r}"
|
||||
tmpl_rules = pro["images"].get("__template__")
|
||||
assert tmpl_rules == {"image_max_batch": 1}, (
|
||||
f"pro branch template rules wrong: {tmpl_rules!r}"
|
||||
)
|
||||
qual = br["grok-imagine-image-quality"]
|
||||
assert qual["images"].get("__template__") == {"image_max_batch": 3}
|
||||
print("PASS: _collect_local_validate output unchanged (branches + template intact).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. AUTOGROW with a dynamic template input is rejected.
|
||||
# ---------------------------------------------------------------------------
|
||||
ag_with_dyn_template = [
|
||||
"AUTOGROW",
|
||||
{
|
||||
"template": [
|
||||
"DYNAMIC_COMBO",
|
||||
{"options": [{"key": "k", "inputs": []}]},
|
||||
],
|
||||
"prefix": "img",
|
||||
"min": 1,
|
||||
"max": 3,
|
||||
},
|
||||
]
|
||||
assert (
|
||||
proxy_node._parse_input_spec("bad", ag_with_dyn_template, optional=False) is None
|
||||
), "AUTOGROW with DYNAMIC_COMBO template should be rejected"
|
||||
print("PASS: AUTOGROW rejects dynamic template input (matches upstream assert).")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Source-level: docstring + dispatch mention DYNAMIC_COMBO + AUTOGROW.
|
||||
# ---------------------------------------------------------------------------
|
||||
proxy_src = open(
|
||||
os.path.join(_CLIENT_DIR, "proxy_node.py"), "r", encoding="utf-8",
|
||||
).read()
|
||||
assert "DYNAMIC_COMBO" in proxy_src, "proxy_node source missing DYNAMIC_COMBO branch"
|
||||
assert "AUTOGROW" in proxy_src, "proxy_node source missing AUTOGROW branch"
|
||||
assert "_parse_named_input_spec" in proxy_src, (
|
||||
"proxy_node source missing _parse_named_input_spec helper"
|
||||
)
|
||||
assert "_build_input" in proxy_src, "proxy_node source missing _build_input helper"
|
||||
print("PASS: proxy_node source contains the new dispatch + helpers.")
|
||||
|
||||
print("ALL CHECKS PASSED.")
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Smoke test for the client-side MODEL_3D decoder.
|
||||
|
||||
Verifies:
|
||||
|
||||
1. The vendored ``protocol.HEAVY_TYPES`` now recognises ``model_3d`` so
|
||||
``is_envelope`` returns True for an inbound MODEL_3D envelope.
|
||||
2. ``serialization.decode_model3d_envelope`` round-trips an inline
|
||||
``glb_inline`` envelope into a ``comfy_api.latest._util.File3D``
|
||||
whose ``BytesIO`` payload matches the original GLB bytes.
|
||||
3. ``decode_envelope`` dispatches ``type=model_3d`` to the new decoder.
|
||||
4. The proxy_node ``_OUTPUT_CLASSES`` now maps ``"MODEL_3D"`` to
|
||||
``IO.File3DGLB.Output`` so a descriptor with ``output=["MODEL_3D"]``
|
||||
parses without falling through to the opaque IO.Custom branch.
|
||||
5. ``client.CLIENT_CAPABILITIES`` advertises
|
||||
``model_3d:glb_inline`` on every outbound request so descriptors
|
||||
gated on the token negotiate cleanly.
|
||||
6. End-to-end against the server's ``make_model3d_envelope`` helper
|
||||
(imported from the comfy-rnp-server worktree) — the bytes the
|
||||
server packs are exactly the bytes the client decodes.
|
||||
|
||||
Run with the ComfyUI venv (needs torch / comfy_api on PYTHONPATH):
|
||||
|
||||
python notes/run_model3d_decoder.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Locate the worktree under test.
|
||||
# ---------------------------------------------------------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_CLIENT_DIR = os.path.dirname(_HERE)
|
||||
_COMFYUI_DIR = os.path.dirname(os.path.dirname(_CLIENT_DIR))
|
||||
_SERVER_DIR = os.path.normpath(
|
||||
os.path.join(_COMFYUI_DIR, "..", "comfy-rnp-server")
|
||||
)
|
||||
|
||||
|
||||
def _load_module(name: str, path: str) -> types.ModuleType:
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
# Make ComfyUI importable so ``comfy_api.latest._util.File3D`` resolves.
|
||||
if _COMFYUI_DIR not in sys.path:
|
||||
sys.path.insert(0, _COMFYUI_DIR)
|
||||
# And the server worktree so we can import the ENCODER side end-to-end.
|
||||
if _SERVER_DIR not in sys.path:
|
||||
sys.path.insert(0, _SERVER_DIR)
|
||||
|
||||
# ComfyUI's full ``comfy_api`` tree pulls torch / PIL / comfy /
|
||||
# comfy_execution at import time — way too heavy for a decoder
|
||||
# round-trip check. Stub just the leaf module our decoder imports
|
||||
# (``comfy_api.latest._util``) with a faithful pure-Python ``File3D``.
|
||||
class _StubFile3D:
|
||||
def __init__(self, source, file_format: str = "") -> None:
|
||||
self._source = source
|
||||
self._format = file_format
|
||||
|
||||
_comfy_api = types.ModuleType("comfy_api")
|
||||
_comfy_api_latest = types.ModuleType("comfy_api.latest")
|
||||
_comfy_api_latest_util = types.ModuleType("comfy_api.latest._util")
|
||||
_comfy_api_latest_util.File3D = _StubFile3D
|
||||
_comfy_api.latest = _comfy_api_latest
|
||||
_comfy_api_latest._util = _comfy_api_latest_util
|
||||
sys.modules.setdefault("comfy_api", _comfy_api)
|
||||
sys.modules.setdefault("comfy_api.latest", _comfy_api_latest)
|
||||
sys.modules.setdefault("comfy_api.latest._util", _comfy_api_latest_util)
|
||||
|
||||
# Load the client modules as if they were a package — the ``protocol``
|
||||
# module imports from ``.protocol`` relative to its package, so we
|
||||
# emulate the package layout under the name ``comfy_remote_nodes``.
|
||||
pkg = types.ModuleType("comfy_remote_nodes_test")
|
||||
pkg.__path__ = [_CLIENT_DIR]
|
||||
sys.modules["comfy_remote_nodes_test"] = pkg
|
||||
|
||||
protocol = _load_module(
|
||||
"comfy_remote_nodes_test.protocol",
|
||||
os.path.join(_CLIENT_DIR, "protocol.py"),
|
||||
)
|
||||
# serialization imports ``client`` for ``rnp_client`` but we don't need
|
||||
# the HTTP client for the decoder path; provide a tiny stub so the
|
||||
# import succeeds.
|
||||
client_stub = types.ModuleType("comfy_remote_nodes_test.client")
|
||||
sys.modules["comfy_remote_nodes_test.client"] = client_stub
|
||||
serialization = _load_module(
|
||||
"comfy_remote_nodes_test.serialization",
|
||||
os.path.join(_CLIENT_DIR, "serialization.py"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build a minimal valid binary glTF 2.0 (GLB) payload for the round-trip.
|
||||
# Format: 12-byte header + N chunks. Header: magic(4) + version(4) + length(4).
|
||||
# Magic must be b"glTF", version=2. We include exactly one JSON chunk that
|
||||
# describes an empty asset (enough to satisfy any GLB-magic check; downstream
|
||||
# nodes do their own validation).
|
||||
# ---------------------------------------------------------------------------
|
||||
def _make_minimal_glb() -> bytes:
|
||||
json_payload = b'{"asset":{"version":"2.0"}}'
|
||||
# Chunks must be 4-byte aligned; pad JSON with spaces (0x20).
|
||||
pad = (4 - (len(json_payload) % 4)) % 4
|
||||
json_payload = json_payload + (b" " * pad)
|
||||
json_chunk = (
|
||||
len(json_payload).to_bytes(4, "little") # chunk length
|
||||
+ b"JSON" # chunk type
|
||||
+ json_payload # chunk data
|
||||
)
|
||||
total = 12 + len(json_chunk)
|
||||
header = (
|
||||
b"glTF"
|
||||
+ (2).to_bytes(4, "little")
|
||||
+ total.to_bytes(4, "little")
|
||||
)
|
||||
return header + json_chunk
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. HEAVY_TYPES recognises model_3d.
|
||||
# ---------------------------------------------------------------------------
|
||||
assert "model_3d" in protocol.HEAVY_TYPES, (
|
||||
f"HEAVY_TYPES missing 'model_3d': {sorted(protocol.HEAVY_TYPES)}"
|
||||
)
|
||||
assert protocol.Capability.MODEL_3D_GLB_INLINE == "model_3d:glb_inline", (
|
||||
f"Capability.MODEL_3D_GLB_INLINE wrong value: "
|
||||
f"{protocol.Capability.MODEL_3D_GLB_INLINE!r}"
|
||||
)
|
||||
print("PASS: HEAVY_TYPES includes 'model_3d'; Capability constant intact.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. is_envelope returns True for a MODEL_3D envelope.
|
||||
# ---------------------------------------------------------------------------
|
||||
glb_bytes = _make_minimal_glb()
|
||||
envelope = {
|
||||
"type": "model_3d",
|
||||
"encoding": "glb_inline",
|
||||
"data": base64.b64encode(glb_bytes).decode("ascii"),
|
||||
"format": "glb",
|
||||
"byte_size": len(glb_bytes),
|
||||
}
|
||||
assert protocol.is_envelope(envelope), (
|
||||
"is_envelope returned False for a well-formed MODEL_3D envelope; "
|
||||
"HEAVY_TYPES update did not take effect."
|
||||
)
|
||||
print("PASS: is_envelope recognises model_3d envelopes.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. decode_model3d_envelope round-trips inline bytes.
|
||||
# ---------------------------------------------------------------------------
|
||||
file3d = serialization.decode_model3d_envelope(envelope)
|
||||
from comfy_api.latest._util import File3D as _File3D_for_check # noqa: E402
|
||||
assert isinstance(file3d, _File3D_for_check), (
|
||||
f"Expected File3D, got {type(file3d).__name__}"
|
||||
)
|
||||
# File3D exposes get_data() / get_source(); the public API on this
|
||||
# version returns a BytesIO when constructed with one. Drain bytes:
|
||||
src = file3d._source # internal, but stable across this code path
|
||||
assert hasattr(src, "read"), f"File3D source not BytesIO-like: {type(src)}"
|
||||
src.seek(0)
|
||||
round_trip = src.read()
|
||||
assert round_trip == glb_bytes, (
|
||||
f"GLB byte mismatch after decode: input={len(glb_bytes)} bytes, "
|
||||
f"output={len(round_trip)} bytes"
|
||||
)
|
||||
assert round_trip[:4] == b"glTF", "GLB magic bytes lost in round-trip"
|
||||
print(f"PASS: decode_model3d_envelope round-trips {len(glb_bytes)} GLB bytes.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. decode_envelope dispatches model_3d to the new decoder.
|
||||
# ---------------------------------------------------------------------------
|
||||
file3d2 = serialization.decode_envelope(envelope)
|
||||
assert isinstance(file3d2, _File3D_for_check), (
|
||||
f"decode_envelope dispatch failed for type=model_3d: got "
|
||||
f"{type(file3d2).__name__}"
|
||||
)
|
||||
print("PASS: decode_envelope dispatches type='model_3d' correctly.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Unsupported encoding raises a hard error (defence-in-depth so a
|
||||
# future encoding never silently downgrades to garbage bytes).
|
||||
# ---------------------------------------------------------------------------
|
||||
bad = dict(envelope, encoding="obj_inline")
|
||||
try:
|
||||
serialization.decode_model3d_envelope(bad)
|
||||
except protocol.RnpProtocolError as e:
|
||||
assert "Unsupported model_3d encoding" in str(e), (
|
||||
f"Wrong error message for unsupported encoding: {e!r}"
|
||||
)
|
||||
assert e.code == protocol.ErrorCode.INTERNAL
|
||||
print("PASS: unsupported model_3d encoding raises RnpProtocolError.")
|
||||
else:
|
||||
raise AssertionError(
|
||||
"decode_model3d_envelope accepted an unsupported encoding"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. End-to-end: server's make_model3d_envelope -> client decode.
|
||||
# Skips when the server worktree isn't on disk (e.g. CI running
|
||||
# against just the client repo).
|
||||
# ---------------------------------------------------------------------------
|
||||
server_protocol_path = os.path.join(
|
||||
_SERVER_DIR, "comfy_rnp_protocol", "envelopes.py",
|
||||
)
|
||||
if os.path.exists(server_protocol_path):
|
||||
# Import the server-side envelopes module standalone (it has no
|
||||
# ComfyUI deps and uses its own sibling ``constants`` import).
|
||||
server_pkg = types.ModuleType("comfy_rnp_protocol_e2e")
|
||||
server_pkg.__path__ = [os.path.join(_SERVER_DIR, "comfy_rnp_protocol")]
|
||||
sys.modules["comfy_rnp_protocol_e2e"] = server_pkg
|
||||
server_constants = _load_module(
|
||||
"comfy_rnp_protocol_e2e.constants",
|
||||
os.path.join(_SERVER_DIR, "comfy_rnp_protocol", "constants.py"),
|
||||
)
|
||||
server_envelopes = _load_module(
|
||||
"comfy_rnp_protocol_e2e.envelopes",
|
||||
os.path.join(_SERVER_DIR, "comfy_rnp_protocol", "envelopes.py"),
|
||||
)
|
||||
# Build using the server helper exactly as a provider would.
|
||||
server_env = server_envelopes.make_model3d_envelope(
|
||||
encoding="glb_inline",
|
||||
data=base64.b64encode(glb_bytes).decode("ascii"),
|
||||
format="glb",
|
||||
byte_size=len(glb_bytes),
|
||||
)
|
||||
assert server_env["type"] == "model_3d", server_env
|
||||
assert server_env["encoding"] == "glb_inline", server_env
|
||||
assert "model_3d" in server_envelopes.HEAVY_TYPES, (
|
||||
"Server HEAVY_TYPES missing 'model_3d' — server PR #37 should "
|
||||
"have added it; reinstall comfy_rnp_protocol if this fires."
|
||||
)
|
||||
# Pipe through the client decoder.
|
||||
file3d3 = serialization.decode_envelope(server_env)
|
||||
src = file3d3._source
|
||||
src.seek(0)
|
||||
assert src.read() == glb_bytes, "End-to-end byte mismatch"
|
||||
print(
|
||||
"PASS: end-to-end server make_model3d_envelope -> client "
|
||||
"decode_envelope preserves bytes."
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"SKIP end-to-end: server worktree not found at {_SERVER_DIR!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. proxy_node._OUTPUT_CLASSES maps "MODEL_3D" -> IO.File3DGLB.Output.
|
||||
# proxy_node pulls in the full comfy_api / server / comfy_api_nodes
|
||||
# tree at import time (too heavy to stub here), so we verify the
|
||||
# mapping with a source-level check instead. The pattern is exact:
|
||||
# a quoted ``"MODEL_3D":`` key whose value is the
|
||||
# ``IO.File3DGLB.Output`` class reference inside the
|
||||
# ``_OUTPUT_CLASSES`` dict literal. The same source check would
|
||||
# catch a typo (e.g. ``IO.File3DAny.Output``) that a smoke test
|
||||
# importing the live module would miss too — the dict literal is
|
||||
# the contract.
|
||||
# ---------------------------------------------------------------------------
|
||||
proxy_src = open(
|
||||
os.path.join(_CLIENT_DIR, "proxy_node.py"), "r", encoding="utf-8",
|
||||
).read()
|
||||
assert "_OUTPUT_CLASSES = {" in proxy_src, "proxy_node missing _OUTPUT_CLASSES dict"
|
||||
assert '"MODEL_3D":' in proxy_src and "IO.File3DGLB.Output" in proxy_src, (
|
||||
"proxy_node._OUTPUT_CLASSES does not appear to map "
|
||||
"'MODEL_3D' -> IO.File3DGLB.Output"
|
||||
)
|
||||
# Verify the mapping is in the same dict literal (not somewhere else).
|
||||
oc_start = proxy_src.index("_OUTPUT_CLASSES = {")
|
||||
oc_end = proxy_src.index("}", oc_start)
|
||||
oc_block = proxy_src[oc_start:oc_end]
|
||||
assert '"MODEL_3D":' in oc_block, (
|
||||
"'MODEL_3D' key not inside the _OUTPUT_CLASSES dict literal"
|
||||
)
|
||||
assert "IO.File3DGLB.Output" in oc_block, (
|
||||
"IO.File3DGLB.Output value not inside the _OUTPUT_CLASSES dict literal"
|
||||
)
|
||||
print(
|
||||
"PASS: proxy_node._OUTPUT_CLASSES dict literal contains "
|
||||
"'MODEL_3D' -> IO.File3DGLB.Output."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. CLIENT_CAPABILITIES advertises model_3d:glb_inline.
|
||||
# client.py imports aiohttp / comfy_api_nodes too — same source-level
|
||||
# contract check.
|
||||
# ---------------------------------------------------------------------------
|
||||
client_src = open(
|
||||
os.path.join(_CLIENT_DIR, "client.py"), "r", encoding="utf-8",
|
||||
).read()
|
||||
assert "CLIENT_CAPABILITIES = [" in client_src, (
|
||||
"client.py missing CLIENT_CAPABILITIES list"
|
||||
)
|
||||
cc_start = client_src.index("CLIENT_CAPABILITIES = [")
|
||||
cc_end = client_src.index("]", cc_start)
|
||||
cc_block = client_src[cc_start:cc_end]
|
||||
assert "Capability.MODEL_3D_GLB_INLINE" in cc_block, (
|
||||
"Capability.MODEL_3D_GLB_INLINE not advertised in CLIENT_CAPABILITIES"
|
||||
)
|
||||
print("PASS: CLIENT_CAPABILITIES advertises Capability.MODEL_3D_GLB_INLINE.")
|
||||
|
||||
print("ALL CHECKS PASSED.")
|
||||
+12
-1
@@ -153,9 +153,20 @@ class Capability:
|
||||
# dispatch passes opaque dict/list/string values through
|
||||
# ``_encode_one`` unchanged — they're already JSON-serialisable.
|
||||
IO_OPAQUE = "io:opaque"
|
||||
# 3D MODEL envelope: a single-mesh GLB (binary glTF 2.0, magic
|
||||
# b"glTF") wrapped opaquely in ``data`` (base64). Mirrors the
|
||||
# parallel-encoding pattern already used for VIDEO_MP4_INLINE — the
|
||||
# server emits this token only when the inbound request advertised
|
||||
# it; legacy clients without the token will surface
|
||||
# NEGOTIATION_FAILED at descriptor-load time. The decoder on the
|
||||
# client side hands raw GLB bytes straight to ComfyUI's
|
||||
# ``Types.File3D`` / ``IO.File3DGLB`` machinery (no in-server GLB
|
||||
# parse — the magic-byte check at offset 0 is the only validity
|
||||
# guard the server applies).
|
||||
MODEL_3D_GLB_INLINE = "model_3d:glb_inline"
|
||||
|
||||
|
||||
HEAVY_TYPES = frozenset({"image", "video", "audio", "mask"})
|
||||
HEAVY_TYPES = frozenset({"image", "video", "audio", "mask", "model_3d"})
|
||||
|
||||
|
||||
def is_envelope(value: Any) -> bool:
|
||||
|
||||
+198
-27
@@ -3,13 +3,17 @@
|
||||
The descriptor wire format is V3 ``Schema.get_v1_info()`` verbatim, so
|
||||
per-input dicts are handed straight to V3's IO classes without
|
||||
reinterpretation. ``_parse_input_spec`` knows STRING / INT / FLOAT /
|
||||
BOOLEAN / COMBO / IMAGE / VIDEO / AUDIO / MASK; any other io_type
|
||||
string falls through to ``IO.Custom(io_type)`` (the §B opaque
|
||||
pass-through bucket — partner helper-config types like RECRAFT_*,
|
||||
OPENAI_INPUT_FILES, OPENAI_CHAT_CONFIG, GEMINI_INPUT_FILES round-trip
|
||||
as raw JSON blobs with the original io_type preserved on the V3
|
||||
socket, so node-to-node connections only chain when the strings
|
||||
match). Only a non-string / empty io_type causes a skip.
|
||||
BOOLEAN / COMBO / IMAGE / VIDEO / AUDIO / MASK plus the two V3
|
||||
dynamic input types DYNAMIC_COMBO (branch-selector dropdown with
|
||||
per-branch sub-widgets) and AUTOGROW (variable-arity slot template);
|
||||
any other io_type string falls through to ``IO.Custom(io_type)`` (the
|
||||
§B opaque pass-through bucket — partner helper-config types like
|
||||
RECRAFT_*, OPENAI_INPUT_FILES, OPENAI_CHAT_CONFIG, GEMINI_INPUT_FILES
|
||||
round-trip as raw JSON blobs with the original io_type preserved on
|
||||
the V3 socket, so node-to-node connections only chain when the strings
|
||||
match). Only a non-string / empty io_type causes a skip. Malformed
|
||||
DYNAMIC_COMBO / AUTOGROW specs skip the descriptor (return ``None``)
|
||||
rather than degrading to a single opaque socket.
|
||||
|
||||
Hidden inputs (``auth_token_comfy_org`` / ``api_key_comfy_org`` /
|
||||
``unique_id`` / ``prompt`` / ``extra_pnginfo`` / ``dynprompt``) are
|
||||
@@ -576,34 +580,90 @@ def _parse_inputs(descriptor: dict[str, Any]) -> tuple[list[Any] | None, list[st
|
||||
|
||||
|
||||
def _parse_input_spec(name: str, spec: list[Any], optional: bool) -> Any | None:
|
||||
"""Map one V3-shaped ``[io_type, options_dict]`` entry to a V3 IO Input."""
|
||||
"""Map one V3-shaped ``[io_type, options_dict]`` entry to a V3 IO Input.
|
||||
|
||||
This is the top-level entry used by ``_parse_inputs`` (where the
|
||||
name comes from the descriptor's ``input.required[name]`` /
|
||||
``input.optional[name]`` key) and recursively as the AUTOGROW
|
||||
template entry (which also lacks a leading name field — the slot
|
||||
name is provided by the surrounding AUTOGROW). Branch sub-inputs
|
||||
inside a DYNAMIC_COMBO option use the 3-tuple ``[name, io_type,
|
||||
options]`` form instead — see :func:`_parse_named_input_spec`.
|
||||
"""
|
||||
if not isinstance(spec, (list, tuple)) or len(spec) < 1:
|
||||
return None
|
||||
io_type = spec[0]
|
||||
options: dict[str, Any] = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {}
|
||||
return _build_input(name, io_type, options, optional)
|
||||
|
||||
|
||||
def _parse_named_input_spec(spec: list[Any], optional: bool) -> Any | None:
|
||||
"""Map one V3-shaped ``[name, io_type, options_dict]`` entry to a V3 IO Input.
|
||||
|
||||
This is the shape used inside ``DYNAMIC_COMBO`` options' ``inputs``
|
||||
list (each branch sub-input carries its own name as ``spec[0]``).
|
||||
Top-level descriptor inputs use the 2-tuple form handled by
|
||||
:func:`_parse_input_spec`.
|
||||
"""
|
||||
if (
|
||||
not isinstance(spec, (list, tuple))
|
||||
or len(spec) < 2
|
||||
or not isinstance(spec[0], str)
|
||||
):
|
||||
return None
|
||||
name = spec[0]
|
||||
io_type = spec[1]
|
||||
options: dict[str, Any] = spec[2] if len(spec) > 2 and isinstance(spec[2], dict) else {}
|
||||
return _build_input(name, io_type, options, optional)
|
||||
|
||||
|
||||
def _build_input(name: str, io_type: Any, options: dict[str, Any], optional: bool) -> Any | None:
|
||||
"""Shared dispatch: construct one V3 IO Input from a parsed
|
||||
``(name, io_type, options)`` triple.
|
||||
|
||||
Recurses for the two V3 dynamic input types:
|
||||
|
||||
* ``DYNAMIC_COMBO`` — selector dropdown whose per-branch sub-inputs
|
||||
live in ``options.options[i].inputs`` (each entry is the 3-tuple
|
||||
``[name, io_type, options]`` form).
|
||||
* ``AUTOGROW`` — variable-arity slot template. The RNP wire dialect
|
||||
flattens upstream's ``Autogrow.Input.as_dict`` shape: ``template``
|
||||
is a 2-tuple ``[io_type, options]`` entry directly, with
|
||||
``prefix`` / ``min`` / ``max`` (or ``names`` / ``min``) as siblings
|
||||
of ``template`` rather than nested under ``template.template``.
|
||||
|
||||
Malformed dynamic specs return ``None`` (caller logs + skips the
|
||||
whole descriptor) rather than falling through to the opaque
|
||||
``IO.Custom`` bucket — that would mask a protocol/schema bug as
|
||||
"looks fine but renders as opaque socket".
|
||||
"""
|
||||
common = {
|
||||
"tooltip": options.get("tooltip"),
|
||||
"optional": optional,
|
||||
}
|
||||
# ``advanced`` is only accepted by WidgetInput subclasses — the
|
||||
# dynamic constructors (DynamicCombo.Input / Autogrow.Input) do
|
||||
# not take it. Build a separate kwargs bag for primitives.
|
||||
primitive_common = dict(common)
|
||||
if options.get("advanced") is not None:
|
||||
common["advanced"] = bool(options.get("advanced"))
|
||||
primitive_common["advanced"] = bool(options.get("advanced"))
|
||||
|
||||
if isinstance(io_type, list):
|
||||
# Legacy V1-style combo: io_type *is* the options list.
|
||||
return IO.Combo.Input(name, options=list(io_type),
|
||||
default=options.get("default"), **common)
|
||||
default=options.get("default"), **primitive_common)
|
||||
if io_type == "COMBO":
|
||||
opts = options.get("options")
|
||||
if not isinstance(opts, (list, tuple)):
|
||||
return None
|
||||
return IO.Combo.Input(name, options=list(opts),
|
||||
default=options.get("default"), **common)
|
||||
default=options.get("default"), **primitive_common)
|
||||
if io_type == "STRING":
|
||||
return IO.String.Input(
|
||||
name,
|
||||
default=options.get("default", ""),
|
||||
multiline=bool(options.get("multiline", False)),
|
||||
**common,
|
||||
**primitive_common,
|
||||
)
|
||||
if io_type == "INT":
|
||||
return IO.Int.Input(
|
||||
@@ -612,7 +672,7 @@ def _parse_input_spec(name: str, spec: list[Any], optional: bool) -> Any | None:
|
||||
min=options.get("min", 0),
|
||||
max=options.get("max", 2147483647),
|
||||
step=options.get("step", 1),
|
||||
**common,
|
||||
**primitive_common,
|
||||
)
|
||||
if io_type == "FLOAT":
|
||||
return IO.Float.Input(
|
||||
@@ -621,20 +681,115 @@ def _parse_input_spec(name: str, spec: list[Any], optional: bool) -> Any | None:
|
||||
min=options.get("min", 0.0),
|
||||
max=options.get("max", 1.0),
|
||||
step=options.get("step", 0.01),
|
||||
**common,
|
||||
**primitive_common,
|
||||
)
|
||||
if io_type == "BOOLEAN":
|
||||
return IO.Boolean.Input(
|
||||
name, default=bool(options.get("default", False)), **common,
|
||||
name, default=bool(options.get("default", False)), **primitive_common,
|
||||
)
|
||||
if io_type == "IMAGE":
|
||||
return IO.Image.Input(name, **common)
|
||||
return IO.Image.Input(name, **primitive_common)
|
||||
if io_type == "VIDEO":
|
||||
return IO.Video.Input(name, **common)
|
||||
return IO.Video.Input(name, **primitive_common)
|
||||
if io_type == "AUDIO":
|
||||
return IO.Audio.Input(name, **common)
|
||||
return IO.Audio.Input(name, **primitive_common)
|
||||
if io_type == "MASK":
|
||||
return IO.Mask.Input(name, **common)
|
||||
return IO.Mask.Input(name, **primitive_common)
|
||||
if io_type == "DYNAMIC_COMBO":
|
||||
# Wire shape: ``["DYNAMIC_COMBO", {"options": [{"key": str,
|
||||
# "inputs": [[name, io_type, opts], ...]}, ...], "tooltip": ...}]``.
|
||||
# Empty branch input lists are valid (e.g. Bria moderation's
|
||||
# "false" branch with ``"inputs": []``); the branch dropdown
|
||||
# still needs to render. Malformed shape -> None (no opaque
|
||||
# fallthrough — would mask a schema bug).
|
||||
opts = options.get("options")
|
||||
if not isinstance(opts, (list, tuple)):
|
||||
return None
|
||||
parsed_options: list[Any] = []
|
||||
for opt in opts:
|
||||
if not isinstance(opt, dict):
|
||||
return None
|
||||
key = opt.get("key")
|
||||
if not isinstance(key, str):
|
||||
return None
|
||||
sub_specs = opt.get("inputs") or []
|
||||
if not isinstance(sub_specs, (list, tuple)):
|
||||
return None
|
||||
sub_inputs: list[Any] = []
|
||||
for sub_spec in sub_specs:
|
||||
# Branch sub-inputs are always treated as required at
|
||||
# schema-construction time — the wire dialect doesn't
|
||||
# express per-branch optionality. Server-side runtime
|
||||
# validation enforces presence via ``__branches__``.
|
||||
sub = _parse_named_input_spec(sub_spec, optional=False)
|
||||
if sub is None:
|
||||
return None
|
||||
sub_inputs.append(sub)
|
||||
parsed_options.append(IO.DynamicCombo.Option(key=key, inputs=sub_inputs))
|
||||
return IO.DynamicCombo.Input(
|
||||
name,
|
||||
options=parsed_options,
|
||||
tooltip=options.get("tooltip"),
|
||||
optional=optional,
|
||||
)
|
||||
if io_type == "AUTOGROW":
|
||||
# Wire shape (RNP-flattened):
|
||||
# ``["AUTOGROW", {"template": [io_type, options],
|
||||
# "prefix": str, "min": int, "max": int,
|
||||
# "tooltip": ...}]``
|
||||
# or with ``names`` instead of ``prefix`` / ``max`` for the
|
||||
# TemplateNames variant.
|
||||
tmpl_spec = options.get("template")
|
||||
if not isinstance(tmpl_spec, (list, tuple)) or len(tmpl_spec) < 1:
|
||||
return None
|
||||
# Reuse top-level parser for the template — the slot name is
|
||||
# cosmetic since TemplatePrefix / TemplateNames rename each
|
||||
# cached copy per-slot.
|
||||
template_input = _parse_input_spec(name, tmpl_spec, optional=False)
|
||||
if template_input is None:
|
||||
return None
|
||||
# Upstream Autogrow asserts the template input is NOT itself a
|
||||
# DynamicInput (DynamicCombo.Input / Autogrow.Input). Mirror
|
||||
# that check at parse time so a malformed descriptor surfaces
|
||||
# as "skipped" rather than blowing up later in the IO machinery.
|
||||
if isinstance(
|
||||
template_input,
|
||||
(IO.DynamicCombo.Input, IO.Autogrow.Input),
|
||||
):
|
||||
return None
|
||||
if "names" in options:
|
||||
names = options.get("names")
|
||||
if not isinstance(names, list) or not all(isinstance(n, str) for n in names):
|
||||
return None
|
||||
try:
|
||||
template = IO.Autogrow.TemplateNames(
|
||||
template_input,
|
||||
names=names,
|
||||
min=int(options.get("min", 1)),
|
||||
)
|
||||
except AssertionError:
|
||||
return None
|
||||
elif "prefix" in options:
|
||||
prefix = options.get("prefix")
|
||||
if not isinstance(prefix, str):
|
||||
return None
|
||||
try:
|
||||
template = IO.Autogrow.TemplatePrefix(
|
||||
template_input,
|
||||
prefix=prefix,
|
||||
min=int(options.get("min", 1)),
|
||||
max=int(options.get("max", 10)),
|
||||
)
|
||||
except AssertionError:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
return IO.Autogrow.Input(
|
||||
name,
|
||||
template=template,
|
||||
tooltip=options.get("tooltip"),
|
||||
optional=optional,
|
||||
)
|
||||
# Opaque custom-IO fallthrough — any unknown io_type string becomes
|
||||
# an ``IO.Custom(io_type)`` socket so partner helper-config nodes
|
||||
# (OpenAIInputFiles → OPENAI_INPUT_FILES, OpenAIChatConfig →
|
||||
@@ -649,6 +804,11 @@ def _parse_input_spec(name: str, spec: list[Any], optional: bool) -> Any | None:
|
||||
# ``serialization`` already passes non-tensor / non-audio values
|
||||
# through ``_encode_one`` unchanged, and the deserializer treats
|
||||
# non-envelope values as already-native Python objects.
|
||||
#
|
||||
# NOTE: ``DYNAMIC_COMBO`` and ``AUTOGROW`` are handled above and
|
||||
# never reach this fallthrough — malformed dynamic specs return
|
||||
# ``None`` instead, so the caller logs + skips the descriptor
|
||||
# rather than silently degrading to a single opaque socket.
|
||||
if isinstance(io_type, str) and io_type:
|
||||
return IO.Custom(io_type).Input(name, **common)
|
||||
return None
|
||||
@@ -757,14 +917,25 @@ def _parse_outputs(descriptor: dict[str, Any]) -> list[Any] | None:
|
||||
|
||||
|
||||
_OUTPUT_CLASSES = {
|
||||
"VIDEO": IO.Video.Output,
|
||||
"IMAGE": IO.Image.Output,
|
||||
"AUDIO": IO.Audio.Output,
|
||||
"MASK": IO.Mask.Output,
|
||||
"STRING": IO.String.Output,
|
||||
"INT": IO.Int.Output,
|
||||
"FLOAT": IO.Float.Output,
|
||||
"BOOLEAN": IO.Boolean.Output,
|
||||
"VIDEO": IO.Video.Output,
|
||||
"IMAGE": IO.Image.Output,
|
||||
"AUDIO": IO.Audio.Output,
|
||||
"MASK": IO.Mask.Output,
|
||||
"STRING": IO.String.Output,
|
||||
"INT": IO.Int.Output,
|
||||
"FLOAT": IO.Float.Output,
|
||||
"BOOLEAN": IO.Boolean.Output,
|
||||
# 3D MODEL output: the Tripo / Rodin / Meshy partner endpoints all
|
||||
# return a single GLB file; the server emits a ``model_3d:glb_inline``
|
||||
# envelope which ``serialization.decode_model3d_envelope`` turns
|
||||
# back into a ``File3D`` so downstream nodes (preview3d / SaveGLB /
|
||||
# Load3D) chain off the output socket unchanged. The descriptor
|
||||
# io_type string is the logical name ``"MODEL_3D"`` (consistent
|
||||
# across vendors) — the resulting V3 socket carries the upstream
|
||||
# ``"FILE_3D_GLB"`` comfytype tag from ``IO.File3DGLB`` so
|
||||
# frontend-side input/output matching works against any GLB-aware
|
||||
# node.
|
||||
"MODEL_3D": IO.File3DGLB.Output,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+33
-4
@@ -480,15 +480,43 @@ def decode_video_envelope(envelope: dict[str, Any]) -> Any:
|
||||
return InputImpl.VideoFromFile(BytesIO(decode_envelope_data(envelope)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3D model decode (encode lands when a remote node accepts MODEL_3D inputs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def decode_model3d_envelope(envelope: dict[str, Any]) -> Any:
|
||||
"""Decode a 3D-model envelope into a ComfyUI ``File3D`` object.
|
||||
|
||||
Mirrors :func:`decode_video_envelope`: the only currently supported
|
||||
encoding is ``glb_inline`` (binary glTF 2.0 — magic ``b"glTF"`` at
|
||||
offset 0). The bytes are handed to ``File3D`` as a ``BytesIO`` so
|
||||
downstream nodes (preview3d / SaveGLB / Load3D) see exactly the
|
||||
same value shape they would from a local Tripo / Rodin / Meshy
|
||||
node. The envelope's optional ``format`` extra is forwarded as the
|
||||
second constructor arg so the materialiser picks the right file
|
||||
extension when persisting.
|
||||
"""
|
||||
encoding = envelope.get("encoding")
|
||||
if encoding != "glb_inline":
|
||||
raise RnpProtocolError(
|
||||
f"Unsupported model_3d encoding: {encoding!r}",
|
||||
code=ErrorCode.INTERNAL,
|
||||
)
|
||||
from comfy_api.latest._util import File3D
|
||||
file_format = envelope.get("format") or "glb"
|
||||
return File3D(BytesIO(decode_envelope_data(envelope)), str(file_format))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch by envelope type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DECODERS = {
|
||||
"image": decode_image_envelope,
|
||||
"mask": decode_mask_envelope,
|
||||
"audio": decode_audio_envelope,
|
||||
"video": decode_video_envelope,
|
||||
"image": decode_image_envelope,
|
||||
"mask": decode_mask_envelope,
|
||||
"audio": decode_audio_envelope,
|
||||
"video": decode_video_envelope,
|
||||
"model_3d": decode_model3d_envelope,
|
||||
}
|
||||
|
||||
|
||||
@@ -538,6 +566,7 @@ __all__ = [
|
||||
"encode_audio_input",
|
||||
"decode_audio_envelope",
|
||||
"decode_video_envelope",
|
||||
"decode_model3d_envelope",
|
||||
"decode_envelope",
|
||||
"is_envelope",
|
||||
"is_image_tensor",
|
||||
|
||||
Reference in New Issue
Block a user