From 8fff8814ad4cc6bc20ba9f1e822ca3434defcd3b Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:09:23 +0300 Subject: [PATCH 01/28] init --- comfy_extras/nodes_save_advanced.py | 349 ++++++++++++++++++++++++++++ nodes.py | 3 +- 2 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 comfy_extras/nodes_save_advanced.py diff --git a/comfy_extras/nodes_save_advanced.py b/comfy_extras/nodes_save_advanced.py new file mode 100644 index 000000000..b1686bead --- /dev/null +++ b/comfy_extras/nodes_save_advanced.py @@ -0,0 +1,349 @@ +import io +import av +import os +import json +import torch +import struct +import zlib +import logging +import numpy as np +import folder_paths +from comfy_api.latest import IO +from typing_extensions import override +from comfy_api.latest import ComfyExtension +from comfy.cli_args import args + +def create_png_chunk(chunk_type: bytes, data: bytes) -> bytes: + """Creates a valid PNG chunk with Length, Type, Data, and CRC32.""" + chunk = struct.pack('>I', len(data)) + chunk_type + data + crc = zlib.crc32(chunk_type + data) & 0xffffffff + return chunk + struct.pack('>I', crc) + +def inject_comfy_metadata_png(png_bytes, prompt=None, extra_pnginfo=None): + # IEND chunk is the last 12 bytes of png files + content = png_bytes[:-12] + iend = png_bytes[-12:] + + metadata_chunks = b"" + + if prompt is not None: + payload = b'prompt\x00' + json.dumps(prompt).encode('utf-8') + metadata_chunks += create_png_chunk(b'tEXt', payload) + + if extra_pnginfo is not None: + for k, v in extra_pnginfo.items(): + payload = k.encode('utf-8') + b'\x00' + json.dumps(v).encode('utf-8') + metadata_chunks += create_png_chunk(b'tEXt', payload) + + return content + metadata_chunks + iend + +def inject_comfy_metadata_exr(exr_bytes: bytes, prompt, extra_pnginfo) -> bytes: + # skip magic and version + idx = 8 + + # parse through existing attributes to find the end of the header + while True: + name_start = idx + while exr_bytes[idx] != 0: + idx += 1 + name = exr_bytes[name_start:idx] + idx += 1 + + # empty name means we hit the header terminator + if len(name) == 0: + break + + # skip attribute type string + while exr_bytes[idx] != 0: + idx += 1 + idx += 1 + + # read attribute size and skip the value + attr_size = struct.unpack(' bytes: + metadata = {} + if prompt is not None: + metadata["prompt"] = prompt + if extra_pnginfo is not None: + for k, v in extra_pnginfo.items(): + metadata[k] = v + + payload = json.dumps(metadata).encode('utf-8') + + # 16-byte uuid required by isobmff spec + # 'comfyui_workflow' is exactly 16 bytes long! + comfy_uuid = b'comfyui_workflow' + + # box size: 4 (size) + 4 (type) + 16 (uuid) + payload length + box_size = 4 + 4 + 16 + len(payload) + uuid_box = struct.pack('>I', box_size) + b'uuid' + comfy_uuid + payload + + # isobmff allows top-level boxes at the end of the file. + return avif_bytes + uuid_box + +class SaveImageAdvanced(IO.ComfyNode): + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="SaveImageAdvanced", + category="image/advanced_io", + output_node=True, + inputs=[ + IO.Image.Input("images"), + IO.String.Input("filename_prefix", default="ComfyUI"), + IO.Combo.Input("file_format", options=["png", "exr", "avif"], default="png"), + IO.Combo.Input("bit_depth", options=["8-bit", "16-bit", "32-bit"], default="8-bit"), + IO.Boolean.Input("embed_workflow", default=True), + IO.Hidden.Input("prompt", type="PROMPT"), + IO.Hidden.Input("extra_pnginfo", type="EXTRA_PNGINFO"), + ], + outputs=[] + ) + + @classmethod + def execute(cls, images, filename_prefix="ComfyUI", file_format="png", bit_depth="8-bit", + embed_workflow=True, prompt=None, extra_pnginfo=None) -> IO.NodeOutput: + + output_dir = folder_paths.get_output_directory() + + full_output_folder, filename, counter, subfolder, filename_prefix = \ + folder_paths.get_save_image_path(filename_prefix, output_dir, images[0].shape[1], images[0].shape[0]) + + results = list() + + for batch_number, image in enumerate(images): + img_tensor = image.clone() + + height, width, num_channels = img_tensor.shape + has_alpha = (num_channels == 4) + + # file pathing + filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) + file = f"{filename_with_batch_num}_{counter:05}.{file_format}" + file_path = os.path.join(full_output_folder, file) + + if file_format in ["png", "exr", "avif"]: + + # safe bit downcasting + if (file_format == "png" or file_format == "avif") and bit_depth == "32-bit": + bit_depth = "16-bit" + if file_format == "exr" and bit_depth == "8-bit": + bit_depth = "16-bit" + + if bit_depth == "32-bit": + img_np = img_tensor.cpu().numpy() + # rgba128le handles 4x32f, gbrpf32le handles 3x32f planar + av_fmt = 'rgba128le' if has_alpha else 'gbrpf32le' + elif bit_depth == "16-bit": + img_np = (img_tensor * 65535.0).clamp(0, 65535).to(torch.int32).cpu().numpy().astype(np.uint16) + if file_format == "png": + # png requires Big-Endian (be) for 16-bit + av_fmt = 'rgba64be' if has_alpha else 'rgb48be' + img_np = img_np.byteswap().view(img_np.dtype.newbyteorder('>')) + else: + av_fmt = 'rgba64le' if has_alpha else 'rgb48le' + else: + img_np = (img_tensor * 255.0).clamp(0, 255).to(torch.int32).cpu().numpy().astype(np.uint8) + av_fmt = 'rgba' if has_alpha else 'rgb24' + + memory_buffer = io.BytesIO() + container_format = "image2" if file_format in ["png", "exr"] else "avif" + container = av.open(memory_buffer, mode='w', format=container_format) + + if file_format == "exr": + stream = container.add_stream('exr', rate=1) + stream.pix_fmt = av_fmt + elif file_format == "avif": + stream = container.add_stream('av1', rate=1) + # YUV color spaces + stream.pix_fmt = 'yuv444p12le' if bit_depth in["16-bit", "32-bit"] else 'yuv444p' + elif file_format == "png": + stream = container.add_stream('png', rate=1) + stream.pix_fmt = av_fmt + + stream.width = width + stream.height = height + + # planar: all red, all blue, all green instead of r, g, b, r, g, b + is_planar = av_fmt.startswith('gbrp') or 'p' in av_fmt.split('rgba')[-1] + if is_planar: + img_np = img_np.transpose(2, 0, 1) + + try: + frame = av.VideoFrame.from_ndarray(img_np, format=av_fmt) + except ValueError: + # FFMPEG Float32 Fallback: not all ffmpeg versions are able to handle float32 format for images + # float16 fallback conversion + logging.warning("[WARNING] Current FFMPEG Binary can't save float32 images. Fallbacking to float16") + img_np = (img_tensor * 65535.0).clamp(0, 65535).to(torch.int32).cpu().numpy().astype(np.uint16) + av_fmt = 'rgba64le' if has_alpha else 'rgb48le' + frame = av.VideoFrame.from_ndarray(img_np, format=av_fmt) + if file_format == "exr" or file_format == "png": + stream.pix_fmt = av_fmt + + for packet in stream.encode(frame): + container.mux(packet) + for packet in stream.encode(): + container.mux(packet) + + container.close() + + final_bytes = memory_buffer.getvalue() + + if embed_workflow and not args.disable_metadata: + if file_format == "png": + final_bytes = inject_comfy_metadata_png(final_bytes, prompt, extra_pnginfo) + elif file_format == "exr": + final_bytes = inject_comfy_metadata_exr(final_bytes, prompt, extra_pnginfo) + else: + final_bytes = inject_comfy_metadata_avif(final_bytes, prompt, extra_pnginfo) + + with open(file_path, "wb") as f: + f.write(final_bytes) + + results.append({ + "filename": file, + "subfolder": subfolder, + "type": "output" + }) + counter += 1 + + return IO.NodeOutput(ui={"images": results}) + +# Rec.709 to Rec.2020 Gamut Conversion Matrix +M_709_to_2020 = torch.tensor([[0.6274, 0.3293, 0.0433],[0.0691, 0.9195, 0.0114],[0.0164, 0.0880, 0.8956] +]) + +# Rec.2020 to Rec.709 Gamut Conversion Matrix +M_2020_to_709 = torch.tensor([[ 1.6605, -0.5876, -0.0728],[-0.1246, 1.1329, -0.0083],[-0.0182, -0.1006, 1.1187] +]) + +def srgb_to_linear(tensor): + mask = tensor <= 0.04045 + return torch.where(mask, tensor / 12.92, torch.pow((tensor + 0.055) / 1.055, 2.4)) + +def linear_to_srgb(tensor): + mask = tensor <= 0.0031308 + return torch.where(mask, tensor * 12.92, 1.055 * torch.pow(tensor.clamp(min=1e-8), 1.0 / 2.4) - 0.055) + +def linear_to_pq(linear_tensor): + """SMPTE ST 2084 (PQ) encoding""" + m1, m2 = (2610 / 4096 / 4), (2523 / 4096 * 128) + c1, c2, c3 = (3424 / 4096), (2413 / 4096 * 32), (2392 / 4096 * 32) + l_norm = torch.clamp(linear_tensor, 0.0, 1.0) + l_m1 = torch.pow(l_norm, m1) + return torch.pow((c1 + c2 * l_m1) / (1 + c3 * l_m1), m2) + +def pq_to_linear(pq_tensor): + """Inverse SMPTE ST 2084 (PQ) decoding""" + m1, m2 = (2610 / 4096 / 4), (2523 / 4096 * 128) + c1, c2, c3 = (3424 / 4096), (2413 / 4096 * 32), (2392 / 4096 * 32) + n = torch.pow(torch.clamp(pq_tensor, 0.0, 1.0), 1/m2) + return torch.pow(torch.clamp((n - c1) / (c2 - c3 * n), min=0.0), 1/m1) + +class ConvertColorSpace(IO.ComfyNode): + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="Convert Color Space", + category="image/color", + inputs=[ + IO.Image.Input("images"), + IO.Combo.Input("source_color_space", options=["sRGB", "Linear", "HDR (Rec.2020)", "Grayscale"], default="sRGB"), + IO.Combo.Input("target_color_space", options=["sRGB", "Linear", "HDR (Rec.2020)", "Grayscale"], default="Linear"), + ], + outputs=[ + IO.Image.Output("images"), + ] + ) + + @classmethod + def execute(cls, images, source_color_space, target_color_space) -> IO.NodeOutput: + img_tensor = images.clone() + device = img_tensor.device + + has_alpha = img_tensor.shape[-1] == 4 + alpha = img_tensor[..., 3:4] if has_alpha else None + rgb = img_tensor[..., :3] + + # turn source into linear + if source_color_space == "sRGB": + rgb = srgb_to_linear(rgb) + + elif source_color_space == "Grayscale": + # assume Grayscale has sRGB gamma + luma = 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2] + rgb = luma.unsqueeze(-1).repeat(1, 1, 1, 3) + rgb = linear_to_srgb(rgb) + + elif source_color_space == "HDR (Rec.2020)": + # assuming Linear Rec.2020 input. Convert to Linear Rec.709 + matrix = M_2020_to_709.to(device) + rgb = pq_to_linear(rgb) + rgb = torch.matmul(rgb, matrix.T) + + + # turn source into target space + if target_color_space == "sRGB": + rgb = linear_to_srgb(rgb) + + elif target_color_space == "Grayscale": + luma = 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2] + rgb = luma.unsqueeze(-1).repeat(1, 1, 1, 3) + rgb = linear_to_srgb(rgb) # reapply srgb gamma + + elif target_color_space == "HDR (Rec.2020)": + # convert Gamut from Linear Rec.709 to Linear Rec.2020 + rgb = torch.matmul(rgb, M_709_to_2020.to(device).T).clamp(min=0) + rgb = linear_to_pq(rgb) + + img_tensor = torch.cat([rgb, alpha], dim=-1) if has_alpha else rgb + + return IO.NodeOutput(images=img_tensor) + +class AdvancedImageSave(ComfyExtension): + @override + async def get_node_list(self) -> list[type[IO.ComfyNode]]: + return [ + SaveImageAdvanced, + ConvertColorSpace + ] + + +async def comfy_entrypoint() -> AdvancedImageSave: + return AdvancedImageSave() diff --git a/nodes.py b/nodes.py index 299b3d758..f0f0dead9 100644 --- a/nodes.py +++ b/nodes.py @@ -2457,7 +2457,8 @@ async def init_builtin_extra_nodes(): "nodes_number_convert.py", "nodes_painter.py", "nodes_curve.py", - "nodes_rtdetr.py" + "nodes_rtdetr.py", + "nodes_save_advanced.py" ] import_failed = [] From ebb9acf3cfed81963fd41c19f7059012579cf566 Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Fri, 24 Apr 2026 23:27:59 +0300 Subject: [PATCH 02/28] bytesio -> tempfile --- comfy_extras/nodes_save_advanced.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/comfy_extras/nodes_save_advanced.py b/comfy_extras/nodes_save_advanced.py index b1686bead..6f1f45ef4 100644 --- a/comfy_extras/nodes_save_advanced.py +++ b/comfy_extras/nodes_save_advanced.py @@ -1,4 +1,3 @@ -import io import av import os import json @@ -7,6 +6,7 @@ import struct import zlib import logging import numpy as np +import tempfile import folder_paths from comfy_api.latest import IO from typing_extensions import override @@ -181,9 +181,10 @@ class SaveImageAdvanced(IO.ComfyNode): img_np = (img_tensor * 255.0).clamp(0, 255).to(torch.int32).cpu().numpy().astype(np.uint8) av_fmt = 'rgba' if has_alpha else 'rgb24' - memory_buffer = io.BytesIO() + fd, tmp_path = tempfile.mkstemp(suffix=f".{file_format}") + os.close(fd) container_format = "image2" if file_format in ["png", "exr"] else "avif" - container = av.open(memory_buffer, mode='w', format=container_format) + container = av.open(tmp_path, mode='w', format=container_format) if file_format == "exr": stream = container.add_stream('exr', rate=1) @@ -223,7 +224,9 @@ class SaveImageAdvanced(IO.ComfyNode): container.close() - final_bytes = memory_buffer.getvalue() + with open(tmp_path, "rb") as f: + final_bytes = f.read() + os.remove(tmp_path) if embed_workflow and not args.disable_metadata: if file_format == "png": From 80e421b626223528ae49c57559b734444f2ee01b Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:52:44 +0300 Subject: [PATCH 03/28] correct avif code --- comfy_extras/nodes_save_advanced.py | 44 ++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/comfy_extras/nodes_save_advanced.py b/comfy_extras/nodes_save_advanced.py index 6f1f45ef4..d983d8ffd 100644 --- a/comfy_extras/nodes_save_advanced.py +++ b/comfy_extras/nodes_save_advanced.py @@ -12,6 +12,7 @@ from comfy_api.latest import IO from typing_extensions import override from comfy_api.latest import ComfyExtension from comfy.cli_args import args +from fractions import Fraction def create_png_chunk(chunk_type: bytes, data: bytes) -> bytes: """Creates a valid PNG chunk with Length, Type, Data, and CRC32.""" @@ -189,10 +190,31 @@ class SaveImageAdvanced(IO.ComfyNode): if file_format == "exr": stream = container.add_stream('exr', rate=1) stream.pix_fmt = av_fmt + elif file_format == "avif": - stream = container.add_stream('av1', rate=1) - # YUV color spaces - stream.pix_fmt = 'yuv444p12le' if bit_depth in["16-bit", "32-bit"] else 'yuv444p' + try: + stream = container.add_stream('libsvtav1', rate=1) + except Exception: + stream = container.add_stream('av1', rate=1) + + stream.time_base = Fraction(1, 1) + + if bit_depth in ["16-bit", "32-bit"]: + stream.pix_fmt = 'yuv420p10le' + else: + stream.pix_fmt = 'yuv420p' + + stream.codec_context.color_range = 2 + stream.codec_context.colorspace = 1 + stream.codec_context.color_primaries = 1 + stream.codec_context.color_trc = 1 + + stream.options = { + 'preset': '10', + 'svtav1-params': 'rc=0:qp=20:color-range=1:color-matrix=1:enable-overlays=1', + 'g': '1' + } + elif file_format == "png": stream = container.add_stream('png', rate=1) stream.pix_fmt = av_fmt @@ -208,8 +230,6 @@ class SaveImageAdvanced(IO.ComfyNode): try: frame = av.VideoFrame.from_ndarray(img_np, format=av_fmt) except ValueError: - # FFMPEG Float32 Fallback: not all ffmpeg versions are able to handle float32 format for images - # float16 fallback conversion logging.warning("[WARNING] Current FFMPEG Binary can't save float32 images. Fallbacking to float16") img_np = (img_tensor * 65535.0).clamp(0, 65535).to(torch.int32).cpu().numpy().astype(np.uint16) av_fmt = 'rgba64le' if has_alpha else 'rgb48le' @@ -217,6 +237,18 @@ class SaveImageAdvanced(IO.ComfyNode): if file_format == "exr" or file_format == "png": stream.pix_fmt = av_fmt + # reformat for avif + if file_format == "avif": + frame = frame.reformat( + format=stream.pix_fmt, + src_colorspace=1, dst_colorspace=1, + src_color_range=2, dst_color_range=2 + ) + frame.pts = 0 + frame.time_base = stream.time_base + frame.color_range = 2 + frame.colorspace = 1 + for packet in stream.encode(frame): container.mux(packet) for packet in stream.encode(): @@ -246,8 +278,6 @@ class SaveImageAdvanced(IO.ComfyNode): }) counter += 1 - return IO.NodeOutput(ui={"images": results}) - # Rec.709 to Rec.2020 Gamut Conversion Matrix M_709_to_2020 = torch.tensor([[0.6274, 0.3293, 0.0433],[0.0691, 0.9195, 0.0114],[0.0164, 0.0880, 0.8956] ]) From 29653e7fcceaa28376327aa1fc7be1a120087f25 Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:40:13 +0300 Subject: [PATCH 04/28] png, exr, avif --- comfy_extras/nodes_save_advanced.py | 50 ++++++++++++++++------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/comfy_extras/nodes_save_advanced.py b/comfy_extras/nodes_save_advanced.py index d983d8ffd..af0bdefe5 100644 --- a/comfy_extras/nodes_save_advanced.py +++ b/comfy_extras/nodes_save_advanced.py @@ -167,16 +167,14 @@ class SaveImageAdvanced(IO.ComfyNode): bit_depth = "16-bit" if bit_depth == "32-bit": - img_np = img_tensor.cpu().numpy() - # rgba128le handles 4x32f, gbrpf32le handles 3x32f planar - av_fmt = 'rgba128le' if has_alpha else 'gbrpf32le' + img_np = img_tensor.cpu().numpy().astype(np.float32) + av_fmt = 'gbrapf32le' if has_alpha else 'gbrpf32le' elif bit_depth == "16-bit": - img_np = (img_tensor * 65535.0).clamp(0, 65535).to(torch.int32).cpu().numpy().astype(np.uint16) - if file_format == "png": - # png requires Big-Endian (be) for 16-bit - av_fmt = 'rgba64be' if has_alpha else 'rgb48be' - img_np = img_np.byteswap().view(img_np.dtype.newbyteorder('>')) + if file_format == "exr": + img_np = img_tensor.cpu().numpy().astype(np.float16) + av_fmt = 'gbrapf16le' if has_alpha else 'gbrpf16le' else: + img_np = (img_tensor * 65535.0).clamp(0, 65535).to(torch.int32).cpu().numpy().astype(np.uint16) av_fmt = 'rgba64le' if has_alpha else 'rgb48le' else: img_np = (img_tensor * 255.0).clamp(0, 255).to(torch.int32).cpu().numpy().astype(np.uint8) @@ -217,37 +215,43 @@ class SaveImageAdvanced(IO.ComfyNode): elif file_format == "png": stream = container.add_stream('png', rate=1) - stream.pix_fmt = av_fmt + if bit_depth == "16-bit": + stream.pix_fmt = 'rgba64be' if has_alpha else 'rgb48be' + else: + stream.pix_fmt = av_fmt stream.width = width stream.height = height + stream.time_base = Fraction(1, 1) - # planar: all red, all blue, all green instead of r, g, b, r, g, b is_planar = av_fmt.startswith('gbrp') or 'p' in av_fmt.split('rgba')[-1] if is_planar: + if av_fmt.startswith('gbrp'): + img_np = img_np[:, :, [1, 2, 0, 3]] if has_alpha else img_np[:, :, [1, 2, 0]] img_np = img_np.transpose(2, 0, 1) try: frame = av.VideoFrame.from_ndarray(img_np, format=av_fmt) except ValueError: - logging.warning("[WARNING] Current FFMPEG Binary can't save float32 images. Fallbacking to float16") + logging.warning("[WARNING] Current FFMPEG Binary can't save natively. Fallbacking.") img_np = (img_tensor * 65535.0).clamp(0, 65535).to(torch.int32).cpu().numpy().astype(np.uint16) av_fmt = 'rgba64le' if has_alpha else 'rgb48le' frame = av.VideoFrame.from_ndarray(img_np, format=av_fmt) - if file_format == "exr" or file_format == "png": - stream.pix_fmt = av_fmt - # reformat for avif - if file_format == "avif": - frame = frame.reformat( - format=stream.pix_fmt, - src_colorspace=1, dst_colorspace=1, - src_color_range=2, dst_color_range=2 - ) + # reformat for both avif and exr to ensure correct internal conversion + if file_format in ["avif", "exr"] or (file_format == "png" and bit_depth == "16-bit"): + reformat_kwargs = {"format": stream.pix_fmt} + if file_format == "avif": + reformat_kwargs.update({ + "src_colorspace": 1, "dst_colorspace": 1, + "src_color_range": 2, "dst_color_range": 2 + }) + frame = frame.reformat(**reformat_kwargs) frame.pts = 0 frame.time_base = stream.time_base - frame.color_range = 2 - frame.colorspace = 1 + if file_format == "avif": + frame.color_range = 2 + frame.colorspace = 1 for packet in stream.encode(frame): container.mux(packet) @@ -278,6 +282,8 @@ class SaveImageAdvanced(IO.ComfyNode): }) counter += 1 + return IO.NodeOutput(ui={"images": results}) + # Rec.709 to Rec.2020 Gamut Conversion Matrix M_709_to_2020 = torch.tensor([[0.6274, 0.3293, 0.0433],[0.0691, 0.9195, 0.0114],[0.0164, 0.0880, 0.8956] ]) From 34d7a5da7330fd069d939f3519fb6b2d3910789e Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:56:42 +0300 Subject: [PATCH 05/28] default pyav build doesn't support float16 exr --- comfy_extras/nodes_save_advanced.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/comfy_extras/nodes_save_advanced.py b/comfy_extras/nodes_save_advanced.py index af0bdefe5..b0b4e501a 100644 --- a/comfy_extras/nodes_save_advanced.py +++ b/comfy_extras/nodes_save_advanced.py @@ -171,8 +171,9 @@ class SaveImageAdvanced(IO.ComfyNode): av_fmt = 'gbrapf32le' if has_alpha else 'gbrpf32le' elif bit_depth == "16-bit": if file_format == "exr": - img_np = img_tensor.cpu().numpy().astype(np.float16) - av_fmt = 'gbrapf16le' if has_alpha else 'gbrpf16le' + # default pyav build doesn't come with a codec for float16 exr format + img_np = img_tensor.cpu().numpy().astype(np.float32) + av_fmt = 'gbrapf32le' if has_alpha else 'gbrpf32le' else: img_np = (img_tensor * 65535.0).clamp(0, 65535).to(torch.int32).cpu().numpy().astype(np.uint16) av_fmt = 'rgba64le' if has_alpha else 'rgb48le' From 92ab48531f5f2ab3d1e1b64965e6bd36532fd2c5 Mon Sep 17 00:00:00 2001 From: Alexis Rolland Date: Wed, 29 Apr 2026 12:12:12 +0800 Subject: [PATCH 06/28] Iterate on new Save Image node --- comfy_extras/nodes_convert_color_space.py | 109 ++++++++ comfy_extras/nodes_images.py | 308 +++++++++++++++++++++- nodes.py | 3 +- 3 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 comfy_extras/nodes_convert_color_space.py diff --git a/comfy_extras/nodes_convert_color_space.py b/comfy_extras/nodes_convert_color_space.py new file mode 100644 index 000000000..0481eaa71 --- /dev/null +++ b/comfy_extras/nodes_convert_color_space.py @@ -0,0 +1,109 @@ + +import torch +from comfy_api.latest import IO +from typing_extensions import override +from comfy_api.latest import ComfyExtension + + +# Rec.709 to Rec.2020 Gamut Conversion Matrix +M_709_to_2020 = torch.tensor([[0.6274, 0.3293, 0.0433],[0.0691, 0.9195, 0.0114],[0.0164, 0.0880, 0.8956] +]) + +# Rec.2020 to Rec.709 Gamut Conversion Matrix +M_2020_to_709 = torch.tensor([[ 1.6605, -0.5876, -0.0728],[-0.1246, 1.1329, -0.0083],[-0.0182, -0.1006, 1.1187] +]) + +def srgb_to_linear(tensor): + mask = tensor <= 0.04045 + return torch.where(mask, tensor / 12.92, torch.pow((tensor + 0.055) / 1.055, 2.4)) + +def linear_to_srgb(tensor): + mask = tensor <= 0.0031308 + return torch.where(mask, tensor * 12.92, 1.055 * torch.pow(tensor.clamp(min=1e-8), 1.0 / 2.4) - 0.055) + +def linear_to_pq(linear_tensor): + """SMPTE ST 2084 (PQ) encoding""" + m1, m2 = (2610 / 4096 / 4), (2523 / 4096 * 128) + c1, c2, c3 = (3424 / 4096), (2413 / 4096 * 32), (2392 / 4096 * 32) + l_norm = torch.clamp(linear_tensor, 0.0, 1.0) + l_m1 = torch.pow(l_norm, m1) + return torch.pow((c1 + c2 * l_m1) / (1 + c3 * l_m1), m2) + +def pq_to_linear(pq_tensor): + """Inverse SMPTE ST 2084 (PQ) decoding""" + m1, m2 = (2610 / 4096 / 4), (2523 / 4096 * 128) + c1, c2, c3 = (3424 / 4096), (2413 / 4096 * 32), (2392 / 4096 * 32) + n = torch.pow(torch.clamp(pq_tensor, 0.0, 1.0), 1/m2) + return torch.pow(torch.clamp((n - c1) / (c2 - c3 * n), min=0.0), 1/m1) + +class ConvertColorSpace(IO.ComfyNode): + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="Convert Color Space", + category="image/color", + inputs=[ + IO.Image.Input("images"), + IO.Combo.Input("source_color_space", options=["sRGB", "Linear", "HDR (Rec.2020)", "Grayscale"], default="sRGB"), + IO.Combo.Input("target_color_space", options=["sRGB", "Linear", "HDR (Rec.2020)", "Grayscale"], default="Linear"), + ], + outputs=[ + IO.Image.Output("images"), + ] + ) + + @classmethod + def execute(cls, images, source_color_space, target_color_space) -> IO.NodeOutput: + img_tensor = images.clone() + device = img_tensor.device + + has_alpha = img_tensor.shape[-1] == 4 + alpha = img_tensor[..., 3:4] if has_alpha else None + rgb = img_tensor[..., :3] + + # turn source into linear + if source_color_space == "sRGB": + rgb = srgb_to_linear(rgb) + + elif source_color_space == "Grayscale": + # assume Grayscale has sRGB gamma + luma = 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2] + rgb = luma.unsqueeze(-1).repeat(1, 1, 1, 3) + rgb = linear_to_srgb(rgb) + + elif source_color_space == "HDR (Rec.2020)": + # assuming Linear Rec.2020 input. Convert to Linear Rec.709 + matrix = M_2020_to_709.to(device) + rgb = pq_to_linear(rgb) + rgb = torch.matmul(rgb, matrix.T) + + + # turn source into target space + if target_color_space == "sRGB": + rgb = linear_to_srgb(rgb) + + elif target_color_space == "Grayscale": + luma = 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2] + rgb = luma.unsqueeze(-1).repeat(1, 1, 1, 3) + rgb = linear_to_srgb(rgb) # reapply srgb gamma + + elif target_color_space == "HDR (Rec.2020)": + # convert Gamut from Linear Rec.709 to Linear Rec.2020 + rgb = torch.matmul(rgb, M_709_to_2020.to(device).T).clamp(min=0) + rgb = linear_to_pq(rgb) + + img_tensor = torch.cat([rgb, alpha], dim=-1) if has_alpha else rgb + + return IO.NodeOutput(images=img_tensor) + + +class ConvertColorSpaceExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[IO.ComfyNode]]: + return [ + ConvertColorSpace + ] + + +async def comfy_entrypoint() -> ConvertColorSpaceExtension: + return ConvertColorSpaceExtension() diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index a77f0641f..60d1fe739 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -3,15 +3,22 @@ from __future__ import annotations import nodes import folder_paths +import av +import io import json +import logging import os import re import math +import numpy as np +import struct import torch +import zlib import comfy.utils from server import PromptServer -from comfy_api.latest import ComfyExtension, IO, UI +from comfy_api.latest import ComfyExtension, Input, IO, UI +from comfy.cli_args import args from typing_extensions import override SVG = IO.SVG.Type # TODO: temporary solution for backward compatibility, will be removed later. @@ -823,6 +830,304 @@ class ImageMergeTileList(IO.ComfyNode): return IO.NodeOutput(merged_image) +def _create_png_chunk(chunk_type: bytes, data: bytes) -> bytes: + """Creates a valid PNG chunk with Length, Type, Data, and CRC32.""" + chunk = struct.pack('>I', len(data)) + chunk_type + data + crc = zlib.crc32(chunk_type + data) & 0xffffffff + return chunk + struct.pack('>I', crc) + + +def _inject_metadata_png(png_bytes, prompt=None, extra_pnginfo=None): + # IEND chunk is the last 12 bytes of png files + content = png_bytes[:-12] + iend = png_bytes[-12:] + + metadata_chunks = b"" + + if prompt is not None: + payload = b'prompt\x00' + json.dumps(prompt).encode('utf-8') + metadata_chunks += _create_png_chunk(b'tEXt', payload) + + if extra_pnginfo is not None: + for k, v in extra_pnginfo.items(): + payload = k.encode('utf-8') + b'\x00' + json.dumps(v).encode('utf-8') + metadata_chunks += _create_png_chunk(b'tEXt', payload) + + return content + metadata_chunks + iend + +def _inject_metadata_exr(exr_bytes: bytes, prompt, extra_pnginfo) -> bytes: + # skip magic and version + idx = 8 + + # parse through existing attributes to find the end of the header + while True: + name_start = idx + while exr_bytes[idx] != 0: + idx += 1 + name = exr_bytes[name_start:idx] + idx += 1 + + # empty name means we hit the header terminator + if len(name) == 0: + break + + # skip attribute type string + while exr_bytes[idx] != 0: + idx += 1 + idx += 1 + + # read attribute size and skip the value + attr_size = struct.unpack(' bytes: + metadata = {} + if prompt is not None: + metadata["prompt"] = prompt + if extra_pnginfo is not None: + for k, v in extra_pnginfo.items(): + metadata[k] = v + + payload = json.dumps(metadata).encode('utf-8') + + # 16-byte uuid required by isobmff spec + # 'comfyui_workflow' is exactly 16 bytes long! + comfy_uuid = b'comfyui_workflow' + + # box size: 4 (size) + 4 (type) + 16 (uuid) + payload length + box_size = 4 + 4 + 16 + len(payload) + uuid_box = struct.pack('>I', box_size) + b'uuid' + comfy_uuid + payload + + # isobmff allows top-level boxes at the end of the file. + return avif_bytes + uuid_box + + +class SaveImageAdvanced(IO.ComfyNode): + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="SaveImageAdvanced", + search_aliases=["save", "save image", "export image", "output image", "write image", "download"], + display_name="Save Image", + description="Saves the input images to your ComfyUI output directory.", + category="image", + essentials_category="Basics", + inputs=[ + IO.Image.Input( + "images", + tooltip="The images to save." + ), + IO.String.Input( + "filename_prefix", + 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.", + ), + IO.DynamicCombo.Input( + "file_format", + options=[ + IO.DynamicCombo.Option( + "png", + [ + IO.Combo.Input( + "bit_depth", + options=["8-bit", "16-bit"], + default="8-bit", + advanced=True, + ), + IO.Combo.Input( + "color_space", + options=["Raw/Data", "sRGB"], + default="sRGB", + advanced=True, + ), + ], + ), + IO.DynamicCombo.Option( + "avif", + [ + IO.Combo.Input( + "bit_depth", + options=["8-bit", "10-bit", "12-bit"], + default="8-bit", + advanced=True, + ), + IO.Combo.Input( + "color_space", + options=["sRGB"], + default="sRGB", + advanced=True, + ), + ], + ), + IO.DynamicCombo.Option( + "exr", + [ + IO.Combo.Input( + "bit_depth", + options=["16-bit (half-float)", "32-bit"], + default="16-bit (half-float)", + advanced=True, + ), + IO.Combo.Input( + "color_space", + options=["Linear", "Raw/Data"], + default="Linear", + advanced=True, + ), + ], + ), + ], + tooltip="The file format in which to save the image.", + ), + IO.Boolean.Input("embed_workflow", default=True, advanced=True), + ], + hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo], + is_output_node=True, + ) + + @classmethod + def execute( + cls, + images: Input.Image, + filename_prefix: str, + file_format: dict, + embed_workflow: bool, + prompt=None, + extra_pnginfo=None + ) -> IO.NodeOutput: + output_dir = folder_paths.get_output_directory() + full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, output_dir, images[0].shape[1], images[0].shape[0]) + results = list() + + for batch_number, image in enumerate(images): + img_tensor = image.clone() + height, width, num_channels = img_tensor.shape + has_alpha = (num_channels == 4) + + # file pathing + filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) + file = f"{filename_with_batch_num}_{counter:05}.{file_format}" + file_path = os.path.join(full_output_folder, file) + + # get widget values from dynamic combo + format = file_format["file_format"] + bit_depth = file_format["bit_depth"] + color_space = file_format["color_space"] + + if bit_depth == "32-bit": + img_np = img_tensor.cpu().numpy() + # rgba128le handles 4x32f, gbrpf32le handles 3x32f planar + av_fmt = 'rgba128le' if has_alpha else 'gbrpf32le' + elif bit_depth == "16-bit": + img_np = (img_tensor * 65535.0).clamp(0, 65535).to(torch.int32).cpu().numpy().astype(np.uint16) + if format == "png": + # png requires Big-Endian (be) for 16-bit + av_fmt = 'rgba64be' if has_alpha else 'rgb48be' + img_np = img_np.byteswap().view(img_np.dtype.newbyteorder('>')) + else: + av_fmt = 'rgba64le' if has_alpha else 'rgb48le' + else: + img_np = (img_tensor * 255.0).clamp(0, 255).to(torch.int32).cpu().numpy().astype(np.uint8) + av_fmt = 'rgba' if has_alpha else 'rgb24' + + memory_buffer = io.BytesIO() + container_format = "image2" if format in ["png", "exr"] else "avif" + container = av.open(memory_buffer, mode='w', format=container_format) + + if format == "exr": + stream = container.add_stream('exr', rate=1) + stream.pix_fmt = av_fmt + elif format == "avif": + stream = container.add_stream('av1', rate=1) + # YUV color spaces + stream.pix_fmt = 'yuv444p12le' if bit_depth in ["16-bit", "32-bit"] else 'yuv444p' + elif format == "png": + stream = container.add_stream('png', rate=1) + stream.pix_fmt = av_fmt + + stream.width = width + stream.height = height + + # planar: all red, all blue, all green instead of r, g, b, r, g, b + is_planar = av_fmt.startswith('gbrp') or 'p' in av_fmt.split('rgba')[-1] + if is_planar: + img_np = img_np.transpose(2, 0, 1) + + try: + frame = av.VideoFrame.from_ndarray(img_np, format=av_fmt) + except ValueError: + # FFMPEG Float32 Fallback: not all ffmpeg versions are able to handle float32 format for images + # float16 fallback conversion + logging.warning("[WARNING] Current FFMPEG Binary can't save float32 images. Fallbacking to float16") + img_np = (img_tensor * 65535.0).clamp(0, 65535).to(torch.int32).cpu().numpy().astype(np.uint16) + av_fmt = 'rgba64le' if has_alpha else 'rgb48le' + frame = av.VideoFrame.from_ndarray(img_np, format=av_fmt) + if file_format == "exr" or file_format == "png": + stream.pix_fmt = av_fmt + + for packet in stream.encode(frame): + container.mux(packet) + for packet in stream.encode(): + container.mux(packet) + + container.close() + + final_bytes = memory_buffer.getvalue() + + if embed_workflow and not args.disable_metadata: + if format == "png": + final_bytes = _inject_metadata_png(final_bytes, prompt, extra_pnginfo) + elif format == "exr": + final_bytes = _inject_metadata_exr(final_bytes, prompt, extra_pnginfo) + else: + final_bytes = _inject_metadata_avif(final_bytes, prompt, extra_pnginfo) + + with open(file_path, "wb") as f: + f.write(final_bytes) + + results.append({ + "filename": file, + "subfolder": subfolder, + "type": "output" + }) + counter += 1 + + return IO.NodeOutput(ui={"images": results}) + + class ImagesExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: @@ -835,6 +1140,7 @@ class ImagesExtension(ComfyExtension): ImageAddNoise, SaveAnimatedWEBP, SaveAnimatedPNG, + SaveImageAdvanced, SaveSVGNode, ImageStitch, ResizeAndPadImage, diff --git a/nodes.py b/nodes.py index db989a501..2c50d3021 100644 --- a/nodes.py +++ b/nodes.py @@ -1652,6 +1652,7 @@ class SaveImage: ESSENTIALS_CATEGORY = "Basics" DESCRIPTION = "Saves the input images to your ComfyUI output directory." SEARCH_ALIASES = ["save", "save image", "export image", "output image", "write image", "download"] + DEPRECATED = True def save_images(self, images, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None): filename_prefix += self.prefix_append @@ -2157,7 +2158,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "LatentFromBatch" : "Latent From Batch", "RepeatLatentBatch": "Repeat Latent Batch", # Image - "SaveImage": "Save Image", + "SaveImage": "Save Image (DEPRECATED)", "PreviewImage": "Preview Image", "LoadImage": "Load Image", "LoadImageMask": "Load Image (as Mask)", From 923c2afd96f89219d7be7b86e4daa2d0ef51b6ed Mon Sep 17 00:00:00 2001 From: Alexis Rolland Date: Wed, 29 Apr 2026 12:52:03 +0800 Subject: [PATCH 07/28] Rename file_format to format for consistency with Save Video node Co-authored-by: Copilot --- comfy_extras/nodes_images.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index 60d1fe739..28de035b1 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -956,7 +956,7 @@ class SaveImageAdvanced(IO.ComfyNode): 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.", ), IO.DynamicCombo.Input( - "file_format", + "format", options=[ IO.DynamicCombo.Option( "png", @@ -1023,7 +1023,7 @@ class SaveImageAdvanced(IO.ComfyNode): cls, images: Input.Image, filename_prefix: str, - file_format: dict, + format: dict, embed_workflow: bool, prompt=None, extra_pnginfo=None @@ -1033,27 +1033,27 @@ class SaveImageAdvanced(IO.ComfyNode): results = list() for batch_number, image in enumerate(images): + # get widget values from dynamic combo + extension = format["format"] + bit_depth = format["bit_depth"] + color_space = format["color_space"] + img_tensor = image.clone() height, width, num_channels = img_tensor.shape has_alpha = (num_channels == 4) # file pathing filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) - file = f"{filename_with_batch_num}_{counter:05}.{file_format}" + file = f"{filename_with_batch_num}_{counter:05}.{extension}" file_path = os.path.join(full_output_folder, file) - # get widget values from dynamic combo - format = file_format["file_format"] - bit_depth = file_format["bit_depth"] - color_space = file_format["color_space"] - if bit_depth == "32-bit": img_np = img_tensor.cpu().numpy() # rgba128le handles 4x32f, gbrpf32le handles 3x32f planar av_fmt = 'rgba128le' if has_alpha else 'gbrpf32le' elif bit_depth == "16-bit": img_np = (img_tensor * 65535.0).clamp(0, 65535).to(torch.int32).cpu().numpy().astype(np.uint16) - if format == "png": + if extension == "png": # png requires Big-Endian (be) for 16-bit av_fmt = 'rgba64be' if has_alpha else 'rgb48be' img_np = img_np.byteswap().view(img_np.dtype.newbyteorder('>')) @@ -1064,17 +1064,17 @@ class SaveImageAdvanced(IO.ComfyNode): av_fmt = 'rgba' if has_alpha else 'rgb24' memory_buffer = io.BytesIO() - container_format = "image2" if format in ["png", "exr"] else "avif" + container_format = "image2" if extension in ["png", "exr"] else "avif" container = av.open(memory_buffer, mode='w', format=container_format) - if format == "exr": + if extension == "exr": stream = container.add_stream('exr', rate=1) stream.pix_fmt = av_fmt - elif format == "avif": + elif extension == "avif": stream = container.add_stream('av1', rate=1) # YUV color spaces stream.pix_fmt = 'yuv444p12le' if bit_depth in ["16-bit", "32-bit"] else 'yuv444p' - elif format == "png": + elif extension == "png": stream = container.add_stream('png', rate=1) stream.pix_fmt = av_fmt @@ -1095,7 +1095,7 @@ class SaveImageAdvanced(IO.ComfyNode): img_np = (img_tensor * 65535.0).clamp(0, 65535).to(torch.int32).cpu().numpy().astype(np.uint16) av_fmt = 'rgba64le' if has_alpha else 'rgb48le' frame = av.VideoFrame.from_ndarray(img_np, format=av_fmt) - if file_format == "exr" or file_format == "png": + if extension == "exr" or extension == "png": stream.pix_fmt = av_fmt for packet in stream.encode(frame): @@ -1108,9 +1108,9 @@ class SaveImageAdvanced(IO.ComfyNode): final_bytes = memory_buffer.getvalue() if embed_workflow and not args.disable_metadata: - if format == "png": + if extension == "png": final_bytes = _inject_metadata_png(final_bytes, prompt, extra_pnginfo) - elif format == "exr": + elif extension == "exr": final_bytes = _inject_metadata_exr(final_bytes, prompt, extra_pnginfo) else: final_bytes = _inject_metadata_avif(final_bytes, prompt, extra_pnginfo) From 941a4a920303db0d8c18eeb3fda8b49a1c328732 Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:02:53 +0300 Subject: [PATCH 08/28] move covert color space node with grayscale fix --- comfy_extras/nodes_convert_color_space.py | 106 ++++++++++++++++++++++ comfy_extras/nodes_save_advanced.py | 92 ------------------- 2 files changed, 106 insertions(+), 92 deletions(-) create mode 100644 comfy_extras/nodes_convert_color_space.py diff --git a/comfy_extras/nodes_convert_color_space.py b/comfy_extras/nodes_convert_color_space.py new file mode 100644 index 000000000..acfd3d0c9 --- /dev/null +++ b/comfy_extras/nodes_convert_color_space.py @@ -0,0 +1,106 @@ +import torch +from comfy_api.latest import IO +from typing_extensions import override +from comfy_api.latest import ComfyExtension + +# Rec.709 to Rec.2020 Gamut Conversion Matrix +M_709_to_2020 = torch.tensor([[0.6274, 0.3293, 0.0433],[0.0691, 0.9195, 0.0114],[0.0164, 0.0880, 0.8956] +]) + +# Rec.2020 to Rec.709 Gamut Conversion Matrix +M_2020_to_709 = torch.tensor([[ 1.6605, -0.5876, -0.0728],[-0.1246, 1.1329, -0.0083],[-0.0182, -0.1006, 1.1187] +]) + +def srgb_to_linear(tensor): + mask = tensor <= 0.04045 + return torch.where(mask, tensor / 12.92, torch.pow((tensor + 0.055) / 1.055, 2.4)) + +def linear_to_srgb(tensor): + mask = tensor <= 0.0031308 + return torch.where(mask, tensor * 12.92, 1.055 * torch.pow(tensor.clamp(min=1e-8), 1.0 / 2.4) - 0.055) + +def linear_to_pq(linear_tensor): + """SMPTE ST 2084 (PQ) encoding""" + m1, m2 = (2610 / 4096 / 4), (2523 / 4096 * 128) + c1, c2, c3 = (3424 / 4096), (2413 / 4096 * 32), (2392 / 4096 * 32) + l_norm = torch.clamp(linear_tensor, 0.0, 1.0) + l_m1 = torch.pow(l_norm, m1) + return torch.pow((c1 + c2 * l_m1) / (1 + c3 * l_m1), m2) + +def pq_to_linear(pq_tensor): + """Inverse SMPTE ST 2084 (PQ) decoding""" + m1, m2 = (2610 / 4096 / 4), (2523 / 4096 * 128) + c1, c2, c3 = (3424 / 4096), (2413 / 4096 * 32), (2392 / 4096 * 32) + n = torch.pow(torch.clamp(pq_tensor, 0.0, 1.0), 1/m2) + return torch.pow(torch.clamp((n - c1) / (c2 - c3 * n), min=0.0), 1/m1) + +class ConvertColorSpace(IO.ComfyNode): + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="Convert Color Space", + category="image/color", + inputs=[ + IO.Image.Input("images"), + IO.Combo.Input("source_color_space", options=["sRGB", "Linear", "HDR (Rec.2020)", "Grayscale"], default="sRGB"), + IO.Combo.Input("target_color_space", options=["sRGB", "Linear", "HDR (Rec.2020)", "Grayscale"], default="Linear"), + ], + outputs=[ + IO.Image.Output("images"), + ] + ) + + @classmethod + def execute(cls, images, source_color_space, target_color_space) -> IO.NodeOutput: + img_tensor = images.clone() + device = img_tensor.device + + has_alpha = img_tensor.shape[-1] == 4 + alpha = img_tensor[..., 3:4] if has_alpha else None + rgb = img_tensor[..., :3] + + # turn source into linear + if source_color_space == "sRGB": + rgb = srgb_to_linear(rgb) + + elif source_color_space == "Grayscale": + # assume Grayscale has sRGB gamma + luma = 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2] + rgb = luma.unsqueeze(-1).repeat(1, 1, 1, 3) + rgb = srgb_to_linear(rgb) + + elif source_color_space == "HDR (Rec.2020)": + # assuming Linear Rec.2020 input. Convert to Linear Rec.709 + matrix = M_2020_to_709.to(device) + rgb = pq_to_linear(rgb) + rgb = torch.matmul(rgb, matrix.T) + + + # turn source into target space + if target_color_space == "sRGB": + rgb = linear_to_srgb(rgb) + + elif target_color_space == "Grayscale": + luma = 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2] + rgb = luma.unsqueeze(-1).repeat(1, 1, 1, 3) + rgb = linear_to_srgb(rgb) # reapply srgb gamma + + elif target_color_space == "HDR (Rec.2020)": + # convert Gamut from Linear Rec.709 to Linear Rec.2020 + rgb = torch.matmul(rgb, M_709_to_2020.to(device).T).clamp(min=0) + rgb = linear_to_pq(rgb) + + img_tensor = torch.cat([rgb, alpha], dim=-1) if has_alpha else rgb + + return IO.NodeOutput(images=img_tensor) + +class ConvertColorSpaceExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[IO.ComfyNode]]: + return [ + ConvertColorSpace + ] + + +async def comfy_entrypoint() -> ConvertColorSpaceExtension: + return ConvertColorSpaceExtension() diff --git a/comfy_extras/nodes_save_advanced.py b/comfy_extras/nodes_save_advanced.py index b0b4e501a..91ac68edc 100644 --- a/comfy_extras/nodes_save_advanced.py +++ b/comfy_extras/nodes_save_advanced.py @@ -285,103 +285,11 @@ class SaveImageAdvanced(IO.ComfyNode): return IO.NodeOutput(ui={"images": results}) -# Rec.709 to Rec.2020 Gamut Conversion Matrix -M_709_to_2020 = torch.tensor([[0.6274, 0.3293, 0.0433],[0.0691, 0.9195, 0.0114],[0.0164, 0.0880, 0.8956] -]) - -# Rec.2020 to Rec.709 Gamut Conversion Matrix -M_2020_to_709 = torch.tensor([[ 1.6605, -0.5876, -0.0728],[-0.1246, 1.1329, -0.0083],[-0.0182, -0.1006, 1.1187] -]) - -def srgb_to_linear(tensor): - mask = tensor <= 0.04045 - return torch.where(mask, tensor / 12.92, torch.pow((tensor + 0.055) / 1.055, 2.4)) - -def linear_to_srgb(tensor): - mask = tensor <= 0.0031308 - return torch.where(mask, tensor * 12.92, 1.055 * torch.pow(tensor.clamp(min=1e-8), 1.0 / 2.4) - 0.055) - -def linear_to_pq(linear_tensor): - """SMPTE ST 2084 (PQ) encoding""" - m1, m2 = (2610 / 4096 / 4), (2523 / 4096 * 128) - c1, c2, c3 = (3424 / 4096), (2413 / 4096 * 32), (2392 / 4096 * 32) - l_norm = torch.clamp(linear_tensor, 0.0, 1.0) - l_m1 = torch.pow(l_norm, m1) - return torch.pow((c1 + c2 * l_m1) / (1 + c3 * l_m1), m2) - -def pq_to_linear(pq_tensor): - """Inverse SMPTE ST 2084 (PQ) decoding""" - m1, m2 = (2610 / 4096 / 4), (2523 / 4096 * 128) - c1, c2, c3 = (3424 / 4096), (2413 / 4096 * 32), (2392 / 4096 * 32) - n = torch.pow(torch.clamp(pq_tensor, 0.0, 1.0), 1/m2) - return torch.pow(torch.clamp((n - c1) / (c2 - c3 * n), min=0.0), 1/m1) - -class ConvertColorSpace(IO.ComfyNode): - @classmethod - def define_schema(cls): - return IO.Schema( - node_id="Convert Color Space", - category="image/color", - inputs=[ - IO.Image.Input("images"), - IO.Combo.Input("source_color_space", options=["sRGB", "Linear", "HDR (Rec.2020)", "Grayscale"], default="sRGB"), - IO.Combo.Input("target_color_space", options=["sRGB", "Linear", "HDR (Rec.2020)", "Grayscale"], default="Linear"), - ], - outputs=[ - IO.Image.Output("images"), - ] - ) - - @classmethod - def execute(cls, images, source_color_space, target_color_space) -> IO.NodeOutput: - img_tensor = images.clone() - device = img_tensor.device - - has_alpha = img_tensor.shape[-1] == 4 - alpha = img_tensor[..., 3:4] if has_alpha else None - rgb = img_tensor[..., :3] - - # turn source into linear - if source_color_space == "sRGB": - rgb = srgb_to_linear(rgb) - - elif source_color_space == "Grayscale": - # assume Grayscale has sRGB gamma - luma = 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2] - rgb = luma.unsqueeze(-1).repeat(1, 1, 1, 3) - rgb = linear_to_srgb(rgb) - - elif source_color_space == "HDR (Rec.2020)": - # assuming Linear Rec.2020 input. Convert to Linear Rec.709 - matrix = M_2020_to_709.to(device) - rgb = pq_to_linear(rgb) - rgb = torch.matmul(rgb, matrix.T) - - - # turn source into target space - if target_color_space == "sRGB": - rgb = linear_to_srgb(rgb) - - elif target_color_space == "Grayscale": - luma = 0.2126 * rgb[..., 0] + 0.7152 * rgb[..., 1] + 0.0722 * rgb[..., 2] - rgb = luma.unsqueeze(-1).repeat(1, 1, 1, 3) - rgb = linear_to_srgb(rgb) # reapply srgb gamma - - elif target_color_space == "HDR (Rec.2020)": - # convert Gamut from Linear Rec.709 to Linear Rec.2020 - rgb = torch.matmul(rgb, M_709_to_2020.to(device).T).clamp(min=0) - rgb = linear_to_pq(rgb) - - img_tensor = torch.cat([rgb, alpha], dim=-1) if has_alpha else rgb - - return IO.NodeOutput(images=img_tensor) - class AdvancedImageSave(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: return [ SaveImageAdvanced, - ConvertColorSpace ] From 693919e787885be15e74938470c3d5d9ed341768 Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:09:44 +0300 Subject: [PATCH 09/28] moving to nodes_images --- comfy_extras/nodes_images.py | 280 ++++++++++++++++++++++++++ comfy_extras/nodes_save_advanced.py | 297 ---------------------------- 2 files changed, 280 insertions(+), 297 deletions(-) delete mode 100644 comfy_extras/nodes_save_advanced.py diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index a77f0641f..5c32305db 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -4,15 +4,23 @@ import nodes import folder_paths import json +import av import os import re import math import torch +import struct +import zlib +import tempfile +import logging import comfy.utils +import numpy as np +from fractions import Fraction from server import PromptServer from comfy_api.latest import ComfyExtension, IO, UI from typing_extensions import override +from comfy.cli_args import args SVG = IO.SVG.Type # TODO: temporary solution for backward compatibility, will be removed later. @@ -823,6 +831,277 @@ class ImageMergeTileList(IO.ComfyNode): return IO.NodeOutput(merged_image) +def create_png_chunk(chunk_type: bytes, data: bytes) -> bytes: + """Creates a valid PNG chunk with Length, Type, Data, and CRC32.""" + chunk = struct.pack('>I', len(data)) + chunk_type + data + crc = zlib.crc32(chunk_type + data) & 0xffffffff + return chunk + struct.pack('>I', crc) + +def inject_comfy_metadata_png(png_bytes, prompt=None, extra_pnginfo=None): + # IEND chunk is the last 12 bytes of png files + content = png_bytes[:-12] + iend = png_bytes[-12:] + + metadata_chunks = b"" + + if prompt is not None: + payload = b'prompt\x00' + json.dumps(prompt).encode('utf-8') + metadata_chunks += create_png_chunk(b'tEXt', payload) + + if extra_pnginfo is not None: + for k, v in extra_pnginfo.items(): + payload = k.encode('utf-8') + b'\x00' + json.dumps(v).encode('utf-8') + metadata_chunks += create_png_chunk(b'tEXt', payload) + + return content + metadata_chunks + iend + +def inject_comfy_metadata_exr(exr_bytes: bytes, prompt, extra_pnginfo) -> bytes: + # skip magic and version + idx = 8 + + # parse through existing attributes to find the end of the header + while True: + name_start = idx + while exr_bytes[idx] != 0: + idx += 1 + name = exr_bytes[name_start:idx] + idx += 1 + + # empty name means we hit the header terminator + if len(name) == 0: + break + + # skip attribute type string + while exr_bytes[idx] != 0: + idx += 1 + idx += 1 + + # read attribute size and skip the value + attr_size = struct.unpack(' bytes: + metadata = {} + if prompt is not None: + metadata["prompt"] = prompt + if extra_pnginfo is not None: + for k, v in extra_pnginfo.items(): + metadata[k] = v + + payload = json.dumps(metadata).encode('utf-8') + + # 16-byte uuid required by isobmff spec + # 'comfyui_workflow' is exactly 16 bytes long! + comfy_uuid = b'comfyui_workflow' + + # box size: 4 (size) + 4 (type) + 16 (uuid) + payload length + box_size = 4 + 4 + 16 + len(payload) + uuid_box = struct.pack('>I', box_size) + b'uuid' + comfy_uuid + payload + + # isobmff allows top-level boxes at the end of the file. + return avif_bytes + uuid_box + +class SaveImageAdvanced(IO.ComfyNode): + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="SaveImageAdvanced", + category="image/advanced_io", + output_node=True, + inputs=[ + IO.Image.Input("images"), + IO.String.Input("filename_prefix", default="ComfyUI"), + IO.Combo.Input("file_format", options=["png", "exr", "avif"], default="png"), + IO.Combo.Input("bit_depth", options=["8-bit", "16-bit", "32-bit"], default="8-bit"), + IO.Boolean.Input("embed_workflow", default=True), + IO.Hidden.Input("prompt", type="PROMPT"), + IO.Hidden.Input("extra_pnginfo", type="EXTRA_PNGINFO"), + ], + outputs=[] + ) + + @classmethod + def execute(cls, images, filename_prefix="ComfyUI", file_format="png", bit_depth="8-bit", + embed_workflow=True, prompt=None, extra_pnginfo=None) -> IO.NodeOutput: + + output_dir = folder_paths.get_output_directory() + + full_output_folder, filename, counter, subfolder, filename_prefix = \ + folder_paths.get_save_image_path(filename_prefix, output_dir, images[0].shape[1], images[0].shape[0]) + + results = list() + + for batch_number, image in enumerate(images): + img_tensor = image.clone() + + height, width, num_channels = img_tensor.shape + has_alpha = (num_channels == 4) + + # file pathing + filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) + file = f"{filename_with_batch_num}_{counter:05}.{file_format}" + file_path = os.path.join(full_output_folder, file) + + if file_format in ["png", "exr", "avif"]: + + # safe bit downcasting + if (file_format == "png" or file_format == "avif") and bit_depth == "32-bit": + bit_depth = "16-bit" + if file_format == "exr" and bit_depth == "8-bit": + bit_depth = "16-bit" + + if bit_depth == "32-bit": + img_np = img_tensor.cpu().numpy().astype(np.float32) + av_fmt = 'gbrapf32le' if has_alpha else 'gbrpf32le' + elif bit_depth == "16-bit": + if file_format == "exr": + # default pyav build doesn't come with a codec for float16 exr format + img_np = img_tensor.cpu().numpy().astype(np.float32) + av_fmt = 'gbrapf32le' if has_alpha else 'gbrpf32le' + else: + img_np = (img_tensor * 65535.0).clamp(0, 65535).to(torch.int32).cpu().numpy().astype(np.uint16) + av_fmt = 'rgba64le' if has_alpha else 'rgb48le' + else: + img_np = (img_tensor * 255.0).clamp(0, 255).to(torch.int32).cpu().numpy().astype(np.uint8) + av_fmt = 'rgba' if has_alpha else 'rgb24' + + fd, tmp_path = tempfile.mkstemp(suffix=f".{file_format}") + os.close(fd) + container_format = "image2" if file_format in ["png", "exr"] else "avif" + container = av.open(tmp_path, mode='w', format=container_format) + + if file_format == "exr": + stream = container.add_stream('exr', rate=1) + stream.pix_fmt = av_fmt + + elif file_format == "avif": + try: + stream = container.add_stream('libsvtav1', rate=1) + except Exception: + stream = container.add_stream('av1', rate=1) + + stream.time_base = Fraction(1, 1) + + if bit_depth in ["16-bit", "32-bit"]: + stream.pix_fmt = 'yuv420p10le' + else: + stream.pix_fmt = 'yuv420p' + + stream.codec_context.color_range = 2 + stream.codec_context.colorspace = 1 + stream.codec_context.color_primaries = 1 + stream.codec_context.color_trc = 1 + + stream.options = { + 'preset': '10', + 'svtav1-params': 'rc=0:qp=20:color-range=1:color-matrix=1:enable-overlays=1', + 'g': '1' + } + + elif file_format == "png": + stream = container.add_stream('png', rate=1) + if bit_depth == "16-bit": + stream.pix_fmt = 'rgba64be' if has_alpha else 'rgb48be' + else: + stream.pix_fmt = av_fmt + + stream.width = width + stream.height = height + stream.time_base = Fraction(1, 1) + + is_planar = av_fmt.startswith('gbrp') or 'p' in av_fmt.split('rgba')[-1] + if is_planar: + if av_fmt.startswith('gbrp'): + img_np = img_np[:, :, [1, 2, 0, 3]] if has_alpha else img_np[:, :, [1, 2, 0]] + img_np = img_np.transpose(2, 0, 1) + + try: + frame = av.VideoFrame.from_ndarray(img_np, format=av_fmt) + except ValueError: + logging.warning("[WARNING] Current FFMPEG Binary can't save natively. Fallbacking.") + img_np = (img_tensor * 65535.0).clamp(0, 65535).to(torch.int32).cpu().numpy().astype(np.uint16) + av_fmt = 'rgba64le' if has_alpha else 'rgb48le' + frame = av.VideoFrame.from_ndarray(img_np, format=av_fmt) + + # reformat for both avif and exr to ensure correct internal conversion + if file_format in ["avif", "exr"] or (file_format == "png" and bit_depth == "16-bit"): + reformat_kwargs = {"format": stream.pix_fmt} + if file_format == "avif": + reformat_kwargs.update({ + "src_colorspace": 1, "dst_colorspace": 1, + "src_color_range": 2, "dst_color_range": 2 + }) + frame = frame.reformat(**reformat_kwargs) + frame.pts = 0 + frame.time_base = stream.time_base + if file_format == "avif": + frame.color_range = 2 + frame.colorspace = 1 + + for packet in stream.encode(frame): + container.mux(packet) + for packet in stream.encode(): + container.mux(packet) + + container.close() + + with open(tmp_path, "rb") as f: + final_bytes = f.read() + os.remove(tmp_path) + + if embed_workflow and not args.disable_metadata: + if file_format == "png": + final_bytes = inject_comfy_metadata_png(final_bytes, prompt, extra_pnginfo) + elif file_format == "exr": + final_bytes = inject_comfy_metadata_exr(final_bytes, prompt, extra_pnginfo) + else: + final_bytes = inject_comfy_metadata_avif(final_bytes, prompt, extra_pnginfo) + + with open(file_path, "wb") as f: + f.write(final_bytes) + + results.append({ + "filename": file, + "subfolder": subfolder, + "type": "output" + }) + counter += 1 + + return IO.NodeOutput(ui={"images": results}) + class ImagesExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: @@ -844,6 +1123,7 @@ class ImagesExtension(ComfyExtension): ImageScaleToMaxDimension, SplitImageToTileList, ImageMergeTileList, + SaveImageAdvanced ] diff --git a/comfy_extras/nodes_save_advanced.py b/comfy_extras/nodes_save_advanced.py deleted file mode 100644 index 91ac68edc..000000000 --- a/comfy_extras/nodes_save_advanced.py +++ /dev/null @@ -1,297 +0,0 @@ -import av -import os -import json -import torch -import struct -import zlib -import logging -import numpy as np -import tempfile -import folder_paths -from comfy_api.latest import IO -from typing_extensions import override -from comfy_api.latest import ComfyExtension -from comfy.cli_args import args -from fractions import Fraction - -def create_png_chunk(chunk_type: bytes, data: bytes) -> bytes: - """Creates a valid PNG chunk with Length, Type, Data, and CRC32.""" - chunk = struct.pack('>I', len(data)) + chunk_type + data - crc = zlib.crc32(chunk_type + data) & 0xffffffff - return chunk + struct.pack('>I', crc) - -def inject_comfy_metadata_png(png_bytes, prompt=None, extra_pnginfo=None): - # IEND chunk is the last 12 bytes of png files - content = png_bytes[:-12] - iend = png_bytes[-12:] - - metadata_chunks = b"" - - if prompt is not None: - payload = b'prompt\x00' + json.dumps(prompt).encode('utf-8') - metadata_chunks += create_png_chunk(b'tEXt', payload) - - if extra_pnginfo is not None: - for k, v in extra_pnginfo.items(): - payload = k.encode('utf-8') + b'\x00' + json.dumps(v).encode('utf-8') - metadata_chunks += create_png_chunk(b'tEXt', payload) - - return content + metadata_chunks + iend - -def inject_comfy_metadata_exr(exr_bytes: bytes, prompt, extra_pnginfo) -> bytes: - # skip magic and version - idx = 8 - - # parse through existing attributes to find the end of the header - while True: - name_start = idx - while exr_bytes[idx] != 0: - idx += 1 - name = exr_bytes[name_start:idx] - idx += 1 - - # empty name means we hit the header terminator - if len(name) == 0: - break - - # skip attribute type string - while exr_bytes[idx] != 0: - idx += 1 - idx += 1 - - # read attribute size and skip the value - attr_size = struct.unpack(' bytes: - metadata = {} - if prompt is not None: - metadata["prompt"] = prompt - if extra_pnginfo is not None: - for k, v in extra_pnginfo.items(): - metadata[k] = v - - payload = json.dumps(metadata).encode('utf-8') - - # 16-byte uuid required by isobmff spec - # 'comfyui_workflow' is exactly 16 bytes long! - comfy_uuid = b'comfyui_workflow' - - # box size: 4 (size) + 4 (type) + 16 (uuid) + payload length - box_size = 4 + 4 + 16 + len(payload) - uuid_box = struct.pack('>I', box_size) + b'uuid' + comfy_uuid + payload - - # isobmff allows top-level boxes at the end of the file. - return avif_bytes + uuid_box - -class SaveImageAdvanced(IO.ComfyNode): - @classmethod - def define_schema(cls): - return IO.Schema( - node_id="SaveImageAdvanced", - category="image/advanced_io", - output_node=True, - inputs=[ - IO.Image.Input("images"), - IO.String.Input("filename_prefix", default="ComfyUI"), - IO.Combo.Input("file_format", options=["png", "exr", "avif"], default="png"), - IO.Combo.Input("bit_depth", options=["8-bit", "16-bit", "32-bit"], default="8-bit"), - IO.Boolean.Input("embed_workflow", default=True), - IO.Hidden.Input("prompt", type="PROMPT"), - IO.Hidden.Input("extra_pnginfo", type="EXTRA_PNGINFO"), - ], - outputs=[] - ) - - @classmethod - def execute(cls, images, filename_prefix="ComfyUI", file_format="png", bit_depth="8-bit", - embed_workflow=True, prompt=None, extra_pnginfo=None) -> IO.NodeOutput: - - output_dir = folder_paths.get_output_directory() - - full_output_folder, filename, counter, subfolder, filename_prefix = \ - folder_paths.get_save_image_path(filename_prefix, output_dir, images[0].shape[1], images[0].shape[0]) - - results = list() - - for batch_number, image in enumerate(images): - img_tensor = image.clone() - - height, width, num_channels = img_tensor.shape - has_alpha = (num_channels == 4) - - # file pathing - filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) - file = f"{filename_with_batch_num}_{counter:05}.{file_format}" - file_path = os.path.join(full_output_folder, file) - - if file_format in ["png", "exr", "avif"]: - - # safe bit downcasting - if (file_format == "png" or file_format == "avif") and bit_depth == "32-bit": - bit_depth = "16-bit" - if file_format == "exr" and bit_depth == "8-bit": - bit_depth = "16-bit" - - if bit_depth == "32-bit": - img_np = img_tensor.cpu().numpy().astype(np.float32) - av_fmt = 'gbrapf32le' if has_alpha else 'gbrpf32le' - elif bit_depth == "16-bit": - if file_format == "exr": - # default pyav build doesn't come with a codec for float16 exr format - img_np = img_tensor.cpu().numpy().astype(np.float32) - av_fmt = 'gbrapf32le' if has_alpha else 'gbrpf32le' - else: - img_np = (img_tensor * 65535.0).clamp(0, 65535).to(torch.int32).cpu().numpy().astype(np.uint16) - av_fmt = 'rgba64le' if has_alpha else 'rgb48le' - else: - img_np = (img_tensor * 255.0).clamp(0, 255).to(torch.int32).cpu().numpy().astype(np.uint8) - av_fmt = 'rgba' if has_alpha else 'rgb24' - - fd, tmp_path = tempfile.mkstemp(suffix=f".{file_format}") - os.close(fd) - container_format = "image2" if file_format in ["png", "exr"] else "avif" - container = av.open(tmp_path, mode='w', format=container_format) - - if file_format == "exr": - stream = container.add_stream('exr', rate=1) - stream.pix_fmt = av_fmt - - elif file_format == "avif": - try: - stream = container.add_stream('libsvtav1', rate=1) - except Exception: - stream = container.add_stream('av1', rate=1) - - stream.time_base = Fraction(1, 1) - - if bit_depth in ["16-bit", "32-bit"]: - stream.pix_fmt = 'yuv420p10le' - else: - stream.pix_fmt = 'yuv420p' - - stream.codec_context.color_range = 2 - stream.codec_context.colorspace = 1 - stream.codec_context.color_primaries = 1 - stream.codec_context.color_trc = 1 - - stream.options = { - 'preset': '10', - 'svtav1-params': 'rc=0:qp=20:color-range=1:color-matrix=1:enable-overlays=1', - 'g': '1' - } - - elif file_format == "png": - stream = container.add_stream('png', rate=1) - if bit_depth == "16-bit": - stream.pix_fmt = 'rgba64be' if has_alpha else 'rgb48be' - else: - stream.pix_fmt = av_fmt - - stream.width = width - stream.height = height - stream.time_base = Fraction(1, 1) - - is_planar = av_fmt.startswith('gbrp') or 'p' in av_fmt.split('rgba')[-1] - if is_planar: - if av_fmt.startswith('gbrp'): - img_np = img_np[:, :, [1, 2, 0, 3]] if has_alpha else img_np[:, :, [1, 2, 0]] - img_np = img_np.transpose(2, 0, 1) - - try: - frame = av.VideoFrame.from_ndarray(img_np, format=av_fmt) - except ValueError: - logging.warning("[WARNING] Current FFMPEG Binary can't save natively. Fallbacking.") - img_np = (img_tensor * 65535.0).clamp(0, 65535).to(torch.int32).cpu().numpy().astype(np.uint16) - av_fmt = 'rgba64le' if has_alpha else 'rgb48le' - frame = av.VideoFrame.from_ndarray(img_np, format=av_fmt) - - # reformat for both avif and exr to ensure correct internal conversion - if file_format in ["avif", "exr"] or (file_format == "png" and bit_depth == "16-bit"): - reformat_kwargs = {"format": stream.pix_fmt} - if file_format == "avif": - reformat_kwargs.update({ - "src_colorspace": 1, "dst_colorspace": 1, - "src_color_range": 2, "dst_color_range": 2 - }) - frame = frame.reformat(**reformat_kwargs) - frame.pts = 0 - frame.time_base = stream.time_base - if file_format == "avif": - frame.color_range = 2 - frame.colorspace = 1 - - for packet in stream.encode(frame): - container.mux(packet) - for packet in stream.encode(): - container.mux(packet) - - container.close() - - with open(tmp_path, "rb") as f: - final_bytes = f.read() - os.remove(tmp_path) - - if embed_workflow and not args.disable_metadata: - if file_format == "png": - final_bytes = inject_comfy_metadata_png(final_bytes, prompt, extra_pnginfo) - elif file_format == "exr": - final_bytes = inject_comfy_metadata_exr(final_bytes, prompt, extra_pnginfo) - else: - final_bytes = inject_comfy_metadata_avif(final_bytes, prompt, extra_pnginfo) - - with open(file_path, "wb") as f: - f.write(final_bytes) - - results.append({ - "filename": file, - "subfolder": subfolder, - "type": "output" - }) - counter += 1 - - return IO.NodeOutput(ui={"images": results}) - -class AdvancedImageSave(ComfyExtension): - @override - async def get_node_list(self) -> list[type[IO.ComfyNode]]: - return [ - SaveImageAdvanced, - ] - - -async def comfy_entrypoint() -> AdvancedImageSave: - return AdvancedImageSave() From cde0936e423cd5eee15702d7c8c7f293d5f6a3d9 Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:06:40 +0300 Subject: [PATCH 10/28] interept_as as combo --- comfy_extras/nodes_images.py | 51 +++++++++--------------------------- 1 file changed, 13 insertions(+), 38 deletions(-) diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index 5495ad73f..b7ad14319 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -963,11 +963,17 @@ class SaveImageAdvanced(IO.ComfyNode): 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.", ), + IO.Combo.Input( + "interept_as", + options=["Raw/Data", "sRGB"], + default="sRGB", + advanced=True, + ), IO.DynamicCombo.Input( "format", options=[ IO.DynamicCombo.Option( - "png", + "png", [ IO.Combo.Input( "bit_depth", @@ -975,12 +981,6 @@ class SaveImageAdvanced(IO.ComfyNode): default="8-bit", advanced=True, ), - IO.Combo.Input( - "color_space", - options=["Raw/Data", "sRGB"], - default="sRGB", - advanced=True, - ), ], ), IO.DynamicCombo.Option( @@ -992,12 +992,6 @@ class SaveImageAdvanced(IO.ComfyNode): default="8-bit", advanced=True, ), - IO.Combo.Input( - "color_space", - options=["sRGB"], - default="sRGB", - advanced=True, - ), ], ), IO.DynamicCombo.Option( @@ -1005,14 +999,8 @@ class SaveImageAdvanced(IO.ComfyNode): [ IO.Combo.Input( "bit_depth", - options=["16-bit (half-float)", "32-bit"], - default="16-bit (half-float)", - advanced=True, - ), - IO.Combo.Input( - "color_space", - options=["Linear", "Raw/Data"], - default="Linear", + options=["16-bit", "32-bit"], + default="16-bit", advanced=True, ), ], @@ -1027,22 +1015,15 @@ class SaveImageAdvanced(IO.ComfyNode): ) @classmethod - def execute( - cls, - images: Input.Image, - filename_prefix: str, - format: dict, - embed_workflow: bool, - prompt=None, - extra_pnginfo=None - ) -> IO.NodeOutput: + def execute(cls, images, filename_prefix: str, format: dict, embed_workflow: bool, prompt=None, extra_pnginfo=None) -> IO.NodeOutput: output_dir = folder_paths.get_output_directory() - full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, output_dir, images[0].shape[1], images[0].shape[0]) + full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.\ + get_save_image_path(filename_prefix, output_dir, images[0].shape[1], images[0].shape[0]) results = list() for batch_number, image in enumerate(images): # get widget values from dynamic combo - extension = format["format"] + file_format = format["format"] bit_depth = format["bit_depth"] color_space = format["color_space"] @@ -1059,12 +1040,6 @@ class SaveImageAdvanced(IO.ComfyNode): if file_format in ["png", "exr", "avif"]: - # safe bit downcasting - if (file_format == "png" or file_format == "avif") and bit_depth == "32-bit": - bit_depth = "16-bit" - if file_format == "exr" and bit_depth == "8-bit": - bit_depth = "16-bit" - if bit_depth == "32-bit": img_np = img_tensor.cpu().numpy().astype(np.float32) av_fmt = 'gbrapf32le' if has_alpha else 'gbrpf32le' From 8e3396c0353277925c7b76cbbf79b6ef5ebba010 Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:32:48 +0300 Subject: [PATCH 11/28] renaming --- comfy_extras/nodes_convert_color_space.py | 8 +++---- comfy_extras/nodes_images.py | 26 +++++++++++++++++------ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/comfy_extras/nodes_convert_color_space.py b/comfy_extras/nodes_convert_color_space.py index 3f8a23718..f8c7b5568 100644 --- a/comfy_extras/nodes_convert_color_space.py +++ b/comfy_extras/nodes_convert_color_space.py @@ -42,8 +42,8 @@ class ConvertColorSpace(IO.ComfyNode): category="image/color", inputs=[ IO.Image.Input("images"), - IO.Combo.Input("source_color_space", options=["sRGB", "Linear", "HDR (Rec.2020)", "Grayscale"], default="sRGB"), - IO.Combo.Input("target_color_space", options=["sRGB", "Linear", "HDR (Rec.2020)", "Grayscale"], default="Linear"), + IO.Combo.Input("source_color_space", options=["sRGB", "Linear", "HDR Display (PQ/Rec.2020)", "Grayscale"], default="sRGB"), + IO.Combo.Input("target_color_space", options=["sRGB", "Linear", "HDR Display (PQ/Rec.2020)", "Grayscale"], default="Linear"), ], outputs=[ IO.Image.Output("images"), @@ -69,7 +69,7 @@ class ConvertColorSpace(IO.ComfyNode): rgb = luma.unsqueeze(-1).repeat(1, 1, 1, 3) rgb = srgb_to_linear(rgb) - elif source_color_space == "HDR (Rec.2020)": + elif source_color_space == "HDR Display (PQ/Rec.2020)": # assuming Linear Rec.2020 input. Convert to Linear Rec.709 matrix = M_2020_to_709.to(device) rgb = pq_to_linear(rgb) @@ -85,7 +85,7 @@ class ConvertColorSpace(IO.ComfyNode): rgb = luma.unsqueeze(-1).repeat(1, 1, 1, 3) rgb = linear_to_srgb(rgb) # reapply srgb gamma - elif target_color_space == "HDR (Rec.2020)": + elif target_color_space == "HDR Display (PQ/Rec.2020)": # convert Gamut from Linear Rec.709 to Linear Rec.2020 rgb = torch.matmul(rgb, M_709_to_2020.to(device).T).clamp(min=0) rgb = linear_to_pq(rgb) diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index b7ad14319..373e54e89 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -963,12 +963,6 @@ class SaveImageAdvanced(IO.ComfyNode): 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.", ), - IO.Combo.Input( - "interept_as", - options=["Raw/Data", "sRGB"], - default="sRGB", - advanced=True, - ), IO.DynamicCombo.Input( "format", options=[ @@ -981,6 +975,12 @@ class SaveImageAdvanced(IO.ComfyNode): default="8-bit", advanced=True, ), + IO.Combo.Input( + "interept_as", + options=["sRGB", "Linear", "Raw/Data"], + default="sRGB", + advanced=True, + ), ], ), IO.DynamicCombo.Option( @@ -992,6 +992,12 @@ class SaveImageAdvanced(IO.ComfyNode): default="8-bit", advanced=True, ), + IO.Combo.Input( + "interept_as", + options=["sRGB", "Raw/Data"], + default="sRGB", + advanced=True, + ), ], ), IO.DynamicCombo.Option( @@ -1003,6 +1009,12 @@ class SaveImageAdvanced(IO.ComfyNode): default="16-bit", advanced=True, ), + IO.Combo.Input( + "interept_as", + options=["Linear", "Raw/Data"], + default="Linear", + advanced=True, + ), ], ), ], @@ -1025,7 +1037,7 @@ class SaveImageAdvanced(IO.ComfyNode): # get widget values from dynamic combo file_format = format["format"] bit_depth = format["bit_depth"] - color_space = format["color_space"] + interept_as = format["interept_as"] img_tensor = image.clone() From e3e26fbdb037a7a3272eb466b85e4021d543f342 Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:35:00 +0300 Subject: [PATCH 12/28] . --- comfy_extras/nodes_convert_color_space.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_extras/nodes_convert_color_space.py b/comfy_extras/nodes_convert_color_space.py index f8c7b5568..92f8cde91 100644 --- a/comfy_extras/nodes_convert_color_space.py +++ b/comfy_extras/nodes_convert_color_space.py @@ -92,7 +92,7 @@ class ConvertColorSpace(IO.ComfyNode): img_tensor = torch.cat([rgb, alpha], dim=-1) if has_alpha else rgb - return IO.NodeOutput(images=img_tensor) + return IO.NodeOutput(img_tensor) class ConvertColorSpaceExtension(ComfyExtension): From 4b51c8f774ab0591e2facffd9edfe66269c60c60 Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:35:33 +0300 Subject: [PATCH 13/28] imports --- comfy_extras/nodes_images.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index 373e54e89..8890066e8 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -4,10 +4,8 @@ import nodes import folder_paths import av -import io import json -import av import os import re import math @@ -15,19 +13,16 @@ import numpy as np import struct import torch -import struct import zlib import tempfile import logging import comfy.utils -import numpy as np from fractions import Fraction from server import PromptServer -from comfy_api.latest import ComfyExtension, Input, IO, UI +from comfy_api.latest import ComfyExtension, IO, UI from comfy.cli_args import args from typing_extensions import override -from comfy.cli_args import args SVG = IO.SVG.Type # TODO: temporary solution for backward compatibility, will be removed later. From 1e8fc2f1a820ace53fdefb8224e112e3aedba6fa Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:06:03 +0300 Subject: [PATCH 14/28] .. --- comfy_extras/nodes_images.py | 8 ++++---- nodes.py | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index 8890066e8..84d15639b 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -971,7 +971,7 @@ class SaveImageAdvanced(IO.ComfyNode): advanced=True, ), IO.Combo.Input( - "interept_as", + "interpret_as", options=["sRGB", "Linear", "Raw/Data"], default="sRGB", advanced=True, @@ -988,7 +988,7 @@ class SaveImageAdvanced(IO.ComfyNode): advanced=True, ), IO.Combo.Input( - "interept_as", + "interpret_as", options=["sRGB", "Raw/Data"], default="sRGB", advanced=True, @@ -1005,7 +1005,7 @@ class SaveImageAdvanced(IO.ComfyNode): advanced=True, ), IO.Combo.Input( - "interept_as", + "interpret_as", options=["Linear", "Raw/Data"], default="Linear", advanced=True, @@ -1032,7 +1032,7 @@ class SaveImageAdvanced(IO.ComfyNode): # get widget values from dynamic combo file_format = format["format"] bit_depth = format["bit_depth"] - interept_as = format["interept_as"] + interpret_as = format["interpret_as"] img_tensor = image.clone() diff --git a/nodes.py b/nodes.py index 2c50d3021..574aca44d 100644 --- a/nodes.py +++ b/nodes.py @@ -2463,7 +2463,6 @@ async def init_builtin_extra_nodes(): "nodes_painter.py", "nodes_curve.py", "nodes_rtdetr.py", - "nodes_save_advanced.py", "nodes_frame_interpolation.py", "nodes_sam3.py" ] From 5cca14c7982acb301ef34e20a934eda93ddc7c9d Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:07:35 +0300 Subject: [PATCH 15/28] quick fix for alpha --- comfy_extras/nodes_images.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index 84d15639b..bb8d31db4 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -1108,7 +1108,7 @@ class SaveImageAdvanced(IO.ComfyNode): is_planar = av_fmt.startswith('gbrp') or 'p' in av_fmt.split('rgba')[-1] if is_planar: - if av_fmt.startswith('gbrp'): + if av_fmt.startswith('gbr'): img_np = img_np[:, :, [1, 2, 0, 3]] if has_alpha else img_np[:, :, [1, 2, 0]] img_np = img_np.transpose(2, 0, 1) From c77b36d98fcd8d8ad92340bb155275dcfeae7727 Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:23:28 +0300 Subject: [PATCH 16/28] add interpret_as --- comfy_extras/nodes_images.py | 40 +++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index bb8d31db4..bc1ee6525 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -983,13 +983,13 @@ class SaveImageAdvanced(IO.ComfyNode): [ IO.Combo.Input( "bit_depth", - options=["8-bit", "10-bit", "12-bit"], + options=["8-bit", "10-bit"], default="8-bit", advanced=True, ), IO.Combo.Input( "interpret_as", - options=["sRGB", "Raw/Data"], + options=["sRGB", "Linear", "Raw/Data"], default="sRGB", advanced=True, ), @@ -1000,13 +1000,13 @@ class SaveImageAdvanced(IO.ComfyNode): [ IO.Combo.Input( "bit_depth", - options=["16-bit", "32-bit"], - default="16-bit", + options=["32-bit"], + default="32-bit", advanced=True, ), IO.Combo.Input( "interpret_as", - options=["Linear", "Raw/Data"], + options=["sRGB", "Linear", "Raw/Data"], default="Linear", advanced=True, ), @@ -1050,14 +1050,9 @@ class SaveImageAdvanced(IO.ComfyNode): if bit_depth == "32-bit": img_np = img_tensor.cpu().numpy().astype(np.float32) av_fmt = 'gbrapf32le' if has_alpha else 'gbrpf32le' - elif bit_depth == "16-bit": - if file_format == "exr": - # default pyav build doesn't come with a codec for float16 exr format - img_np = img_tensor.cpu().numpy().astype(np.float32) - av_fmt = 'gbrapf32le' if has_alpha else 'gbrpf32le' - else: - img_np = (img_tensor * 65535.0).clamp(0, 65535).to(torch.int32).cpu().numpy().astype(np.uint16) - av_fmt = 'rgba64le' if has_alpha else 'rgb48le' + elif bit_depth in ["10-bit", "12-bit", "16-bit"]: + img_np = (img_tensor * 65535.0).clamp(0, 65535).to(torch.int32).cpu().numpy().astype(np.uint16) + av_fmt = 'rgba64le' if has_alpha else 'rgb48le' else: img_np = (img_tensor * 255.0).clamp(0, 255).to(torch.int32).cpu().numpy().astype(np.uint8) av_fmt = 'rgba' if has_alpha else 'rgb24' @@ -1079,15 +1074,26 @@ class SaveImageAdvanced(IO.ComfyNode): stream.time_base = Fraction(1, 1) - if bit_depth in ["16-bit", "32-bit"]: + if bit_depth == "12-bit": + stream.pix_fmt = 'yuv420p12le' + elif bit_depth in ["10-bit", "16-bit", "32-bit"]: stream.pix_fmt = 'yuv420p10le' else: stream.pix_fmt = 'yuv420p' stream.codec_context.color_range = 2 - stream.codec_context.colorspace = 1 - stream.codec_context.color_primaries = 1 - stream.codec_context.color_trc = 1 + if interpret_as == "Raw/Data": # 2 == unspecified + stream.codec_context.colorspace = 2 + stream.codec_context.color_primaries = 2 + stream.codec_context.color_trc = 2 + elif interpret_as == "Linear": + stream.codec_context.colorspace = 1 + stream.codec_context.color_primaries = 1 + stream.codec_context.color_trc = 8 + else: # sRGB + stream.codec_context.colorspace = 1 + stream.codec_context.color_primaries = 1 + stream.codec_context.color_trc = 13 stream.options = { 'preset': '10', From 7069f6a92fb58304487224ff01cb7e6a4ed20922 Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:36:01 +0300 Subject: [PATCH 17/28] transpose error for exr --- comfy_extras/nodes_images.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index bc1ee6525..5e7855e9a 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -1116,7 +1116,6 @@ class SaveImageAdvanced(IO.ComfyNode): if is_planar: if av_fmt.startswith('gbr'): img_np = img_np[:, :, [1, 2, 0, 3]] if has_alpha else img_np[:, :, [1, 2, 0]] - img_np = img_np.transpose(2, 0, 1) try: frame = av.VideoFrame.from_ndarray(img_np, format=av_fmt) @@ -1194,7 +1193,6 @@ class ImagesExtension(ComfyExtension): ImageScaleToMaxDimension, SplitImageToTileList, ImageMergeTileList, - SaveImageAdvanced ] From 6620c1898b4be71deda8a91a572705eb3b1311ef Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:11:36 +0300 Subject: [PATCH 18/28] .. --- comfy_extras/nodes_images.py | 10 +++------- nodes.py | 3 ++- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index 5e7855e9a..56352b57b 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -1042,13 +1042,14 @@ class SaveImageAdvanced(IO.ComfyNode): # file pathing filename_with_batch_num = filename.replace("%batch_num%", str(batch_number)) - file = f"{filename_with_batch_num}_{counter:05}.{file_format}" + file = f"{filename_with_batch_num}_{counter:05}_.{file_format}" file_path = os.path.join(full_output_folder, file) if file_format in ["png", "exr", "avif"]: if bit_depth == "32-bit": img_np = img_tensor.cpu().numpy().astype(np.float32) + img_np = img_np[:, :, [1, 2, 0, 3]] if has_alpha else img_np[:, :,[1, 2, 0]] av_fmt = 'gbrapf32le' if has_alpha else 'gbrpf32le' elif bit_depth in ["10-bit", "12-bit", "16-bit"]: img_np = (img_tensor * 65535.0).clamp(0, 65535).to(torch.int32).cpu().numpy().astype(np.uint16) @@ -1112,11 +1113,6 @@ class SaveImageAdvanced(IO.ComfyNode): stream.height = height stream.time_base = Fraction(1, 1) - is_planar = av_fmt.startswith('gbrp') or 'p' in av_fmt.split('rgba')[-1] - if is_planar: - if av_fmt.startswith('gbr'): - img_np = img_np[:, :, [1, 2, 0, 3]] if has_alpha else img_np[:, :, [1, 2, 0]] - try: frame = av.VideoFrame.from_ndarray(img_np, format=av_fmt) except ValueError: @@ -1138,7 +1134,7 @@ class SaveImageAdvanced(IO.ComfyNode): frame.time_base = stream.time_base if file_format == "avif": frame.color_range = 2 - frame.colorspace = 1 + frame.colorspace = stream.codec_context.colorspace for packet in stream.encode(frame): container.mux(packet) diff --git a/nodes.py b/nodes.py index 574aca44d..5f8f618ff 100644 --- a/nodes.py +++ b/nodes.py @@ -2464,7 +2464,8 @@ async def init_builtin_extra_nodes(): "nodes_curve.py", "nodes_rtdetr.py", "nodes_frame_interpolation.py", - "nodes_sam3.py" + "nodes_sam3.py", + "nodes_convert_color_space.py", ] import_failed = [] From 5648a89f9488759d3e60ce39ece12af52b6ba17e Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:15:09 +0300 Subject: [PATCH 19/28] exr fix --- comfy_extras/nodes_images.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index 56352b57b..de783e00c 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -1113,6 +1113,12 @@ class SaveImageAdvanced(IO.ComfyNode): stream.height = height stream.time_base = Fraction(1, 1) + is_planar = av_fmt.startswith('gbrp') or 'p' in av_fmt.split('rgba')[-1] + if is_planar: + if av_fmt.startswith('gbr'): + img_np = img_np[:, :, [1, 2, 0, 3]] if has_alpha else img_np[:, :, [1, 2, 0]] + img_np = img_np.transpose(2, 0, 1) + try: frame = av.VideoFrame.from_ndarray(img_np, format=av_fmt) except ValueError: From 87514354a557505d606a1f8329fdd7cda49692b8 Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:43:53 +0300 Subject: [PATCH 20/28] ... --- comfy_extras/nodes_convert_color_space.py | 4 ++-- comfy_extras/nodes_images.py | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/comfy_extras/nodes_convert_color_space.py b/comfy_extras/nodes_convert_color_space.py index 92f8cde91..45d940108 100644 --- a/comfy_extras/nodes_convert_color_space.py +++ b/comfy_extras/nodes_convert_color_space.py @@ -71,7 +71,7 @@ class ConvertColorSpace(IO.ComfyNode): elif source_color_space == "HDR Display (PQ/Rec.2020)": # assuming Linear Rec.2020 input. Convert to Linear Rec.709 - matrix = M_2020_to_709.to(device) + matrix = M_2020_to_709.to(device=device, dtype=rgb.dtype) rgb = pq_to_linear(rgb) rgb = torch.matmul(rgb, matrix.T) @@ -87,7 +87,7 @@ class ConvertColorSpace(IO.ComfyNode): elif target_color_space == "HDR Display (PQ/Rec.2020)": # convert Gamut from Linear Rec.709 to Linear Rec.2020 - rgb = torch.matmul(rgb, M_709_to_2020.to(device).T).clamp(min=0) + rgb = torch.matmul(rgb, M_709_to_2020.to(device=device, dtype=rgb.dtype).T).clamp(min=0) rgb = linear_to_pq(rgb) img_tensor = torch.cat([rgb, alpha], dim=-1) if has_alpha else rgb diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index de783e00c..97f67846f 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -1068,10 +1068,7 @@ class SaveImageAdvanced(IO.ComfyNode): stream.pix_fmt = av_fmt elif file_format == "avif": - try: - stream = container.add_stream('libsvtav1', rate=1) - except Exception: - stream = container.add_stream('av1', rate=1) + stream = container.add_stream('libsvtav1', rate=1) stream.time_base = Fraction(1, 1) From 632771d988152be906dc31ddd33cae4f33778040 Mon Sep 17 00:00:00 2001 From: "Yousef R. Gamaleldin" <81116377+yousef-rafat@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:53:47 +0300 Subject: [PATCH 21/28] remove download Co-authored-by: Alexis Rolland --- comfy_extras/nodes_images.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index 97f67846f..a9ef91459 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -943,7 +943,7 @@ class SaveImageAdvanced(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="SaveImageAdvanced", - search_aliases=["save", "save image", "export image", "output image", "write image", "download"], + search_aliases=["save", "save image", "export image", "output image", "write image"], display_name="Save Image", description="Saves the input images to your ComfyUI output directory.", category="image", From e996b817cd2d82ade5dd381b19c41068bc4c3113 Mon Sep 17 00:00:00 2001 From: "Yousef R. Gamaleldin" <81116377+yousef-rafat@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:57:41 +0300 Subject: [PATCH 22/28] Update comfy_extras/nodes_convert_color_space.py Co-authored-by: Alexis Rolland --- comfy_extras/nodes_convert_color_space.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_extras/nodes_convert_color_space.py b/comfy_extras/nodes_convert_color_space.py index 45d940108..242b4ec2b 100644 --- a/comfy_extras/nodes_convert_color_space.py +++ b/comfy_extras/nodes_convert_color_space.py @@ -38,7 +38,7 @@ class ConvertColorSpace(IO.ComfyNode): @classmethod def define_schema(cls): return IO.Schema( - node_id="Convert Color Space", + node_id="ConvertColorSpace", category="image/color", inputs=[ IO.Image.Input("images"), From 15f993a036b7efe5ad0792546ccf5753b56d06eb Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:58:18 +0300 Subject: [PATCH 23/28] remove 12-bit --- comfy_extras/nodes_images.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index a9ef91459..c3aa807a4 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -1072,9 +1072,7 @@ class SaveImageAdvanced(IO.ComfyNode): stream.time_base = Fraction(1, 1) - if bit_depth == "12-bit": - stream.pix_fmt = 'yuv420p12le' - elif bit_depth in ["10-bit", "16-bit", "32-bit"]: + if bit_depth in ["10-bit", "16-bit", "32-bit"]: stream.pix_fmt = 'yuv420p10le' else: stream.pix_fmt = 'yuv420p' From 59075cf255fa607a9c6eca7bb777d92f38b58f03 Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:02:26 +0300 Subject: [PATCH 24/28] display_name --- comfy_extras/nodes_convert_color_space.py | 1 + 1 file changed, 1 insertion(+) diff --git a/comfy_extras/nodes_convert_color_space.py b/comfy_extras/nodes_convert_color_space.py index 242b4ec2b..d9d346c71 100644 --- a/comfy_extras/nodes_convert_color_space.py +++ b/comfy_extras/nodes_convert_color_space.py @@ -39,6 +39,7 @@ class ConvertColorSpace(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="ConvertColorSpace", + display_name="Convert Color Space", category="image/color", inputs=[ IO.Image.Input("images"), From f10bb1e7808199d6c796817093b6163e1b358c14 Mon Sep 17 00:00:00 2001 From: "Yousef R. Gamaleldin" <81116377+yousef-rafat@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:08:19 +0300 Subject: [PATCH 25/28] remove srgb from exr Co-authored-by: Alexis Rolland --- comfy_extras/nodes_images.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index c3aa807a4..5273f51b8 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -1006,7 +1006,7 @@ class SaveImageAdvanced(IO.ComfyNode): ), IO.Combo.Input( "interpret_as", - options=["sRGB", "Linear", "Raw/Data"], + options=["Linear", "Raw/Data"], default="Linear", advanced=True, ), From 0e3c8c07c3885f91c6420e2501abd4f367673eab Mon Sep 17 00:00:00 2001 From: "Yousef R. Gamaleldin" <81116377+yousef-rafat@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:12:02 +0300 Subject: [PATCH 26/28] remvoe linear and raw from avif Co-authored-by: Alexis Rolland --- comfy_extras/nodes_images.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index 5273f51b8..5c280c4f9 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -989,7 +989,7 @@ class SaveImageAdvanced(IO.ComfyNode): ), IO.Combo.Input( "interpret_as", - options=["sRGB", "Linear", "Raw/Data"], + options=["sRGB"], default="sRGB", advanced=True, ), From f6c6c4c2b7c1be723c896bd7d71f5dd8dfa2e5f2 Mon Sep 17 00:00:00 2001 From: "Yousef R. Gamaleldin" <81116377+yousef-rafat@users.noreply.github.com> Date: Fri, 1 May 2026 11:10:24 +0300 Subject: [PATCH 27/28] remove linear from png Co-authored-by: Alexis Rolland --- comfy_extras/nodes_images.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index 5c280c4f9..7e0216ef1 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -972,7 +972,7 @@ class SaveImageAdvanced(IO.ComfyNode): ), IO.Combo.Input( "interpret_as", - options=["sRGB", "Linear", "Raw/Data"], + options=["sRGB", "Raw/Data"], default="sRGB", advanced=True, ), From 88d7b1bcabd9c23645a79bd31bb14e96b247fa55 Mon Sep 17 00:00:00 2001 From: Yousef Rafat <81116377+yousef-rafat@users.noreply.github.com> Date: Fri, 1 May 2026 14:29:44 +0300 Subject: [PATCH 28/28] workflow embedded fix --- comfy_extras/nodes_images.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index 5c280c4f9..7dc53430b 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -1022,12 +1022,15 @@ class SaveImageAdvanced(IO.ComfyNode): ) @classmethod - def execute(cls, images, filename_prefix: str, format: dict, embed_workflow: bool, prompt=None, extra_pnginfo=None) -> IO.NodeOutput: + def execute(cls, images, filename_prefix: str, format: dict, embed_workflow: bool) -> IO.NodeOutput: output_dir = folder_paths.get_output_directory() full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.\ get_save_image_path(filename_prefix, output_dir, images[0].shape[1], images[0].shape[0]) results = list() + prompt = cls.hidden.prompt + extra_pnginfo = cls.hidden.extra_pnginfo + for batch_number, image in enumerate(images): # get widget values from dynamic combo file_format = format["format"]