[PR #16] [MERGED] Add client-side multi-file 3D bundle decoder + BundledFile3D #16

Closed
opened 2026-10-26 22:28:56 +00:00 by bsakaguchi · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/Comfy-Org/comfy-remote-nodes/pull/16
Author: @Kosinkadink
Created: 5/17/2026
Status: ✅ Merged
Merged: 5/17/2026
Merged by: @Kosinkadink

Base: main ← Head: feat/model-3d-bundle-decoder


📝 Commits (1)

  • c962fa5 Add client-side multi-file 3D bundle decoder + BundledFile3D

📊 Changes

4 files changed (+838 additions, -15 deletions)

View changed files

📝 client.py (+12 -0)
➕ notes/run_model3d_bundle_decoder.py (+461 -0)
📝 protocol.py (+61 -0)
📝 serialization.py (+304 -15)

📄 Description

What

Closes the client side of the multi-file 3D bundle envelope landed in comfy-rnp-server#38 (commit 50f9f4f on main).

Changes

  • 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).

  • 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

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 the server's make_model3d_bundle_envelope helper (from comfy-rnp-server#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 #14's notes/run_model3d_decoder.py (8 checks) and PR #15's notes/run_dynamic_combo_parser.py (8 checks) also still pass — no regression on existing paths.

What's deferred

Hunyuan3D / Tencent and 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 PR sequence.


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/Comfy-Org/comfy-remote-nodes/pull/16 **Author:** [@Kosinkadink](https://github.com/Kosinkadink) **Created:** 5/17/2026 **Status:** ✅ Merged **Merged:** 5/17/2026 **Merged by:** [@Kosinkadink](https://github.com/Kosinkadink) **Base:** `main` ← **Head:** `feat/model-3d-bundle-decoder` --- ### 📝 Commits (1) - [`c962fa5`](https://github.com/Comfy-Org/comfy-remote-nodes/commit/c962fa5adfb4d4fe44785a75b392a1087cc68fcb) Add client-side multi-file 3D bundle decoder + BundledFile3D ### 📊 Changes **4 files changed** (+838 additions, -15 deletions) <details> <summary>View changed files</summary> 📝 `client.py` (+12 -0) ➕ `notes/run_model3d_bundle_decoder.py` (+461 -0) 📝 `protocol.py` (+61 -0) 📝 `serialization.py` (+304 -15) </details> ### 📄 Description ## What Closes the client side of the multi-file 3D bundle envelope landed in [comfy-rnp-server#38](https://github.com/Comfy-Org/comfy-rnp-server/pull/38) (commit `50f9f4f` on `main`). ## Changes - **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)`. - **`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 `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 the server's `make_model3d_bundle_envelope` helper (from [comfy-rnp-server#38](https://github.com/Comfy-Org/comfy-rnp-server/pull/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 #14's `notes/run_model3d_decoder.py` (8 checks) and PR #15's `notes/run_dynamic_combo_parser.py` (8 checks) also still pass — no regression on existing paths. ## What's deferred Hunyuan3D / Tencent and 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 PR sequence. --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
bsakaguchi 2026-10-26 22:28:56 +00:00
Sign in to join this conversation.
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Comfy-Org/comfy-remote-nodes#16
No description provided.