Add support for modern image formats (WebP and AVIF) and implement image conversion commands

This commit is contained in:
Joakim Hellsén 2026-02-12 21:29:17 +01:00
commit 477bb753ae
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
15 changed files with 629 additions and 93 deletions

View file

@ -0,0 +1,188 @@
"""Management command to convert existing images to WebP and AVIF formats."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from django.conf import settings
from django.core.management.base import BaseCommand
from PIL import Image
if TYPE_CHECKING:
from argparse import ArgumentParser
logger = logging.getLogger("ttvdrops")
class Command(BaseCommand):
"""Convert all existing JPG/PNG images to WebP and AVIF formats."""
help = "Convert existing images in MEDIA_ROOT to WebP and AVIF formats"
def add_arguments(self, parser: ArgumentParser) -> None:
"""Add command-line arguments."""
parser.add_argument(
"--overwrite",
action="store_true",
help="Overwrite existing WebP/AVIF files",
)
parser.add_argument(
"--quality",
type=int,
default=85,
help="Quality for WebP/AVIF encoding (1-100, default: 85)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be converted without actually converting",
)
def handle(self, **options) -> None:
"""Execute the command."""
overwrite: bool = bool(options.get("overwrite"))
quality: int = int(options.get("quality", 85))
dry_run: bool = bool(options.get("dry_run"))
media_root = Path(settings.MEDIA_ROOT)
if not media_root.exists():
self.stdout.write(self.style.WARNING(f"MEDIA_ROOT does not exist: {media_root}"))
return
# Find all JPG and PNG files
image_extensions = {".jpg", ".jpeg", ".png"}
image_files = [f for f in media_root.rglob("*") if f.is_file() and f.suffix.lower() in image_extensions]
if not image_files:
self.stdout.write(self.style.SUCCESS("No images found to convert"))
return
self.stdout.write(f"Found {len(image_files)} images to process")
converted_count = 0
skipped_count = 0
error_count = 0
for image_path in image_files:
base_path = image_path.with_suffix("")
webp_path = base_path.with_suffix(".webp")
avif_path = base_path.with_suffix(".avif")
# Check if conversion is needed
needs_webp = overwrite or not webp_path.exists()
needs_avif = overwrite or not avif_path.exists()
if not needs_webp and not needs_avif:
skipped_count += 1
continue
if dry_run:
self.stdout.write(f"Would convert: {image_path.relative_to(media_root)}")
if needs_webp:
self.stdout.write(f"{webp_path.relative_to(media_root)}")
if needs_avif:
self.stdout.write(f"{avif_path.relative_to(media_root)}")
converted_count += 1
continue
try:
result = self._convert_image(
image_path,
webp_path if needs_webp else None,
avif_path if needs_avif else None,
quality,
media_root,
)
if result:
converted_count += 1
else:
error_count += 1
except Exception as e:
error_count += 1
self.stdout.write(
self.style.ERROR(f"✗ Error converting {image_path.relative_to(media_root)}: {e}"),
)
logger.exception("Failed to convert image: %s", image_path)
# Summary
self.stdout.write("\n" + "=" * 50)
if dry_run:
self.stdout.write(self.style.SUCCESS(f"Dry run complete. Would convert {converted_count} images"))
else:
self.stdout.write(self.style.SUCCESS(f"Converted: {converted_count}"))
self.stdout.write(f"Skipped (already exist): {skipped_count}")
if error_count > 0:
self.stdout.write(self.style.ERROR(f"Errors: {error_count}"))
def _convert_image(
self,
source_path: Path,
webp_path: Path | None,
avif_path: Path | None,
quality: int,
media_root: Path,
) -> bool:
"""Convert a single image to WebP and/or AVIF.
Args:
source_path: Path to the source image
webp_path: Path for WebP output (None to skip)
avif_path: Path for AVIF output (None to skip)
quality: Quality setting for encoding
media_root: Media root path for relative display
Returns:
True if conversion succeeded, False otherwise
"""
with Image.open(source_path) as original_img:
# Convert to RGB if needed (AVIF doesn't support RGBA well)
rgb_img = self._prepare_image_for_encoding(original_img)
# Save as WebP
if webp_path:
rgb_img.save(
webp_path,
"WEBP",
quality=quality,
method=6, # Slowest but best compression
)
self.stdout.write(
self.style.SUCCESS(f"✓ WebP: {webp_path.relative_to(media_root)}"),
)
# Save as AVIF
if avif_path:
rgb_img.save(
avif_path,
"AVIF",
quality=quality,
speed=4, # 0-10, lower is slower but better compression
)
self.stdout.write(
self.style.SUCCESS(f"✓ AVIF: {avif_path.relative_to(media_root)}"),
)
return True
def _prepare_image_for_encoding(self, img: Image.Image) -> Image.Image:
"""Prepare an image for WebP/AVIF encoding by converting to RGB.
Args:
img: Source PIL Image
Returns:
RGB PIL Image ready for encoding
"""
if img.mode in {"RGBA", "LA"} or (img.mode == "P" and "transparency" in img.info):
# Create white background for transparency
background = Image.new("RGB", img.size, (255, 255, 255))
rgba_img = img.convert("RGBA") if img.mode == "P" else img
background.paste(rgba_img, mask=rgba_img.split()[-1] if rgba_img.mode in {"RGBA", "LA"} else None)
return background
if img.mode != "RGB":
return img.convert("RGB")
return img

View file

@ -10,6 +10,7 @@ from django.conf import settings
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand
from django.core.management.base import CommandParser
from PIL import Image
from twitch.models import Game
from twitch.utils import is_twitch_box_art_url
@ -89,6 +90,10 @@ class Command(BaseCommand):
continue
game.box_art_file.save(file_name, ContentFile(response.content), save=True)
# Auto-convert to WebP and AVIF
self._convert_to_modern_formats(game.box_art_file.path)
downloaded += 1
self.stdout.write(
@ -99,3 +104,40 @@ class Command(BaseCommand):
)
box_art_dir: Path = Path(settings.MEDIA_ROOT) / "games" / "box_art"
self.stdout.write(self.style.SUCCESS(f"Saved box art to: {box_art_dir}"))
def _convert_to_modern_formats(self, image_path: str) -> None:
"""Convert downloaded image to WebP and AVIF formats.
Args:
image_path: Absolute path to the downloaded image file
"""
try:
source_path = Path(image_path)
if not source_path.exists() or source_path.suffix.lower() not in {".jpg", ".jpeg", ".png"}:
return
base_path = source_path.with_suffix("")
webp_path = base_path.with_suffix(".webp")
avif_path = base_path.with_suffix(".avif")
with Image.open(source_path) as img:
# Convert to RGB if needed
if img.mode in {"RGBA", "LA"} or (img.mode == "P" and "transparency" in img.info):
background = Image.new("RGB", img.size, (255, 255, 255))
rgba_img = img.convert("RGBA") if img.mode == "P" else img
background.paste(rgba_img, mask=rgba_img.split()[-1] if rgba_img.mode in {"RGBA", "LA"} else None)
rgb_img = background
elif img.mode != "RGB":
rgb_img = img.convert("RGB")
else:
rgb_img = img
# Save WebP
rgb_img.save(webp_path, "WEBP", quality=85, method=6)
# Save AVIF
rgb_img.save(avif_path, "AVIF", quality=85, speed=4)
except (OSError, ValueError) as e:
# Don't fail the download if conversion fails
self.stdout.write(self.style.WARNING(f"Failed to convert {image_path}: {e}"))

View file

@ -12,6 +12,7 @@ from django.conf import settings
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand
from django.core.management.base import CommandParser
from PIL import Image
from twitch.models import DropBenefit
from twitch.models import DropCampaign
@ -245,10 +246,51 @@ class Command(BaseCommand):
# Save the image to the FileField
if hasattr(file_field, "save"):
file_field.save(file_name, ContentFile(response.content), save=True)
# Auto-convert to WebP and AVIF
self._convert_to_modern_formats(file_field.path)
return "downloaded"
return "failed"
def _convert_to_modern_formats(self, image_path: str) -> None:
"""Convert downloaded image to WebP and AVIF formats.
Args:
image_path: Absolute path to the downloaded image file
"""
try:
source_path = Path(image_path)
if not source_path.exists() or source_path.suffix.lower() not in {".jpg", ".jpeg", ".png"}:
return
base_path = source_path.with_suffix("")
webp_path = base_path.with_suffix(".webp")
avif_path = base_path.with_suffix(".avif")
with Image.open(source_path) as img:
# Convert to RGB if needed
if img.mode in {"RGBA", "LA"} or (img.mode == "P" and "transparency" in img.info):
background = Image.new("RGB", img.size, (255, 255, 255))
rgba_img = img.convert("RGBA") if img.mode == "P" else img
background.paste(rgba_img, mask=rgba_img.split()[-1] if rgba_img.mode in {"RGBA", "LA"} else None)
rgb_img = background
elif img.mode != "RGB":
rgb_img = img.convert("RGB")
else:
rgb_img = img
# Save WebP
rgb_img.save(webp_path, "WEBP", quality=85, method=6)
# Save AVIF
rgb_img.save(avif_path, "AVIF", quality=85, speed=4)
except (OSError, ValueError) as e:
# Don't fail the download if conversion fails
self.stdout.write(self.style.WARNING(f"Failed to convert {image_path}: {e}"))
def _merge_stats(self, total: dict[str, int], new: dict[str, int]) -> None:
"""Merge statistics from a single model into the total stats."""
for key in ["total", "downloaded", "skipped", "failed", "placeholders_404"]:

View file

@ -0,0 +1 @@
# Template tags for the twitch app

View file

@ -0,0 +1,103 @@
"""Custom template tags for rendering responsive images with modern formats."""
from __future__ import annotations
from typing import TYPE_CHECKING
from urllib.parse import urlparse
from django import template
from django.utils.html import format_html
from django.utils.safestring import SafeString
if TYPE_CHECKING:
from django.utils.safestring import SafeText
register = template.Library()
def get_format_url(image_url: str, fmt: str) -> str:
"""Convert an image URL to a different format.
Args:
image_url: The original image URL
fmt: The target format (webp or avif)
Returns:
The URL with the new format extension
"""
if not image_url:
return ""
# Parse the URL to separate the path from query params
parsed = urlparse(image_url)
path = parsed.path
# Only convert jpg, jpeg, and png to modern formats
if not path.lower().endswith((".jpg", ".jpeg", ".png")):
return image_url
# Replace extension with new format using string manipulation to preserve forward slashes
# (Path would convert to backslashes on Windows)
dot_index = path.rfind(".")
new_path = path[:dot_index] + f".{fmt}"
# Reconstruct URL with new path
return parsed._replace(path=new_path).geturl()
@register.simple_tag
def picture( # noqa: PLR0913, PLR0917
src: str,
alt: str = "",
width: int | None = None,
height: int | None = None,
loading: str = "lazy",
css_class: str = "",
style: str = "",
) -> SafeText:
"""Render a responsive picture element with modern image formats.
Args:
src: The source image URL (jpg/png fallback)
alt: Alt text for the image
width: Width attribute
height: Height attribute
loading: Loading strategy (lazy/eager)
css_class: CSS class to apply
style: Inline styles to apply
Returns:
SafeText containing the picture element HTML
"""
if not src:
return SafeString("")
# Generate URLs for modern formats
avif_url: str = get_format_url(src, "avif")
webp_url: str = get_format_url(src, "webp")
# Build source elements using format_html for safety
sources: list[SafeString] = []
# AVIF first (best compression)
if avif_url != src:
sources.append(format_html('<source srcset="{}" type="image/avif" />', avif_url))
# WebP second (good compression, widely supported)
if webp_url != src:
sources.append(format_html('<source srcset="{}" type="image/webp" />', webp_url))
# Build img tag with format_html
img_html: SafeString = format_html(
format_string='<img src="{src}"{width}{height}{loading}{css_class}{style}{alt} />',
src=src,
width=format_html(' width="{}"', width) if width else "",
height=format_html(' height="{}"', height) if height else "",
loading=format_html(' loading="{}"', loading) if loading else "",
css_class=format_html(' class="{}"', css_class) if css_class else "",
style=format_html(' style="{}"', style) if style else "",
alt=format_html(' alt="{}"', alt) if alt is not None else "",
)
# Combine all parts safely
return format_html("<picture>{}{}</picture>", SafeString("".join(sources)), img_html)

View file

@ -0,0 +1,227 @@
"""Tests for custom image template tags."""
from __future__ import annotations
from django.template import Context
from django.template import Template
from django.utils.safestring import SafeString
from twitch.templatetags.image_tags import get_format_url
from twitch.templatetags.image_tags import picture
class TestGetFormatUrl:
"""Tests for the get_format_url helper function."""
def test_empty_url(self) -> None:
"""Test that empty URL returns empty string."""
assert not get_format_url("", "webp")
def test_jpg_to_webp(self) -> None:
"""Test converting JPG to WebP."""
assert get_format_url("/static/img/banner.jpg", "webp") == "/static/img/banner.webp"
def test_jpeg_to_avif(self) -> None:
"""Test converting JPEG to AVIF."""
assert get_format_url("/static/img/photo.jpeg", "avif") == "/static/img/photo.avif"
def test_png_to_webp(self) -> None:
"""Test converting PNG to WebP."""
assert get_format_url("/static/img/logo.png", "webp") == "/static/img/logo.webp"
def test_uppercase_extension(self) -> None:
"""Test converting uppercase extensions."""
assert get_format_url("/static/img/photo.JPG", "webp") == "/static/img/photo.webp"
def test_non_convertible_format(self) -> None:
"""Test that non-convertible formats return unchanged."""
svg_url = "/static/img/icon.svg"
assert get_format_url(svg_url, "webp") == svg_url
gif_url = "/static/img/animated.gif"
assert get_format_url(gif_url, "avif") == gif_url
def test_url_with_query_params(self) -> None:
"""Test URL with query parameters preserves them."""
result = get_format_url("/static/img/photo.jpg?v=123", "webp")
assert result == "/static/img/photo.webp?v=123"
def test_full_url(self) -> None:
"""Test full URL with domain."""
result: str = get_format_url("https://example.com/img/photo.jpg", "avif")
assert result == "https://example.com/img/photo.avif"
class TestPictureTag:
"""Tests for the picture template tag."""
def test_empty_src(self) -> None:
"""Test that empty src returns empty string."""
result: SafeString = picture("")
assert not result
def test_basic_picture(self) -> None:
"""Test basic picture tag with minimal parameters."""
result: SafeString = picture("/static/img/photo.jpg")
# Should contain picture element
assert "<picture>" in result
assert "</picture>" in result
# Should have AVIF and WebP sources
assert '<source srcset="/static/img/photo.avif" type="image/avif" />' in result
assert '<source srcset="/static/img/photo.webp" type="image/webp" />' in result
# Should have fallback img tag
assert '<img src="/static/img/photo.jpg"' in result
assert 'loading="lazy"' in result
def test_all_attributes(self) -> None:
"""Test picture tag with all optional attributes."""
result: SafeString = picture(
src="/static/img/photo.jpg",
alt="Test photo",
width=800,
height=600,
loading="eager",
css_class="rounded shadow",
style="max-width: 100%",
)
assert 'alt="Test photo"' in result
assert 'width="800"' in result
assert 'height="600"' in result
assert 'loading="eager"' in result
assert 'class="rounded shadow"' in result
assert 'style="max-width: 100%"' in result
def test_non_convertible_format(self) -> None:
"""Test that non-convertible formats don't generate source tags."""
result: SafeString = picture("/static/img/icon.svg")
# Should not have source tags since SVG can't be converted
assert "<source" not in result
# Should still have picture and img tags
assert "<picture>" in result
assert '<img src="/static/img/icon.svg"' in result
assert "</picture>" in result
def test_xss_prevention_in_src(self) -> None:
"""Test that XSS attempts in src are escaped."""
malicious_src = '"><script>alert("xss")</script><img src="'
result = picture(malicious_src)
# Should escape the malicious code
assert "<script>" not in result
assert "</script>" not in result
assert "&quot;" in result
def test_xss_prevention_in_alt(self) -> None:
"""Test that XSS attempts in alt text are escaped."""
result: SafeString = picture(
"/static/img/photo.jpg",
alt='"><script>alert("xss")</script>',
)
# Should escape the malicious code
assert "<script>" not in result
assert "</script>" not in result
assert "&quot;" in result
def test_xss_prevention_in_css_class(self) -> None:
"""Test that XSS attempts in CSS class are escaped."""
result: SafeString = picture(
"/static/img/photo.jpg",
css_class='"><script>alert("xss")</script>',
)
# Should escape the malicious code
assert "<script>" not in result
assert "</script>" not in result
def test_xss_prevention_in_style(self) -> None:
"""Test that XSS attempts in style are escaped."""
result: SafeString = picture(
"/static/img/photo.jpg",
style='"><script>alert("xss")</script>',
)
# Should escape the malicious code
assert "<script>" not in result
assert "</script>" not in result
def test_returns_safestring(self) -> None:
"""Test that the result is a SafeString."""
result: SafeString = picture("/static/img/photo.jpg")
assert isinstance(result, SafeString)
def test_alt_empty_string(self) -> None:
"""Test that alt="" includes empty alt attribute."""
result: SafeString = picture("/static/img/photo.jpg", alt="")
assert 'alt=""' in result
def test_no_width_or_height(self) -> None:
"""Test that missing width/height are not included."""
result: SafeString = picture("/static/img/photo.jpg")
# Should not have width or height attributes
assert 'width="' not in result
assert 'height="' not in result
def test_url_with_query_params(self) -> None:
"""Test that query parameters are preserved in formats."""
result: SafeString = picture("/static/img/photo.jpg?v=123")
assert "/static/img/photo.avif?v=123" in result
assert "/static/img/photo.webp?v=123" in result
assert "/static/img/photo.jpg?v=123" in result
def test_full_url(self) -> None:
"""Test with full URL including domain."""
result: SafeString = picture("https://cdn.example.com/images/photo.jpg")
assert "https://cdn.example.com/images/photo.avif" in result
assert "https://cdn.example.com/images/photo.webp" in result
assert "https://cdn.example.com/images/photo.jpg" in result
class TestPictureTagTemplate:
"""Tests for the picture tag used in templates."""
def test_picture_tag_in_template(self) -> None:
"""Test that the picture tag works when called from a template."""
template = Template('{% load image_tags %}{% picture src="/img/photo.jpg" alt="Test" %}')
context = Context({})
result: SafeString = template.render(context)
assert "<picture>" in result
assert "</picture>" in result
assert '<source srcset="/img/photo.avif"' in result
assert '<source srcset="/img/photo.webp"' in result
assert '<img src="/img/photo.jpg"' in result
assert 'alt="Test"' in result
def test_picture_tag_with_context_variables(self) -> None:
"""Test using context variables in the picture tag."""
template = Template("{% load image_tags %}{% picture src=image_url alt=image_alt width=image_width %}")
context = Context({
"image_url": "/img/banner.png",
"image_alt": "Banner image",
"image_width": 1200,
})
result: SafeString = template.render(context)
assert '<img src="/img/banner.png"' in result
assert 'alt="Banner image"' in result
assert 'width="1200"' in result
assert "/img/banner.avif" in result
def test_picture_tag_with_no_parameters(self) -> None:
"""Test that the tag requires at least src parameter."""
# This should work with empty src but return empty string
template = Template('{% load image_tags %}{% picture src="" %}')
context = Context({})
result: SafeString = template.render(context)
assert not result.strip()