Skip to content

Instantly share code, notes, and snippets.

@DraconicDragon
Last active October 25, 2025 23:12
Show Gist options
  • Select an option

  • Save DraconicDragon/a00740bcb6dc54070b48c69a141b1c62 to your computer and use it in GitHub Desktop.

Select an option

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…
# 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