Add support for modern image formats (WebP and AVIF) and implement image conversion commands
This commit is contained in:
parent
7128f19b9e
commit
477bb753ae
15 changed files with 629 additions and 93 deletions
|
|
@ -1,4 +1,5 @@
|
|||
{% extends "base.html" %}
|
||||
{% load image_tags %}
|
||||
{% block title %}
|
||||
Chat Badges - ttvdrops
|
||||
{% endblock title %}
|
||||
|
|
@ -14,11 +15,7 @@
|
|||
<tr>
|
||||
<td style="width: 40px;">
|
||||
<a href="{% url 'twitch:badge_set_detail' set_id=data.set.set_id %}">
|
||||
<img src="{{ badge.image_url_4x }}"
|
||||
height="36"
|
||||
width="36"
|
||||
alt="{{ badge.title }}"
|
||||
title="{{ badge.description }}" />
|
||||
{% picture badge.image_url_4x alt=badge.title width=36 height=36 %}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{% extends "base.html" %}
|
||||
{% load image_tags %}
|
||||
{% block title %}
|
||||
{{ badge_set.set_id }} Badges - ttvdrops
|
||||
{% endblock title %}
|
||||
|
|
@ -31,14 +32,7 @@
|
|||
<code>{{ badge.badge_id }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<img src="{{ badge.image_url_4x }}"
|
||||
height="72"
|
||||
width="72"
|
||||
alt="{{ badge.title }}"
|
||||
title="{{ badge.description }}"
|
||||
style="width: 72px !important;
|
||||
height: 72px !important;
|
||||
object-fit: contain" />
|
||||
{% picture badge.image_url_4x alt=badge.title width=72 height=72 style="width: 72px !important; height: 72px !important; object-fit: contain" %}
|
||||
</td>
|
||||
<td>{{ badge.title }}</td>
|
||||
<td>{{ badge.description }}</td>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load image_tags %}
|
||||
{% block title %}
|
||||
{{ campaign.clean_name }}
|
||||
{% endblock title %}
|
||||
|
|
@ -20,10 +21,7 @@
|
|||
{% endfor %}
|
||||
<!-- Campaign image -->
|
||||
{% if campaign.image_best_url %}
|
||||
<img height="160"
|
||||
width="160"
|
||||
src="{{ campaign.image_best_url }}"
|
||||
alt="{{ campaign.name }}" />
|
||||
{% picture campaign.image_best_url alt=campaign.name width=160 height=160 %}
|
||||
{% endif %}
|
||||
<!-- Campaign description -->
|
||||
<p>{{ campaign.description|linebreaksbr }}</p>
|
||||
|
|
@ -106,24 +104,13 @@
|
|||
<td>
|
||||
{% for benefit in drop.drop.benefits.all %}
|
||||
{% if benefit.image_asset_url %}
|
||||
<img height="160"
|
||||
width="160"
|
||||
loading="lazy"
|
||||
style="object-fit: cover;
|
||||
margin-right: 3px"
|
||||
src="{{ benefit.image_best_url|default:benefit.image_asset_url }}"
|
||||
alt="{{ benefit.name }}" />
|
||||
{% picture benefit.image_best_url|default:benefit.image_asset_url alt=benefit.name width=160 height=160 style="object-fit: cover; margin-right: 3px" %}
|
||||
{% endif %}
|
||||
{% if benefit.distribution_type == "BADGE" and drop.awarded_badge %}
|
||||
<div style="margin-top: 6px; font-size: 0.9em;">
|
||||
<strong>Awards Badge:</strong>
|
||||
<a href="{% url 'twitch:badge_set_detail' set_id=drop.awarded_badge.badge_set.set_id %}">
|
||||
<img src="{{ drop.awarded_badge.image_url_2x }}"
|
||||
alt="{{ drop.awarded_badge.title }} badge"
|
||||
height="24"
|
||||
width="24"
|
||||
style="vertical-align: middle;
|
||||
margin-right: 4px" />
|
||||
{% 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" %}
|
||||
{{ drop.awarded_badge.title }}
|
||||
</a>
|
||||
{% if drop.awarded_badge.description %}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load image_tags %}
|
||||
{% load image_tags %}
|
||||
{% block title %}
|
||||
Drop Campaigns - Twitch Drops Tracker
|
||||
{% endblock title %}
|
||||
|
|
@ -53,11 +55,7 @@
|
|||
<div style="display: flex; gap: 1rem;">
|
||||
<div>
|
||||
{% if game_group.grouper.box_art_best_url %}
|
||||
<img src="{{ game_group.grouper.box_art_best_url }}"
|
||||
loading="lazy"
|
||||
alt="Box art for {{ game_group.grouper.display_name }}"
|
||||
width="120"
|
||||
height="160" />
|
||||
{% picture game_group.grouper.box_art_best_url alt="Box art for "|add:game_group.grouper.display_name width=120 height=160 %}
|
||||
{% else %}
|
||||
<div style="width: 120px;
|
||||
height: 160px;
|
||||
|
|
@ -98,11 +96,7 @@
|
|||
<a href="{% url 'twitch:campaign_detail' campaign.twitch_id %}"
|
||||
style="text-decoration: none">
|
||||
{% if campaign.image_best_url %}
|
||||
<img src="{{ campaign.image_best_url }}"
|
||||
alt="Campaign artwork for {{ campaign.name }}"
|
||||
width="120"
|
||||
height="120"
|
||||
style="border-radius: 4px" />
|
||||
{% picture campaign.image_best_url alt="Campaign artwork for "|add:campaign.name width=120 height=120 style="border-radius: 4px" %}
|
||||
{% endif %}
|
||||
<h4 style="margin: 0.5rem 0; text-align: left">{{ campaign.clean_name }}</h4>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load image_tags %}
|
||||
{% block title %}
|
||||
Twitch drops
|
||||
{% endblock title %}
|
||||
|
|
@ -35,11 +36,7 @@ Hover over the end time to see the exact date and time.
|
|||
</header>
|
||||
<div style="display: flex; gap: 1rem;">
|
||||
<div style="flex-shrink: 0;">
|
||||
<img src="{{ game_data.box_art }}"
|
||||
alt="Box art for {{ game_data.name }}"
|
||||
width="200"
|
||||
height="267"
|
||||
style="border-radius: 8px" />
|
||||
{% picture game_data.box_art alt="Box art for "|add:game_data.name width=200 height=267 style="border-radius: 8px" %}
|
||||
</div>
|
||||
<div style="flex: 1; overflow-x: auto;">
|
||||
<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">
|
||||
<div>
|
||||
<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 }}"
|
||||
alt="Image for {{ campaign_data.campaign.name }}"
|
||||
width="120"
|
||||
height="120"
|
||||
style="border-radius: 4px" />
|
||||
{% 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" %}
|
||||
<h4 style="margin: 0.5rem 0; text-align: left;">{{ campaign_data.campaign.clean_name }}</h4>
|
||||
</a>
|
||||
<time datetime="{{ campaign_data.campaign.end_at|date:'c' }}"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{% extends "base.html" %}
|
||||
{% load image_tags %}
|
||||
{% block title %}
|
||||
Emotes
|
||||
{% endblock title %}
|
||||
|
|
@ -8,13 +9,7 @@
|
|||
<a href="{% url 'twitch:campaign_detail' emote.campaign.twitch_id %}"
|
||||
title="{{ emote.campaign.name }}"
|
||||
style="display: inline-block">
|
||||
<img src="{{ emote.image_url }}"
|
||||
height="96"
|
||||
width="96"
|
||||
alt="Emote"
|
||||
style="max-width: 96px;
|
||||
max-height: 96px"
|
||||
loading="lazy" />
|
||||
{% picture emote.image_url alt="Emote" width=96 height=96 style="max-width: 96px; max-height: 96px" %}
|
||||
</a>
|
||||
{% empty %}
|
||||
<p>No drop campaigns with emotes found.</p>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{% extends "base.html" %}
|
||||
{% load image_tags %}
|
||||
{% block title %}
|
||||
{{ game.display_name }}
|
||||
{% endblock title %}
|
||||
|
|
@ -23,11 +24,7 @@
|
|||
</div>
|
||||
<!-- Game image -->
|
||||
{% if game.box_art_best_url %}
|
||||
<img id="game-image"
|
||||
height="160"
|
||||
width="160"
|
||||
src="{{ game.box_art_best_url }}"
|
||||
alt="{{ game.name }}" />
|
||||
{% picture game.box_art_best_url alt=game.name width=160 height=160 %}
|
||||
{% endif %}
|
||||
<!-- Game owner -->
|
||||
{% if owners %}
|
||||
|
|
@ -53,13 +50,7 @@
|
|||
{% for benefit in campaign.sorted_benefits %}
|
||||
<span class="benefit-item" title="{{ benefit.name }}">
|
||||
{% if benefit.image_best_url or benefit.image_asset_url %}
|
||||
<img src="{{ 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" />
|
||||
{% 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" %}
|
||||
{% endif %}
|
||||
{{ benefit.name }}
|
||||
</span>
|
||||
|
|
@ -88,13 +79,7 @@
|
|||
{% for benefit in campaign.sorted_benefits %}
|
||||
<span class="benefit-item" title="{{ benefit.name }}">
|
||||
{% if benefit.image_best_url or benefit.image_asset_url %}
|
||||
<img src="{{ 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" />
|
||||
{% 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" %}
|
||||
{% endif %}
|
||||
{{ benefit.name }}
|
||||
</span>
|
||||
|
|
@ -124,13 +109,7 @@
|
|||
{% for benefit in campaign.sorted_benefits %}
|
||||
<span class="benefit-item" title="{{ benefit.name }}">
|
||||
{% if benefit.image_best_url or benefit.image_asset_url %}
|
||||
<img src="{{ 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" />
|
||||
{% 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" %}
|
||||
{% endif %}
|
||||
{{ benefit.name }}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{% extends "base.html" %}
|
||||
{% load image_tags %}
|
||||
{% block title %}
|
||||
Games - Grid View
|
||||
{% endblock title %}
|
||||
|
|
@ -27,12 +28,7 @@
|
|||
text-align: center">
|
||||
<div style="margin-bottom: 0.25rem;">
|
||||
{% if item.game.box_art_best_url %}
|
||||
<img src="{{ item.game.box_art_best_url }}"
|
||||
alt="Box art for {{ item.game.display_name }}"
|
||||
loading="lazy"
|
||||
width="180"
|
||||
height="240"
|
||||
style="border-radius: 8px" />
|
||||
{% picture item.game.box_art_best_url alt="Box art for "|add:item.game.display_name width=180 height=240 style="border-radius: 8px" %}
|
||||
{% else %}
|
||||
<div style="width: 180px;
|
||||
height: 240px;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% load image_tags %}
|
||||
{% block title %}
|
||||
{{ reward_campaign.name }}
|
||||
{% endblock title %}
|
||||
|
|
@ -16,10 +17,7 @@
|
|||
</p>
|
||||
<!-- Campaign image -->
|
||||
{% if reward_campaign.image_best_url %}
|
||||
<img height="160"
|
||||
width="160"
|
||||
src="{{ reward_campaign.image_best_url }}"
|
||||
alt="{{ reward_campaign.name }}" />
|
||||
{% picture reward_campaign.image_best_url alt=reward_campaign.name width=160 height=160 %}
|
||||
{% endif %}
|
||||
<!-- RSS Feeds -->
|
||||
<div style="margin-bottom: 1rem;">
|
||||
|
|
|
|||
188
twitch/management/commands/convert_images_to_modern_formats.py
Normal file
188
twitch/management/commands/convert_images_to_modern_formats.py
Normal 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
|
||||
|
|
@ -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}"))
|
||||
|
|
|
|||
|
|
@ -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"]:
|
||||
|
|
|
|||
1
twitch/templatetags/__init__.py
Normal file
1
twitch/templatetags/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Template tags for the twitch app
|
||||
103
twitch/templatetags/image_tags.py
Normal file
103
twitch/templatetags/image_tags.py
Normal 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)
|
||||
227
twitch/tests/test_image_tags.py
Normal file
227
twitch/tests/test_image_tags.py
Normal 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 """ 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 """ 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue