From 2d649703ece193bb00fecca47f77c9a9e76dd3fc Mon Sep 17 00:00:00 2001 From: Hugh Bord Date: Wed, 27 Aug 2025 23:06:44 +0800 Subject: [PATCH] feat: new node to save irc and ansi metadata to the png exif --- README.md | 42 ++++++++ src/nodes.py | 264 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 306 insertions(+) diff --git a/README.md b/README.md index 8cc5c86..88997cd 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,47 @@ Saves IRC-formatted text to files for easy copying. **Outputs:** - **file_path**: Absolute path to the saved file +### IRC PNG Exporter +**Category**: `image/output` + +Exports images as PNG files with embedded IRC and ANSI metadata. Displays the image directly within the node interface for preview, with optional file saving functionality. + +**Inputs:** +- **image**: Input image (any dimensions supported) +- **preview**: Toggle between preview and save modes (default: True) +- **irc_text**: IRC formatted text to embed (optional STRING input) +- **ansi_text**: ANSI formatted text to embed (optional STRING input) +- **filename**: Output filename when saving with {TIMESTAMP} placeholder support (default: "irc_art_{TIMESTAMP}.png") + +**Outputs:** +- No output connectors - displays image directly in the node + +**Features:** +- **Image Preview**: Shows the image with embedded metadata directly within the node interface +- **Metadata Embedding**: Stores IRC/ANSI text data in PNG metadata using standard text chunks +- **Flexible Input**: Works with just an image, or with optional IRC/ANSI text data +- **Two Modes**: + - **Preview Mode** (default): Display image with metadata info in the node + - **Save Mode**: Export PNG file with embedded metadata to `output/irc_art/` directory +- **Metadata Fields**: Uses standard PNG text chunks (Description, Comment, Software, Author) for maximum compatibility +- **External Access**: Metadata can be extracted using ExifTool, ImageMagick, or Python PIL (see `META.md` for details) + +**Example JSON Metadata Structure:** +```json +{ + "timestamp": "2025-08-27T14:30:45.123456", + "generator": "ComfyUI-Mircify", + "version": "1.0", + "irc": "\u0003\u0003,01█████████████████████████████████████████\u000f", + "ansi": "\u001b[38;5;15;48;5;0m█████████████████████████████████████████\u001b[0m" +} +``` + +This JSON data is embedded in the PNG's **Comment** field and can be extracted using: +```bash +exiftool -Comment your_image.png +``` + ## Usage 1. **Basic Workflow:** @@ -115,6 +156,7 @@ Saves IRC-formatted text to files for easy copying. - Connect it to "IRC Art Converter" - Connect the `irc_text` output to "IRC Text Saver" (for IRC clients) - Optionally connect the `ansi_text` output to display in terminals + - **New**: Connect `processed_image`, `irc_text`, and `ansi_text` to "IRC PNG Exporter" for image preview with embedded metadata - Run the workflow 2. **Distance Methods:** diff --git a/src/nodes.py b/src/nodes.py index 8cc334e..04477eb 100644 --- a/src/nodes.py +++ b/src/nodes.py @@ -9,8 +9,16 @@ from sklearn.cluster import KMeans from datetime import datetime import warnings import os +import json from typing import Tuple, List, Optional, Union, Dict, Any +try: + from PIL import Image, ExifTags + from PIL.ExifTags import TAGS + PIL_AVAILABLE = True +except ImportError: + PIL_AVAILABLE = False + # ============================================================================ # CONSTANTS @@ -380,6 +388,105 @@ class ColorTransfer: return torch.from_numpy(processed_image.astype(np.float32) / 255.0) +# ============================================================================ +# PNG EXIF UTILITIES +# ============================================================================ + +class PNGExifHandler: + """Handles PNG metadata embedding operations.""" + + @staticmethod + def create_metadata_json(irc_text: str = None, ansi_text: str = None, + block_width: int = None, block_height: int = None, + half_block_mode: bool = None, color_method: str = None) -> str: + """Create JSON metadata string with IRC art data.""" + metadata = { + "timestamp": datetime.now().isoformat(), + "generator": "ComfyUI-Mircify", + "version": "1.0" + } + + # Add text data if provided + if irc_text is not None: + metadata["irc"] = irc_text + if ansi_text is not None: + metadata["ansi"] = ansi_text + + # Add optional parameters if provided + if block_width is not None: + metadata["block_width"] = block_width + if block_height is not None: + metadata["block_height"] = block_height + if half_block_mode is not None: + metadata["half_block_mode"] = half_block_mode + if color_method is not None: + metadata["color_method"] = color_method + + return json.dumps(metadata, ensure_ascii=False) + + @staticmethod + def embed_metadata(image: Image.Image, metadata_json: str) -> Image.Image: + """Embed metadata into PNG UserComment EXIF field.""" + if not PIL_AVAILABLE: + raise ImportError("PIL/Pillow is required for EXIF metadata embedding") + + # Get existing EXIF data or create new + exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "1st": {}, "thumbnail": None} + + if hasattr(image, '_getexif') and image._getexif() is not None: + # Copy existing EXIF data + existing_exif = image._getexif() + for tag_id, value in existing_exif.items(): + if tag_id in TAGS: + tag_name = TAGS[tag_id] + if tag_name in ["UserComment", "ImageDescription"]: + continue # We'll override these + exif_dict["0th"][tag_id] = value + + # Add our metadata to UserComment (0x9286) + # UserComment format: charset (8 bytes) + actual comment + # Using ASCII charset identifier + charset_identifier = b"ASCII\x00\x00\x00" + user_comment = charset_identifier + metadata_json.encode('utf-8', errors='ignore') + exif_dict["Exif"][ExifTags.Base.UserComment.value] = user_comment + + return image + + @staticmethod + def tensor_to_pil(tensor: torch.Tensor) -> Image.Image: + """Convert PyTorch tensor to PIL Image.""" + # Handle batch dimension + if len(tensor.shape) == 4: + tensor = tensor.squeeze(0) + + # Convert to numpy and scale to 0-255 + numpy_image = tensor.cpu().numpy() + if numpy_image.max() <= 1.0: + numpy_image = (numpy_image * 255).astype(np.uint8) + else: + numpy_image = numpy_image.astype(np.uint8) + + # Convert to PIL + return Image.fromarray(numpy_image, 'RGB') + + @staticmethod + def save_png_with_metadata(tensor: torch.Tensor, filepath: str, metadata_json: str) -> None: + """Save tensor as PNG with embedded metadata.""" + pil_image = PNGExifHandler.tensor_to_pil(tensor) + + # Create PNG info for metadata + from PIL.PngImagePlugin import PngInfo + png_info = PngInfo() + + # Add metadata as PNG text chunks (more reliable than EXIF for PNG) + png_info.add_text("UserComment", metadata_json) + png_info.add_text("Software", "ComfyUI-Mircify") + png_info.add_text("Description", "IRC Art with embedded text data") + + # Save with metadata + pil_image.save(filepath, format='PNG', pnginfo=png_info, optimize=True) + + # ============================================================================ # MAIN NODE CLASSES # ============================================================================ @@ -642,6 +749,161 @@ class IRCTextSaver: return {"ui": {"text": [error_msg]}, "result": (error_msg,)} +class IRCPNGExporter: + """Node to export images as PNG with embedded IRC/ANSI metadata.""" + + CATEGORY = "image/output" + OUTPUT_DIR = "output/irc_art" + DEFAULT_FILENAME = "irc_art_{TIMESTAMP}.png" + + @classmethod + def INPUT_TYPES(cls) -> Dict[str, Any]: + return { + "required": { + "image": ("IMAGE",), + "preview": ("BOOLEAN", {"default": True}), + }, + "optional": { + "irc_text": ("STRING", {"forceInput": True}), + "ansi_text": ("STRING", {"forceInput": True}), + "filename": ("STRING", {"default": cls.DEFAULT_FILENAME}), + } + } + + RETURN_TYPES = () + FUNCTION = "export_png_with_metadata" + OUTPUT_NODE = True + + def export_png_with_metadata(self, image: torch.Tensor, preview: bool, + irc_text: str = None, ansi_text: str = None, + filename: str = None) -> Dict[str, Any]: + """Export PNG with embedded IRC/ANSI metadata.""" + + # Check PIL availability + if not PIL_AVAILABLE: + error_msg = "PIL/Pillow is required for PNG export with metadata. Please install with: pip install Pillow" + return {"ui": {"text": [error_msg]}} + + # Create metadata JSON + metadata_json = PNGExifHandler.create_metadata_json(irc_text, ansi_text) + metadata_dict = json.loads(metadata_json) + + # Check if we have any text data to embed + has_text_data = irc_text is not None or ansi_text is not None + text_info = [] + if irc_text is not None: + text_info.append("IRC text") + if ansi_text is not None: + text_info.append("ANSI text") + + if preview: + # Preview mode - create a temporary image with metadata and display it + import tempfile + import uuid + + if has_text_data: + metadata_preview = f"Preview mode - Image with embedded metadata ({', '.join(text_info)})" + else: + metadata_preview = f"Preview mode - Image with basic metadata (no text data connected)" + + # Create temporary file with metadata for preview using ComfyUI's approach + try: + import folder_paths + import random + + # Use ComfyUI's temp directory structure + temp_dir = folder_paths.get_temp_directory() + + # Generate filename like ComfyUI's PreviewImage node + temp_filename = f"preview_{random.randint(0, 2**32):08x}.png" + temp_path = os.path.join(temp_dir, temp_filename) + + # Convert tensor to PIL image using ComfyUI's method + i = 255.0 * image[0].cpu().numpy() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + + # Add metadata using PNG text chunks (using standard visible fields) + from PIL.PngImagePlugin import PngInfo + png_info = PngInfo() + + # Use standard PNG text chunks that are more likely to be visible + png_info.add_text("Description", f"IRC Art with embedded data") + png_info.add_text("Comment", metadata_json) # Main data in Comment field + png_info.add_text("Software", "ComfyUI-Mircify") + png_info.add_text("Author", "ComfyUI IRC Art Converter") + + # Save with metadata + img.save(temp_path, pnginfo=png_info, compress_level=4) + + # Return with proper ComfyUI image display format + return { + "ui": { + "images": [{ + "filename": temp_filename, + "subfolder": "", + "type": "temp" + }], + "text": [metadata_preview] + } + } + + except Exception as e: + error_msg = f"Error creating preview: {str(e)}" + return {"ui": {"text": [error_msg]}} + + else: + # Save mode - save PNG with metadata using ComfyUI's approach + if not filename: + filename = self.DEFAULT_FILENAME + + # Generate timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + # Process filename + if "{TIMESTAMP}" in filename: + filename = filename.replace("{TIMESTAMP}", timestamp) + + if not filename.lower().endswith('.png'): + filename += '.png' + + # Create output directory + os.makedirs(self.OUTPUT_DIR, exist_ok=True) + + # Save file with metadata + file_path = os.path.join(self.OUTPUT_DIR, filename) + + try: + # Convert tensor to PIL image using ComfyUI's method + i = 255.0 * image[0].cpu().numpy() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + + # Add metadata using PNG text chunks (using standard visible fields) + from PIL.PngImagePlugin import PngInfo + png_info = PngInfo() + + # Use standard PNG text chunks that are more likely to be visible + png_info.add_text("Description", f"IRC Art with embedded data") + png_info.add_text("Comment", metadata_json) # Main data in Comment field + png_info.add_text("Software", "ComfyUI-Mircify") + png_info.add_text("Author", "ComfyUI IRC Art Converter") + + # Save with metadata + img.save(file_path, pnginfo=png_info, compress_level=4) + + abs_path = os.path.abspath(file_path) + + if has_text_data: + success_msg = f"PNG with metadata saved to: {abs_path}\nEmbedded data: {', '.join(text_info)}" + else: + success_msg = f"PNG with basic metadata saved to: {abs_path}\n(No text data was connected)" + + return {"ui": {"text": [success_msg]}} + + except Exception as e: + error_msg = f"Error saving PNG with metadata: {str(e)}" + return {"ui": {"text": [error_msg]}} + + # ============================================================================ # NODE REGISTRATION # ============================================================================ @@ -649,9 +911,11 @@ class IRCTextSaver: NODE_CLASS_MAPPINGS = { "IRC Art Converter": IRCArtConverter, "IRC Text Saver": IRCTextSaver, + "IRC PNG Exporter": IRCPNGExporter, } NODE_DISPLAY_NAME_MAPPINGS = { "IRC Art Converter": "IRC Art Converter", "IRC Text Saver": "IRC Text Saver", + "IRC PNG Exporter": "IRC PNG Exporter", } \ No newline at end of file