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
This commit is contained in:
bymyself
2026-02-25 22:56:56 -08:00
parent ac4a943ff3
commit c8bce44549
3 changed files with 99 additions and 3 deletions

View File

@@ -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 = []

View File

@@ -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"},
}

View File

@@ -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