Last active
October 25, 2025 23:12
-
-
Save DraconicDragon/a00740bcb6dc54070b48c69a141b1c62 to your computer and use it in GitHub Desktop.
Python script to convert PNGs with ComfyUI workflow + A1111-style metadata to WebP while preserving the A1111-style data in the UserComment exif field and/or as stealth metadata in the WebP's alpha channel. Works for both lossy and lossless webp (for lossy webp the alpha channel is encoded losslessly). Ref.: https://github.com/Panchovix/stable-d…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Before running the script, put it in the directory you want it to convert PNGs into WebPs | |
| # Then run it, it will go through every image in the same directory the script is in (no single image mode sorry, too lazy) | |
| # and save it in a subdirectory. It will skip any existing WebPs so if you want to replace the existing webps with new ones | |
| # like with stealth enabled this time, if you had it disabled before, then youll have to delete the existing webps. | |
| # There are some boolean values you can change as well as some other stuff in the "Configuration" part | |
| # like a save as animated webp option which is useful for sharing the webp with a1111 style metadata in usercomment over discord | |
| # because discord won't remove the metadata if it detects the webp as animated webp | |
| # What this script doesn't do: | |
| # Copy over comfy workflow metadata from the png, but maybe in the future, | |
| # comfyUI's save as animated webp node uses camera settings exif fields in the web for this | |
| import gzip | |
| import logging | |
| import os | |
| from io import BytesIO | |
| from pathlib import Path | |
| import piexif | |
| import piexif.helper | |
| from PIL import Image | |
| from PIL.PngImagePlugin import PngInfo | |
| # ─── Configuration ───────────────────────────────────────────────────────────── | |
| ENABLE_STEALTH_METADATA = False # Embed PNG's "parameters" field data into webp's alpha channels | |
| ENABLE_USERCOMMENT_METADATA = True # Embed PNG's "Parameter" exif field data into webp's "UserComment" field | |
| WEBP_LOSSLESS = False # Set to False for lossy WebP, True for lossless | |
| WEBP_LOSSY_QUALITY = 95 # Quality for lossy WebP (1-100), only used if WEBP_LOSSLESS is False | |
| SAVE_AS_ANIMATED = True # (True for Discord metadata hack, has no drawbacks, probably) | |
| FIELDS_TO_REMOVE = [ | |
| "prompt", | |
| "workflow", | |
| ] # doesn't have any effect in the end no matter the option (unless its "parameters") but might be used in future | |
| SCRIPT_DIR = Path(__file__).resolve().parent | |
| TARGET_DIR = SCRIPT_DIR / "wf_r" | |
| # ─── Colorful Logging ────────────────────────────────────────────────────────── | |
| # region logging | |
| RESET = "\033[0m" | |
| COLOR_DEBUG = "\033[92m" # Green | |
| COLOR_INFO = "\033[94m" # Bright Blue | |
| COLOR_WARNING = "\033[93m" # Yellow | |
| COLOR_ERROR = "\033[91m" # Red | |
| COLOR_CRITICAL = "\033[95m" # Magenta | |
| class ColorFormatter(logging.Formatter): | |
| COLORS = { | |
| "DEBUG": COLOR_DEBUG, | |
| "INFO": COLOR_INFO, | |
| "WARNING": COLOR_WARNING, | |
| "ERROR": COLOR_ERROR, | |
| "CRITICAL": COLOR_CRITICAL, | |
| } | |
| def format(self, record): | |
| msg = super().format(record) | |
| color = self.COLORS.get(record.levelname, RESET) | |
| return f"{color}{msg}{RESET}" | |
| handler = logging.StreamHandler() | |
| formatter = ColorFormatter("%(levelname)s: %(message)s") | |
| handler.setFormatter(formatter) | |
| logger = logging.getLogger() | |
| logger.handlers = [] # Clear existing handlers | |
| logger.addHandler(handler) | |
| logger.setLevel(logging.INFO) | |
| # endregion | |
| # ─── Startup Settings Printout ────────────────────────────────────────────────── | |
| def print_settings(): | |
| # Using bright cyan for keys and white for values | |
| CYAN = "\033[96m" | |
| WHITE = "\033[97m" | |
| BRIGHT_GREEN = "\033[92m" | |
| RED = "\033[91m" | |
| # print(f"{CYAN}Startup Settings:{RESET}") | |
| settings = { | |
| "Stealth?": ENABLE_STEALTH_METADATA, | |
| "Normal?": ENABLE_USERCOMMENT_METADATA, | |
| "Lossy?": not WEBP_LOSSLESS, # yeah kinda cringe i know but i want it to say lossy | |
| "Lossy Quality": WEBP_LOSSY_QUALITY, | |
| "Saved as Animated WebP?": SAVE_AS_ANIMATED, | |
| # "FIELDS_TO_REMOVE": FIELDS_TO_REMOVE, | |
| "SCRIPT_DIR": SCRIPT_DIR, | |
| # "TARGET_DIR": TARGET_DIR, | |
| } | |
| if WEBP_LOSSLESS: | |
| settings.pop("Lossy Quality") | |
| for key, value in settings.items(): | |
| if isinstance(value, bool): | |
| color = BRIGHT_GREEN if value else RED | |
| print(f"{CYAN}{key}:{RESET} {color}{value}{RESET}") | |
| else: | |
| print(f"{CYAN}{key}:{RESET} {WHITE}{value}{RESET}") | |
| print() # Blank line for spacing | |
| # ─── Metadata Handling ───────────────────────────────────────────────────────── | |
| def encode_user_comment(text: str) -> bytes: | |
| """Encodes text for EXIF UserComment in the most compatible way. | |
| - Uses 'UNICODE\0' marker (expected by most readers) | |
| - Actually encodes in UTF-8 (smaller size, full Unicode support) | |
| - Works with exiftool, websites, and most metadata tools. | |
| """ | |
| encoding_marker = b"UNICODE\0" # 8-byte null-terminated ASCII | |
| encoded_text = text.encode("utf-8") | |
| return encoding_marker + encoded_text | |
| # ─── Stealth Metadata Embedding ──────────────────────────────────────────────── | |
| def embed_stealth_metadata(image: Image.Image, metadata: str, compressed: bool = True) -> Image.Image: | |
| if image.mode != "RGBA": | |
| image = image.convert("RGBA") | |
| signature = "stealth_pngcomp" if compressed else "stealth_pnginfo" | |
| binary_signature = "".join(format(byte, "08b") for byte in signature.encode("utf-8")) | |
| if compressed: | |
| metadata_bytes = gzip.compress(metadata.encode("utf-8")) | |
| else: | |
| metadata_bytes = metadata.encode("utf-8") | |
| binary_metadata = "".join(format(byte, "08b") for byte in metadata_bytes) | |
| binary_param_len = format(len(binary_metadata), "032b") | |
| binary_data = binary_signature + binary_param_len + binary_metadata | |
| pixels = image.load() | |
| width, height = image.size | |
| index = 0 | |
| end_write = False | |
| for x in range(width): | |
| for y in range(height): | |
| if index >= len(binary_data): | |
| end_write = True | |
| break | |
| r, g, b, a = pixels[x, y] | |
| a = (a & ~1) | int(binary_data[index]) | |
| pixels[x, y] = (r, g, b, a) | |
| index += 1 | |
| if end_write: | |
| break | |
| return image | |
| # ─── Image Conversion Logic ──────────────────────────────────────────────────── | |
| def remove_png_metadata_and_convert(image_filename: str, metadata_fields_to_remove, start_dir: Path): | |
| image_path = start_dir / image_filename | |
| new_webp_path = TARGET_DIR / image_filename.replace(".png", ".webp") | |
| if new_webp_path.exists(): | |
| logging.info(f"Skipping conversion for {image_filename}: WebP already exists.") | |
| return new_webp_path | |
| with Image.open(image_path) as img: | |
| if img.mode not in ("RGB", "L", "RGBA"): | |
| img = img.convert("RGB") | |
| img.save(image_path) | |
| logging.info(f"Converted {image_filename} to \033[91mR\033[0m\033[92mG\033[0m\033[94mB\033[0m mode.") | |
| img = Image.open(image_path) | |
| metadata = img.text | |
| new_metadata = PngInfo() | |
| for key, value in metadata.items(): | |
| if key not in metadata_fields_to_remove: | |
| new_metadata.add_text(key, value) | |
| parameters = metadata.get("parameters", "") | |
| if parameters and ENABLE_STEALTH_METADATA: | |
| img = embed_stealth_metadata(img, parameters, compressed=True) | |
| png_buffer = BytesIO() | |
| img.save(png_buffer, "PNG", pnginfo=new_metadata) | |
| png_buffer.seek(0) | |
| cleaned_img = Image.open(png_buffer) | |
| new_webp_path.parent.mkdir(parents=True, exist_ok=True) | |
| icc_profile = cleaned_img.info.get("icc_profile") | |
| save_options = { | |
| "method": 6, | |
| "icc_profile": icc_profile, | |
| } | |
| if WEBP_LOSSLESS: | |
| save_options["lossless"] = True | |
| save_options["quality"] = 100 | |
| else: | |
| save_options["lossless"] = False | |
| save_options["quality"] = WEBP_LOSSY_QUALITY | |
| if ENABLE_STEALTH_METADATA and cleaned_img.mode == "RGBA": | |
| save_options["alpha_quality"] = 100 | |
| # Save as animated WebP if enabled (Discord metadata hack) | |
| if SAVE_AS_ANIMATED: | |
| # To make it appear like an animated webp to Discord, | |
| # here we create a single frame "animation", a noticeable duration, and set it to loop infinitely. | |
| save_options["append_images"] = [] # None since it's single frame | |
| save_options["duration"] = 200 # Duration in milliseconds for each frame (e.g., 200ms per frame) | |
| save_options["loop"] = 0 # Loop in ms (0 = infinite loop) | |
| save_options["minimize_size"] = False # Preserve quality | |
| save_options["save_all"] = True # Required for animated WebP | |
| # Prepare EXIF data to be embedded by Pillow during save | |
| if parameters and ENABLE_USERCOMMENT_METADATA: | |
| try: | |
| # Ensure parameters is a string for UserComment.dump | |
| user_comment_str = str(parameters) | |
| # piexif.helper.UserComment.dump handles the encoding prefix (e.g., "UNICODE\0") | |
| user_comment_bytes = piexif.helper.UserComment.dump(user_comment_str, encoding="unicode") | |
| exif_dict = {"Exif": {piexif.ExifIFD.UserComment: user_comment_bytes}} | |
| exif_bytes_to_embed = piexif.dump(exif_dict) | |
| if exif_bytes_to_embed: | |
| save_options["exif"] = exif_bytes_to_embed | |
| except Exception as e: | |
| logging.warning(f"Could not prepare EXIF UserComment for embedding: {e}") | |
| cleaned_img.save(new_webp_path, "WEBP", **save_options) | |
| # The piexif.insert call is removed as EXIF is now handled by Pillow's save method. | |
| logging.info(f"Converted: {new_webp_path}") | |
| return new_webp_path | |
| # ─── Batch Processor ─────────────────────────────────────────────────────────── | |
| def process_images_in_directory(start_dir: Path, metadata_fields_to_remove): | |
| for root, dirs, files in os.walk(start_dir): | |
| for file in files: | |
| if file.lower().endswith(".png"): | |
| rel_path = Path(root).relative_to(start_dir) / file | |
| try: | |
| remove_png_metadata_and_convert(str(rel_path), metadata_fields_to_remove, start_dir) | |
| except Exception as e: | |
| logging.error(f"Error processing {file}: {e}") | |
| # ─── Main ────────────────────────────────────────────────────────────────────── | |
| if __name__ == "__main__": | |
| print_settings() | |
| process_images_in_directory(SCRIPT_DIR, FIELDS_TO_REMOVE) | |
| input("\n\nfinished...") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment