From 2b6d4e5d8b24780525c494615fe6831867ba0ab0 Mon Sep 17 00:00:00 2001 From: "s.ivanov" Date: Mon, 15 Jun 2026 09:28:49 +0200 Subject: [PATCH] Add AVIF and JXL image support with brotli metadata decompression --- py/middleware/cache_middleware.py | 2 + py/utils/constants.py | 4 +- py/utils/example_images_processor.py | 6 + py/utils/exif_utils.py | 114 ++++++++++++++++-- requirements.txt | 4 + .../shared/showcase/ShowcaseView.js | 6 +- 6 files changed, 125 insertions(+), 11 deletions(-) diff --git a/py/middleware/cache_middleware.py b/py/middleware/cache_middleware.py index 4df22b30..62633910 100644 --- a/py/middleware/cache_middleware.py +++ b/py/middleware/cache_middleware.py @@ -16,6 +16,8 @@ IMG_EXTENSIONS = ( ".tif", ".tiff", ".webp", + ".avif", + ".jxl", ".mp4" ) diff --git a/py/utils/constants.py b/py/utils/constants.py index 99f08965..cf812a06 100644 --- a/py/utils/constants.py +++ b/py/utils/constants.py @@ -31,6 +31,8 @@ PREVIEW_EXTENSIONS = [ ".mp4", ".gif", ".webm", + ".avif", + ".jxl", ] # Card preview image width @@ -41,7 +43,7 @@ EXAMPLE_IMAGE_WIDTH = 832 # Supported media extensions for example downloads SUPPORTED_MEDIA_EXTENSIONS = { - "images": [".jpg", ".jpeg", ".png", ".webp", ".gif"], + "images": [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif", ".jxl"], "videos": [".mp4", ".webm"], } diff --git a/py/utils/example_images_processor.py b/py/utils/example_images_processor.py index a44b0c61..101f239d 100644 --- a/py/utils/example_images_processor.py +++ b/py/utils/example_images_processor.py @@ -62,6 +62,10 @@ class ExampleImagesProcessor: return '.gif' elif content.startswith(b'RIFF') and b'WEBP' in content[:12]: return '.webp' + elif len(content) >= 12 and content[4:8] == b'ftyp' and b'avif' in content[8:24]: + return '.avif' + elif content.startswith(b'\x00\x00\x00\x0cJXL \x0d\x0a\x87\x0a'): + return '.jxl' elif content.startswith(b'\x00\x00\x00\x18ftypmp4') or content.startswith(b'\x00\x00\x00\x20ftypmp4'): return '.mp4' elif content.startswith(b'\x1A\x45\xDF\xA3'): @@ -75,6 +79,8 @@ class ExampleImagesProcessor: 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp', + 'image/avif': '.avif', + 'image/jxl': '.jxl', 'video/mp4': '.mp4', 'video/webm': '.webm', 'video/quicktime': '.mov' diff --git a/py/utils/exif_utils.py b/py/utils/exif_utils.py index df0f26d1..08e0cd14 100644 --- a/py/utils/exif_utils.py +++ b/py/utils/exif_utils.py @@ -1,17 +1,115 @@ import json import logging import os +import struct from io import BytesIO from typing import Any, Optional import piexif from PIL import Image, PngImagePlugin +try: + import brotli + _BROTLI_AVAILABLE = True +except ImportError: + brotli = None + _BROTLI_AVAILABLE = False + logger = logging.getLogger(__name__) class ExifUtils: """Utility functions for working with EXIF data in images""" + @staticmethod + def _parse_isobmff_boxes(data: bytes, offset: int = 0) -> list[dict]: + boxes = [] + while offset + 8 <= len(data): + size = struct.unpack('>I', data[offset:offset + 4])[0] + box_type = data[offset + 4:offset + 8] + if size == 0: + break + if size < 8 or offset + size > len(data): + break + box_data = data[offset + 8:offset + size] + boxes.append({'type': box_type, 'data': box_data, 'size': size}) + offset += size + return boxes + + @staticmethod + def _is_jxl_container(data: bytes) -> bool: + if len(data) < 32: + return False + return ( + struct.unpack('>I', data[:4])[0] == 12 + and data[4:8] == b'JXL ' + and data[8:12] == bytes([0x0d, 0x0a, 0x87, 0x0a]) + and struct.unpack('>I', data[12:16])[0] == 20 + and data[16:20] == b'ftyp' + and data[20:24] == b'jxl ' + ) + + @staticmethod + def _is_avif_container(data: bytes) -> bool: + if len(data) < 16: + return False + for box in ExifUtils._parse_isobmff_boxes(data): + if box['type'] == b'ftyp' and b'avif' in box['data']: + return True + return False + + @staticmethod + def _extract_isobmff_brotli(image_path: str) -> Optional[dict]: + try: + with open(image_path, 'rb') as f: + data = f.read() + except Exception: + return None + + if ExifUtils._is_jxl_container(data): + boxes = ExifUtils._parse_isobmff_boxes(data, offset=12) + elif ExifUtils._is_avif_container(data): + boxes = ExifUtils._parse_isobmff_boxes(data) + else: + return None + + brob = None + for box in boxes: + if box['type'] == b'brob': + brob = box + break + if brob is None: + return None + + payload = brob['data'] + if payload[:4] != b'comf': + return None + compressed = payload[4:] + + if _BROTLI_AVAILABLE: + try: + decompressed = brotli.decompress(compressed) + except Exception: + decompressed = None + else: + decompressed = None + + raw = decompressed if decompressed is not None else compressed + try: + meta = json.loads(raw.decode('utf-8')) + except Exception: + return None + + result = {"parameters": None, "prompt": None, "workflow": None, "comment": None} + if isinstance(meta.get("prompt"), (dict, list)): + result["prompt"] = json.dumps(meta["prompt"]) + elif isinstance(meta.get("prompt"), str): + result["prompt"] = meta["prompt"] + if isinstance(meta.get("workflow"), (dict, list)): + result["workflow"] = json.dumps(meta["workflow"]) + elif isinstance(meta.get("workflow"), str): + result["workflow"] = meta["workflow"] + return result + @staticmethod def _decode_user_comment(user_comment: Any) -> Optional[str]: if user_comment is None: @@ -43,6 +141,12 @@ class ExifUtils: "comment": None, } + ext = os.path.splitext(image_path)[1].lower() + if ext in ('.avif', '.jxl'): + brotli_meta = ExifUtils._extract_isobmff_brotli(image_path) + if brotli_meta: + return brotli_meta + with Image.open(image_path) as img: info = getattr(img, "info", {}) or {} @@ -149,7 +253,6 @@ class ExifUtils: Optional[str]: Extracted metadata or None if not found """ try: - # Skip for video files if image_path: ext = os.path.splitext(image_path)[1].lower() if ext in ['.mp4', '.webm']: @@ -177,10 +280,9 @@ class ExifUtils: str: Path to the updated image """ try: - # Skip for video files if image_path: ext = os.path.splitext(image_path)[1].lower() - if ext in ['.mp4', '.webm']: + if ext in ['.mp4', '.webm', '.avif', '.jxl']: return image_path metadata_fields = ExifUtils._load_structured_metadata(image_path) @@ -212,10 +314,9 @@ class ExifUtils: def append_recipe_metadata(image_path, recipe_data) -> str: """Append recipe metadata to an image's EXIF data""" try: - # Skip for video files if image_path: ext = os.path.splitext(image_path)[1].lower() - if ext in ['.mp4', '.webm']: + if ext in ['.mp4', '.webm', '.avif', '.jxl']: return image_path # First, extract existing metadata @@ -327,10 +428,9 @@ class ExifUtils: Tuple of (optimized_image_data, extension) """ try: - # Skip for video files early if it's a file path if isinstance(image_data, str) and os.path.exists(image_data): ext = os.path.splitext(image_data)[1].lower() - if ext in ['.mp4', '.webm']: + if ext in ['.mp4', '.webm', '.avif', '.jxl']: try: with open(image_data, 'rb') as f: return f.read(), ext diff --git a/requirements.txt b/requirements.txt index 21c7d851..e8e97ea9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,7 @@ aiosqlite beautifulsoup4 platformdirs pyyaml +# imagecodecs provides JXL/AVIF encode/decode with full ISOBMFF control +# brotli provides metadata compression for JXL/AVIF custom boxes +imagecodecs +brotli \ No newline at end of file diff --git a/static/js/components/shared/showcase/ShowcaseView.js b/static/js/components/shared/showcase/ShowcaseView.js index d24992fb..bcba22b4 100644 --- a/static/js/components/shared/showcase/ShowcaseView.js +++ b/static/js/components/shared/showcase/ShowcaseView.js @@ -355,9 +355,9 @@ function renderImportInterface(isEmpty) { -

Supported formats: jpg, png, gif, webp, mp4, webm

+

Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm

- +