diff --git a/py/nodes/save_image.py b/py/nodes/save_image.py index 30c6af25..2b251f56 100644 --- a/py/nodes/save_image.py +++ b/py/nodes/save_image.py @@ -220,6 +220,24 @@ class SaveImageLM: "tooltip": "Compression quality for JPEG and lossy WebP formats (1-100). Higher values mean better quality but larger files.", }, ), + "webp_method": ( + "INT", + { + "default": 6, + "min": 0, + "max": 6, + "tooltip": "WebP compression method (0-6). 0=fastest/largest, 6=slowest/smallest. Only applies when file_format is 'webp'.", + }, + ), + "jpeg_subsampling": ( + "INT", + { + "default": 0, + "min": 0, + "max": 2, + "tooltip": "JPEG chroma subsampling level. 0=4:4:4 (best quality), 1=4:2:2, 2=4:2:0 (smallest files). Only applies when file_format is 'jpeg'.", + }, + ), "embed_workflow": ( "BOOLEAN", { @@ -756,6 +774,8 @@ class SaveImageLM: extra_pnginfo=None, lossless_webp=True, quality=100, + webp_method=6, + jpeg_subsampling=0, embed_workflow=False, save_with_metadata=True, add_counter_to_filename=True, @@ -810,15 +830,14 @@ class SaveImageLM: elif file_format == "jpeg": file = base_filename + ".jpg" file_extension = ".jpg" - save_kwargs = {"quality": quality, "optimize": True} + save_kwargs = {"quality": quality, "optimize": True, "subsampling": jpeg_subsampling} elif file_format == "webp": file = base_filename + ".webp" file_extension = ".webp" - # Add optimization param to control performance save_kwargs = { "quality": quality, "lossless": lossless_webp, - "method": 0, + "method": webp_method, } else: raise ValueError(f"Unsupported file format: {file_format}") @@ -905,6 +924,8 @@ class SaveImageLM: extra_pnginfo=None, lossless_webp=True, quality=100, + webp_method=6, + jpeg_subsampling=0, embed_workflow=False, save_with_metadata=True, add_counter_to_filename=True, @@ -934,6 +955,8 @@ class SaveImageLM: extra_pnginfo, lossless_webp, quality, + webp_method, + jpeg_subsampling, embed_workflow, save_with_metadata, add_counter_to_filename, diff --git a/tests/nodes/test_save_image.py b/tests/nodes/test_save_image.py index 8c5fb21a..92e078a2 100644 --- a/tests/nodes/test_save_image.py +++ b/tests/nodes/test_save_image.py @@ -363,3 +363,102 @@ def test_save_image_as_recipe_writes_recipe_without_async_scanner_calls( assert recipe["gen_params"] == {"prompt": "prompt text", "seed": 123} assert scanner._json_path_map[recipe["id"]] == os.path.normpath(str(recipe_files[0])) assert scanner.fts_updates == [(recipe["id"], "add")] + + +# --------------------------------------------------------------------------- +# Tests for webp_method and jpeg_subsampling parameters +# --------------------------------------------------------------------------- + +def _capture_save_kwargs(monkeypatch): + """Monkeypatch Image.Image.save to capture kwargs while still saving to disk.""" + real_save = Image.Image.save + captured_kwargs = {} + + def _fake_save(self, fp, *args, **kwargs): + captured_kwargs.update(kwargs) + return real_save(self, fp, *args, **kwargs) + + monkeypatch.setattr(Image.Image, "save", _fake_save) + return captured_kwargs + + +def test_webp_method_default_passed_to_pillow_save(monkeypatch, tmp_path): + _configure_save_paths(monkeypatch, tmp_path) + _configure_metadata(monkeypatch, {"prompt": "test", "seed": 1}) + captured = _capture_save_kwargs(monkeypatch) + + node = SaveImageLM() + node.save_images([_make_image()], "ComfyUI", "webp", id="node-1") + + assert "method" in captured + assert captured["method"] == 6 + + +def test_webp_method_custom_value_passed_to_pillow_save(monkeypatch, tmp_path): + _configure_save_paths(monkeypatch, tmp_path) + _configure_metadata(monkeypatch, {"prompt": "test", "seed": 1}) + captured = _capture_save_kwargs(monkeypatch) + + node = SaveImageLM() + node.save_images( + [_make_image()], "ComfyUI", "webp", id="node-1", webp_method=3 + ) + + assert captured["method"] == 3 + + +def test_jpeg_subsampling_default_passed_to_pillow_save(monkeypatch, tmp_path): + _configure_save_paths(monkeypatch, tmp_path) + _configure_metadata(monkeypatch, {"prompt": "test", "seed": 1}) + captured = _capture_save_kwargs(monkeypatch) + + node = SaveImageLM() + node.save_images([_make_image()], "ComfyUI", "jpeg", id="node-1") + + assert "subsampling" in captured + assert captured["subsampling"] == 0 + + +def test_jpeg_subsampling_custom_value_passed_to_pillow_save(monkeypatch, tmp_path): + _configure_save_paths(monkeypatch, tmp_path) + _configure_metadata(monkeypatch, {"prompt": "test", "seed": 1}) + captured = _capture_save_kwargs(monkeypatch) + + node = SaveImageLM() + node.save_images( + [_make_image()], "ComfyUI", "jpeg", id="node-1", jpeg_subsampling=1 + ) + + assert captured["subsampling"] == 1 + + +class TestParameterDefaultConsistency: + """Verify defaults match across INPUT_TYPES, save_images(), and process_image().""" + + def test_webp_method_defaults_are_consistent(self): + input_types = SaveImageLM.INPUT_TYPES() + optional = input_types["optional"] + + assert optional["webp_method"][1]["default"] == 6 + assert SaveImageLM.save_images.__defaults__[4] == 6 # positional: webp_method=6 is at index 4 + assert SaveImageLM.process_image.__defaults__[6] == 6 + + def test_jpeg_subsampling_defaults_are_consistent(self): + input_types = SaveImageLM.INPUT_TYPES() + optional = input_types["optional"] + + assert optional["jpeg_subsampling"][1]["default"] == 0 + assert SaveImageLM.save_images.__defaults__[5] == 0 + assert SaveImageLM.process_image.__defaults__[7] == 0 + + +def test_png_does_not_pass_webp_method_or_jpeg_subsampling(monkeypatch, tmp_path): + _configure_save_paths(monkeypatch, tmp_path) + _configure_metadata(monkeypatch, {"prompt": "test", "seed": 1}) + captured = _capture_save_kwargs(monkeypatch) + + node = SaveImageLM() + node.save_images([_make_image()], "ComfyUI", "png", id="node-1") + + assert "method" not in captured + assert "subsampling" not in captured diff --git a/web/comfyui/save_image_extra_output.js b/web/comfyui/save_image_extra_output.js index 4fd0d613..202c3031 100644 --- a/web/comfyui/save_image_extra_output.js +++ b/web/comfyui/save_image_extra_output.js @@ -130,6 +130,35 @@ app.registerExtension({ widget.serializeValue = () => { return applyTextReplacements(widget.value); }; + + // --- Conditional widget visibility for webp_method / jpeg_subsampling --- + const formatWidget = getWidgetByName(this, "file_format"); + const webpMethodWidget = getWidgetByName(this, "webp_method"); + const jpegSubWidget = getWidgetByName(this, "jpeg_subsampling"); + + function updateFormatConditional() { + const fmt = formatWidget?.value; + if (webpMethodWidget) { + webpMethodWidget.disabled = fmt !== "webp"; + webpMethodWidget.hidden = fmt !== "webp"; + } + if (jpegSubWidget) { + jpegSubWidget.disabled = fmt !== "jpeg"; + jpegSubWidget.hidden = fmt !== "jpeg"; + } + } + + // Set initial state + updateFormatConditional(); + + // Watch for format changes + if (formatWidget) { + const origCallback = formatWidget.callback; + formatWidget.callback = function (value) { + origCallback?.call(this, value); + updateFormatConditional(); + }; + } }); }, });