From 83cf7873fa3b43a95e6beb39a845d4bbc473925c Mon Sep 17 00:00:00 2001 From: yiyixuxu Date: Mon, 26 Oct 2026 23:53:44 +0100 Subject: [PATCH 1/2] [modular] fill a missing input from the first block that reads it, keep a passed None `get_block_state` fell back to the block's own default whenever the state held None. That let a later block in a sequence swap in its default on top of the first block's, and made a passed None indistinguishable from "not passed". Now a missing input is filled in by the first block that reads it and `set_block_state` records the fill, so every block after it reads the same value. The pipeline stores a passed None as is and no longer pre-fills a None default. Conditional branches need no special code: the branch that runs is the first reader. Co-Authored-By: Claude Fable 5.1 --- .../modular_pipelines/modular_pipeline.py | 23 +++++---- .../modular_pipeline_utils.py | 4 +- .../test_conditional_pipeline_blocks.py | 26 ++++++++++ .../test_sequential_pipeline_blocks.py | 49 +++++++++++++++++++ 4 files changed, 90 insertions(+), 12 deletions(-) create mode 100644 tests/modular_pipelines/test_sequential_pipeline_blocks.py diff --git a/src/diffusers/modular_pipelines/modular_pipeline.py b/src/diffusers/modular_pipelines/modular_pipeline.py index c501d7b1fc42..ccaaac0e8acf 100644 --- a/src/diffusers/modular_pipelines/modular_pipeline.py +++ b/src/diffusers/modular_pipelines/modular_pipeline.py @@ -573,13 +573,12 @@ def get_block_state(self, state: PipelineState) -> dict: # Check inputs for input_param in state_inputs: if input_param.name: - value = state.get(input_param.name) - if value is None: - # 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 + if input_param.name in state.values: + # a value in the state, None included, is what the user passed or an earlier block set + value = state.get(input_param.name) + else: + # the first block that reads an input fills in its own default; `set_block_state` records it so + # the blocks after it read the same value value = input_param.default if input_param.required and value is None: raise ValueError(f"Required input '{input_param.name}' is missing") @@ -609,9 +608,10 @@ def set_block_state(self, state: PipelineState, block_state: BlockState): for input_param in self.inputs: if input_param.name and hasattr(block_state, input_param.name): param = getattr(block_state, input_param.name) - # Only add if the value is different from what's in the state + # Only add if the value is different from what's in the state, or the state did not hold it yet (the + # default this block filled in, see `get_block_state`) current_value = state.get(input_param.name) - if current_value is not param: # Using identity comparison to check if object was modified + if input_param.name not in state.values or current_value is not param: # identity: object modified? state.set(input_param.name, param, input_param.kwargs_type) elif input_param.kwargs_type: @@ -2991,6 +2991,9 @@ def __call__(self, state: PipelineState = None, output: str | list[str] = None, # Add inputs to state, using defaults if not provided in the kwargs or the state # if same input already in the state, will override it if provided in the kwargs + # a None passed by the user is stored as None. A None default is not prefilled: the first block that reads + # the input fills in its own default and records it, see `get_block_state` and `set_block_state`. That is how + # a conditional branch gets its own default (`defaults_by_block`) for expected_input_param in self._blocks.inputs: name = expected_input_param.name default = expected_input_param.default @@ -3001,7 +3004,7 @@ def __call__(self, state: PipelineState = None, output: str | list[str] = None, kwargs_dict = passed_kwargs.pop(kwargs_type) for k, v in kwargs_dict.items(): state.set(k, v, kwargs_type) - elif name is not None and name not in state.values: + elif name is not None and name not in state.values and default is not None: state.set(name, default, kwargs_type) # Warn about unexpected inputs diff --git a/src/diffusers/modular_pipelines/modular_pipeline_utils.py b/src/diffusers/modular_pipelines/modular_pipeline_utils.py index e4a81e398e8c..3962a6af3127 100644 --- a/src/diffusers/modular_pipelines/modular_pipeline_utils.py +++ b/src/diffusers/modular_pipelines/modular_pipeline_utils.py @@ -1082,8 +1082,8 @@ def combine_inputs(*named_input_lists: list[tuple[str, list[InputParam]]]) -> li """ 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. + recorded in `defaults_by_block`; the branch that runs fills in its own default when it reads the state + (`get_block_state`), and the docstring formatters render the per-block defaults. Example: diff --git a/tests/modular_pipelines/test_conditional_pipeline_blocks.py b/tests/modular_pipelines/test_conditional_pipeline_blocks.py index 184f0bfd4535..bfd018417f62 100644 --- a/tests/modular_pipelines/test_conditional_pipeline_blocks.py +++ b/tests/modular_pipelines/test_conditional_pipeline_blocks.py @@ -372,3 +372,29 @@ def test_nested_defaults_prefixed_with_sub_block_name(self): # 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 + + def test_explicit_none_reaches_every_branch(self): + # a passed None is a value, not "use the default": the block keeps it instead of filling in its default + pipe = ImageToImageBlock().init_pipeline() + state = pipe(prompt="p", image="i", strength=None) + assert state.get("strength") is None + + # and no branch of a conditional block swaps in its own default either + pipe = AutoImageBlocks().init_pipeline() + state = pipe(prompt="p", image="i", strength=None) + assert state.get("workflow") == "img2img" + assert state.get("strength") is None + + state = pipe(prompt="p", image="i", mask="m", strength=None) + assert state.get("workflow") == "inpaint" + assert state.get("strength") is None + + def test_nested_branch_resolves_own_default(self): + pipe = NestedImageBlocks().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 diff --git a/tests/modular_pipelines/test_sequential_pipeline_blocks.py b/tests/modular_pipelines/test_sequential_pipeline_blocks.py new file mode 100644 index 000000000000..a42253e8582b --- /dev/null +++ b/tests/modular_pipelines/test_sequential_pipeline_blocks.py @@ -0,0 +1,49 @@ +# Copyright 2026 The HuggingFace Team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from diffusers.modular_pipelines import InputParam, ModularPipelineBlocks, SequentialPipelineBlocks + +from .test_conditional_pipeline_blocks import ImageToImageBlock + + +class NoDefaultStrengthBlock(ModularPipelineBlocks): + @property + def inputs(self): + return [InputParam(name="strength", type_hint=float)] + + def __call__(self, components, state): + block_state = self.get_block_state(state) + self.set_block_state(state, block_state) + return components, state + + +class NoDefaultThenImg2Img(SequentialPipelineBlocks): + block_classes = [NoDefaultStrengthBlock, ImageToImageBlock] + block_names = ["no_default", "img2img"] + + +class TestSequenceDefaults: + def test_first_reader_fills_and_records_the_default(self): + # in a sequence the input is one shared value: the first block that reads `strength` fills in its own default + # (None) and records it, so the img2img block reads None instead of swapping in its 0.3 + pipe = NoDefaultThenImg2Img().init_pipeline() + state = pipe(prompt="p", image="i") + assert state.get("workflow") == "img2img" + assert "strength" in state.values + assert state.get("strength") is None + + # a passed value reaches every block + state = pipe(prompt="p", image="i", strength=0.7) + assert state.get("strength") == 0.7 From 2635cdb77da0ca4ba3f72644f6d56a9df132392b Mon Sep 17 00:00:00 2001 From: yiyixuxu Date: Tue, 27 Oct 2026 00:29:22 +0100 Subject: [PATCH 2/2] [modular] move the sequence test next to the branch-default tests It shows that per-branch defaults do not affect sequential blocks: the first block that reads an input fills in its default and records it, so a later block with a different default reads the same value. Co-Authored-By: Claude Fable 5.1 --- .../test_conditional_pipeline_blocks.py | 37 ++++++++++++++ .../test_sequential_pipeline_blocks.py | 49 ------------------- 2 files changed, 37 insertions(+), 49 deletions(-) delete mode 100644 tests/modular_pipelines/test_sequential_pipeline_blocks.py diff --git a/tests/modular_pipelines/test_conditional_pipeline_blocks.py b/tests/modular_pipelines/test_conditional_pipeline_blocks.py index bfd018417f62..cc0075d0022f 100644 --- a/tests/modular_pipelines/test_conditional_pipeline_blocks.py +++ b/tests/modular_pipelines/test_conditional_pipeline_blocks.py @@ -19,6 +19,7 @@ InputParam, ModularPipelineBlocks, OutputParam, + SequentialPipelineBlocks, ) from diffusers.modular_pipelines.modular_pipeline_utils import combine_inputs @@ -320,6 +321,27 @@ def select_block(self, mask=None) -> str | None: return None +class NoDefaultStrengthBlock(ModularPipelineBlocks): + @property + def inputs(self): + return [InputParam(name="strength", type_hint=float)] + + @property + def intermediate_outputs(self): + return [OutputParam(name="strength_seen_first")] + + def __call__(self, components, state): + block_state = self.get_block_state(state) + block_state.strength_seen_first = block_state.strength + self.set_block_state(state, block_state) + return components, state + + +class NoDefaultThenImg2Img(SequentialPipelineBlocks): + block_classes = [NoDefaultStrengthBlock, ImageToImageBlock] + block_names = ["no_default", "img2img"] + + 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 @@ -398,3 +420,18 @@ def test_nested_branch_resolves_own_default(self): state = pipe(prompt="p", image="i", mask="m") assert state.get("workflow") == "inpaint" assert state.get("strength") == 0.9999 + + def test_branch_defaults_do_not_affect_sequential_blocks(self): + # in a sequence an input is one value for every block. The first block that reads `strength` fills in its + # default (None) and records it; the img2img block then reads that None instead of its own 0.3, so the value + # the first block saw and the value left in the state are the same + pipe = NoDefaultThenImg2Img().init_pipeline() + state = pipe(prompt="p", image="i") + assert state.get("workflow") == "img2img" + assert state.get("strength_seen_first") is None + assert state.get("strength") is None + + # a passed value reaches every block + state = pipe(prompt="p", image="i", strength=0.7) + assert state.get("strength_seen_first") == 0.7 + assert state.get("strength") == 0.7 diff --git a/tests/modular_pipelines/test_sequential_pipeline_blocks.py b/tests/modular_pipelines/test_sequential_pipeline_blocks.py deleted file mode 100644 index a42253e8582b..000000000000 --- a/tests/modular_pipelines/test_sequential_pipeline_blocks.py +++ /dev/null @@ -1,49 +0,0 @@ -# Copyright 2026 The HuggingFace Team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -from diffusers.modular_pipelines import InputParam, ModularPipelineBlocks, SequentialPipelineBlocks - -from .test_conditional_pipeline_blocks import ImageToImageBlock - - -class NoDefaultStrengthBlock(ModularPipelineBlocks): - @property - def inputs(self): - return [InputParam(name="strength", type_hint=float)] - - def __call__(self, components, state): - block_state = self.get_block_state(state) - self.set_block_state(state, block_state) - return components, state - - -class NoDefaultThenImg2Img(SequentialPipelineBlocks): - block_classes = [NoDefaultStrengthBlock, ImageToImageBlock] - block_names = ["no_default", "img2img"] - - -class TestSequenceDefaults: - def test_first_reader_fills_and_records_the_default(self): - # in a sequence the input is one shared value: the first block that reads `strength` fills in its own default - # (None) and records it, so the img2img block reads None instead of swapping in its 0.3 - pipe = NoDefaultThenImg2Img().init_pipeline() - state = pipe(prompt="p", image="i") - assert state.get("workflow") == "img2img" - assert "strength" in state.values - assert state.get("strength") is None - - # a passed value reaches every block - state = pipe(prompt="p", image="i", strength=0.7) - assert state.get("strength") == 0.7