feat/task-handle-decoder
7
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1f136526cf |
Add client-side cross-node task_handle decoder + TaskHandle dataclass
Mirrors comfy-rnp-server PR #39 on the client. Adds the surfaces needed for chained partner provider nodes (Tripo Texture/Refine/ Rig/Retarget/Conversion, Kling VideoExtend, future partner chained nodes) to pass vendor-native task / video / job IDs through a workflow with their lineage metadata intact. Surfaces: * ``protocol.Capability.IO_TASK_HANDLE = "io:task_handle"`` and ``"task_handle"`` added to ``protocol.HEAVY_TYPES`` so ``is_envelope`` recognises the new envelope. * ``serialization.TaskHandle`` dataclass with the wire-shape fields (``vendor``, ``kind``, ``native_id``, ``origin_node_id``, ``parent_chain``). ``__str__`` surfaces ``native_id`` for log / UI preview without leaking the lineage chain. * ``serialization.decode_task_handle_envelope`` validates every field (mirroring server-side ``_validate_task_handle_ref`` so a bad envelope surfaces the same error message on both sides) and returns the dataclass. Rejects: - wrong encoding (only ``vendor_inline`` recognised); - missing / empty top-level fields; - comma-union ``kind`` strings (the input-acceptance string used by some provider nodes, NOT an emitted handle kind); - non-list ``parent_chain``; - malformed parent_chain entries (missing fields, non-dict, comma-union kind in lineage). * ``serialization.encode_task_handle`` re-encodes the dataclass back to wire shape — symmetric counterpart of the decoder so a handle decoded from one provider's output and fed into another provider's input round-trips losslessly without ``proxy_node`` having to know anything provider-specific. * ``decode_envelope`` dispatcher routes ``type="task_handle"`` to the new decoder so the generic ``_deserialize_output`` path picks it up. * ``client.CLIENT_CAPABILITIES`` advertises ``Capability.IO_TASK_HANDLE`` so server-side capability-gated descriptors (whole-descriptor gating — partial-socket synthesis is rejected because a chain that breaks meaningfully if either end is missing is worse than no chain at all) negotiate cleanly. The dataclass return (NOT a plain ``str``) is deliberate: a plain string would silently drop ``parent_chain`` when the value is passed through the workflow, breaking the lineage the server uses for future cascading-replay semantics (deferred to a follow-up PR alongside server-side replay-cache storage — see PR #39 for the replay design notes). Smoke test ``notes/run_task_handle_decoder.py`` mirrors the pattern used by ``run_model3d_bundle_decoder.py``: stubs out the ``client`` import for the relative ``from . import client as rnp_client`` at the top of serialization.py, then exercises the 9 contract points end-to-end. End-to-end check imports the server's ``make_task_handle_envelope`` from the comfy-rnp-server worktree and verifies the wire shape round-trips losslessly through decode -> encode. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019e3431-c416-742b-973c-e262ab13f2ff |
||
|
|
c962fa5adf |
Add client-side multi-file 3D bundle decoder + BundledFile3D
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
|
||
|
|
37b77f837a |
Parse DYNAMIC_COMBO + AUTOGROW inputs into real V3 IO objects
`proxy_node._parse_input_spec` previously had explicit branches only
for the primitive V3 input types (STRING / INT / FLOAT / BOOLEAN /
COMBO / IMAGE / VIDEO / AUDIO / MASK); every other `io_type` string —
including the two V3 dynamic input types `DYNAMIC_COMBO` and
`AUTOGROW` — fell through to the opaque `IO.Custom(io_type).Input`
bucket. Server-side runtime validation already worked (rules-
extraction at `_rules_from_input_spec` walks `__branches__` and
`__template__`), but on the frontend every Topaz / Magnific / Vidu /
Luma / Wan / Bria / Grok / ElevenLabs / HitPaw provider that declares
a `DynamicCombo` rendered as one opaque socket instead of the
branch-selector dropdown with per-branch sub-widgets the V3 IO is
designed to produce.
Refactor the parser so the construction logic is shared between two
entry points (top-level descriptor inputs use the 2-tuple
`[io_type, options]` form; DynamicCombo branch sub-inputs use the
3-tuple `[name, io_type, options]` form) and add real handlers for
both dynamic types:
* `_parse_input_spec(name, spec, optional)` — top-level entry, same
signature as before. Strips the `[io_type, options]` head and
delegates.
* `_parse_named_input_spec(spec, optional)` — branch-sub-input entry.
Strips the leading name and delegates.
* `_build_input(name, io_type, options, optional)` — shared
constructor dispatch. All primitive io_types route through the same
bag of kwargs; the new `DYNAMIC_COMBO` branch builds
`IO.DynamicCombo.Input(name, options=[IO.DynamicCombo.Option(key,
inputs=[...]), ...])` by recursing into `_parse_named_input_spec`
for each branch sub-input; the new `AUTOGROW` branch builds
`IO.Autogrow.Input(name, template=IO.Autogrow.TemplatePrefix(...))`
(or `TemplateNames`) by recursing into `_parse_input_spec` for the
template entry.
The flattened RNP wire dialect for `AUTOGROW` (`template` is a flat
`[io_type, options]` entry directly, with `prefix` / `min` / `max` or
`names` / `min` as siblings of `template`) is what every server
provider already emits (e.g. `grok.py:_grok_v2_model_subinputs`); the
parser mirrors that shape rather than the upstream
`Autogrow.Input.as_dict` nested form.
Guard rails:
* Empty branch input lists are valid (Bria moderation's `"false"`
branch carries `"inputs": []`).
* The branch selector's id is the outer input id (`"moderation"`,
`"inputs"`, etc.) — not always `"model"`.
* Branch sub-inputs are treated as required at schema-construction
time; the wire dialect doesn't express per-branch optionality, and
server-side `__branches__` enforcement covers runtime presence.
* AUTOGROW template inputs that are themselves dynamic
(`DynamicCombo.Input` / `Autogrow.Input`) are rejected at parse
time, mirroring the upstream `_AutogrowTemplate.__init__` assert.
* Malformed `DYNAMIC_COMBO` / `AUTOGROW` specs return `None` (caller
logs + skips the descriptor) instead of degrading to a single
opaque socket — the opaque fallthrough is reserved for genuinely
unknown io_type strings, not for known-but-broken dynamic specs.
* `advanced` only routes to primitive `WidgetInput` constructors; the
dynamic constructors don't accept it.
No capability gating in this PR. The two dynamic io_type strings are
already V3-canonical so a legacy client without the parser fix sees
the same opaque-socket degradation it sees today (just rendered as
`Custom("DYNAMIC_COMBO")` instead of the real DynamicCombo input).
Adding `Capability.DYNAMIC_COMBO` / `Capability.AUTOGROW` would
require pairing the cap with real descriptor-load-time enforcement
(server-side filtering of `/object_info` or client-side rejection
based on `required_capabilities`); neither exists yet, so adding
caps now would just be inert metadata. The cap pair is the right
follow-up alongside the enforcement plumbing.
Smoke test at `notes/run_dynamic_combo_parser.py` — 8 checks covering
Bria's 2-branch DynamicCombo (with empty branch), Grok V2's 3-branch
DynamicCombo with nested AUTOGROW(IMAGE), ElevenLabs' branch mixing
custom-IO (voiceN) + primitive STRING, a synthetic nested
DynamicCombo-in-DynamicCombo (defensive recursion coverage), the
malformed-spec rejection path, a regression check that
`_collect_local_validate` output is byte-identical before and after,
the AUTOGROW-rejects-dynamic-template assertion, and a source-level
check that the new helpers + branches exist.
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e331c-c202-746b-96ec-86ce0b9c54a5
|
||
|
|
58d93daf2a |
feat(model_3d): add client-side MODEL_3D decoder + capability advertisement
Closes the loop on the just-merged comfy-rnp-server PR #37 which added the four Tripo / Rodin 3D providers and the ``Capability.MODEL_3D_GLB_INLINE`` token. Without this client-side half the providers were unreachable from any RNP client — descriptors would fail negotiation because the proxy_node didn't advertise the token. Client changes (all inside ``ComfyUI/custom_nodes/comfy_remote_nodes``): * ``protocol.py`` — mirror the new ``Capability.MODEL_3D_GLB_INLINE`` constant and add ``"model_3d"`` to ``HEAVY_TYPES`` so ``is_envelope`` recognises inbound 3D-model envelopes. The vendored ``protocol`` module is the single source of truth for the proxy_node and serialization layers; both already consume ``is_envelope`` / ``decode_envelope_data`` from here. * ``serialization.py`` — add ``decode_model3d_envelope`` that hands raw GLB bytes to ``comfy_api.latest._util.File3D`` via ``BytesIO``, mirroring the existing ``decode_video_envelope`` shape. Only ``glb_inline`` is accepted today; any other encoding surfaces ``RnpProtocolError(code=INTERNAL)`` as defence-in-depth so a future encoding never silently downgrades to garbage bytes. Registered in the ``_DECODERS`` dispatch table and re-exported via ``__all__``. * ``proxy_node.py`` — add ``"MODEL_3D" -> IO.File3DGLB.Output`` to ``_OUTPUT_CLASSES`` so descriptors with ``output=["MODEL_3D"]`` parse to a real socket. The descriptor io_type string is the logical name ``"MODEL_3D"`` (consistent across vendors) — the resulting V3 Output carries the upstream ``"FILE_3D_GLB"`` comfytype tag so frontend-side socket matching works against any GLB-aware downstream node (preview3d / SaveGLB / Load3D). * ``client.py`` — advertise ``Capability.MODEL_3D_GLB_INLINE`` in ``CLIENT_CAPABILITIES`` so servers gate provider descriptors on the presence of the token. Legacy clients that don't advertise it still see NEGOTIATION_FAILED at descriptor-load time (safe degradation), not a parse error at execute. Smoke test (``notes/run_model3d_decoder.py``) covers: 1. ``HEAVY_TYPES`` includes ``"model_3d"``. 2. ``is_envelope`` recognises a MODEL_3D envelope. 3. ``decode_model3d_envelope`` round-trips a minimal valid GLB (binary glTF 2.0, magic ``b"glTF"``). 4. ``decode_envelope`` dispatches ``type=model_3d`` to the new decoder. 5. Unsupported encoding raises ``RnpProtocolError``. 6. End-to-end against the server's ``make_model3d_envelope`` helper — server packs, client decodes, bytes match. 7. ``proxy_node._OUTPUT_CLASSES`` contract: ``"MODEL_3D" -> IO.File3DGLB.Output``. 8. ``client.CLIENT_CAPABILITIES`` advertises ``Capability.MODEL_3D_GLB_INLINE``. No server-side changes. The 4 just-shipped Tripo / Rodin providers (``TripoTextToModelApi_RNP``, ``TripoImageToModelApi_RNP``, ``TripoMultiviewToModelApi_RNP``, ``Rodin3DRegularApi_RNP``) are now reachable end-to-end from ComfyUI. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019e3314-4336-71d2-a333-6d1e33a9986e |
||
|
|
35b8a3b17f |
feat(serialization): tighten batch dispatch + accept response-side batch URIs
Three follow-ups identified in the PR-2 / PR-3 review pass: 1. ``serialization.maybe_externalize`` now keys batch dispatch off the ``encoding`` string (``png_base64_batch``) instead of the presence of a ``frames`` field. The old ``isinstance(frames, list) and frames`` heuristic would silently take the batch branch on a malformed envelope that mixed both fields and externalise garbage under a non-batch encoding. 2. An empty / missing ``frames`` list on a ``png_base64_batch`` envelope now raises ``RnpProtocolError(INTERNAL)`` rather than silently no-op-ing through to the wire (where the server's ``resolve_image_envelope_pngs`` rejects it after the round trip anyway). Mirrors the server-side ``make_image_batch_envelope`` contract: an empty batch is a hard wire bug. 3. ``proxy_node._deserialize_output`` now accepts the externalised ``png_base64_batch_uri`` shape on the response path. No server emits this shape today (future-proofing only — the symmetric counterpart of the request-side externalisation), but the decoder owns the symmetry: any envelope shape the client may externalise on the way out, the client must also accept on the way in. Per-frame URIs are fetched in parallel via ``asyncio.gather`` so a 9-frame batch import is one round-trip rather than N. Frame ordering is preserved (gather returns positional results) — the IMAGE tensor's batch axis carries semantic order. Fetched bytes are re-stamped as inline ``frames`` so the existing ``_decode_image_batch_envelope`` path handles them uniformly. notes/run_batch_uri_followups.py — new standalone smoke test that exercises all three changes (encoding-keyed dispatch, empty-frames hard-error, response-side batch URI parallel fetch + ordering). Amp-Thread-ID: https://ampcode.com/threads/T-019e2ce6-9403-720c-ae5b-1597b8068be7 Co-authored-by: Amp <amp@ampcode.com> |
||
|
|
6ab23718a4 |
fix(local-validate): traverse AUTOGROW templates and DYNAMIC_COMBO branches
Pre-PR-4 the descriptor-driven `image_max_batch` cap fired only on
top-level IMAGE inputs (Flux2 `images`, LTXV i2v `image`).
Providers whose cap rides a nested template — GPT Image V2 (16),
Grok ImageEditV2 (3 / 1 by branch), Grok VideoReference (7) — silently
fell through to the server's defense-in-depth path, defeating the
"no paid round trip" guarantee PR-3 promised.
## Client
* `_collect_local_validate` now recurses via the new
`_rules_from_input_spec` helper. AUTOGROW specs surface their
template's rules under a private `__template__` key; DYNAMIC_COMBO
specs surface a `__branches__` map of `{branch_key: {sub_name: rules}}`.
* `_enforce_local_validate` walks `__template__` against each
Autogrow slot value (`{"image0": <env>, "image1": <env>, ...}`) and
`__branches__` against the DynamicCombo runtime dict, dispatching
on `value[<name>]` to pick the active branch's sub-input rules.
* `image_max_batch` checking lifted into a dedicated
`_check_image_max_batch` helper so the recursive enforcer keeps a
single rule-application code path.
* Failure messages now carry a `parent.slot[.sub]` label so the
surfaced `INPUT_INVALID` says
`input 'model.images.image0' accepts at most 3 image frames…`
for nested cases instead of just the parent name.
## Docs
* No descriptor or matrix changes — the server already published the
`local_validate.image_max_batch` rules on the nested templates in
PR-3 (eat(descriptor): declare image_max_batch local_validate caps,
comfy-rnp-server#15). The wire shape is unchanged; this PR just
makes the client honour what the server already declared.
## Verification
* Extended `notes/run_image_max_batch.py` with two new fixtures
(`_autogrow_descriptor` and `_dyncombo_with_autogrow_descriptor`)
and 9 new sub-cases covering: AUTOGROW slot over-cap fires,
AUTOGROW None-slot tolerance, AUTOGROW all-under-cap passes,
DYNAMIC_COMBO branch-A over-cap fires, DYNAMIC_COMBO branch-A
under-cap passes, DYNAMIC_COMBO branch-B (cap=1) fires the
single-frame copy, DYNAMIC_COMBO unknown branch is a defensive
no-op, plus collected-rules shape assertions.
* End-to-end check against live registered descriptors confirmed all
four nested-cap providers fire client-side: GrokImageEditNodeV2_RNP
(`model.images.image0` cap=3 and cap=1 branches),
GrokVideoReferenceNode_RNP (`model.reference_images.reference0`
cap=7), OpenAIGPTImageNodeV2_RNP (`model.images.image0` cap=16).
## Lockstep
Client-only fix. Server already publishes the rules; no paired
server PR required. The companion paper trail is comfy-rnp-server#15
(declarative caps) and the parent code-review note from the merged
PR-2/PR-3 wave that flagged this gap as the blocking follow-up.
Amp-Thread-ID: T-019e29b4-d606-7278-8584-52910661c981
Co-authored-by: Amp <amp@ampcode.com>
|
||
|
|
37a4961d6d |
feat(local-validate): fail-fast on over-cap image batches
Wire ``proxy_node._encode_inputs`` to consult each input's
``descriptor.input.<section>.<name>[1].local_validate.image_max_batch``
rule. When set, the encoder asserts ``tensor.shape[0] <= cap`` for
IMAGE inputs (rank-4 ``[B,H,W,C]`` tensors) and raises
``RnpProtocolError(code=INPUT_INVALID, user_facing=True)`` *before*
any HTTP round trip — so a workflow with N>cap reference frames
surfaces the per-provider error in the standard friendly-message
dialog instead of paying for a doomed upstream call.
## Server
(See companion PR ``Comfy-Org/comfy-rnp-server#15`` —
``feat(descriptor): declare image_max_batch local_validate caps``.)
The server-side per-call caps inside each provider's ``execute()``
stay in place as defense-in-depth; non-compliant clients still fail.
## Client
- ``proxy_node._collect_local_validate(descriptor)`` walks
``descriptor.input.{required,optional}`` and returns
``{name: rules}`` for every input that carries a non-empty
``local_validate`` block. Inputs nested inside ``AUTOGROW`` /
``DYNAMIC_COMBO`` aren't surfaced — the parser doesn't yet flatten
them into top-level inputs, so the cap declared on those templates
documents the future contract rather than firing today.
- ``proxy_node._enforce_local_validate(name, value, rules,
*, node_id)`` applies each supported rule to ``value``. Today only
``image_max_batch`` is implemented: rank-4 / rank-3-channels-last
tensors with ``shape[0] > cap`` raise ``RnpProtocolError`` with
code ``INPUT_INVALID`` and ``user_facing=True``. Friendly per-
provider message echoes the upstream wording (Flux2's "The
current maximum number of supported images is N" for cap≥2;
a "expects a single image frame" copy for cap=1 like LTXV i2v).
- ``_encode_inputs`` runs the validator before the encoder so the
raise happens before any tensor → PNG conversion or
``maybe_externalize`` upload — the cheapest possible fail point.
- Lookup is defensive at every layer: missing ``local_validate`` →
no-op, missing ``image_max_batch`` sub-key → no-op, non-tensor
input → no-op, non-image rank → no-op. Older descriptors emit
no rule and the encoder behaves identically to before.
## Docs
(Matrix prose updated on the server side — the canonical
``notes/remote-nodes-port-matrix.md`` lives in the rnp-server repo.)
## Verification
- ``notes/run_image_max_batch.py`` — new standalone smoke test
(loads the helper functions via AST extraction so the test runs
in a bare venv with just ``torch`` installed; doesn't drag in
the ComfyUI package). Asserts:
1. ``_collect_local_validate`` extracts the rule from a Flux2-
shaped descriptor.
2. A 3-frame tensor against ``cap=2`` raises ``RnpProtocolError``
with ``code=INPUT_INVALID``, ``user_facing=True``, and the
``node_id`` + counts mentioned in the message.
3. A 2-frame tensor against ``cap=2`` encodes cleanly.
4. ``cap=1`` surfaces the single-frame copy.
5. Descriptor without ``local_validate`` is a no-op.
6. ``local_validate`` block missing the ``image_max_batch`` key
is a no-op.
Run with: ``python notes/run_image_max_batch.py`` — all
assertions pass on PASS.
## Lockstep
Paired with server PR ``Comfy-Org/comfy-rnp-server#15``
(``feat/image-max-batch-rule``). Both branches were cut off
``origin/main`` before PR-2 lands; the orchestrator will rebase PR-3
onto PR-2 before merging.
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019e2adb-6ddf-72ad-88e1-8b30c57e873f
|