Merge pull request #982 from koloved/main

Add AVIF and JXL image support with brotli metadata decompression
This commit is contained in:
Will Miao
2026-06-16 16:24:30 +08:00
6 changed files with 123 additions and 11 deletions

View File

@@ -16,6 +16,8 @@ IMG_EXTENSIONS = (
".tif", ".tif",
".tiff", ".tiff",
".webp", ".webp",
".avif",
".jxl",
".mp4" ".mp4"
) )

View File

@@ -31,6 +31,8 @@ PREVIEW_EXTENSIONS = [
".mp4", ".mp4",
".gif", ".gif",
".webm", ".webm",
".avif",
".jxl",
] ]
# Card preview image width # Card preview image width
@@ -41,7 +43,7 @@ EXAMPLE_IMAGE_WIDTH = 832
# Supported media extensions for example downloads # Supported media extensions for example downloads
SUPPORTED_MEDIA_EXTENSIONS = { SUPPORTED_MEDIA_EXTENSIONS = {
"images": [".jpg", ".jpeg", ".png", ".webp", ".gif"], "images": [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif", ".jxl"],
"videos": [".mp4", ".webm"], "videos": [".mp4", ".webm"],
} }

View File

@@ -62,6 +62,10 @@ class ExampleImagesProcessor:
return '.gif' return '.gif'
elif content.startswith(b'RIFF') and b'WEBP' in content[:12]: elif content.startswith(b'RIFF') and b'WEBP' in content[:12]:
return '.webp' 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'): elif content.startswith(b'\x00\x00\x00\x18ftypmp4') or content.startswith(b'\x00\x00\x00\x20ftypmp4'):
return '.mp4' return '.mp4'
elif content.startswith(b'\x1A\x45\xDF\xA3'): elif content.startswith(b'\x1A\x45\xDF\xA3'):
@@ -75,6 +79,8 @@ class ExampleImagesProcessor:
'image/png': '.png', 'image/png': '.png',
'image/gif': '.gif', 'image/gif': '.gif',
'image/webp': '.webp', 'image/webp': '.webp',
'image/avif': '.avif',
'image/jxl': '.jxl',
'video/mp4': '.mp4', 'video/mp4': '.mp4',
'video/webm': '.webm', 'video/webm': '.webm',
'video/quicktime': '.mov' 'video/quicktime': '.mov'

View File

@@ -1,17 +1,115 @@
import json import json
import logging import logging
import os import os
import struct
from io import BytesIO from io import BytesIO
from typing import Any, Optional from typing import Any, Optional
import piexif import piexif
from PIL import Image, PngImagePlugin from PIL import Image, PngImagePlugin
try:
import brotli
_BROTLI_AVAILABLE = True
except ImportError:
brotli = None
_BROTLI_AVAILABLE = False
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ExifUtils: class ExifUtils:
"""Utility functions for working with EXIF data in images""" """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 @staticmethod
def _decode_user_comment(user_comment: Any) -> Optional[str]: def _decode_user_comment(user_comment: Any) -> Optional[str]:
if user_comment is None: if user_comment is None:
@@ -43,6 +141,12 @@ class ExifUtils:
"comment": None, "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: with Image.open(image_path) as img:
info = getattr(img, "info", {}) or {} info = getattr(img, "info", {}) or {}
@@ -149,7 +253,6 @@ class ExifUtils:
Optional[str]: Extracted metadata or None if not found Optional[str]: Extracted metadata or None if not found
""" """
try: try:
# Skip for video files
if image_path: if image_path:
ext = os.path.splitext(image_path)[1].lower() ext = os.path.splitext(image_path)[1].lower()
if ext in ['.mp4', '.webm']: if ext in ['.mp4', '.webm']:
@@ -177,10 +280,9 @@ class ExifUtils:
str: Path to the updated image str: Path to the updated image
""" """
try: try:
# Skip for video files
if image_path: if image_path:
ext = os.path.splitext(image_path)[1].lower() ext = os.path.splitext(image_path)[1].lower()
if ext in ['.mp4', '.webm']: if ext in ['.mp4', '.webm', '.avif', '.jxl']:
return image_path return image_path
metadata_fields = ExifUtils._load_structured_metadata(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: def append_recipe_metadata(image_path, recipe_data) -> str:
"""Append recipe metadata to an image's EXIF data""" """Append recipe metadata to an image's EXIF data"""
try: try:
# Skip for video files
if image_path: if image_path:
ext = os.path.splitext(image_path)[1].lower() ext = os.path.splitext(image_path)[1].lower()
if ext in ['.mp4', '.webm']: if ext in ['.mp4', '.webm', '.avif', '.jxl']:
return image_path return image_path
# First, extract existing metadata # First, extract existing metadata
@@ -327,10 +428,9 @@ class ExifUtils:
Tuple of (optimized_image_data, extension) Tuple of (optimized_image_data, extension)
""" """
try: try:
# Skip for video files early if it's a file path
if isinstance(image_data, str) and os.path.exists(image_data): if isinstance(image_data, str) and os.path.exists(image_data):
ext = os.path.splitext(image_data)[1].lower() ext = os.path.splitext(image_data)[1].lower()
if ext in ['.mp4', '.webm']: if ext in ['.mp4', '.webm', '.avif', '.jxl']:
try: try:
with open(image_data, 'rb') as f: with open(image_data, 'rb') as f:
return f.read(), ext return f.read(), ext

View File

@@ -13,3 +13,5 @@ aiosqlite
beautifulsoup4 beautifulsoup4
platformdirs platformdirs
pyyaml pyyaml
# brotli — ISOBMFF (AVIF/JXL) metadata decompression
brotli>=1.2.0

View File

@@ -355,9 +355,9 @@ function renderImportInterface(isEmpty) {
<button class="select-files-btn" id="selectExampleFilesBtn"> <button class="select-files-btn" id="selectExampleFilesBtn">
<i class="fas fa-folder-open"></i> Select Files <i class="fas fa-folder-open"></i> Select Files
</button> </button>
<p class="import-formats">Supported formats: jpg, png, gif, webp, mp4, webm</p> <p class="import-formats">Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm</p>
</div> </div>
<input type="file" id="exampleFilesInput" multiple accept="image/*,video/mp4,video/webm" style="display: none;"> <input type="file" id="exampleFilesInput" multiple accept="image/*,image/avif,image/jxl,video/mp4,video/webm" style="display: none;">
<div class="import-progress-container" style="display: none;"> <div class="import-progress-container" style="display: none;">
<div class="import-progress"> <div class="import-progress">
<div class="progress-bar"></div> <div class="progress-bar"></div>
@@ -473,7 +473,7 @@ export function initExampleImport(modelHash, container) {
*/ */
async function handleImportFiles(files, modelHash, importContainer) { async function handleImportFiles(files, modelHash, importContainer) {
// Filter for supported file types // Filter for supported file types
const supportedImages = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; const supportedImages = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.jxl'];
const supportedVideos = ['.mp4', '.webm']; const supportedVideos = ['.mp4', '.webm'];
const supportedExtensions = [...supportedImages, ...supportedVideos]; const supportedExtensions = [...supportedImages, ...supportedVideos];