From a78f700552668d2c296010d03c80a96641486e0f Mon Sep 17 00:00:00 2001 From: bymyself Date: Wed, 25 Feb 2026 22:56:56 -0800 Subject: [PATCH 1/2] feat: add accumulate toggle to SaveImage and PreviewImage nodes Add 'accumulate' BOOLEAN optional input (advanced, default False) to SaveImage and PreviewImage INPUT_TYPES. Accept the parameter in save_images() so the execution engine can pass it through. Set merge flag in the 'executed' websocket message when accumulate is enabled, so the frontend appends outputs instead of replacing them. Add unit tests for the accumulate input definition and merge flag derivation logic. Amp-Thread-ID: https://ampcode.com/threads/T-019cbb66-bb43-771e-b47b-0f05a436b9cb --- execution.py | 5 +- nodes.py | 8 +- .../execution_test/test_accumulate_merge.py | 89 +++++++++++++++++++ 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 tests-unit/execution_test/test_accumulate_merge.py diff --git a/execution.py b/execution.py index 1a6c3429c..1236b1b39 100644 --- a/execution.py +++ b/execution.py @@ -419,11 +419,12 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed, inputs = dynprompt.get_node(unique_id)['inputs'] class_type = dynprompt.get_node(unique_id)['class_type'] class_def = nodes.NODE_CLASS_MAPPINGS[class_type] + merge = inputs.get('accumulate') is True cached = await caches.outputs.get(unique_id) if cached is not None: if server.client_id is not None: cached_ui = cached.ui or {} - server.send_sync("executed", { "node": unique_id, "display_node": display_node_id, "output": cached_ui.get("output",None), "prompt_id": prompt_id }, server.client_id) + server.send_sync("executed", { "node": unique_id, "display_node": display_node_id, "output": cached_ui.get("output",None), "prompt_id": prompt_id, "merge": merge }, server.client_id) if cached.ui is not None: ui_outputs[unique_id] = cached.ui get_progress_state().finish_progress(unique_id) @@ -550,7 +551,7 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed, "output": output_ui } if server.client_id is not None: - server.send_sync("executed", { "node": unique_id, "display_node": display_node_id, "output": output_ui, "prompt_id": prompt_id }, server.client_id) + server.send_sync("executed", { "node": unique_id, "display_node": display_node_id, "output": output_ui, "prompt_id": prompt_id, "merge": merge }, server.client_id) if has_subgraph: cached_outputs = [] new_node_ids = [] diff --git a/nodes.py b/nodes.py index e93fa9767..8a5349ade 100644 --- a/nodes.py +++ b/nodes.py @@ -1638,6 +1638,9 @@ class SaveImage: "images": ("IMAGE", {"tooltip": "The images to save."}), "filename_prefix": ("STRING", {"default": "ComfyUI", "tooltip": "The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes."}) }, + "optional": { + "accumulate": ("BOOLEAN", {"default": False, "tooltip": "When enabled, outputs accumulate into a growing gallery across queue runs instead of being replaced.", "advanced": True}), + }, "hidden": { "prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO" }, @@ -1653,7 +1656,7 @@ class SaveImage: DESCRIPTION = "Saves the input images to your ComfyUI output directory." SEARCH_ALIASES = ["save", "save image", "export image", "output image", "write image", "download"] - def save_images(self, images, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None): + def save_images(self, images, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None, accumulate=False): filename_prefix += self.prefix_append full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]) results = list() @@ -1694,6 +1697,9 @@ class PreviewImage(SaveImage): def INPUT_TYPES(s): return {"required": {"images": ("IMAGE", ), }, + "optional": { + "accumulate": ("BOOLEAN", {"default": False, "tooltip": "When enabled, outputs accumulate into a growing gallery across queue runs instead of being replaced.", "advanced": True}), + }, "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, } diff --git a/tests-unit/execution_test/test_accumulate_merge.py b/tests-unit/execution_test/test_accumulate_merge.py new file mode 100644 index 000000000..00910a2cd --- /dev/null +++ b/tests-unit/execution_test/test_accumulate_merge.py @@ -0,0 +1,89 @@ +""" +Unit tests for the accumulate toggle on SaveImage and PreviewImage nodes. + +Tests that the accumulate input is correctly defined and that the merge flag +derivation logic in execution.py works for all input shapes. +""" +import inspect + +import pytest + +import nodes + + +class TestSaveImageAccumulateInput: + """Test SaveImage node definition includes accumulate input.""" + + def test_accumulate_in_optional_inputs(self): + input_types = nodes.SaveImage.INPUT_TYPES() + assert "optional" in input_types + assert "accumulate" in input_types["optional"] + + def test_accumulate_is_boolean_type(self): + input_types = nodes.SaveImage.INPUT_TYPES() + accumulate_def = input_types["optional"]["accumulate"] + assert accumulate_def[0] == "BOOLEAN" + + def test_accumulate_defaults_to_false(self): + input_types = nodes.SaveImage.INPUT_TYPES() + accumulate_def = input_types["optional"]["accumulate"] + assert accumulate_def[1]["default"] is False + + def test_accumulate_is_advanced(self): + input_types = nodes.SaveImage.INPUT_TYPES() + accumulate_def = input_types["optional"]["accumulate"] + assert accumulate_def[1].get("advanced") is True + + def test_save_images_accepts_accumulate_parameter(self): + sig = inspect.signature(nodes.SaveImage.save_images) + assert "accumulate" in sig.parameters + assert sig.parameters["accumulate"].default is False + + +class TestPreviewImageAccumulateInput: + """Test PreviewImage node definition includes accumulate input.""" + + def test_accumulate_in_optional_inputs(self): + input_types = nodes.PreviewImage.INPUT_TYPES() + assert "optional" in input_types + assert "accumulate" in input_types["optional"] + + def test_accumulate_is_boolean_type(self): + input_types = nodes.PreviewImage.INPUT_TYPES() + accumulate_def = input_types["optional"]["accumulate"] + assert accumulate_def[0] == "BOOLEAN" + + def test_accumulate_defaults_to_false(self): + input_types = nodes.PreviewImage.INPUT_TYPES() + accumulate_def = input_types["optional"]["accumulate"] + assert accumulate_def[1]["default"] is False + + +class TestAccumulateMergeFlagDerivation: + """Test the merge flag logic used in execution.py. + + In execution.py, the merge flag is derived as: + merge = inputs.get('accumulate') is True + + This must return True only for literal True, not for truthy values + like lists (which represent node links in the prompt). + """ + + @pytest.mark.parametrize( + "inputs,expected", + [ + ({"accumulate": True}, True), + ({"accumulate": False}, False), + ({}, False), + ({"accumulate": None}, False), + # Node link: accumulate connected to another node's output + ({"accumulate": ["other_node_id", 0]}, False), + # String "true" should not match + ({"accumulate": "true"}, False), + # Integer 1 should not match + ({"accumulate": 1}, False), + ], + ) + def test_merge_flag(self, inputs, expected): + merge = inputs.get("accumulate") is True + assert merge is expected From c69f9791d7d86df8874a40da9b4e287f809a96f7 Mon Sep 17 00:00:00 2001 From: bymyself Date: Tue, 17 Mar 2026 09:20:36 +0000 Subject: [PATCH 2/2] chore: remove accumulate unit tests to reduce PR scope --- .../execution_test/test_accumulate_merge.py | 89 ------------------- 1 file changed, 89 deletions(-) delete mode 100644 tests-unit/execution_test/test_accumulate_merge.py diff --git a/tests-unit/execution_test/test_accumulate_merge.py b/tests-unit/execution_test/test_accumulate_merge.py deleted file mode 100644 index 00910a2cd..000000000 --- a/tests-unit/execution_test/test_accumulate_merge.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -Unit tests for the accumulate toggle on SaveImage and PreviewImage nodes. - -Tests that the accumulate input is correctly defined and that the merge flag -derivation logic in execution.py works for all input shapes. -""" -import inspect - -import pytest - -import nodes - - -class TestSaveImageAccumulateInput: - """Test SaveImage node definition includes accumulate input.""" - - def test_accumulate_in_optional_inputs(self): - input_types = nodes.SaveImage.INPUT_TYPES() - assert "optional" in input_types - assert "accumulate" in input_types["optional"] - - def test_accumulate_is_boolean_type(self): - input_types = nodes.SaveImage.INPUT_TYPES() - accumulate_def = input_types["optional"]["accumulate"] - assert accumulate_def[0] == "BOOLEAN" - - def test_accumulate_defaults_to_false(self): - input_types = nodes.SaveImage.INPUT_TYPES() - accumulate_def = input_types["optional"]["accumulate"] - assert accumulate_def[1]["default"] is False - - def test_accumulate_is_advanced(self): - input_types = nodes.SaveImage.INPUT_TYPES() - accumulate_def = input_types["optional"]["accumulate"] - assert accumulate_def[1].get("advanced") is True - - def test_save_images_accepts_accumulate_parameter(self): - sig = inspect.signature(nodes.SaveImage.save_images) - assert "accumulate" in sig.parameters - assert sig.parameters["accumulate"].default is False - - -class TestPreviewImageAccumulateInput: - """Test PreviewImage node definition includes accumulate input.""" - - def test_accumulate_in_optional_inputs(self): - input_types = nodes.PreviewImage.INPUT_TYPES() - assert "optional" in input_types - assert "accumulate" in input_types["optional"] - - def test_accumulate_is_boolean_type(self): - input_types = nodes.PreviewImage.INPUT_TYPES() - accumulate_def = input_types["optional"]["accumulate"] - assert accumulate_def[0] == "BOOLEAN" - - def test_accumulate_defaults_to_false(self): - input_types = nodes.PreviewImage.INPUT_TYPES() - accumulate_def = input_types["optional"]["accumulate"] - assert accumulate_def[1]["default"] is False - - -class TestAccumulateMergeFlagDerivation: - """Test the merge flag logic used in execution.py. - - In execution.py, the merge flag is derived as: - merge = inputs.get('accumulate') is True - - This must return True only for literal True, not for truthy values - like lists (which represent node links in the prompt). - """ - - @pytest.mark.parametrize( - "inputs,expected", - [ - ({"accumulate": True}, True), - ({"accumulate": False}, False), - ({}, False), - ({"accumulate": None}, False), - # Node link: accumulate connected to another node's output - ({"accumulate": ["other_node_id", 0]}, False), - # String "true" should not match - ({"accumulate": "true"}, False), - # Integer 1 should not match - ({"accumulate": 1}, False), - ], - ) - def test_merge_flag(self, inputs, expected): - merge = inputs.get("accumulate") is True - assert merge is expected