diff --git a/execution.py b/execution.py index 7ccdbf93e..a47d93dca 100644 --- a/execution.py +++ b/execution.py @@ -418,11 +418,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 = 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) @@ -549,7 +550,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 5be9b16f9..fe9d23fe0 100644 --- a/nodes.py +++ b/nodes.py @@ -1640,6 +1640,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" }, @@ -1655,7 +1658,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() @@ -1696,6 +1699,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