From ed027a16ef8b9720375ab683dfc2a9fd736ba565 Mon Sep 17 00:00:00 2001 From: dseditor Date: Fri, 26 Dec 2025 19:20:32 +0800 Subject: [PATCH] Add BatchToPSD node for converting batch images to multi-layer PSD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Converts batch images to PSD file with layers - Skips first image in batch (merged result) - Supports RGBA transparency preservation - Auto-detects alpha channels and uses appropriate PSD mode - Includes layer order reversal option 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- nodes.py | 182 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/nodes.py b/nodes.py index ed0c79c..9b30471 100644 --- a/nodes.py +++ b/nodes.py @@ -1049,6 +1049,186 @@ class SimpleWildCardPlayer: return (prompt_list,) +class BatchToPSD: + """ + Convert batch PNG images to a multi-layer PSD file. + The first image in the batch is skipped (merged result), + and subsequent images become layers in the PSD file. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "images": ("IMAGE",), + "filename_prefix": ("STRING", { + "default": "ComfyUI_PSD", + "tooltip": "Prefix for the output PSD filename" + }), + }, + "optional": { + "reverse_layer_order": ("BOOLEAN", { + "default": False, + "tooltip": "Reverse the order of layers in PSD (bottom to top)" + }), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("message",) + FUNCTION = "convert_to_psd" + OUTPUT_NODE = True + CATEGORY = "ListHelper" + + def convert_to_psd(self, images, filename_prefix, reverse_layer_order=False): + """ + Convert batch images to multi-layer PSD file. + + Args: + images: Batch of images [N, H, W, C] + filename_prefix: Filename prefix for output PSD + reverse_layer_order: Whether to reverse layer order + + Returns: + Status message with file path or installation instruction + """ + import numpy as np + + # Try to import psd-tools + try: + from psd_tools import PSDImage + from psd_tools.api.layers import PixelLayer + from PIL import Image + except ImportError: + return ("ERROR: Required library 'psd-tools' not found. Please install: pip install psd-tools",) + + # Validate input + if images is None or images.shape[0] == 0: + return ("ERROR: No images provided in batch.",) + + if images.shape[0] == 1: + return ("ERROR: Batch must contain at least 2 images (first image is skipped, remaining become layers).",) + + # Skip first image and get remaining layers + layer_images = images[1:] + num_layers = layer_images.shape[0] + + print(f"BatchToPSD: Processing {num_layers} layers (skipped first image in batch)") + + # Create output directory if it doesn't exist + output_dir = folder_paths.get_output_directory() + + # Generate unique filename with timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{filename_prefix}_{timestamp}.psd" + filepath = os.path.join(output_dir, filename) + + # Ensure directory exists + os.makedirs(os.path.dirname(filepath) if os.path.dirname(filepath) else output_dir, exist_ok=True) + + try: + # Convert tensors to PIL Images and detect if we need alpha channel + pil_layers = [] + has_alpha = False + + print(f"\n DEBUG: Input batch shape: {images.shape}") + print(f" DEBUG: Layer images shape (after skipping first): {layer_images.shape}") + + for i in range(num_layers): + # Convert from torch tensor [H, W, C] with values in [0, 1] to numpy array + img_array = layer_images[i].cpu().numpy() + + # Convert to uint8 [0, 255] + img_array = (img_array * 255).astype(np.uint8) + + # Detect number of channels + print(f" DEBUG: Layer {i+1} array shape: {img_array.shape}, channels: {img_array.shape[2]}") + + if img_array.shape[2] == 4: + has_alpha = True + pil_img = Image.fromarray(img_array, mode='RGBA') + # Check actual transparency + alpha_channel = img_array[:, :, 3] + transparent_pixels = np.sum(alpha_channel == 0) + total_pixels = alpha_channel.size + print(f" DEBUG: Layer {i+1} has RGBA, transparent pixels: {transparent_pixels:,} ({transparent_pixels/total_pixels*100:.2f}%)") + elif img_array.shape[2] == 3: + pil_img = Image.fromarray(img_array, mode='RGB') + print(f" DEBUG: Layer {i+1} is RGB (NO alpha channel)") + else: + return (f"ERROR: Unsupported number of channels: {img_array.shape[2]}",) + + pil_layers.append(pil_img) + print(f" Converted layer {i+1}/{num_layers} - Size: {pil_img.size}, Mode: {pil_img.mode}") + + # CRITICAL FIX: Use RGBA mode for PSD when any layer has transparency + # This prevents PixelLayer.frompil() from losing transparency when PSD is in RGB mode + # Issue: PixelLayer.frompil() mixes RGBA image's transparent areas with RGB colors + # Solution: Create PSD in RGBA mode to preserve alpha channel correctly + + # IMPORTANT: Always use RGBA mode if any layer has alpha channel + # This matches the successful test script behavior + target_mode = 'RGBA' if has_alpha else 'RGB' + print(f"\n Target PSD mode: {target_mode} (has_alpha={has_alpha})") + + # If we detected alpha, we MUST use RGBA mode + # If no alpha detected but user expects transparency, force RGBA anyway + # This is safer and matches Photoshop's behavior + if has_alpha: + print(f" ✓ Using RGBA mode to preserve transparency") + else: + print(f" Using RGB mode (no alpha channels detected)") + + # Convert all layers to match PSD mode + # This is critical - we must convert BEFORE creating the PSD + for i in range(len(pil_layers)): + if pil_layers[i].mode != target_mode: + original_mode = pil_layers[i].mode + pil_layers[i] = pil_layers[i].convert(target_mode) + print(f" Converted layer {i+1} from {original_mode} to {target_mode}") + + # Reverse layer order if requested + if reverse_layer_order: + pil_layers.reverse() + print(" Reversed layer order") + + # Get dimensions from first layer + width, height = pil_layers[0].size + + # Create PSD with proper color mode (RGBA preserves transparency!) + psd = PSDImage.new(target_mode, (width, height), depth=8) + print(f" Created PSD document: {width}x{height}, mode={target_mode}, depth=8") + + # Add layers to PSD + for i, pil_img in enumerate(pil_layers): + layer_name = f"Layer_{i+1}" + + # Create layer from PIL image + # When PSD is in RGBA mode, PixelLayer.frompil() preserves transparency correctly + layer = PixelLayer.frompil(pil_img, psd) + layer.name = layer_name + + # Add to PSD + psd.append(layer) + print(f" Added layer: {layer_name} (mode={pil_img.mode})") + + # Save PSD file + psd.save(filepath) + + success_msg = f"SUCCESS: PSD file saved to: {filepath} ({num_layers} layers, mode={target_mode})" + print(f"BatchToPSD: {success_msg}") + return (success_msg,) + + except Exception as e: + error_msg = f"ERROR: Failed to create PSD file: {str(e)}" + print(f"BatchToPSD: {error_msg}") + import traceback + traceback.print_exc() + return (error_msg,) + + + + NODE_CLASS_MAPPINGS = { "AudioListGenerator": AudioListGenerator, @@ -1064,6 +1244,7 @@ NODE_CLASS_MAPPINGS = { "SimpleWildCardPlayer": SimpleWildCardPlayer, "QwenGPUInference": QwenGPUInference, "GGUFInference": GGUFInference, + "BatchToPSD": BatchToPSD, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -1079,5 +1260,6 @@ NODE_DISPLAY_NAME_MAPPINGS = { "SimpleWildCardPlayer": "Simple WildCard Player", "QwenGPUInference": "Qwen_TE_LLM", "GGUFInference": "GGUF_LLM", + "BatchToPSD": "Batch to PSD", }