From acfc08c2391fabe40ebb2045a42ba91c6756b78b Mon Sep 17 00:00:00 2001 From: Conor-Collins Date: Tue, 17 Jun 2025 15:17:52 -0700 Subject: [PATCH] Completely rewrote the saver node to address a few bugs and clean up the code --- __init__.py | 6 +- js/saver.js | 163 +++++++++++++----- modules/saver.py | 435 +++++++++++++++++++++-------------------------- node.zip | Bin 28739 -> 36964 bytes pyproject.toml | 2 +- 5 files changed, 316 insertions(+), 290 deletions(-) diff --git a/__init__.py b/__init__.py index 9091b1a..bf6e72e 100644 --- a/__init__.py +++ b/__init__.py @@ -29,9 +29,9 @@ NODE_CLASS_MAPPINGS.update({ # Add display names for better UI presentation NODE_DISPLAY_NAME_MAPPINGS.update({ - "ImageLoader": "Image Loader", - "LoadExr": "Load EXR", - "SaverNode": "Save Image", + "ImageLoader": "coco Loader", + "LoadExr": "coco Load EXR", + "SaverNode": "coco Saver", "LoadExrLayerByName": "Load EXR Layer by Name", "CryptomatteLayer": "Cryptomatte Layer", "ColorspaceNode": "Colorspace", diff --git a/js/saver.js b/js/saver.js index d60d955..e7eb87a 100644 --- a/js/saver.js +++ b/js/saver.js @@ -3,8 +3,8 @@ import { app } from "../../../scripts/app.js"; app.registerExtension({ name: "Coco.Saver", async beforeRegisterNodeDef(nodeType, nodeData) { - // IMPORTANT: This must match the key in NODE_CLASS_MAPPINGS - if (nodeData.name !== "SaverNode") { + // CRITICAL: Must match NODE_CLASS_MAPPINGS key + if (nodeData.name !== "SaverNode") { return; } @@ -14,68 +14,141 @@ app.registerExtension({ nodeType.prototype.onNodeCreated = function() { const me = onNodeCreated?.apply(this); - console.log("Saver node created, setting up dynamic widgets"); + // Format specifications matching Python + const FORMAT_SPECS = { + "exr": { + depths: ["16", "32"], // EXR only supports half and full float + showWidgets: ["exr_compression", "bit_depth", "save_as_grayscale"] + }, + "png": { + depths: ["8", "16"], // PNG only supports integer formats + showWidgets: ["bit_depth", "save_as_grayscale"] + }, + "jpg": { + depths: ["8"], + showWidgets: ["quality"] + }, + "webp": { + depths: ["8"], + showWidgets: ["quality"] + }, + "tiff": { + depths: ["8", "16", "32"], + showWidgets: ["bit_depth", "save_as_grayscale"] + } + }; // Get all widgets - const fileType = this.widgets.find(w => w.name === "file_type"); - const bitDepth = this.widgets.find(w => w.name === "bit_depth"); - const exrCompression = this.widgets.find(w => w.name === "exr_compression"); - const quality = this.widgets.find(w => w.name === "quality"); - const saveAsGrayscale = this.widgets.find(w => w.name === "save_as_grayscale"); + const widgets = { + fileType: this.widgets.find(w => w.name === "file_type"), + bitDepth: this.widgets.find(w => w.name === "bit_depth"), + exrCompression: this.widgets.find(w => w.name === "exr_compression"), + quality: this.widgets.find(w => w.name === "quality"), + saveAsGrayscale: this.widgets.find(w => w.name === "save_as_grayscale") + }; - if (!fileType) { + if (!widgets.fileType) { console.warn("Saver: file_type widget not found"); return me; } - // Define the callback that updates widget visibility - const updateWidgetVisibility = () => { - console.log("Saver: Updating widget visibility for type:", fileType.value); + // Store original bit depth options + const allBitDepthOptions = ["8", "16", "32"]; + + // Update bit depth options based on format + const updateBitDepthOptions = (format) => { + if (!widgets.bitDepth) return; - // Hide all format-specific widgets first - if (bitDepth) bitDepth.hidden = true; - if (exrCompression) exrCompression.hidden = true; - if (quality) quality.hidden = true; - if (saveAsGrayscale) saveAsGrayscale.hidden = true; - - // Show relevant widgets based on file type - switch (fileType.value) { - case "exr": - if (exrCompression) exrCompression.hidden = false; - if (bitDepth) bitDepth.hidden = false; - if (saveAsGrayscale) saveAsGrayscale.hidden = false; - break; - case "png": - case "tiff": - if (bitDepth) bitDepth.hidden = false; - if (saveAsGrayscale) saveAsGrayscale.hidden = false; - break; - case "jpg": - case "webp": - if (quality) quality.hidden = false; - break; + const spec = FORMAT_SPECS[format]; + if (spec && spec.depths) { + // Update widget options + widgets.bitDepth.options.values = spec.depths; + + // If current value is not valid, reset to first valid option + if (!spec.depths.includes(widgets.bitDepth.value)) { + widgets.bitDepth.value = spec.depths[0]; + + // Visual feedback - briefly highlight the change + const originalColor = widgets.bitDepth.color; + widgets.bitDepth.color = "#ff6b6b"; + setTimeout(() => { + widgets.bitDepth.color = originalColor; + app.canvas.setDirty(true); + }, 300); + } } + }; - // Update node size to accommodate the visible widgets + // Update widget visibility and options + const updateWidgets = () => { + const format = widgets.fileType.value; + const spec = FORMAT_SPECS[format]; + + console.log(`Saver: Updating for format: ${format}`); + + // Hide all optional widgets + Object.keys(widgets).forEach(key => { + if (key !== "fileType" && widgets[key]) { + widgets[key].hidden = true; + } + }); + + // Show relevant widgets + if (spec && spec.showWidgets) { + spec.showWidgets.forEach(widgetName => { + const camelCaseName = widgetName.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); + if (widgets[camelCaseName]) { + widgets[camelCaseName].hidden = false; + } + }); + } + + // Update bit depth options + updateBitDepthOptions(format); + + // Resize node requestAnimationFrame(() => { - this.setSize([this.size[0], this.computeSize([this.size[0], this.size[1]])[1]]); + const newHeight = this.computeSize([this.size[0], this.size[1]])[1]; + this.setSize([this.size[0], newHeight]); app.canvas.setDirty(true); }); }; - // Attach the callback to the file_type widget - const originalCallback = fileType.callback; - fileType.callback = function(...args) { - if (originalCallback) { - originalCallback.apply(this, args); + // Override file type callback + const originalFileTypeCallback = widgets.fileType.callback; + widgets.fileType.callback = function(...args) { + if (originalFileTypeCallback) { + originalFileTypeCallback.apply(this, args); } - updateWidgetVisibility(); + updateWidgets(); }; - // Initial update with a small delay to ensure widgets are ready - setTimeout(updateWidgetVisibility, 1); + // Override bit depth callback for validation + if (widgets.bitDepth) { + const originalBitDepthCallback = widgets.bitDepth.callback; + widgets.bitDepth.callback = function(value) { + // Validate bit depth for current format + const currentFormat = widgets.fileType.value; + const spec = FORMAT_SPECS[currentFormat]; + + if (spec && !spec.depths.includes(value)) { + console.warn(`Invalid bit depth ${value} for format ${currentFormat}`); + // Reset to first valid option + this.value = spec.depths[0]; + app.canvas.setDirty(true); + return; + } + + if (originalBitDepthCallback) { + originalBitDepthCallback.apply(this, arguments); + } + }; + } + + // Initial setup with delay + setTimeout(updateWidgets, 10); return me; }; } -}); \ No newline at end of file +}); diff --git a/modules/saver.py b/modules/saver.py index 1e2de2a..b06cf1f 100644 --- a/modules/saver.py +++ b/modules/saver.py @@ -3,32 +3,35 @@ import torch import numpy as np import tifffile import folder_paths -from typing import Dict, Tuple +from typing import Dict, Tuple, Optional import OpenImageIO as oiio from datetime import datetime -import sys os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1" import cv2 as cv class saver: - """Image saver node with dynamic widget behavior""" + """Optimized image saver node with consistent bit depth handling""" + + # Format specifications + FORMAT_SPECS = { + "exr": {"depths": [16, 32], "opencv": False}, # EXR only supports half and full float + "png": {"depths": [8, 16], "opencv": False}, # PNG only supports integer formats + "jpg": {"depths": [8], "opencv": True}, + "webp": {"depths": [8], "opencv": True}, + "tiff": {"depths": [8, 16, 32], "opencv": False} + } @classmethod def INPUT_TYPES(cls): return { "required": { - # Common widgets that are always visible at the top "images": ("IMAGE",), - "file_path": ("STRING", {"default": "ComfyUI"}), + "file_path": ("STRING", {"default": ""}), + "filename": ("STRING", {"default": "ComfyUI"}), + "use_versioning": ("BOOLEAN", {"default": False}), "version": ("INT", {"default": 1, "min": -1, "max": 999}), - "start_frame": ("INT", {"default": 1001, "min": 0, "max": 99999999}), - "frame_pad": ("INT", {"default": 4, "min": 1, "max": 8}), - - # This widget controls the visibility of others "file_type": (["exr", "png", "jpg", "webp", "tiff"], {"default": "png"}), - - # These widgets will be shown/hidden based on file_type "bit_depth": (["8", "16", "32"], {"default": "16"}), "exr_compression": (["none", "zip", "zips", "rle", "pxr24", "b44", "b44a", "dwaa", "dwab"], {"default": "zips"}), @@ -45,277 +48,227 @@ class saver: FUNCTION = "save_images" OUTPUT_NODE = True CATEGORY = "COCO Tools/Savers" - - # This tells ComfyUI that file_type widget needs custom handling - CUSTOM_WIDGETS = ["file_type"] def __init__(self): self.output_dir = folder_paths.get_output_directory() - self.type = "output" @staticmethod - def is_grayscale(image: np.ndarray) -> bool: - """Check if an image is grayscale by either being single channel or having identical RGB channels.""" + def is_grayscale_fast(image: np.ndarray, sample_rate: float = 0.1) -> bool: + """Fast grayscale detection by sampling pixels""" if len(image.shape) == 2 or image.shape[-1] == 1: return True if image.shape[-1] == 3: - return np.allclose(image[..., 0], image[..., 1]) and np.allclose(image[..., 1], image[..., 2]) + # Sample 10% of pixels for large images + total_pixels = image.shape[0] * image.shape[1] + if total_pixels > 1000000: # 1MP + indices = np.random.choice(total_pixels, int(total_pixels * sample_rate), replace=False) + flat_img = image.reshape(-1, 3) + sampled = flat_img[indices] + return np.allclose(sampled[:, 0], sampled[:, 1], rtol=0.01) and \ + np.allclose(sampled[:, 1], sampled[:, 2], rtol=0.01) + else: + return np.allclose(image[..., 0], image[..., 1], rtol=0.01) and \ + np.allclose(image[..., 1], image[..., 2], rtol=0.01) return False - def _validate_format_bitdepth(self, file_type: str, bit_depth: int) -> Tuple[str, int]: - valid_combinations = { - "exr": [16, 32], # OpenEXR supports half and full float - "png": [8, 16, 32], # Now supporting 32-bit PNGs through OpenImageIO - "jpg": [8], - "jpeg": [8], - "webp": [8], - "tiff": [8, 16, 32] - } - - if bit_depth not in valid_combinations[file_type]: - sys.stderr.write(f"Warning: {file_type} format only supports {valid_combinations[file_type]} bit depth. Adjusting to {valid_combinations[file_type][0]} bit.\n") - bit_depth = valid_combinations[file_type][0] - - return file_type, bit_depth + def validate_bit_depth(self, file_type: str, bit_depth: int) -> int: + """Validate and adjust bit depth for format""" + valid_depths = self.FORMAT_SPECS[file_type]["depths"] + if bit_depth not in valid_depths: + return valid_depths[0] + return bit_depth - def increment_filename(self, filepath: str) -> str: - base, ext = os.path.splitext(filepath) - counter = 1 - new_filepath = f"{base}_{counter:05d}{ext}" - while os.path.exists(new_filepath): - counter += 1 - new_filepath = f"{base}_{counter:05d}{ext}" - return new_filepath - - def _convert_bit_depth(self, img: np.ndarray, bit_depth: int) -> np.ndarray: - """Convert image to specified bit depth.""" - if bit_depth == 8: - return (np.clip(img, 0, 1) * 255).astype(np.uint8) - elif bit_depth == 16: - if isinstance(img.dtype, np.floating): - # For floating point data (like EXR), preserve as float16 - return img.astype(np.float16) - else: - # For integer data, scale to 16-bit range - return (np.clip(img, 0, 1) * 65535).astype(np.uint16) - else: # 32-bit - return img.astype(np.float32) - - def _prepare_image_for_saving(self, img: np.ndarray, file_type: str, save_as_grayscale: bool = False) -> np.ndarray: - """Prepare image for saving by handling color space and channel conversion.""" - # Handle single channel images - if len(img.shape) == 2: - img = img[..., np.newaxis] + def convert_to_grayscale(self, img: np.ndarray) -> np.ndarray: + """Proper grayscale conversion using luminance weights""" + if len(img.shape) == 2 or img.shape[-1] == 1: + return img if img.shape[-1] == 1 else img[..., np.newaxis] - # Convert to grayscale if requested or if image is already grayscale - if save_as_grayscale or self.is_grayscale(img): - if img.shape[-1] == 3: - # For EXR workflow, just take R channel to preserve original values - img = img[..., 0:1] - # Convert to BGR for OpenCV formats only (jpg, jpeg, webp) if not saving as grayscale - elif file_type in ['jpg', 'jpeg', 'webp'] and img.shape[-1] >= 3: - img = cv.cvtColor(img, cv.COLOR_RGB2BGR) + if img.shape[-1] >= 3: + # ITU-R BT.709 luminance weights + weights = np.array([0.2126, 0.7152, 0.0722]) + gray = np.dot(img[..., :3], weights) + return gray[..., np.newaxis] - return img + return img[..., 0:1] # Fallback for 2-channel images - def _prepare_image_data(self, img_tensor: torch.Tensor, file_type: str, bit_depth: int, - save_as_grayscale: bool) -> np.ndarray: - """Prepare image data for saving by handling conversion and preprocessing.""" - # Convert from torch tensor if needed - if isinstance(img_tensor, torch.Tensor): - # Remove batch dimension if present - if img_tensor.ndim == 4 and img_tensor.shape[0] == 1: - img_np = img_tensor.squeeze(0).cpu().numpy() - else: - img_np = img_tensor.cpu().numpy() + def prepare_image(self, img_tensor: torch.Tensor, save_as_grayscale: bool) -> np.ndarray: + """Convert tensor to numpy and prepare channels""" + # Convert from tensor + if img_tensor.ndim == 4 and img_tensor.shape[0] == 1: + img_np = img_tensor.squeeze(0).cpu().numpy() else: - img_np = img_tensor + img_np = img_tensor.cpu().numpy() - sys.stderr.write(f"Initial data - Shape: {img_np.shape}, Min: {img_np.min()}, Max: {img_np.max()}\n") + # Ensure float32 [0,1] range + img_np = np.clip(img_np, 0, 1).astype(np.float32) - # Prepare image (handles channel conversion if needed) - img_np = self._prepare_image_for_saving(img_np, file_type, save_as_grayscale) + # Handle channels + if len(img_np.shape) == 2: + img_np = img_np[..., np.newaxis] - # Convert bit depth for all formats - img_np = self._convert_bit_depth(img_np, bit_depth) - sys.stderr.write(f"After bit_depth conversion min: {img_np.min()}, max: {img_np.max()}\n") + # Convert to grayscale if explicitly requested (don't auto-detect for solid colors) + if save_as_grayscale: + img_np = self.convert_to_grayscale(img_np) return img_np - def _save_exr(self, img_np: np.ndarray, out_path: str, save_as_grayscale: bool, exr_compression: str, bit_depth: int) -> None: - """Save image data as EXR file using OpenImageIO.""" - try: - # Determine channels and prepare data - if img_np.ndim == 2 or img_np.shape[-1] == 1 or save_as_grayscale: - channels = 1 - if img_np.ndim == 3 and img_np.shape[-1] > 1: - exr_data = img_np[..., 0:1] # Extract first channel for grayscale - else: - exr_data = img_np[..., np.newaxis] if img_np.ndim == 2 else img_np - else: - channels = img_np.shape[-1] - exr_data = img_np - - sys.stderr.write(f"\nSaving EXR - Shape: {exr_data.shape}, Channels: {channels}\n") - sys.stderr.write(f"Value range - Min: {exr_data.min()}, Max: {exr_data.max()}\n") - - # Ensure data is contiguous and in the right format - if bit_depth == 16: - exr_data = np.ascontiguousarray(exr_data.astype(np.float16)) - pixel_type = oiio.HALF - else: # 32-bit - exr_data = np.ascontiguousarray(exr_data.astype(np.float32)) - pixel_type = oiio.FLOAT - - # Create spec with detected channels - spec = oiio.ImageSpec( - exr_data.shape[1], # width - exr_data.shape[0], # height - channels, # channels - pixel_type # Use appropriate pixel type based on bit depth - ) - - # Set EXR-specific attributes - spec.attribute("compression", exr_compression) - spec.attribute("Orientation", 1) - spec.attribute("DateTime", datetime.now().isoformat()) - spec.attribute("Software", "COCO Tools") - - # Create and write image buffer - buf = oiio.ImageBuf(spec) - buf.set_pixels(oiio.ROI(), exr_data) - - if not buf.write(out_path): - raise RuntimeError(f"Failed to write EXR: {oiio.geterror()}") - - except Exception as e: - raise RuntimeError(f"OpenImageIO EXR save failed: {str(e)}") - - def _save_png(self, img_np: np.ndarray, out_path: str, bit_depth: int) -> None: - """Save image data as PNG file using OpenImageIO.""" - try: - # Determine data type based on bit depth - if bit_depth == 8: - dtype = "uint8" - pixel_type = np.uint8 - elif bit_depth == 16: - dtype = "uint16" - pixel_type = np.uint16 - else: # 32-bit - dtype = "float" - pixel_type = np.float32 - - # Convert and validate array - png_data = np.ascontiguousarray(img_np.astype(pixel_type)) - - # Create image spec - channels = 1 if png_data.ndim == 2 else png_data.shape[-1] - spec = oiio.ImageSpec( - png_data.shape[1], # width - png_data.shape[0], # height - channels, # channels - dtype - ) - - # Set PNG-specific attributes - spec.attribute("compression", "zip") - spec.attribute("png:compressionLevel", 9) - - if bit_depth == 32: - spec.attribute("oiio:ColorSpace", "Linear") - - # Create and write image buffer - buf = oiio.ImageBuf(spec) - buf.set_pixels(oiio.ROI(), png_data) - - if not buf.write(out_path): - raise RuntimeError(f"Failed to write PNG: {oiio.geterror()}") - - except Exception as e: - raise RuntimeError(f"OpenImageIO PNG save error: {str(e)}") - - def _save_jpeg_or_webp(self, img_np: np.ndarray, out_path: str, quality: int) -> None: - """Save image data as JPEG or WebP file using OpenCV.""" - cv.imwrite(out_path, img_np, [cv.IMWRITE_JPEG_QUALITY, quality]) - - def _save_tiff(self, img_np: np.ndarray, out_path: str, bit_depth: int) -> None: - """Save image data as TIFF file using tifffile.""" - # Convert BGR back to RGB for TIFF saving - if img_np.shape[-1] >= 3: - img_np = cv.cvtColor(img_np, cv.COLOR_BGR2RGB) - - # Handle TIFF saving with appropriate bit depth + def save_exr(self, img: np.ndarray, path: str, bit_depth: int, compression: str) -> None: + """Save EXR with proper float handling""" + # Convert to appropriate type based on bit depth if bit_depth == 8: - tifffile.imwrite(out_path, img_np.astype(np.uint8), photometric='rgb') + # 8-bit EXR as uint8 + data = (img * 255).astype(np.uint8) + pixel_type = oiio.UINT8 elif bit_depth == 16: - tifffile.imwrite(out_path, img_np.astype(np.uint16), photometric='rgb') - else: # 32-bit - tifffile.imwrite( - out_path, - img_np.astype(np.float32), - photometric='rgb', - dtype=np.float32 - ) + data = img.astype(np.float16) + pixel_type = oiio.HALF + else: # 32 + data = img.astype(np.float32) + pixel_type = oiio.FLOAT + + data = np.ascontiguousarray(data) + channels = 1 if data.ndim == 2 else data.shape[-1] + + spec = oiio.ImageSpec(data.shape[1], data.shape[0], channels, pixel_type) + spec.attribute("compression", compression) + spec.attribute("Software", "COCO Tools") + + buf = oiio.ImageBuf(spec) + buf.set_pixels(oiio.ROI(), data) + + if not buf.write(path): + raise RuntimeError(f"Failed to write EXR: {oiio.geterror()}") - def save_images(self, images, file_path, file_type, bit_depth, - quality=95, save_as_grayscale=False, - version=1, start_frame=1001, frame_pad=4, - prompt=None, extra_pnginfo=None, exr_compression="zips"): - """Save a batch of images in various formats with support for versioning and frame numbering.""" + def save_png(self, img: np.ndarray, path: str, bit_depth: int) -> None: + """Save PNG with proper bit depth handling""" + # Convert to appropriate type based on bit depth + if bit_depth == 8: + data = (img * 255).astype(np.uint8) + pixel_type = oiio.UINT8 + elif bit_depth == 16: + data = (img * 65535).astype(np.uint16) + pixel_type = oiio.UINT16 + elif bit_depth == 32: + # 32-bit PNG as float32 (preserves full range) + data = img.astype(np.float32) + pixel_type = oiio.FLOAT + else: + # Fallback to 8-bit for unsupported depths + data = (img * 255).astype(np.uint8) + pixel_type = oiio.UINT8 + + data = np.ascontiguousarray(data) + channels = 1 if data.ndim == 2 else data.shape[-1] + + spec = oiio.ImageSpec(data.shape[1], data.shape[0], channels, pixel_type) + spec.attribute("compression", "zip") + spec.attribute("png:compressionLevel", 9) + + # For 32-bit float PNG, set linear color space + if bit_depth == 32: + spec.attribute("oiio:ColorSpace", "Linear") + + buf = oiio.ImageBuf(spec) + buf.set_pixels(oiio.ROI(), data) + + if not buf.write(path): + raise RuntimeError(f"Failed to write PNG: {oiio.geterror()}") + + def save_opencv_format(self, img: np.ndarray, path: str, quality: int = 95) -> None: + """Save JPEG/WebP using OpenCV""" + # Convert to 8-bit BGR + data = (img * 255).astype(np.uint8) + + # Convert RGB to BGR only for 3+ channel images + if data.shape[-1] >= 3: + data = cv.cvtColor(data, cv.COLOR_RGB2BGR) + + # Save with quality setting + if path.endswith(('.jpg', '.jpeg')): + cv.imwrite(path, data, [cv.IMWRITE_JPEG_QUALITY, quality]) + else: # webp + cv.imwrite(path, data, [cv.IMWRITE_WEBP_QUALITY, quality]) + + def save_tiff(self, img: np.ndarray, path: str, bit_depth: int) -> None: + """Save TIFF with proper bit depth""" + if bit_depth == 8: + data = (img * 255).astype(np.uint8) + elif bit_depth == 16: + data = (img * 65535).astype(np.uint16) + else: # 32 + data = img.astype(np.float32) + + # Determine photometric interpretation + photometric = 'minisblack' if data.shape[-1] == 1 else 'rgb' + + tifffile.imwrite(path, data, photometric=photometric) + + def get_unique_filepath(self, base_path: str) -> str: + """Get unique filepath with incremental counter""" + if not os.path.exists(base_path): + return base_path + + dir_name = os.path.dirname(base_path) + base_name = os.path.basename(base_path) + name, ext = os.path.splitext(base_name) + + counter = 1 + while True: + new_path = os.path.join(dir_name, f"{name}_{counter}{ext}") + if not os.path.exists(new_path): + return new_path + counter += 1 + + def save_images(self, images, file_path, filename, file_type, bit_depth, + quality=95, save_as_grayscale=False, use_versioning=True, + version=1, prompt=None, extra_pnginfo=None, exr_compression="zips"): + """Main save function with optimized pipeline""" try: + # Validate inputs bit_depth = int(bit_depth) - file_type, bit_depth = self._validate_format_bitdepth(file_type.lower(), bit_depth) - file_ext = f".{file_type}" - - # Handle absolute vs relative paths - if not os.path.isabs(file_path): - full_output_folder, filename, _, subfolder, _ = folder_paths.get_save_image_path( - file_path, self.output_dir, images.shape[2], images.shape[3] - ) - base_path = os.path.join(full_output_folder, filename) - else: - base_path = file_path - os.makedirs(os.path.dirname(base_path), exist_ok=True) - - # Handle versioning - version_str = f"_v{version:03}" if version >= 0 else "" + file_type = file_type.lower() + bit_depth = self.validate_bit_depth(file_type, bit_depth) - # Process each image in the batch + # Build base path + if file_path: + full_path = os.path.join(self.output_dir, file_path) if not os.path.isabs(file_path) else file_path + os.makedirs(full_path, exist_ok=True) + base_path = os.path.join(full_path, filename) + else: + base_path = os.path.join(self.output_dir, filename) + + # Add version string + version_str = f"_v{version:03d}" if use_versioning and version >= 0 else "" + + # Process each image for i, img_tensor in enumerate(images): - # Prepare image data - img_np = self._prepare_image_data(img_tensor, file_type, bit_depth, save_as_grayscale) + # Prepare image (all formats start from float32 [0,1]) + img_np = self.prepare_image(img_tensor, save_as_grayscale) - # Generate output filename - frame_num = f".{str(start_frame + i).zfill(frame_pad)}" if file_type == "exr" else f"_{i:05d}" - out_path = f"{base_path}{version_str}{frame_num}{file_ext}" + # Build output path + frame_str = f"_{i}" if len(images) > 1 else "" + out_path = f"{base_path}{version_str}{frame_str}.{file_type}" + out_path = self.get_unique_filepath(out_path) - # Use improved filename increment if file exists - if os.path.exists(out_path): - out_path = self.increment_filename(out_path) - - # Save the image based on format + # Save based on format if file_type == "exr": - self._save_exr(img_np, out_path, save_as_grayscale, exr_compression, bit_depth) + self.save_exr(img_np, out_path, bit_depth, exr_compression) elif file_type == "png": - self._save_png(img_np, out_path, bit_depth) + self.save_png(img_np, out_path, bit_depth) elif file_type in ["jpg", "jpeg", "webp"]: - self._save_jpeg_or_webp(img_np, out_path, quality) + self.save_opencv_format(img_np, out_path, quality) elif file_type == "tiff": - self._save_tiff(img_np, out_path, bit_depth) - - # Return success + self.save_tiff(img_np, out_path, bit_depth) + return {"ui": {"images": []}} - + except Exception as e: - sys.stderr.write(f"Error saving images: {e}\n") return {"ui": {"error": str(e)}} -# Register the node NODE_CLASS_MAPPINGS = { "saver": saver, } -# Optional: Register display names NODE_DISPLAY_NAME_MAPPINGS = { "saver": "Image Saver" -} \ No newline at end of file +} diff --git a/node.zip b/node.zip index af8f728500c63fe8c5d3054747829cac2b9d7c81..edcc2c820c2c6309d1b6fe1569c3798dcf76b8e5 100644 GIT binary patch delta 20269 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 Z;$UKsG=4W2uK=z9!YKfN@udH?_#YsH!BPMK delta 12004 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.10.0", "numpy>=1.21.0", "colour-science>=0.4.2", "Pillow>=9.0.0", "opencv-python>=4.5.0", "tifffile", "OpenImageIO>=2.4.13.0", "rich>=10.0.0"]