diff --git a/README.md b/README.md index f3cc905..3047598 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,7 @@ Current mapped-execution rules: - one `Modal Map Input` boundary per remote component - downstream remote nodes reachable from that map marker stay in the same mapped remote component -- mapped inputs may currently be Python lists, `IMAGE` batches, `LATENT` batches, and other batched tensors split on dimension `0` +- mapped inputs may currently be scalar pass-through values, Python lists, `IMAGE` batches, `LATENT` batches, and other batched tensors split on dimension `0` - if queue-time rewrite had to leave a mapped boundary as `io_type="*"`, the runtime still recovers obvious concrete cases such as `list` values of LATENT-like mappings or boundaries whose target sockets all declare the same concrete input type - ordinary remote components without `Modal Map Input` still preserve ComfyUI's usual zipped batch semantics at the remote boundary: if multiple boundary inputs arrive as compatible batches, Modal-Sync fans that component out per item instead of injecting the raw list into primitive widget sockets like `seed: INT` - that implicit batching path now partitions one-time and per-item execute nodes too, so if two remote samplers share the same upstream `MODEL` but only one sampler receives a multi-item batch input, the sibling sampler still runs once instead of repeating once per batch item diff --git a/docs/LEARNINGS.md b/docs/LEARNINGS.md index 79cb57e..6dd882f 100644 --- a/docs/LEARNINGS.md +++ b/docs/LEARNINGS.md @@ -274,3 +274,4 @@ - Prompt-level Modal cancellation has to release both the remote work and the local prompt admission gate. If a user cancels during Modal deploy/readiness, ComfyUI can mark the prompt interrupted while the background Modal call still owns the local gate, leaving the next prompt stuck behind an already-cancelled prompt id. - `ModalMapInput` does not remove the need for ordered split proxies. If a mapped remote component has a local re-entry dependency, such as an early sampler preview feeding a local text/image node that returns into a later mapped sampler, the static side must still split into ordered phases before the mapped phase or the rewritten prompt can create a false dependency cycle. - Implicit mapped cancellation has to stop the local item scheduler, not only the remote payload that is currently running. If a sampler lane observes a prompt interrupt, queued sibling items and final cleanup payloads must be suppressed immediately or a quick cancel/restart can dispatch stale mapped sampler work after the UI already reports cancellation. +- `ModalMapInput` should accept scalar primitive values as a one-item map. This keeps the marker useful as a no-op adapter when an upstream control value like `INT` is not batched, while preserving fan-out behavior for lists, tensor batches, and `LATENT` mappings. diff --git a/serialization.py b/serialization.py index d4178f1..ae38328 100644 --- a/serialization.py +++ b/serialization.py @@ -218,6 +218,9 @@ def split_mapped_value(value: Any, io_type: str) -> list[Any]: return list(value) normalized_io_type = str(io_type) + if _is_scalar(value): + return [value] + if normalized_io_type == "LATENT" and isinstance(value, Mapping): return _split_latent_batch(value) @@ -234,7 +237,7 @@ def split_mapped_value(value: Any, io_type: str) -> list[Any]: return _split_tensor_batch(value) raise TypeError( - "Mapped execution only supports Python lists, tensor batches, and LATENT dictionaries. " + "Mapped execution only supports scalar values, Python lists, tensor batches, and LATENT dictionaries. " f"Unsupported mapped value type {type(value)!r} for io_type={normalized_io_type!r}." ) diff --git a/tests/test_modal_executor_node.py b/tests/test_modal_executor_node.py index 8efcdf0..cf5dede 100644 --- a/tests/test_modal_executor_node.py +++ b/tests/test_modal_executor_node.py @@ -971,6 +971,60 @@ def test_modal_map_input_execute_boosts_exact_warmup_for_registered_context( } +def test_modal_map_input_execute_accepts_scalar_for_single_item_warmup( + modal_executor_module: Any, + remote_modal_app_module: Any, + monkeypatch: Any, +) -> None: + """A scalar Modal Map Input should pass through and warm one mapped item.""" + observed_boost: dict[str, Any] = {} + + def fake_boost_mapped_component_warmup( + payload: dict[str, Any], + *, + total_items: int, + reason: str, + ) -> tuple[int, int]: + """Record the scalar warmup boost request without touching Modal.""" + observed_boost["payload"] = dict(payload) + observed_boost["total_items"] = total_items + observed_boost["reason"] = reason + return 1, 1 + + monkeypatch.setattr( + remote_modal_app_module, + "boost_mapped_component_warmup", + fake_boost_mapped_component_warmup, + ) + with modal_executor_module._MODAL_MAP_WARMUP_CONTEXTS_LOCK: + modal_executor_module._MODAL_MAP_WARMUP_CONTEXTS.clear() + modal_executor_module.register_modal_map_input_warmup_context( + "map-node-1", + { + "prompt_id": "prompt-1", + "component_id": "mapped-component-1", + "extra_data": {"modal": {"mapped_component_ids": ["mapped-component-1"]}}, + }, + "INT", + ) + + result = modal_executor_module.ModalMapInput.execute( + value=7, + unique_id="map-node-1", + ) + + assert result.result == (7,) + assert observed_boost == { + "payload": { + "prompt_id": "prompt-1", + "component_id": "mapped-component-1", + "extra_data": {"modal": {"mapped_component_ids": ["mapped-component-1"]}}, + }, + "total_items": 1, + "reason": "modal_map_input_execute", + } + + def test_local_remote_app_executes_original_node( remote_modal_app_module: Any, serialization_module: Any, diff --git a/tests/test_serialization.py b/tests/test_serialization.py index ce05949..df9e806 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -106,6 +106,13 @@ def test_split_mapped_value_accepts_python_lists(serialization_module: Any) -> N assert items == ["a", "b", "c"] +def test_split_mapped_value_treats_scalar_as_single_item(serialization_module: Any) -> None: + """Mapped execution should treat scalar marker inputs as a one-item map.""" + items = serialization_module.split_mapped_value(7, "INT") + + assert items == [7] + + def test_split_and_join_tensor_batch_for_mapped_execution(serialization_module: Any) -> None: """Mapped execution should split and reassemble tensor batches on the leading dimension.""" torch = pytest.importorskip("torch")