feat/model-3d-bundle-decoder
Closes the client side of the multi-file 3D bundle envelope landed in
comfy-rnp-server#38 (commit 50f9f4f on main).
* Vendored `protocol.py`: mirrors the new
`Capability.MODEL_3D_BUNDLE_INLINE = "model_3d:bundle_inline"`
constant with a full docstring describing the wire shape, path /
role semantics, primary_path / format invariants, capability
negotiation discipline, and the planned future
`MODEL_3D_BUNDLE_URI` sibling. HEAVY_TYPES is unchanged
(`type="model_3d"` already covers the new encoding).
* `serialization.py`: adds `BundledFile3D` as a `File3D` subclass
built lazily via a `_bundled_file3d_class()` factory (so importing
the heavy `comfy_api.latest._util` package doesn't happen at
module load — keeps the smoke-test stubbing pattern intact for
callers that don't exercise the bundle path). The class stores
the primary mesh file plus N companion files in memory as a
`path -> bytes` dict and:
- `get_data()` / `get_bytes()` return the primary file's bytes
without touching disk.
- `get_source()` lazily materialises the whole bundle into a temp
directory using the producer-supplied relative paths, then
returns the on-disk path to the primary file — so a .obj that
references its .mtl by filename (and the .mtl that references
`textures/albedo.png`) resolves via the filesystem.
- `save_to(dest)` copies the WHOLE bundle into the destination
directory: the primary file under the caller-specified name,
sidecars under their ORIGINAL relative paths so cross-file refs
inside the .obj that point at `model.mtl` still resolve when
the user saves the model somewhere stable.
- New convenience accessors `primary_path`, `file_paths`,
`file_bytes(path)`, `file_role(path)`, `paths_with_role(role)`,
and `materialize_to_temp_dir()`.
- Temp directory cleanup on garbage collect (best-effort
`shutil.rmtree(td, ignore_errors=True)` in `__del__`).
- `super().__init__(BytesIO(primary), primary_fmt)` keeps the
upstream `_source` / `_format` attributes populated so existing
downstream consumers that read those plain attributes still
work.
`decode_model3d_envelope` now dispatches on `encoding`:
`glb_inline` falls through to the existing single-file path
unchanged; `bundle_inline` walks the new wire shape, decodes
per-file base64 `data`, hoists `role` / `format` hints into
parallel dicts, and constructs a `BundledFile3D`. Malformed
envelopes (missing `primary_path`, missing `files`, primary not
present in files, file entry missing `data`) raise
`RnpProtocolError(code=INTERNAL)` rather than falling through to
any opaque bucket.
* `client.py`: advertises `Capability.MODEL_3D_BUNDLE_INLINE` in
`CLIENT_CAPABILITIES` with a full docstring matching the
established pattern for the other 3D capabilities.
Design decisions (from oracle consult on wire-shape surface):
* Extend the existing `model_3d` heavy type with a new encoding
(rather than introducing `model_3d_bundle` as a separate
top-level type) — keeps HEAVY_TYPES unchanged, lets the
single-file `glb_inline` decoder stay untouched, and lets
capability gating stay parallel.
* `path` is the authoritative cross-file reference key (not
`role`) — roles are conceptually non-unique (multiple meshes,
LODs, buffers, animations) so `primary_path` pins the entry
point unambiguously.
* `role` is an OPTIONAL producer hint, NOT a closed enum. Unknown
roles round-trip without error.
* Subclass `File3D` directly rather than introducing a new opaque
`ModelBundle3D` type — existing downstream loaders that
type-check or duck-type-check on `File3D` keep working.
`isinstance(bundled, File3D)` succeeds.
* Each file's `data` is base64 (mirrors the single-file
`glb_inline` payload mode). Mixed `data`/`uri` inside one
encoding was rejected per the parallel-encoding pattern already
in use across the rest of the protocol; a future sibling
`Capability.MODEL_3D_BUNDLE_URI` would handle URL-fetch
semantics.
* Capability gating with whole-descriptor enforcement — if a node
has both bundle and non-bundle outputs and the cap is absent,
the whole descriptor is skipped rather than synthesising a
partial-socket variant.
Smoke test at `notes/run_model3d_bundle_decoder.py` — 9 checks:
1. Capability constant intact at protocol layer.
2. is_envelope recognises bundle_inline envelopes.
3. decode dispatch returns BundledFile3D (a File3D subclass) +
primary bytes round-trip via get_data / get_bytes.
4. get_source materialises whole bundle on disk at producer-
supplied paths.
5. save_to copies whole bundle: primary renamed, sidecars keep
filenames.
6. Bundle accessors (primary_path / file_paths / file_role /
paths_with_role) work; unknown roles round-trip.
7. Malformed bundle envelopes raise RnpProtocolError(INTERNAL).
8. End-to-end against server's make_model3d_bundle_envelope helper
(from PR #38) — all files / roles / bytes preserve. Server-side
path sanitization rejects `..` at envelope build time
(defence-in-depth).
9. CLIENT_CAPABILITIES advertises model_3d:bundle_inline.
All 9 pass; PR #1's `notes/run_model3d_decoder.py` (8 checks) and
PR #2's `notes/run_dynamic_combo_parser.py` (8 checks) also still
pass — no regression on existing paths.
Hunyuan3D / Tencent / future Meshy / Rodin / Tripo bundle
providers land in a follow-up PR that consumes this decoder; this
PR is protocol + client decoder only per the planned sequence.
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e331c-c202-746b-96ec-86ce0b9c54a5
comfy-remote-nodes
ComfyUI custom-node package that fetches node descriptors from an RNP/1 (Remote Node Protocol v1) server and exposes them as native V3 nodes inside ComfyUI.
The reference server lives at
Comfy-Org/comfy-rnp-server
(private). This client is intentionally provider-agnostic: any server
that speaks RNP/1 can publish nodes through it.
Install
Clone into your ComfyUI custom_nodes/ directory:
cd ComfyUI/custom_nodes
git clone https://github.com/Comfy-Org/comfy-remote-nodes.git
(The directory name on disk doesn't matter — ComfyUI loads the package
via spec_from_file_location, so comfy-remote-nodes/ is fine.)
Configure
Point the extension at one RNP server URL via env var before starting ComfyUI:
export RNP_SERVER_URL=http://127.0.0.1:9190
python main.py
If RNP_SERVER_URL is unset, the extension registers zero nodes and
logs a single line — ComfyUI starts normally with no remote nodes.
What works today
- Manifest + capability handshake
object_infofetch and dynamic V3 class generation (with ETag conditional GETs)- Sync
executeand asyncexecute_async+ poll/cancel - Image / mask / audio / video envelope encode + decode
- Presigned upload helper for >8 MiB inputs
- Tier 3 (recent):
TASK_LOSTdetection with idempotent resubmission — survives an RNP server restart mid-task without losing the workflowSERVER_BUSY/MAINTENANCE503 envelopes treated as rate limits withRetry-Afterhonoring and friendly per-second progress text ("Server busy, retrying in 30s...")- Out-of-band
url_fetchfor URI-only output envelopes, driven by the descriptor'sremote.url_fetchpolicy - Per-poll auth refresh so long-running tasks can rotate tokens
What does not (yet)
validate_inputs/fingerprint_inputs/check_lazy_status— the server returns501 NOT_IMPLEMENTED; the client falls through to ComfyUI's defaults- Multi-server config file (
comfy_remote_nodes.yamlor similar) — today's setup is one server per ComfyUI process via env var - Streaming execute (server-sent events for incremental previews)
Languages
Python
100%