From 234b3be61343ffafedf489c9ace8dee8adc90983 Mon Sep 17 00:00:00 2001 From: yiyixuxu Date: Sat, 18 Jul 2026 18:41:46 +0000 Subject: [PATCH 01/15] init --- .../modular_pipelines/modular_pipeline.py | 4 + .../modular_pipeline_utils.py | 64 ++++---- .../test_conditional_pipeline_blocks.py | 153 ++++++++++++++++++ 3 files changed, 193 insertions(+), 28 deletions(-) diff --git a/src/diffusers/modular_pipelines/modular_pipeline.py b/src/diffusers/modular_pipelines/modular_pipeline.py index d43825860d8e..f81d3233f7a7 100644 --- a/src/diffusers/modular_pipelines/modular_pipeline.py +++ b/src/diffusers/modular_pipelines/modular_pipeline.py @@ -502,6 +502,10 @@ def get_block_state(self, state: PipelineState) -> dict: for input_param in state_inputs: if input_param.name: value = state.get(input_param.name) + if value is None: + # a ConditionalPipelineBlocks parent merges disagreeing sub-block defaults to None at the + # pipeline level (see combine_inputs); each block falls back to its own declared default here + value = input_param.default if input_param.required and value is None: raise ValueError(f"Required input '{input_param.name}' is missing") elif value is not None or (value is None and input_param.name not in data): diff --git a/src/diffusers/modular_pipelines/modular_pipeline_utils.py b/src/diffusers/modular_pipelines/modular_pipeline_utils.py index 7aa61c55ef1f..576f80b84f49 100644 --- a/src/diffusers/modular_pipelines/modular_pipeline_utils.py +++ b/src/diffusers/modular_pipelines/modular_pipeline_utils.py @@ -14,9 +14,8 @@ import inspect import re -import warnings from collections import OrderedDict -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from types import UnionType from typing import Any, Literal, Type, Union, get_args, get_origin @@ -556,6 +555,10 @@ class InputParam: description: str = "" kwargs_type: str = None metadata: dict[str, Any] = None + # set by `combine_inputs` when sub-blocks of a ConditionalPipelineBlocks declare different defaults for this + # input: maps block name -> that block's declared default; `default` is None in that case and each block + # resolves its own default at runtime in `get_block_state` + defaults_by_block: dict[str, Any] = None def __repr__(self): return f"<{self.name}: {'required' if self.required else 'optional'}, default={self.default}>" @@ -764,12 +767,13 @@ def wrap_text(text, indent, max_length): param_str += "):" # Add description on a new line with additional indentation and wrapping - if param.description: - desc = re.sub(r"\[(.*?)\]\((https?://[^\s\)]+)\)", r"[\1](\2)", param.description) - wrapped_desc = wrap_text(desc, desc_indent, max_line_length) - param_str += f"\n{desc_indent}{wrapped_desc}" - else: - param_str += f"\n{desc_indent}TODO: Add description." + desc = param.description if param.description else "TODO: Add description." + if getattr(param, "defaults_by_block", None): + per_block = ", ".join(f"{v} (`{block}`)" for block, v in param.defaults_by_block.items()) + desc = f"{desc} Default depends on the selected block: {per_block}." + desc = re.sub(r"\[(.*?)\]\((https?://[^\s\)]+)\)", r"[\1](\2)", desc) + wrapped_desc = wrap_text(desc, desc_indent, max_line_length) + param_str += f"\n{desc_indent}{wrapped_desc}" formatted_params.append(param_str) @@ -838,6 +842,9 @@ def get_type_str(type_hint): param_str += ")" desc = param.description if param.description else "No description provided" + if getattr(param, "defaults_by_block", None): + per_block = ", ".join(f"`{v}` (`{block}`)" for block, v in param.defaults_by_block.items()) + desc = f"{desc} Default depends on the selected block: {per_block}." param_str += f": {desc}" lines.append(param_str) @@ -1102,9 +1109,10 @@ def _accumulate(mapping: dict[str, Any]): def combine_inputs(*named_input_lists: list[tuple[str, list[InputParam]]]) -> list[InputParam]: """ - Combines multiple lists of InputParam objects from different blocks. For duplicate inputs, updates only if current - default value is None and new default value is not None. Warns if multiple non-None default values exist for the - same input. + Combines multiple lists of InputParam objects from different blocks. Duplicate inputs keep the first occurrence. + If duplicate inputs declare different defaults, the combined input's default is None and the per-block defaults + are recorded in `defaults_by_block`; each block resolves its own default at runtime in `get_block_state`, and the + docstring formatters render the per-block defaults. Args: named_input_lists: List of tuples containing (block_name, input_param_list) pairs @@ -1113,7 +1121,7 @@ def combine_inputs(*named_input_lists: list[tuple[str, list[InputParam]]]) -> li List[InputParam]: Combined list of unique InputParam objects """ combined_dict = {} # name -> InputParam - value_sources = {} # name -> block_name + defaults_by_block = {} # name -> {block_name: default} for block_name, inputs in named_input_lists: for input_param in inputs: @@ -1121,24 +1129,24 @@ def combine_inputs(*named_input_lists: list[tuple[str, list[InputParam]]]) -> li input_name = "*_" + input_param.kwargs_type else: input_name = input_param.name - if input_name in combined_dict: - current_param = combined_dict[input_name] - if ( - current_param.default is not None - and input_param.default is not None - and current_param.default != input_param.default - ): - warnings.warn( - f"Multiple different default values found for input '{input_name}': " - f"{current_param.default} (from block '{value_sources[input_name]}') and " - f"{input_param.default} (from block '{block_name}'). Using {current_param.default}." - ) - if current_param.default is None and input_param.default is not None: - combined_dict[input_name] = input_param - value_sources[input_name] = block_name + + if input_param.defaults_by_block: + # nested conditional block: prefix its block names with the sub-block name + new_defaults = {f"{block_name}.{k}": v for k, v in input_param.defaults_by_block.items()} else: + new_defaults = {block_name: input_param.default} + + if input_name not in combined_dict: combined_dict[input_name] = input_param - value_sources[input_name] = block_name + defaults_by_block[input_name] = new_defaults + continue + + defaults_by_block[input_name].update(new_defaults) + defaults = list(defaults_by_block[input_name].values()) + if any(d != defaults[0] for d in defaults[1:]): + combined_dict[input_name] = replace( + combined_dict[input_name], default=None, defaults_by_block=dict(defaults_by_block[input_name]) + ) return list(combined_dict.values()) diff --git a/tests/modular_pipelines/test_conditional_pipeline_blocks.py b/tests/modular_pipelines/test_conditional_pipeline_blocks.py index fa5f64f8b31b..b11afdf8900b 100644 --- a/tests/modular_pipelines/test_conditional_pipeline_blocks.py +++ b/tests/modular_pipelines/test_conditional_pipeline_blocks.py @@ -13,12 +13,16 @@ # limitations under the License. +import pytest + from diffusers.modular_pipelines import ( AutoPipelineBlocks, ConditionalPipelineBlocks, InputParam, ModularPipelineBlocks, + OutputParam, ) +from diffusers.modular_pipelines.modular_pipeline_utils import combine_inputs class TextToImageBlock(ModularPipelineBlocks): @@ -240,3 +244,152 @@ def test_sub_block_types(self): def test_description(self): blocks = ConditionalImageBlocks() assert "Conditional" in blocks.description + + +class PlainVideoBlock(ModularPipelineBlocks): + model_name = "video" + + @property + def inputs(self): + return [ + InputParam(name="prompt"), + InputParam(name="num_frames", type_hint=int, default=189, description="Number of frames to generate."), + ] + + @property + def intermediate_outputs(self): + return [OutputParam(name="resolved_num_frames")] + + @property + def description(self): + return "plain video workflow: uses its declared num_frames default" + + def __call__(self, components, state): + block_state = self.get_block_state(state) + block_state.resolved_num_frames = block_state.num_frames + self.set_block_state(state, block_state) + return components, state + + +class ActionVideoBlock(ModularPipelineBlocks): + model_name = "video" + + @property + def inputs(self): + return [ + InputParam(name="prompt"), + InputParam(name="action"), + InputParam(name="num_frames", type_hint=int, default=None, description="Number of frames to generate."), + ] + + @property + def intermediate_outputs(self): + return [OutputParam(name="resolved_num_frames")] + + @property + def description(self): + return "action video workflow: num_frames must not be passed, it is derived from action" + + def __call__(self, components, state): + block_state = self.get_block_state(state) + if block_state.num_frames is not None: + raise ValueError("`num_frames` has to be None if `action` is provided.") + block_state.resolved_num_frames = block_state.action + 1 + self.set_block_state(state, block_state) + return components, state + + +class SingleFrameBlock(ModularPipelineBlocks): + model_name = "video" + + @property + def inputs(self): + return [ + InputParam(name="prompt"), + InputParam(name="image"), + InputParam(name="num_frames", type_hint=int, default=1, description="Number of frames to generate."), + ] + + @property + def intermediate_outputs(self): + return [OutputParam(name="resolved_num_frames")] + + @property + def description(self): + return "single frame workflow" + + def __call__(self, components, state): + block_state = self.get_block_state(state) + block_state.resolved_num_frames = block_state.num_frames + self.set_block_state(state, block_state) + return components, state + + +class AutoVideoBlocks(AutoPipelineBlocks): + block_classes = [ActionVideoBlock, PlainVideoBlock] + block_names = ["action", "plain"] + block_trigger_inputs = ["action", None] + + @property + def description(self): + return "Auto video blocks: runs the action workflow when `action` is provided, plain otherwise" + + +class NestedVideoBlocks(ConditionalPipelineBlocks): + block_classes = [SingleFrameBlock, AutoVideoBlocks] + block_names = ["image", "video"] + block_trigger_inputs = ["image"] + default_block_name = "video" + + @property + def description(self): + return "Nested conditional blocks: single frame when `image` is provided, video otherwise" + + def select_block(self, image=None) -> str | None: + if image is not None: + return "image" + return None + + +class TestConditionalBlocksBranchDefaults: + def test_conflicting_defaults_merge_to_none(self): + merged = {p.name: p for p in AutoVideoBlocks().inputs}["num_frames"] + assert merged.default is None + assert merged.defaults_by_block == {"action": None, "plain": 189} + + def test_agreeing_defaults_stay_untouched(self): + combined = combine_inputs( + ("a", [InputParam(name="x", default=5)]), + ("b", [InputParam(name="x", default=5)]), + ) + assert combined[0].default == 5 + assert combined[0].defaults_by_block is None + + def test_default_branch_resolves_own_default(self): + pipe = AutoVideoBlocks().init_pipeline() + state = pipe(prompt="p") + assert state.get("resolved_num_frames") == 189 + + def test_sentinel_branch_not_polluted_by_sibling_default(self): + pipe = AutoVideoBlocks().init_pipeline() + state = pipe(prompt="p", action=8) + assert state.get("resolved_num_frames") == 9 + + def test_sentinel_branch_rejects_explicit_value(self): + pipe = AutoVideoBlocks().init_pipeline() + with pytest.raises(ValueError, match="has to be None"): + pipe(prompt="p", action=8, num_frames=10) + + def test_standalone_branch_keeps_default(self): + pipe = PlainVideoBlock().init_pipeline() + state = pipe(prompt="p") + assert state.get("resolved_num_frames") == 189 + + def test_doc_renders_per_block_defaults(self): + doc = " ".join(AutoVideoBlocks().doc.split()) + assert "Default depends on the selected block: None (`action`), 189 (`plain`)." in doc + + def test_nested_defaults_prefixed_with_sub_block_name(self): + merged = {p.name: p for p in NestedVideoBlocks().inputs}["num_frames"] + assert merged.default is None + assert merged.defaults_by_block == {"image": 1, "video.action": None, "video.plain": 189} From f3fda64fd9d4545c6572bfc0e16629853f402ecb Mon Sep 17 00:00:00 2001 From: yiyixuxu Date: Sat, 18 Jul 2026 23:05:49 +0000 Subject: [PATCH 02/15] Ensure branch-specific input defaults in ConditionalPipelineBlocks When sibling blocks of a ConditionalPipelineBlocks declare different defaults for the same input, combine_inputs now merges the default to None and records the per-block defaults in a new InputParam.defaults_by_block field, instead of silently promoting the non-None default. get_block_state falls back to the block's own declared default when the state value is None, so each branch resolves its own default when it actually runs and a branch using None as a "user didn't pass this" sentinel is no longer polluted by a sibling's default. Docstrings render conflicted defaults as e.g. "defaults to None or 189, depending on the workflow". Fixes #14132 Co-Authored-By: Claude Fable 5 --- .../modular_pipeline_utils.py | 20 +++++++++++-------- .../test_conditional_pipeline_blocks.py | 2 +- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/diffusers/modular_pipelines/modular_pipeline_utils.py b/src/diffusers/modular_pipelines/modular_pipeline_utils.py index 576f80b84f49..c2ffe2ac359f 100644 --- a/src/diffusers/modular_pipelines/modular_pipeline_utils.py +++ b/src/diffusers/modular_pipelines/modular_pipeline_utils.py @@ -762,15 +762,17 @@ def wrap_text(text, indent, max_length): if hasattr(param, "required"): if not param.required: param_str += ", *optional*" - if param.default is not None: + if getattr(param, "defaults_by_block", None): + distinct_defaults = list(dict.fromkeys(param.defaults_by_block.values())) + param_str += ( + f", defaults to {' or '.join(str(v) for v in distinct_defaults)}, depending on the workflow" + ) + elif param.default is not None: param_str += f", defaults to {param.default}" param_str += "):" # Add description on a new line with additional indentation and wrapping desc = param.description if param.description else "TODO: Add description." - if getattr(param, "defaults_by_block", None): - per_block = ", ".join(f"{v} (`{block}`)" for block, v in param.defaults_by_block.items()) - desc = f"{desc} Default depends on the selected block: {per_block}." desc = re.sub(r"\[(.*?)\]\((https?://[^\s\)]+)\)", r"[\1](\2)", desc) wrapped_desc = wrap_text(desc, desc_indent, max_line_length) param_str += f"\n{desc_indent}{wrapped_desc}" @@ -837,14 +839,16 @@ def get_type_str(type_hint): if hasattr(param, "required") and not param.required: param_str += ", *optional*" - if param.default is not None: + if getattr(param, "defaults_by_block", None): + distinct_defaults = list(dict.fromkeys(param.defaults_by_block.values())) + param_str += ( + f", defaults to {' or '.join(f'`{v}`' for v in distinct_defaults)}, depending on the workflow" + ) + elif param.default is not None: param_str += f", defaults to `{param.default}`" param_str += ")" desc = param.description if param.description else "No description provided" - if getattr(param, "defaults_by_block", None): - per_block = ", ".join(f"`{v}` (`{block}`)" for block, v in param.defaults_by_block.items()) - desc = f"{desc} Default depends on the selected block: {per_block}." param_str += f": {desc}" lines.append(param_str) diff --git a/tests/modular_pipelines/test_conditional_pipeline_blocks.py b/tests/modular_pipelines/test_conditional_pipeline_blocks.py index b11afdf8900b..2ae173619933 100644 --- a/tests/modular_pipelines/test_conditional_pipeline_blocks.py +++ b/tests/modular_pipelines/test_conditional_pipeline_blocks.py @@ -387,7 +387,7 @@ def test_standalone_branch_keeps_default(self): def test_doc_renders_per_block_defaults(self): doc = " ".join(AutoVideoBlocks().doc.split()) - assert "Default depends on the selected block: None (`action`), 189 (`plain`)." in doc + assert "num_frames (`int`, *optional*, defaults to None or 189, depending on the workflow):" in doc def test_nested_defaults_prefixed_with_sub_block_name(self): merged = {p.name: p for p in NestedVideoBlocks().inputs}["num_frames"] From c3b1c58b4f4db5b3af2cd06be6880f2a6025a7a3 Mon Sep 17 00:00:00 2001 From: yiyixuxu Date: Sat, 18 Jul 2026 23:28:44 +0000 Subject: [PATCH 03/15] =?UTF-8?q?Remove=20stale=20note=20about=20kwargs=5F?= =?UTF-8?q?type=20removal=20=E2=80=94=20the=20feature=20is=20staying?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- src/diffusers/modular_pipelines/modular_pipeline_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/diffusers/modular_pipelines/modular_pipeline_utils.py b/src/diffusers/modular_pipelines/modular_pipeline_utils.py index c2ffe2ac359f..3152eeb54fc4 100644 --- a/src/diffusers/modular_pipelines/modular_pipeline_utils.py +++ b/src/diffusers/modular_pipelines/modular_pipeline_utils.py @@ -754,7 +754,6 @@ def wrap_text(text, indent, max_length): for param in params: # Format parameter name and type type_str = get_type_str(param.type_hint) if param.type_hint != Any else "" - # YiYi Notes: remove this line if we remove kwargs_type name = f"**{param.kwargs_type}" if param.name is None and param.kwargs_type is not None else param.name param_str = f"{param_indent}{name} (`{type_str}`" From 13c90237639591e2755a1a3c8089efcd4cc1d092 Mon Sep 17 00:00:00 2001 From: yiyixuxu Date: Sat, 18 Jul 2026 23:33:16 +0000 Subject: [PATCH 04/15] Clarify get_block_state default-resolution comment, drop getattr guards defaults_by_block is a dataclass field so it always exists; the checks are already inside the hasattr(param, "required") InputParam guard. Co-Authored-By: Claude Fable 5 --- src/diffusers/modular_pipelines/modular_pipeline.py | 7 +++++-- src/diffusers/modular_pipelines/modular_pipeline_utils.py | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/diffusers/modular_pipelines/modular_pipeline.py b/src/diffusers/modular_pipelines/modular_pipeline.py index f81d3233f7a7..00b51501210d 100644 --- a/src/diffusers/modular_pipelines/modular_pipeline.py +++ b/src/diffusers/modular_pipelines/modular_pipeline.py @@ -503,8 +503,11 @@ def get_block_state(self, state: PipelineState) -> dict: if input_param.name: value = state.get(input_param.name) if value is None: - # a ConditionalPipelineBlocks parent merges disagreeing sub-block defaults to None at the - # pipeline level (see combine_inputs); each block falls back to its own declared default here + # if the value is None (not passed, or passed as None), the first block that reads it sets the + # default at call time. For sequential blocks the default is already resolved at compile time + # (first declaring block wins, see _get_inputs), but for conditional blocks the selected branch + # is only known at runtime: disagreeing branch defaults merge to None (see combine_inputs) and + # the block that actually runs applies its own declared default here value = input_param.default if input_param.required and value is None: raise ValueError(f"Required input '{input_param.name}' is missing") diff --git a/src/diffusers/modular_pipelines/modular_pipeline_utils.py b/src/diffusers/modular_pipelines/modular_pipeline_utils.py index 3152eeb54fc4..447a8ae94767 100644 --- a/src/diffusers/modular_pipelines/modular_pipeline_utils.py +++ b/src/diffusers/modular_pipelines/modular_pipeline_utils.py @@ -761,7 +761,7 @@ def wrap_text(text, indent, max_length): if hasattr(param, "required"): if not param.required: param_str += ", *optional*" - if getattr(param, "defaults_by_block", None): + if param.defaults_by_block is not None: distinct_defaults = list(dict.fromkeys(param.defaults_by_block.values())) param_str += ( f", defaults to {' or '.join(str(v) for v in distinct_defaults)}, depending on the workflow" @@ -838,7 +838,7 @@ def get_type_str(type_hint): if hasattr(param, "required") and not param.required: param_str += ", *optional*" - if getattr(param, "defaults_by_block", None): + if param.defaults_by_block is not None: distinct_defaults = list(dict.fromkeys(param.defaults_by_block.values())) param_str += ( f", defaults to {' or '.join(f'`{v}`' for v in distinct_defaults)}, depending on the workflow" From 8af5c6411f333dbef78892d2a7b7d9cd7fd1d727 Mon Sep 17 00:00:00 2001 From: yiyixuxu Date: Sun, 19 Jul 2026 00:40:58 +0000 Subject: [PATCH 05/15] Add example comments to combine_inputs and formatters, revert description block restructure Co-Authored-By: Claude Fable 5 --- .../modular_pipeline_utils.py | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/diffusers/modular_pipelines/modular_pipeline_utils.py b/src/diffusers/modular_pipelines/modular_pipeline_utils.py index 447a8ae94767..88d1d235f6e4 100644 --- a/src/diffusers/modular_pipelines/modular_pipeline_utils.py +++ b/src/diffusers/modular_pipelines/modular_pipeline_utils.py @@ -762,6 +762,7 @@ def wrap_text(text, indent, max_length): if not param.required: param_str += ", *optional*" if param.defaults_by_block is not None: + # e.g. ", defaults to None or 189, depending on the workflow" distinct_defaults = list(dict.fromkeys(param.defaults_by_block.values())) param_str += ( f", defaults to {' or '.join(str(v) for v in distinct_defaults)}, depending on the workflow" @@ -771,10 +772,12 @@ def wrap_text(text, indent, max_length): param_str += "):" # Add description on a new line with additional indentation and wrapping - desc = param.description if param.description else "TODO: Add description." - desc = re.sub(r"\[(.*?)\]\((https?://[^\s\)]+)\)", r"[\1](\2)", desc) - wrapped_desc = wrap_text(desc, desc_indent, max_line_length) - param_str += f"\n{desc_indent}{wrapped_desc}" + if param.description: + desc = re.sub(r"\[(.*?)\]\((https?://[^\s\)]+)\)", r"[\1](\2)", param.description) + wrapped_desc = wrap_text(desc, desc_indent, max_line_length) + param_str += f"\n{desc_indent}{wrapped_desc}" + else: + param_str += f"\n{desc_indent}TODO: Add description." formatted_params.append(param_str) @@ -839,6 +842,7 @@ def get_type_str(type_hint): if hasattr(param, "required") and not param.required: param_str += ", *optional*" if param.defaults_by_block is not None: + # e.g. ", defaults to `None` or `189`, depending on the workflow" distinct_defaults = list(dict.fromkeys(param.defaults_by_block.values())) param_str += ( f", defaults to {' or '.join(f'`{v}`' for v in distinct_defaults)}, depending on the workflow" @@ -1117,6 +1121,15 @@ def combine_inputs(*named_input_lists: list[tuple[str, list[InputParam]]]) -> li are recorded in `defaults_by_block`; each block resolves its own default at runtime in `get_block_state`, and the docstring formatters render the per-block defaults. + Example: + combine_inputs( + ("img2img", [InputParam("prompt"), InputParam("strength", default=0.3)]), + ("inpaint", [InputParam("prompt"), InputParam("strength", default=0.9999)]), + ) + returns: + InputParam("prompt") # no disagreement -> first occurrence kept as-is + InputParam("strength", default=None, defaults_by_block={"img2img": 0.3, "inpaint": 0.9999}) + Args: named_input_lists: List of tuples containing (block_name, input_param_list) pairs @@ -1134,7 +1147,8 @@ def combine_inputs(*named_input_lists: list[tuple[str, list[InputParam]]]) -> li input_name = input_param.name if input_param.defaults_by_block: - # nested conditional block: prefix its block names with the sub-block name + # nested conditional block: prefix its block names with the sub-block name, + # e.g. {"img2img": 0.3, "inpaint": 0.9999} from sub-block "image" -> {"image.img2img": 0.3, "image.inpaint": 0.9999} new_defaults = {f"{block_name}.{k}": v for k, v in input_param.defaults_by_block.items()} else: new_defaults = {block_name: input_param.default} From 1f32ed7d5731b1fdfb8cd8aca52b8971223ada09 Mon Sep 17 00:00:00 2001 From: yiyixuxu Date: Sun, 19 Jul 2026 00:46:04 +0000 Subject: [PATCH 06/15] Comment the duplicate-input path in combine_inputs with the strength example Co-Authored-By: Claude Fable 5 --- src/diffusers/modular_pipelines/modular_pipeline_utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/diffusers/modular_pipelines/modular_pipeline_utils.py b/src/diffusers/modular_pipelines/modular_pipeline_utils.py index 88d1d235f6e4..40a41561b900 100644 --- a/src/diffusers/modular_pipelines/modular_pipeline_utils.py +++ b/src/diffusers/modular_pipelines/modular_pipeline_utils.py @@ -1158,9 +1158,13 @@ def combine_inputs(*named_input_lists: list[tuple[str, list[InputParam]]]) -> li defaults_by_block[input_name] = new_defaults continue + # duplicate input: accumulate this block's default, + # e.g. "strength" was {"img2img": 0.3}, after inpaint's turn it is {"img2img": 0.3, "inpaint": 0.9999} defaults_by_block[input_name].update(new_defaults) defaults = list(defaults_by_block[input_name].values()) if any(d != defaults[0] for d in defaults[1:]): + # blocks disagree (0.3 vs 0.9999): combined default becomes None and the per-block defaults are + # recorded on the param; `replace` creates a fresh copy so the blocks' own params are never mutated combined_dict[input_name] = replace( combined_dict[input_name], default=None, defaults_by_block=dict(defaults_by_block[input_name]) ) From 7efee1110c5c2732fcf753136306074a6f3fa9e6 Mon Sep 17 00:00:00 2001 From: yiyixuxu Date: Sun, 19 Jul 2026 01:01:15 +0000 Subject: [PATCH 07/15] Rework branch-defaults tests to reuse the image block fixtures strength on img2img (0.3) / inpaint (0.9999), text2img as the None sentinel branch; drop the separate video fixtures. Also add examples to the combine_inputs accumulator comments. Co-Authored-By: Claude Fable 5 --- .../modular_pipeline_utils.py | 4 +- .../test_conditional_pipeline_blocks.py | 184 ++++++------------ 2 files changed, 64 insertions(+), 124 deletions(-) diff --git a/src/diffusers/modular_pipelines/modular_pipeline_utils.py b/src/diffusers/modular_pipelines/modular_pipeline_utils.py index 40a41561b900..4e501c26b4e0 100644 --- a/src/diffusers/modular_pipelines/modular_pipeline_utils.py +++ b/src/diffusers/modular_pipelines/modular_pipeline_utils.py @@ -1136,8 +1136,8 @@ def combine_inputs(*named_input_lists: list[tuple[str, list[InputParam]]]) -> li Returns: List[InputParam]: Combined list of unique InputParam objects """ - combined_dict = {} # name -> InputParam - defaults_by_block = {} # name -> {block_name: default} + combined_dict = {} # name -> InputParam, e.g. {"strength": InputParam("strength", default=0.3)} + defaults_by_block = {} # name -> {block_name: default}, e.g. {"strength": {"img2img": 0.3, "inpaint": 0.9999}} for block_name, inputs in named_input_lists: for input_param in inputs: diff --git a/tests/modular_pipelines/test_conditional_pipeline_blocks.py b/tests/modular_pipelines/test_conditional_pipeline_blocks.py index 2ae173619933..2bde595add6c 100644 --- a/tests/modular_pipelines/test_conditional_pipeline_blocks.py +++ b/tests/modular_pipelines/test_conditional_pipeline_blocks.py @@ -30,7 +30,8 @@ class TextToImageBlock(ModularPipelineBlocks): @property def inputs(self): - return [InputParam(name="prompt")] + # strength does not apply to text2img: None is a "user didn't pass this" sentinel + return [InputParam(name="prompt"), InputParam(name="strength", type_hint=float, default=None)] @property def intermediate_outputs(self): @@ -42,6 +43,8 @@ def description(self): def __call__(self, components, state): block_state = self.get_block_state(state) + if block_state.strength is not None: + raise ValueError("`strength` is not supported for text2img.") block_state.workflow = "text2img" self.set_block_state(state, block_state) return components, state @@ -52,11 +55,15 @@ class ImageToImageBlock(ModularPipelineBlocks): @property def inputs(self): - return [InputParam(name="prompt"), InputParam(name="image")] + return [ + InputParam(name="prompt"), + InputParam(name="image"), + InputParam(name="strength", type_hint=float, default=0.3), + ] @property def intermediate_outputs(self): - return [] + return [OutputParam(name="resolved_strength")] @property def description(self): @@ -65,6 +72,7 @@ def description(self): def __call__(self, components, state): block_state = self.get_block_state(state) block_state.workflow = "img2img" + block_state.resolved_strength = block_state.strength self.set_block_state(state, block_state) return components, state @@ -74,11 +82,16 @@ class InpaintBlock(ModularPipelineBlocks): @property def inputs(self): - return [InputParam(name="prompt"), InputParam(name="image"), InputParam(name="mask")] + return [ + InputParam(name="prompt"), + InputParam(name="image"), + InputParam(name="mask"), + InputParam(name="strength", type_hint=float, default=0.9999), + ] @property def intermediate_outputs(self): - return [] + return [OutputParam(name="resolved_strength")] @property def description(self): @@ -87,6 +100,7 @@ def description(self): def __call__(self, components, state): block_state = self.get_block_state(state) block_state.workflow = "inpaint" + block_state.resolved_strength = block_state.strength self.set_block_state(state, block_state) return components, state @@ -246,116 +260,27 @@ def test_description(self): assert "Conditional" in blocks.description -class PlainVideoBlock(ModularPipelineBlocks): - model_name = "video" - - @property - def inputs(self): - return [ - InputParam(name="prompt"), - InputParam(name="num_frames", type_hint=int, default=189, description="Number of frames to generate."), - ] - - @property - def intermediate_outputs(self): - return [OutputParam(name="resolved_num_frames")] - - @property - def description(self): - return "plain video workflow: uses its declared num_frames default" - - def __call__(self, components, state): - block_state = self.get_block_state(state) - block_state.resolved_num_frames = block_state.num_frames - self.set_block_state(state, block_state) - return components, state - - -class ActionVideoBlock(ModularPipelineBlocks): - model_name = "video" - - @property - def inputs(self): - return [ - InputParam(name="prompt"), - InputParam(name="action"), - InputParam(name="num_frames", type_hint=int, default=None, description="Number of frames to generate."), - ] - - @property - def intermediate_outputs(self): - return [OutputParam(name="resolved_num_frames")] - - @property - def description(self): - return "action video workflow: num_frames must not be passed, it is derived from action" - - def __call__(self, components, state): - block_state = self.get_block_state(state) - if block_state.num_frames is not None: - raise ValueError("`num_frames` has to be None if `action` is provided.") - block_state.resolved_num_frames = block_state.action + 1 - self.set_block_state(state, block_state) - return components, state - - -class SingleFrameBlock(ModularPipelineBlocks): - model_name = "video" - - @property - def inputs(self): - return [ - InputParam(name="prompt"), - InputParam(name="image"), - InputParam(name="num_frames", type_hint=int, default=1, description="Number of frames to generate."), - ] - - @property - def intermediate_outputs(self): - return [OutputParam(name="resolved_num_frames")] - - @property - def description(self): - return "single frame workflow" - - def __call__(self, components, state): - block_state = self.get_block_state(state) - block_state.resolved_num_frames = block_state.num_frames - self.set_block_state(state, block_state) - return components, state - - -class AutoVideoBlocks(AutoPipelineBlocks): - block_classes = [ActionVideoBlock, PlainVideoBlock] - block_names = ["action", "plain"] - block_trigger_inputs = ["action", None] - - @property - def description(self): - return "Auto video blocks: runs the action workflow when `action` is provided, plain otherwise" - - -class NestedVideoBlocks(ConditionalPipelineBlocks): - block_classes = [SingleFrameBlock, AutoVideoBlocks] - block_names = ["image", "video"] - block_trigger_inputs = ["image"] - default_block_name = "video" +class NestedImageBlocks(ConditionalPipelineBlocks): + block_classes = [InpaintBlock, AutoImageBlocks] + block_names = ["refine", "image"] + block_trigger_inputs = ["mask"] + default_block_name = "image" @property def description(self): - return "Nested conditional blocks: single frame when `image` is provided, video otherwise" + return "Nested conditional blocks: refine when `mask` is provided, auto image blocks otherwise" - def select_block(self, image=None) -> str | None: - if image is not None: - return "image" + def select_block(self, mask=None) -> str | None: + if mask is not None: + return "refine" return None class TestConditionalBlocksBranchDefaults: def test_conflicting_defaults_merge_to_none(self): - merged = {p.name: p for p in AutoVideoBlocks().inputs}["num_frames"] + merged = {p.name: p for p in AutoImageBlocks().inputs}["strength"] assert merged.default is None - assert merged.defaults_by_block == {"action": None, "plain": 189} + assert merged.defaults_by_block == {"inpaint": 0.9999, "img2img": 0.3, "text2img": None} def test_agreeing_defaults_stay_untouched(self): combined = combine_inputs( @@ -365,31 +290,46 @@ def test_agreeing_defaults_stay_untouched(self): assert combined[0].default == 5 assert combined[0].defaults_by_block is None - def test_default_branch_resolves_own_default(self): - pipe = AutoVideoBlocks().init_pipeline() - state = pipe(prompt="p") - assert state.get("resolved_num_frames") == 189 + def test_branch_resolves_own_default(self): + pipe = AutoImageBlocks().init_pipeline() + state = pipe(prompt="p", image="i") + assert state.get("resolved_strength") == 0.3 + + state = pipe(prompt="p", image="i", mask="m") + assert state.get("resolved_strength") == 0.9999 + + def test_explicit_value_overrides_branch_default(self): + pipe = AutoImageBlocks().init_pipeline() + state = pipe(prompt="p", image="i", strength=0.7) + assert state.get("resolved_strength") == 0.7 def test_sentinel_branch_not_polluted_by_sibling_default(self): - pipe = AutoVideoBlocks().init_pipeline() - state = pipe(prompt="p", action=8) - assert state.get("resolved_num_frames") == 9 + pipe = AutoImageBlocks().init_pipeline() + # text2img uses None as a "user didn't pass this" sentinel: without the per-block merge, + # img2img's 0.3 would leak into state and this call would raise + state = pipe(prompt="p") + assert state.get("resolved_strength") is None def test_sentinel_branch_rejects_explicit_value(self): - pipe = AutoVideoBlocks().init_pipeline() - with pytest.raises(ValueError, match="has to be None"): - pipe(prompt="p", action=8, num_frames=10) + pipe = AutoImageBlocks().init_pipeline() + with pytest.raises(ValueError, match="not supported for text2img"): + pipe(prompt="p", strength=0.5) def test_standalone_branch_keeps_default(self): - pipe = PlainVideoBlock().init_pipeline() - state = pipe(prompt="p") - assert state.get("resolved_num_frames") == 189 + pipe = ImageToImageBlock().init_pipeline() + state = pipe(prompt="p", image="i") + assert state.get("resolved_strength") == 0.3 def test_doc_renders_per_block_defaults(self): - doc = " ".join(AutoVideoBlocks().doc.split()) - assert "num_frames (`int`, *optional*, defaults to None or 189, depending on the workflow):" in doc + doc = " ".join(AutoImageBlocks().doc.split()) + assert "strength (`float`, *optional*, defaults to 0.9999 or 0.3 or None, depending on the workflow):" in doc def test_nested_defaults_prefixed_with_sub_block_name(self): - merged = {p.name: p for p in NestedVideoBlocks().inputs}["num_frames"] + merged = {p.name: p for p in NestedImageBlocks().inputs}["strength"] assert merged.default is None - assert merged.defaults_by_block == {"image": 1, "video.action": None, "video.plain": 189} + assert merged.defaults_by_block == { + "refine": 0.9999, + "image.inpaint": 0.9999, + "image.img2img": 0.3, + "image.text2img": None, + } From 1c913ac08c7d27ba5b4d44896d66b43d52c4225f Mon Sep 17 00:00:00 2001 From: yiyixuxu Date: Sun, 19 Jul 2026 01:52:00 +0000 Subject: [PATCH 08/15] Simplify branch-defaults tests: drop t2i sentinel and resolved_strength output Assert on state.get("strength") directly (write-back records the branch's resolved value); cover None-vs-non-None merge with a direct combine_inputs test instead. Co-Authored-By: Claude Fable 5 --- .../test_conditional_pipeline_blocks.py | 48 +++++++------------ 1 file changed, 18 insertions(+), 30 deletions(-) diff --git a/tests/modular_pipelines/test_conditional_pipeline_blocks.py b/tests/modular_pipelines/test_conditional_pipeline_blocks.py index 2bde595add6c..ffa9d58c4a00 100644 --- a/tests/modular_pipelines/test_conditional_pipeline_blocks.py +++ b/tests/modular_pipelines/test_conditional_pipeline_blocks.py @@ -13,14 +13,11 @@ # limitations under the License. -import pytest - from diffusers.modular_pipelines import ( AutoPipelineBlocks, ConditionalPipelineBlocks, InputParam, ModularPipelineBlocks, - OutputParam, ) from diffusers.modular_pipelines.modular_pipeline_utils import combine_inputs @@ -30,8 +27,7 @@ class TextToImageBlock(ModularPipelineBlocks): @property def inputs(self): - # strength does not apply to text2img: None is a "user didn't pass this" sentinel - return [InputParam(name="prompt"), InputParam(name="strength", type_hint=float, default=None)] + return [InputParam(name="prompt")] @property def intermediate_outputs(self): @@ -43,8 +39,6 @@ def description(self): def __call__(self, components, state): block_state = self.get_block_state(state) - if block_state.strength is not None: - raise ValueError("`strength` is not supported for text2img.") block_state.workflow = "text2img" self.set_block_state(state, block_state) return components, state @@ -63,7 +57,7 @@ def inputs(self): @property def intermediate_outputs(self): - return [OutputParam(name="resolved_strength")] + return [] @property def description(self): @@ -72,7 +66,6 @@ def description(self): def __call__(self, components, state): block_state = self.get_block_state(state) block_state.workflow = "img2img" - block_state.resolved_strength = block_state.strength self.set_block_state(state, block_state) return components, state @@ -91,7 +84,7 @@ def inputs(self): @property def intermediate_outputs(self): - return [OutputParam(name="resolved_strength")] + return [] @property def description(self): @@ -100,7 +93,6 @@ def description(self): def __call__(self, components, state): block_state = self.get_block_state(state) block_state.workflow = "inpaint" - block_state.resolved_strength = block_state.strength self.set_block_state(state, block_state) return components, state @@ -280,7 +272,7 @@ class TestConditionalBlocksBranchDefaults: def test_conflicting_defaults_merge_to_none(self): merged = {p.name: p for p in AutoImageBlocks().inputs}["strength"] assert merged.default is None - assert merged.defaults_by_block == {"inpaint": 0.9999, "img2img": 0.3, "text2img": None} + assert merged.defaults_by_block == {"inpaint": 0.9999, "img2img": 0.3} def test_agreeing_defaults_stay_untouched(self): combined = combine_inputs( @@ -290,39 +282,36 @@ def test_agreeing_defaults_stay_untouched(self): assert combined[0].default == 5 assert combined[0].defaults_by_block is None + def test_none_default_counts_as_disagreement(self): + # a None default is a sentinel ("user didn't pass this"), it must not be overridden by a sibling's default + combined = combine_inputs( + ("a", [InputParam(name="x", default=None)]), + ("b", [InputParam(name="x", default=5)]), + ) + assert combined[0].default is None + assert combined[0].defaults_by_block == {"a": None, "b": 5} + def test_branch_resolves_own_default(self): pipe = AutoImageBlocks().init_pipeline() state = pipe(prompt="p", image="i") - assert state.get("resolved_strength") == 0.3 + assert state.get("strength") == 0.3 state = pipe(prompt="p", image="i", mask="m") - assert state.get("resolved_strength") == 0.9999 + assert state.get("strength") == 0.9999 def test_explicit_value_overrides_branch_default(self): pipe = AutoImageBlocks().init_pipeline() state = pipe(prompt="p", image="i", strength=0.7) - assert state.get("resolved_strength") == 0.7 - - def test_sentinel_branch_not_polluted_by_sibling_default(self): - pipe = AutoImageBlocks().init_pipeline() - # text2img uses None as a "user didn't pass this" sentinel: without the per-block merge, - # img2img's 0.3 would leak into state and this call would raise - state = pipe(prompt="p") - assert state.get("resolved_strength") is None - - def test_sentinel_branch_rejects_explicit_value(self): - pipe = AutoImageBlocks().init_pipeline() - with pytest.raises(ValueError, match="not supported for text2img"): - pipe(prompt="p", strength=0.5) + assert state.get("strength") == 0.7 def test_standalone_branch_keeps_default(self): pipe = ImageToImageBlock().init_pipeline() state = pipe(prompt="p", image="i") - assert state.get("resolved_strength") == 0.3 + assert state.get("strength") == 0.3 def test_doc_renders_per_block_defaults(self): doc = " ".join(AutoImageBlocks().doc.split()) - assert "strength (`float`, *optional*, defaults to 0.9999 or 0.3 or None, depending on the workflow):" in doc + assert "strength (`float`, *optional*, defaults to 0.9999 or 0.3, depending on the workflow):" in doc def test_nested_defaults_prefixed_with_sub_block_name(self): merged = {p.name: p for p in NestedImageBlocks().inputs}["strength"] @@ -331,5 +320,4 @@ def test_nested_defaults_prefixed_with_sub_block_name(self): "refine": 0.9999, "image.inpaint": 0.9999, "image.img2img": 0.3, - "image.text2img": None, } From c7cc16b5d9565f8c1f2abf4c34d93229c0b3095e Mon Sep 17 00:00:00 2001 From: yiyixuxu Date: Sun, 19 Jul 2026 02:04:16 +0000 Subject: [PATCH 09/15] Move combine_inputs tests to module level as test_combine_inputs_* block-a/block-b names, whole-list assertions, plus new coverage: first-occurrence-wins, disagreement records every block, direct nested prefixing. Co-Authored-By: Claude Fable 5 --- .../test_conditional_pipeline_blocks.py | 71 ++++++++++++++----- 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/tests/modular_pipelines/test_conditional_pipeline_blocks.py b/tests/modular_pipelines/test_conditional_pipeline_blocks.py index ffa9d58c4a00..38c903df68db 100644 --- a/tests/modular_pipelines/test_conditional_pipeline_blocks.py +++ b/tests/modular_pipelines/test_conditional_pipeline_blocks.py @@ -22,6 +22,60 @@ from diffusers.modular_pipelines.modular_pipeline_utils import combine_inputs +def test_combine_inputs_agreeing_defaults_stay_untouched(): + combined = combine_inputs( + ("block-a", [InputParam(name="x", default=5)]), + ("block-b", [InputParam(name="x", default=5)]), + ) + assert combined == [InputParam(name="x", default=5)] + + +def test_combine_inputs_first_occurrence_wins(): + combined = combine_inputs( + ("block-a", [InputParam(name="x", default=5, description="from block-a")]), + ("block-b", [InputParam(name="x", default=5, description="from block-b")]), + ) + # duplicate inputs keep the first occurrence: block-b's description is dropped + assert combined == [InputParam(name="x", default=5, description="from block-a")] + + +def test_combine_inputs_none_default_counts_as_disagreement(): + # a None default is a sentinel ("user didn't pass this"), it must not be overridden by a sibling's default + combined = combine_inputs( + ("block-a", [InputParam(name="x", default=None)]), + ("block-b", [InputParam(name="x", default=5)]), + ) + assert combined == [InputParam(name="x", default=None, defaults_by_block={"block-a": None, "block-b": 5})] + + +def test_combine_inputs_disagreement_records_every_block(): + combined = combine_inputs( + ("block-a", [InputParam(name="x", default=1)]), + ("block-b", [InputParam(name="x", default=2)]), + ("block-c", [InputParam(name="x", default=1)]), + ) + # any disagreement records every block's default, including the ones that agree with each other + assert combined == [ + InputParam(name="x", default=None, defaults_by_block={"block-a": 1, "block-b": 2, "block-c": 1}) + ] + + +def test_combine_inputs_nested_defaults_prefixed(): + # block-b is itself a conditional block whose branches already disagree, so its param carries defaults_by_block; + # merging at the outer level prefixes those entries with the sub-block name + combined = combine_inputs( + ("block-a", [InputParam(name="x", default=3)]), + ("block-b", [InputParam(name="x", default=None, defaults_by_block={"branch-a": 1, "branch-b": 2})]), + ) + assert combined == [ + InputParam( + name="x", + default=None, + defaults_by_block={"block-a": 3, "block-b.branch-a": 1, "block-b.branch-b": 2}, + ) + ] + + class TextToImageBlock(ModularPipelineBlocks): model_name = "text2img" @@ -274,23 +328,6 @@ def test_conflicting_defaults_merge_to_none(self): assert merged.default is None assert merged.defaults_by_block == {"inpaint": 0.9999, "img2img": 0.3} - def test_agreeing_defaults_stay_untouched(self): - combined = combine_inputs( - ("a", [InputParam(name="x", default=5)]), - ("b", [InputParam(name="x", default=5)]), - ) - assert combined[0].default == 5 - assert combined[0].defaults_by_block is None - - def test_none_default_counts_as_disagreement(self): - # a None default is a sentinel ("user didn't pass this"), it must not be overridden by a sibling's default - combined = combine_inputs( - ("a", [InputParam(name="x", default=None)]), - ("b", [InputParam(name="x", default=5)]), - ) - assert combined[0].default is None - assert combined[0].defaults_by_block == {"a": None, "b": 5} - def test_branch_resolves_own_default(self): pipe = AutoImageBlocks().init_pipeline() state = pipe(prompt="p", image="i") From 240ec8656cfb7e521a463ab7aed55d2083151353 Mon Sep 17 00:00:00 2001 From: yiyixuxu Date: Sun, 19 Jul 2026 02:12:22 +0000 Subject: [PATCH 10/15] Tighten merge assertions: unique strength param + full InputParam equality Also group combine_inputs tests under TestConditionalBlocksInputs. Co-Authored-By: Claude Fable 5 --- .../test_conditional_pipeline_blocks.py | 125 +++++++++--------- 1 file changed, 64 insertions(+), 61 deletions(-) diff --git a/tests/modular_pipelines/test_conditional_pipeline_blocks.py b/tests/modular_pipelines/test_conditional_pipeline_blocks.py index 38c903df68db..077519d54ae7 100644 --- a/tests/modular_pipelines/test_conditional_pipeline_blocks.py +++ b/tests/modular_pipelines/test_conditional_pipeline_blocks.py @@ -22,58 +22,55 @@ from diffusers.modular_pipelines.modular_pipeline_utils import combine_inputs -def test_combine_inputs_agreeing_defaults_stay_untouched(): - combined = combine_inputs( - ("block-a", [InputParam(name="x", default=5)]), - ("block-b", [InputParam(name="x", default=5)]), - ) - assert combined == [InputParam(name="x", default=5)] - - -def test_combine_inputs_first_occurrence_wins(): - combined = combine_inputs( - ("block-a", [InputParam(name="x", default=5, description="from block-a")]), - ("block-b", [InputParam(name="x", default=5, description="from block-b")]), - ) - # duplicate inputs keep the first occurrence: block-b's description is dropped - assert combined == [InputParam(name="x", default=5, description="from block-a")] - - -def test_combine_inputs_none_default_counts_as_disagreement(): - # a None default is a sentinel ("user didn't pass this"), it must not be overridden by a sibling's default - combined = combine_inputs( - ("block-a", [InputParam(name="x", default=None)]), - ("block-b", [InputParam(name="x", default=5)]), - ) - assert combined == [InputParam(name="x", default=None, defaults_by_block={"block-a": None, "block-b": 5})] - - -def test_combine_inputs_disagreement_records_every_block(): - combined = combine_inputs( - ("block-a", [InputParam(name="x", default=1)]), - ("block-b", [InputParam(name="x", default=2)]), - ("block-c", [InputParam(name="x", default=1)]), - ) - # any disagreement records every block's default, including the ones that agree with each other - assert combined == [ - InputParam(name="x", default=None, defaults_by_block={"block-a": 1, "block-b": 2, "block-c": 1}) - ] - - -def test_combine_inputs_nested_defaults_prefixed(): - # block-b is itself a conditional block whose branches already disagree, so its param carries defaults_by_block; - # merging at the outer level prefixes those entries with the sub-block name - combined = combine_inputs( - ("block-a", [InputParam(name="x", default=3)]), - ("block-b", [InputParam(name="x", default=None, defaults_by_block={"branch-a": 1, "branch-b": 2})]), - ) - assert combined == [ - InputParam( - name="x", - default=None, - defaults_by_block={"block-a": 3, "block-b.branch-a": 1, "block-b.branch-b": 2}, +class TestConditionalBlocksInputs: + def test_combine_inputs_agreeing_defaults_stay_untouched(self): + combined = combine_inputs( + ("block-a", [InputParam(name="x", default=5)]), + ("block-b", [InputParam(name="x", default=5)]), + ) + assert combined == [InputParam(name="x", default=5)] + + def test_combine_inputs_first_occurrence_wins(self): + combined = combine_inputs( + ("block-a", [InputParam(name="x", default=5, description="from block-a")]), + ("block-b", [InputParam(name="x", default=5, description="from block-b")]), + ) + # duplicate inputs keep the first occurrence: block-b's description is dropped + assert combined == [InputParam(name="x", default=5, description="from block-a")] + + def test_combine_inputs_none_default_counts_as_disagreement(self): + # a None default is a sentinel ("user didn't pass this"), it must not be overridden by a sibling's default + combined = combine_inputs( + ("block-a", [InputParam(name="x", default=None)]), + ("block-b", [InputParam(name="x", default=5)]), + ) + assert combined == [InputParam(name="x", default=None, defaults_by_block={"block-a": None, "block-b": 5})] + + def test_combine_inputs_disagreement_records_every_block(self): + combined = combine_inputs( + ("block-a", [InputParam(name="x", default=1)]), + ("block-b", [InputParam(name="x", default=2)]), + ("block-c", [InputParam(name="x", default=1)]), + ) + # any disagreement records every block's default, including the ones that agree with each other + assert combined == [ + InputParam(name="x", default=None, defaults_by_block={"block-a": 1, "block-b": 2, "block-c": 1}) + ] + + def test_combine_inputs_nested_defaults_prefixed(self): + # block-b is itself a conditional block whose branches already disagree, so its param carries + # defaults_by_block; merging at the outer level prefixes those entries with the sub-block name + combined = combine_inputs( + ("block-a", [InputParam(name="x", default=3)]), + ("block-b", [InputParam(name="x", default=None, defaults_by_block={"branch-a": 1, "branch-b": 2})]), ) - ] + assert combined == [ + InputParam( + name="x", + default=None, + defaults_by_block={"block-a": 3, "block-b.branch-a": 1, "block-b.branch-b": 2}, + ) + ] class TextToImageBlock(ModularPipelineBlocks): @@ -324,9 +321,14 @@ def select_block(self, mask=None) -> str | None: class TestConditionalBlocksBranchDefaults: def test_conflicting_defaults_merge_to_none(self): - merged = {p.name: p for p in AutoImageBlocks().inputs}["strength"] - assert merged.default is None - assert merged.defaults_by_block == {"inpaint": 0.9999, "img2img": 0.3} + strength_params = [p for p in AutoImageBlocks().inputs if p.name == "strength"] + assert len(strength_params) == 1 + assert strength_params[0] == InputParam( + name="strength", + type_hint=float, + default=None, + defaults_by_block={"inpaint": 0.9999, "img2img": 0.3}, + ) def test_branch_resolves_own_default(self): pipe = AutoImageBlocks().init_pipeline() @@ -351,10 +353,11 @@ def test_doc_renders_per_block_defaults(self): assert "strength (`float`, *optional*, defaults to 0.9999 or 0.3, depending on the workflow):" in doc def test_nested_defaults_prefixed_with_sub_block_name(self): - merged = {p.name: p for p in NestedImageBlocks().inputs}["strength"] - assert merged.default is None - assert merged.defaults_by_block == { - "refine": 0.9999, - "image.inpaint": 0.9999, - "image.img2img": 0.3, - } + strength_params = [p for p in NestedImageBlocks().inputs if p.name == "strength"] + assert len(strength_params) == 1 + assert strength_params[0] == InputParam( + name="strength", + type_hint=float, + default=None, + defaults_by_block={"refine": 0.9999, "image.inpaint": 0.9999, "image.img2img": 0.3}, + ) From 2c4a221c4a97617b6469de3db0e1da1f64ea7aba Mon Sep 17 00:00:00 2001 From: yiyixuxu Date: Sun, 19 Jul 2026 02:19:06 +0000 Subject: [PATCH 11/15] Assert workflow in runtime tests, fold doc rendering into merge tests workflow is now a declared intermediate output on the fixture blocks so each runtime test shows which branch ran; the merge tests also assert the rendered docstring (nested case covers distinct-value dedupe). Co-Authored-By: Claude Fable 5 --- .../test_conditional_pipeline_blocks.py | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/tests/modular_pipelines/test_conditional_pipeline_blocks.py b/tests/modular_pipelines/test_conditional_pipeline_blocks.py index 077519d54ae7..184f0bfd4535 100644 --- a/tests/modular_pipelines/test_conditional_pipeline_blocks.py +++ b/tests/modular_pipelines/test_conditional_pipeline_blocks.py @@ -18,6 +18,7 @@ ConditionalPipelineBlocks, InputParam, ModularPipelineBlocks, + OutputParam, ) from diffusers.modular_pipelines.modular_pipeline_utils import combine_inputs @@ -82,7 +83,7 @@ def inputs(self): @property def intermediate_outputs(self): - return [] + return [OutputParam(name="workflow")] @property def description(self): @@ -108,7 +109,7 @@ def inputs(self): @property def intermediate_outputs(self): - return [] + return [OutputParam(name="workflow")] @property def description(self): @@ -135,7 +136,7 @@ def inputs(self): @property def intermediate_outputs(self): - return [] + return [OutputParam(name="workflow")] @property def description(self): @@ -321,6 +322,8 @@ def select_block(self, mask=None) -> str | None: class TestConditionalBlocksBranchDefaults: def test_conflicting_defaults_merge_to_none(self): + # inpaint (0.9999) and img2img (0.3) disagree on strength: the merged input's default becomes None + # and the per-block defaults are recorded in defaults_by_block strength_params = [p for p in AutoImageBlocks().inputs if p.name == "strength"] assert len(strength_params) == 1 assert strength_params[0] == InputParam( @@ -330,28 +333,32 @@ def test_conflicting_defaults_merge_to_none(self): defaults_by_block={"inpaint": 0.9999, "img2img": 0.3}, ) + # the docstring renders the distinct per-block defaults instead of a single value + doc = " ".join(AutoImageBlocks().doc.split()) + assert "strength (`float`, *optional*, defaults to 0.9999 or 0.3, depending on the workflow):" in doc + def test_branch_resolves_own_default(self): pipe = AutoImageBlocks().init_pipeline() state = pipe(prompt="p", image="i") + assert state.get("workflow") == "img2img" assert state.get("strength") == 0.3 state = pipe(prompt="p", image="i", mask="m") + assert state.get("workflow") == "inpaint" assert state.get("strength") == 0.9999 def test_explicit_value_overrides_branch_default(self): pipe = AutoImageBlocks().init_pipeline() state = pipe(prompt="p", image="i", strength=0.7) + assert state.get("workflow") == "img2img" assert state.get("strength") == 0.7 def test_standalone_branch_keeps_default(self): pipe = ImageToImageBlock().init_pipeline() state = pipe(prompt="p", image="i") + assert state.get("workflow") == "img2img" assert state.get("strength") == 0.3 - def test_doc_renders_per_block_defaults(self): - doc = " ".join(AutoImageBlocks().doc.split()) - assert "strength (`float`, *optional*, defaults to 0.9999 or 0.3, depending on the workflow):" in doc - def test_nested_defaults_prefixed_with_sub_block_name(self): strength_params = [p for p in NestedImageBlocks().inputs if p.name == "strength"] assert len(strength_params) == 1 @@ -361,3 +368,7 @@ def test_nested_defaults_prefixed_with_sub_block_name(self): default=None, defaults_by_block={"refine": 0.9999, "image.inpaint": 0.9999, "image.img2img": 0.3}, ) + + # the docstring renders distinct values only: refine and image.inpaint both use 0.9999, shown once + doc = " ".join(NestedImageBlocks().doc.split()) + assert "strength (`float`, *optional*, defaults to 0.9999 or 0.3, depending on the workflow):" in doc From 324f660a01a135e1859f91e7ae2abb2dd74ff3de Mon Sep 17 00:00:00 2001 From: yiyixuxu Date: Sun, 19 Jul 2026 02:23:21 +0000 Subject: [PATCH 12/15] docs: declare defaults in InputParam, not inside __call__ Co-Authored-By: Claude Fable 5 --- .ai/modular.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.ai/modular.md b/.ai/modular.md index 016b099b0c8b..823f7abcc06a 100644 --- a/.ai/modular.md +++ b/.ai/modular.md @@ -235,6 +235,8 @@ OutputParam( If a template's predefined description doesn't fit (e.g. the `"latents"` output template means "Denoised latents", which is wrong for the noisy latents out of a prepare-latents step) — drop the template and declare the field directly with an accurate description. See gotcha #5. +**Declare defaults in the `InputParam`, not inside `__call__`.** A declared default is part of the block's contract, so the assembled pipeline is aware of it: the generated docstring shows it, `default_call_parameters` reports it, and when branches of a conditional blockset declare different defaults for the same input, the merged input records each branch's value (`defaults_by_block`) and every branch resolves its own default at runtime. Resolving a static default inside the block body instead (`if block_state.num_frames is None: block_state.num_frames = 189`) also works — nothing stops you — but the assembled pipeline is not aware of it: the input renders as `*optional*` with no default, and nothing at the pipeline level can report what the block will actually do. Resolve inside `__call__` only when the default is *computed* — derived from other inputs or component config (`height = components.default_sample_size * components.vae_scale_factor`) — since that genuinely can't be declared. And when several blocks in a sequence share an input, declare the same default on each (or only on the first block that reads it): in a sequence the input is one shared value, so disagreeing declarations are silently resolved first-block-wins. + ## ComponentSpec patterns ```python From 3483a8afd53789ae39901342eee9989393478c88 Mon Sep 17 00:00:00 2001 From: yiyixuxu Date: Sun, 19 Jul 2026 02:31:09 +0000 Subject: [PATCH 13/15] docs: side-by-side default example in modular.md, test reference in combine_inputs Co-Authored-By: Claude Fable 5 --- .ai/modular.md | 13 ++++++++++++- .../modular_pipelines/modular_pipeline_utils.py | 3 +++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.ai/modular.md b/.ai/modular.md index 823f7abcc06a..b68129d08536 100644 --- a/.ai/modular.md +++ b/.ai/modular.md @@ -235,7 +235,18 @@ OutputParam( If a template's predefined description doesn't fit (e.g. the `"latents"` output template means "Denoised latents", which is wrong for the noisy latents out of a prepare-latents step) — drop the template and declare the field directly with an accurate description. See gotcha #5. -**Declare defaults in the `InputParam`, not inside `__call__`.** A declared default is part of the block's contract, so the assembled pipeline is aware of it: the generated docstring shows it, `default_call_parameters` reports it, and when branches of a conditional blockset declare different defaults for the same input, the merged input records each branch's value (`defaults_by_block`) and every branch resolves its own default at runtime. Resolving a static default inside the block body instead (`if block_state.num_frames is None: block_state.num_frames = 189`) also works — nothing stops you — but the assembled pipeline is not aware of it: the input renders as `*optional*` with no default, and nothing at the pipeline level can report what the block will actually do. Resolve inside `__call__` only when the default is *computed* — derived from other inputs or component config (`height = components.default_sample_size * components.vae_scale_factor`) — since that genuinely can't be declared. And when several blocks in a sequence share an input, declare the same default on each (or only on the first block that reads it): in a sequence the input is one shared value, so disagreeing declarations are silently resolved first-block-wins. +**Declare defaults in the `InputParam`, not inside `__call__`.** + +```python +# yes +InputParam(name="num_frames", type_hint=int, default=189) + +# no — works, but the assembled pipeline is not aware of it +if block_state.num_frames is None: + block_state.num_frames = 189 +``` + +A declared default is part of the block's contract, so the assembled pipeline is aware of it: the generated docstring shows it and `default_call_parameters` reports it. Resolved inside the body instead, the input renders as `*optional*` with no default, and nothing at the pipeline level can report what the block will actually do. Don't worry about branches of a conditional blockset declaring different defaults for the same input — each branch resolves its own at runtime. Resolve inside `__call__` only when the default is *computed* — derived from other inputs or component config (`height = components.default_sample_size * components.vae_scale_factor`). And when several blocks in a sequence share an input, declare the same default on each (or only on the first block that reads it): in a sequence the input is one shared value, so disagreeing declarations are silently resolved first-block-wins. ## ComponentSpec patterns diff --git a/src/diffusers/modular_pipelines/modular_pipeline_utils.py b/src/diffusers/modular_pipelines/modular_pipeline_utils.py index 4e501c26b4e0..fe48d7f4a5e0 100644 --- a/src/diffusers/modular_pipelines/modular_pipeline_utils.py +++ b/src/diffusers/modular_pipelines/modular_pipeline_utils.py @@ -1130,6 +1130,9 @@ def combine_inputs(*named_input_lists: list[tuple[str, list[InputParam]]]) -> li InputParam("prompt") # no disagreement -> first occurrence kept as-is InputParam("strength", default=None, defaults_by_block={"img2img": 0.3, "inpaint": 0.9999}) + See `TestConditionalBlocksInputs` in `tests/modular_pipelines/test_conditional_pipeline_blocks.py` for the full + behavior. + Args: named_input_lists: List of tuples containing (block_name, input_param_list) pairs From f8e3a35095b7613703573b7e91fb707210fc0843 Mon Sep 17 00:00:00 2001 From: yiyixuxu Date: Sun, 19 Jul 2026 07:01:43 +0000 Subject: [PATCH 14/15] Fix doc-builder formatting; regenerate cosmos3 use_system_prompt docstring The combine_inputs docstring example is now a fenced code block so doc-builder doesn't reflow it. The cosmos3 use_system_prompt docstring change reflects a real behavior fix this PR makes: the transfer/action branches declare default=True while the standard text branch declares default=None as a sentinel that falls back to the default_use_system_prompt config. Previously the merged default True leaked into state before branch selection, so the config fallback never fired for the standard workflow; each branch now resolves its own default and the config is honored again. Co-Authored-By: Claude Fable 5 --- .../cosmos/modular_blocks_cosmos3.py | 10 ++++---- .../modular_pipeline_utils.py | 23 +++++++++++-------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/diffusers/modular_pipelines/cosmos/modular_blocks_cosmos3.py b/src/diffusers/modular_pipelines/cosmos/modular_blocks_cosmos3.py index b62b6d7fe7b4..ed9bcd8f9346 100644 --- a/src/diffusers/modular_pipelines/cosmos/modular_blocks_cosmos3.py +++ b/src/diffusers/modular_pipelines/cosmos/modular_blocks_cosmos3.py @@ -140,9 +140,8 @@ class Cosmos3AutoTextEncoderStep(AutoPipelineBlocks): The text prompt that guides Cosmos3 generation. negative_prompt (`str`, *optional*): The negative text prompt used for classifier-free guidance. - use_system_prompt (`bool`, *optional*): - Whether to prepend the system prompt. Defaults to the pipeline configuration for standard and action - workflows and to True for transfer. + use_system_prompt (`bool`, *optional*, defaults to True or None, depending on the workflow): + Whether to prepend the Cosmos3 transfer system prompt. action (`CosmosActionCondition`, *optional*): Action-conditioning metadata and its reference visual input. fps (`float`, *optional*, defaults to 24.0): @@ -1166,9 +1165,8 @@ class Cosmos3OmniBlocks(SequentialPipelineBlocks): The text prompt that guides Cosmos3 generation. negative_prompt (`str`, *optional*): The negative text prompt used for classifier-free guidance. - use_system_prompt (`bool`, *optional*): - Whether to prepend the system prompt. Defaults to the pipeline configuration for standard and action - workflows and to True for transfer. + use_system_prompt (`bool`, *optional*, defaults to True or None, depending on the workflow): + Whether to prepend the Cosmos3 transfer system prompt. action (`CosmosActionCondition`, *optional*): Action-conditioning metadata and its reference visual input. fps (`float`, *optional*, defaults to 24.0): diff --git a/src/diffusers/modular_pipelines/modular_pipeline_utils.py b/src/diffusers/modular_pipelines/modular_pipeline_utils.py index fe48d7f4a5e0..2c859cc0ff96 100644 --- a/src/diffusers/modular_pipelines/modular_pipeline_utils.py +++ b/src/diffusers/modular_pipelines/modular_pipeline_utils.py @@ -1116,19 +1116,22 @@ def _accumulate(mapping: dict[str, Any]): def combine_inputs(*named_input_lists: list[tuple[str, list[InputParam]]]) -> list[InputParam]: """ - Combines multiple lists of InputParam objects from different blocks. Duplicate inputs keep the first occurrence. - If duplicate inputs declare different defaults, the combined input's default is None and the per-block defaults - are recorded in `defaults_by_block`; each block resolves its own default at runtime in `get_block_state`, and the + Combines multiple lists of InputParam objects from different blocks. Duplicate inputs keep the first occurrence. If + duplicate inputs declare different defaults, the combined input's default is None and the per-block defaults are + recorded in `defaults_by_block`; each block resolves its own default at runtime in `get_block_state`, and the docstring formatters render the per-block defaults. Example: - combine_inputs( - ("img2img", [InputParam("prompt"), InputParam("strength", default=0.3)]), - ("inpaint", [InputParam("prompt"), InputParam("strength", default=0.9999)]), - ) - returns: - InputParam("prompt") # no disagreement -> first occurrence kept as-is - InputParam("strength", default=None, defaults_by_block={"img2img": 0.3, "inpaint": 0.9999}) + + ```python + combine_inputs( + ("img2img", [InputParam("prompt"), InputParam("strength", default=0.3)]), + ("inpaint", [InputParam("prompt"), InputParam("strength", default=0.9999)]), + ) + # returns: + # InputParam("prompt") # no disagreement -> first occurrence kept as-is + # InputParam("strength", default=None, defaults_by_block={"img2img": 0.3, "inpaint": 0.9999}) + ``` See `TestConditionalBlocksInputs` in `tests/modular_pipelines/test_conditional_pipeline_blocks.py` for the full behavior. From 3a2888f1ba6d6e1dfa636459ada29596a4dbc880 Mon Sep 17 00:00:00 2001 From: yiyixuxu Date: Sun, 19 Jul 2026 08:15:09 +0000 Subject: [PATCH 15/15] Regenerate cosmos3 auto docstrings Mechanical output of utils/modular_auto_docstring.py for the cosmos files this PR touches; regeneration of the remaining stale pipelines moved to its own PR. Co-Authored-By: Claude Fable 5 --- .../cosmos/modular_blocks_cosmos3.py | 22 +++++++++++++++++++ .../modular_blocks_cosmos3_distilled.py | 3 ++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/diffusers/modular_pipelines/cosmos/modular_blocks_cosmos3.py b/src/diffusers/modular_pipelines/cosmos/modular_blocks_cosmos3.py index ed9bcd8f9346..d7c6c846fc8e 100644 --- a/src/diffusers/modular_pipelines/cosmos/modular_blocks_cosmos3.py +++ b/src/diffusers/modular_pipelines/cosmos/modular_blocks_cosmos3.py @@ -123,6 +123,9 @@ class Cosmos3AutoTextEncoderStep(AutoPipelineBlocks): Components: video_processor (`VideoProcessor`) text_tokenizer (`AutoTokenizer`) + Configs: + default_use_system_prompt (default: True) enable_safety_checker (default: True) + Inputs: control_videos (`dict`, *optional*): Mapping of hint name (edge/blur/depth/seg/wsm) to the control video for that modality. @@ -367,6 +370,9 @@ class Cosmos3VisionCoreDenoiseStep(SequentialPipelineBlocks): Components: transformer (`Cosmos3OmniTransformer`) scheduler (`UniPCMultistepScheduler`) + Configs: + use_native_flow_schedule (default: False) + Inputs: cond_input_ids (`None`): Token IDs for the conditional prompt. @@ -435,6 +441,9 @@ class Cosmos3VisionSoundCoreDenoiseStep(SequentialPipelineBlocks): Components: transformer (`Cosmos3OmniTransformer`) scheduler (`UniPCMultistepScheduler`) + Configs: + use_native_flow_schedule (default: False) + Inputs: cond_input_ids (`None`): Token IDs for the conditional prompt. @@ -516,6 +525,9 @@ class Cosmos3VisionActionCoreDenoiseStep(SequentialPipelineBlocks): Components: transformer (`Cosmos3OmniTransformer`) scheduler (`UniPCMultistepScheduler`) + Configs: + use_native_flow_schedule (default: False) + Inputs: cond_input_ids (`None`): Token IDs for the conditional prompt. @@ -601,6 +613,9 @@ class Cosmos3VisionSoundActionCoreDenoiseStep(SequentialPipelineBlocks): Components: transformer (`Cosmos3OmniTransformer`) scheduler (`UniPCMultistepScheduler`) + Configs: + use_native_flow_schedule (default: False) + Inputs: cond_input_ids (`None`): Token IDs for the conditional prompt. @@ -961,6 +976,9 @@ class Cosmos3AutoCoreDenoiseStep(ConditionalPipelineBlocks): transformer (`Cosmos3OmniTransformer`) vae (`AutoencoderKLWan`) video_processor (`VideoProcessor`) scheduler (`UniPCMultistepScheduler`) + Configs: + use_native_flow_schedule (default: False) + Inputs: cond_input_ids (`None`): Token IDs for the conditional prompt. @@ -1148,6 +1166,10 @@ class Cosmos3OmniBlocks(SequentialPipelineBlocks): (`Cosmos3OmniTransformer`) scheduler (`UniPCMultistepScheduler`) sound_tokenizer (`Cosmos3AVAEAudioTokenizer`) + Configs: + default_use_system_prompt (default: True) enable_safety_checker (default: True) use_native_flow_schedule + (default: False) + Inputs: control_videos (`dict`, *optional*): Mapping of hint name (edge/blur/depth/seg/wsm) to the control video for that modality. diff --git a/src/diffusers/modular_pipelines/cosmos/modular_blocks_cosmos3_distilled.py b/src/diffusers/modular_pipelines/cosmos/modular_blocks_cosmos3_distilled.py index ff2be89f2241..e168cc24d8cd 100644 --- a/src/diffusers/modular_pipelines/cosmos/modular_blocks_cosmos3_distilled.py +++ b/src/diffusers/modular_pipelines/cosmos/modular_blocks_cosmos3_distilled.py @@ -165,7 +165,8 @@ class Cosmos3DistilledBlocks(SequentialPipelineBlocks): (`Cosmos3OmniTransformer`) scheduler (`FlowMatchEulerDiscreteScheduler`) Configs: - is_distilled (default: True) distilled_sigmas (default: None) + default_use_system_prompt (default: True) enable_safety_checker (default: True) is_distilled (default: True) + distilled_sigmas (default: None) Inputs: prompt (`str`):