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

@ -1,4 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load image_tags %}
{% block title %} {% block title %}
Chat Badges - ttvdrops Chat Badges - ttvdrops
{% endblock title %} {% endblock title %}
@ -14,11 +15,7 @@
<tr> <tr>
<td style="width: 40px;"> <td style="width: 40px;">
<a href="{% url 'twitch:badge_set_detail' set_id=data.set.set_id %}"> <a href="{% url 'twitch:badge_set_detail' set_id=data.set.set_id %}">
<img src="{{ badge.image_url_4x }}" {% picture badge.image_url_4x alt=badge.title width=36 height=36 %}
height="36"
width="36"
alt="{{ badge.title }}"
title="{{ badge.description }}" />
</a> </a>
</td> </td>
<td> <td>

View file

@ -1,4 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load image_tags %}
{% block title %} {% block title %}
{{ badge_set.set_id }} Badges - ttvdrops {{ badge_set.set_id }} Badges - ttvdrops
{% endblock title %} {% endblock title %}
@ -31,14 +32,7 @@
<code>{{ badge.badge_id }}</code> <code>{{ badge.badge_id }}</code>
</td> </td>
<td> <td>
<img src="{{ badge.image_url_4x }}" {% picture badge.image_url_4x alt=badge.title width=72 height=72 style="width: 72px !important; height: 72px !important; object-fit: contain" %}
height="72"
width="72"
alt="{{ badge.title }}"
title="{{ badge.description }}"
style="width: 72px !important;
height: 72px !important;
object-fit: contain" />
</td> </td>
<td>{{ badge.title }}</td> <td>{{ badge.title }}</td>
<td>{{ badge.description }}</td> <td>{{ badge.description }}</td>

View file

@ -1,5 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load image_tags %}
{% block title %} {% block title %}
{{ campaign.clean_name }} {{ campaign.clean_name }}
{% endblock title %} {% endblock title %}
@ -20,10 +21,7 @@
{% endfor %} {% endfor %}
<!-- Campaign image --> <!-- Campaign image -->
{% if campaign.image_best_url %} {% if campaign.image_best_url %}
<img height="160" {% picture campaign.image_best_url alt=campaign.name width=160 height=160 %}
width="160"
src="{{ campaign.image_best_url }}"
alt="{{ campaign.name }}" />
{% endif %} {% endif %}
<!-- Campaign description --> <!-- Campaign description -->
<p>{{ campaign.description|linebreaksbr }}</p> <p>{{ campaign.description|linebreaksbr }}</p>
@ -106,24 +104,13 @@
<td> <td>
{% for benefit in drop.drop.benefits.all %} {% for benefit in drop.drop.benefits.all %}
{% if benefit.image_asset_url %} {% if benefit.image_asset_url %}
<img height="160" {% picture benefit.image_best_url|default:benefit.image_asset_url alt=benefit.name width=160 height=160 style="object-fit: cover; margin-right: 3px" %}
width="160"
loading="lazy"
style="object-fit: cover;
margin-right: 3px"
src="{{ benefit.image_best_url|default:benefit.image_asset_url }}"
alt="{{ benefit.name }}" />
{% endif %} {% endif %}
{% if benefit.distribution_type == "BADGE" and drop.awarded_badge %} {% if benefit.distribution_type == "BADGE" and drop.awarded_badge %}
<div style="margin-top: 6px; font-size: 0.9em;"> <div style="margin-top: 6px; font-size: 0.9em;">
<strong>Awards Badge:</strong> <strong>Awards Badge:</strong>
<a href="{% url 'twitch:badge_set_detail' set_id=drop.awarded_badge.badge_set.set_id %}"> <a href="{% url 'twitch:badge_set_detail' set_id=drop.awarded_badge.badge_set.set_id %}">
<img src="{{ drop.awarded_badge.image_url_2x }}" {% picture drop.awarded_badge.image_url_2x alt=drop.awarded_badge.title|add:" badge" width=24 height=24 style="vertical-align: middle; margin-right: 4px" %}
alt="{{ drop.awarded_badge.title }} badge"
height="24"
width="24"
style="vertical-align: middle;
margin-right: 4px" />
{{ drop.awarded_badge.title }} {{ drop.awarded_badge.title }}
</a> </a>
{% if drop.awarded_badge.description %} {% if drop.awarded_badge.description %}

View file

@ -1,5 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load image_tags %}
{% load image_tags %}
{% block title %} {% block title %}
Drop Campaigns - Twitch Drops Tracker Drop Campaigns - Twitch Drops Tracker
{% endblock title %} {% endblock title %}
@ -53,11 +55,7 @@
<div style="display: flex; gap: 1rem;"> <div style="display: flex; gap: 1rem;">
<div> <div>
{% if game_group.grouper.box_art_best_url %} {% if game_group.grouper.box_art_best_url %}
<img src="{{ game_group.grouper.box_art_best_url }}" {% picture game_group.grouper.box_art_best_url alt="Box art for "|add:game_group.grouper.display_name width=120 height=160 %}
loading="lazy"
alt="Box art for {{ game_group.grouper.display_name }}"
width="120"
height="160" />
{% else %} {% else %}
<div style="width: 120px; <div style="width: 120px;
height: 160px; height: 160px;
@ -98,11 +96,7 @@
<a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}" <a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}"
style="text-decoration: none"> style="text-decoration: none">
{% if campaign.image_best_url %} {% if campaign.image_best_url %}
<img src="{{ campaign.image_best_url }}" {% picture campaign.image_best_url alt="Campaign artwork for "|add:campaign.name width=120 height=120 style="border-radius: 4px" %}
alt="Campaign artwork for {{ campaign.name }}"
width="120"
height="120"
style="border-radius: 4px" />
{% endif %} {% endif %}
<h4 style="margin: 0.5rem 0; text-align: left">{{ campaign.clean_name }}</h4> <h4 style="margin: 0.5rem 0; text-align: left">{{ campaign.clean_name }}</h4>
</a> </a>

View file

@ -1,5 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load image_tags %}
{% block title %} {% block title %}
Twitch drops Twitch drops
{% endblock title %} {% endblock title %}
@ -35,11 +36,7 @@ Hover over the end time to see the exact date and time.
</header> </header>
<div style="display: flex; gap: 1rem;"> <div style="display: flex; gap: 1rem;">
<div style="flex-shrink: 0;"> <div style="flex-shrink: 0;">
<img src="{{ game_data.box_art }}" {% picture game_data.box_art alt="Box art for "|add:game_data.name width=200 height=267 style="border-radius: 8px" %}
alt="Box art for {{ game_data.name }}"
width="200"
height="267"
style="border-radius: 8px" />
</div> </div>
<div style="flex: 1; overflow-x: auto;"> <div style="flex: 1; overflow-x: auto;">
<div style="display: flex; gap: 1rem; min-width: max-content;"> <div style="display: flex; gap: 1rem; min-width: max-content;">
@ -52,11 +49,7 @@ Hover over the end time to see the exact date and time.
flex-shrink: 0"> flex-shrink: 0">
<div> <div>
<a href="{% url 'twitch:campaign_detail' campaign_data.campaign.twitch_id %}"> <a href="{% url 'twitch:campaign_detail' campaign_data.campaign.twitch_id %}">
<img src="{{ campaign_data.campaign.image_best_url|default:campaign_data.campaign.image_url }}" {% picture campaign_data.campaign.image_best_url|default:campaign_data.campaign.image_url alt="Image for "|add:campaign_data.campaign.name width=120 height=120 style="border-radius: 4px" %}
alt="Image for {{ campaign_data.campaign.name }}"
width="120"
height="120"
style="border-radius: 4px" />
<h4 style="margin: 0.5rem 0; text-align: left;">{{ campaign_data.campaign.clean_name }}</h4> <h4 style="margin: 0.5rem 0; text-align: left;">{{ campaign_data.campaign.clean_name }}</h4>
</a> </a>
<time datetime="{{ campaign_data.campaign.end_at|date:'c' }}" <time datetime="{{ campaign_data.campaign.end_at|date:'c' }}"

View file

@ -1,4 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load image_tags %}
{% block title %} {% block title %}
Emotes Emotes
{% endblock title %} {% endblock title %}
@ -8,13 +9,7 @@
<a href="{% url 'twitch:campaign_detail' emote.campaign.twitch_id %}" <a href="{% url 'twitch:campaign_detail' emote.campaign.twitch_id %}"
title="{{ emote.campaign.name }}" title="{{ emote.campaign.name }}"
style="display: inline-block"> style="display: inline-block">
<img src="{{ emote.image_url }}" {% picture emote.image_url alt="Emote" width=96 height=96 style="max-width: 96px; max-height: 96px" %}
height="96"
width="96"
alt="Emote"
style="max-width: 96px;
max-height: 96px"
loading="lazy" />
</a> </a>
{% empty %} {% empty %}
<p>No drop campaigns with emotes found.</p> <p>No drop campaigns with emotes found.</p>

View file

@ -1,4 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load image_tags %}
{% block title %} {% block title %}
{{ game.display_name }} {{ game.display_name }}
{% endblock title %} {% endblock title %}
@ -23,11 +24,7 @@
</div> </div>
<!-- Game image --> <!-- Game image -->
{% if game.box_art_best_url %} {% if game.box_art_best_url %}
<img id="game-image" {% picture game.box_art_best_url alt=game.name width=160 height=160 %}
height="160"
width="160"
src="{{ game.box_art_best_url }}"
alt="{{ game.name }}" />
{% endif %} {% endif %}
<!-- Game owner --> <!-- Game owner -->
{% if owners %} {% if owners %}
@ -53,13 +50,7 @@
{% for benefit in campaign.sorted_benefits %} {% for benefit in campaign.sorted_benefits %}
<span class="benefit-item" title="{{ benefit.name }}"> <span class="benefit-item" title="{{ benefit.name }}">
{% if benefit.image_best_url or benefit.image_asset_url %} {% if benefit.image_best_url or benefit.image_asset_url %}
<img src="{{ benefit.image_best_url|default:benefit.image_asset_url }}" {% picture benefit.image_best_url|default:benefit.image_asset_url alt=benefit.name width=24 height=24 style="display: inline-block; margin-right: 4px; vertical-align: middle" %}
alt="{{ benefit.name }}"
width="24"
height="24"
style="display: inline-block;
margin-right: 4px;
vertical-align: middle" />
{% endif %} {% endif %}
{{ benefit.name }} {{ benefit.name }}
</span> </span>
@ -88,13 +79,7 @@
{% for benefit in campaign.sorted_benefits %} {% for benefit in campaign.sorted_benefits %}
<span class="benefit-item" title="{{ benefit.name }}"> <span class="benefit-item" title="{{ benefit.name }}">
{% if benefit.image_best_url or benefit.image_asset_url %} {% if benefit.image_best_url or benefit.image_asset_url %}
<img src="{{ benefit.image_best_url|default:benefit.image_asset_url }}" {% picture benefit.image_best_url|default:benefit.image_asset_url alt=benefit.name width=24 height=24 style="display: inline-block; margin-right: 4px; vertical-align: middle" %}
alt="{{ benefit.name }}"
width="24"
height="24"
style="display: inline-block;
margin-right: 4px;
vertical-align: middle" />
{% endif %} {% endif %}
{{ benefit.name }} {{ benefit.name }}
</span> </span>
@ -124,13 +109,7 @@
{% for benefit in campaign.sorted_benefits %} {% for benefit in campaign.sorted_benefits %}
<span class="benefit-item" title="{{ benefit.name }}"> <span class="benefit-item" title="{{ benefit.name }}">
{% if benefit.image_best_url or benefit.image_asset_url %} {% if benefit.image_best_url or benefit.image_asset_url %}
<img src="{{ benefit.image_best_url|default:benefit.image_asset_url }}" {% picture benefit.image_best_url|default:benefit.image_asset_url alt=benefit.name width=24 height=24 style="display: inline-block; margin-right: 4px; vertical-align: middle" %}
alt="{{ benefit.name }}"
width="24"
height="24"
style="display: inline-block;
margin-right: 4px;
vertical-align: middle" />
{% endif %} {% endif %}
{{ benefit.name }} {{ benefit.name }}
</span> </span>

View file

@ -1,4 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load image_tags %}
{% block title %} {% block title %}
Games - Grid View Games - Grid View
{% endblock title %} {% endblock title %}
@ -27,12 +28,7 @@
text-align: center"> text-align: center">
<div style="margin-bottom: 0.25rem;"> <div style="margin-bottom: 0.25rem;">
{% if item.game.box_art_best_url %} {% if item.game.box_art_best_url %}
<img src="{{ item.game.box_art_best_url }}" {% picture item.game.box_art_best_url alt="Box art for "|add:item.game.display_name width=180 height=240 style="border-radius: 8px" %}
alt="Box art for {{ item.game.display_name }}"
loading="lazy"
width="180"
height="240"
style="border-radius: 8px" />
{% else %} {% else %}
<div style="width: 180px; <div style="width: 180px;
height: 240px; height: 240px;

View file

@ -1,5 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% load image_tags %}
{% block title %} {% block title %}
{{ reward_campaign.name }} {{ reward_campaign.name }}
{% endblock title %} {% endblock title %}
@ -16,10 +17,7 @@
</p> </p>
<!-- Campaign image --> <!-- Campaign image -->
{% if reward_campaign.image_best_url %} {% if reward_campaign.image_best_url %}
<img height="160" {% picture reward_campaign.image_best_url alt=reward_campaign.name width=160 height=160 %}
width="160"
src="{{ reward_campaign.image_best_url }}"
alt="{{ reward_campaign.name }}" />
{% endif %} {% endif %}
<!-- RSS Feeds --> <!-- RSS Feeds -->
<div style="margin-bottom: 1rem;"> <div style="margin-bottom: 1rem;">

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.files.base import ContentFile
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.core.management.base import CommandParser from django.core.management.base import CommandParser
from PIL import Image
from twitch.models import Game from twitch.models import Game
from twitch.utils import is_twitch_box_art_url from twitch.utils import is_twitch_box_art_url
@ -89,6 +90,10 @@ class Command(BaseCommand):
continue continue
game.box_art_file.save(file_name, ContentFile(response.content), save=True) 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 downloaded += 1
self.stdout.write( self.stdout.write(
@ -99,3 +104,40 @@ class Command(BaseCommand):
) )
box_art_dir: Path = Path(settings.MEDIA_ROOT) / "games" / "box_art" 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}")) 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.files.base import ContentFile
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.core.management.base import CommandParser from django.core.management.base import CommandParser
from PIL import Image
from twitch.models import DropBenefit from twitch.models import DropBenefit
from twitch.models import DropCampaign from twitch.models import DropCampaign
@ -245,10 +246,51 @@ class Command(BaseCommand):
# Save the image to the FileField # Save the image to the FileField
if hasattr(file_field, "save"): if hasattr(file_field, "save"):
file_field.save(file_name, ContentFile(response.content), save=True) 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 "downloaded"
return "failed" 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: def _merge_stats(self, total: dict[str, int], new: dict[str, int]) -> None:
"""Merge statistics from a single model into the total stats.""" """Merge statistics from a single model into the total stats."""
for key in ["total", "downloaded", "skipped", "failed", "placeholders_404"]: 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()