feat: add automatic image optimization and thumbnails
Image Processing: - Add Pillow dependency for image manipulation - Auto-create optimized versions on upload - Generate main image (max 800x800, 85% quality) - Generate thumbnail (max 400x400, 80% quality) - Delete original after optimization Quality Improvements: - Auto-correct EXIF orientation (photos from phones) - Convert RGBA/transparency to RGB with white background - Use LANCZOS resampling for high-quality downscaling - Optimize JPEG compression Performance: - Smaller file sizes = faster page loads - Thumbnails for product listings - Optimized full-size for detail views - Reduced storage usage Fallback: - Graceful degradation if Pillow not installed - Error handling preserves original on failure - Logging for monitoring optimization success Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,3 +2,4 @@ Flask>=3.0.0
|
|||||||
Flask-WTF>=1.2.0
|
Flask-WTF>=1.2.0
|
||||||
Werkzeug>=3.0.0
|
Werkzeug>=3.0.0
|
||||||
gunicorn>=21.0.0
|
gunicorn>=21.0.0
|
||||||
|
Pillow>=10.0.0
|
||||||
|
|||||||
67
wawi/app.py
67
wawi/app.py
@@ -22,6 +22,13 @@ from pathlib import Path
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
HAS_PIL = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_PIL = False
|
||||||
|
logger.warning("Pillow nicht installiert - Bild-Optimierung deaktiviert")
|
||||||
|
|
||||||
from flask import Flask, Blueprint, g, flash, jsonify, redirect, render_template, request, session, url_for
|
from flask import Flask, Blueprint, g, flash, jsonify, redirect, render_template, request, session, url_for
|
||||||
from flask_wtf.csrf import CSRFProtect
|
from flask_wtf.csrf import CSRFProtect
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
@@ -236,7 +243,7 @@ def now_iso() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def save_upload(file) -> str | None:
|
def save_upload(file) -> str | None:
|
||||||
"""Speichert einen Bild‑Upload und gibt die öffentliche URL zurück."""
|
"""Speichert einen Bild‑Upload mit automatischer Optimierung und Thumbnail-Erstellung."""
|
||||||
if not file or not getattr(file, "filename", ""):
|
if not file or not getattr(file, "filename", ""):
|
||||||
return None
|
return None
|
||||||
ext = os.path.splitext(file.filename)[1].lower()
|
ext = os.path.splitext(file.filename)[1].lower()
|
||||||
@@ -244,11 +251,61 @@ def save_upload(file) -> str | None:
|
|||||||
return None
|
return None
|
||||||
if not (file.mimetype or "").startswith("image/"):
|
if not (file.mimetype or "").startswith("image/"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
name = secure_filename(Path(file.filename).stem) or "image"
|
name = secure_filename(Path(file.filename).stem) or "image"
|
||||||
safe_name = f"{name}-{uuid4().hex}{ext}"
|
unique_id = uuid4().hex
|
||||||
dest = UPLOAD_DIR / safe_name
|
|
||||||
file.save(dest)
|
# Original speichern
|
||||||
return f"{URL_PREFIX}/static/uploads/{safe_name}" if URL_PREFIX else f"/static/uploads/{safe_name}"
|
original_name = f"{name}-{unique_id}{ext}"
|
||||||
|
original_path = UPLOAD_DIR / original_name
|
||||||
|
file.save(original_path)
|
||||||
|
|
||||||
|
# Bild optimieren (falls Pillow verfügbar)
|
||||||
|
if HAS_PIL:
|
||||||
|
try:
|
||||||
|
with Image.open(original_path) as img:
|
||||||
|
# EXIF-Orientierung korrigieren
|
||||||
|
try:
|
||||||
|
from PIL import ImageOps
|
||||||
|
img = ImageOps.exif_transpose(img)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Konvertiere RGBA zu RGB (für JPEG)
|
||||||
|
if img.mode in ('RGBA', 'LA', 'P'):
|
||||||
|
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||||
|
if img.mode == 'P':
|
||||||
|
img = img.convert('RGBA')
|
||||||
|
background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
|
||||||
|
img = background
|
||||||
|
|
||||||
|
# Optimiertes Hauptbild (max 800x800)
|
||||||
|
optimized_name = f"{name}-{unique_id}-opt.jpg"
|
||||||
|
optimized_path = UPLOAD_DIR / optimized_name
|
||||||
|
img_copy = img.copy()
|
||||||
|
img_copy.thumbnail((800, 800), Image.Resampling.LANCZOS)
|
||||||
|
img_copy.save(optimized_path, 'JPEG', quality=85, optimize=True)
|
||||||
|
|
||||||
|
# Thumbnail (max 400x400)
|
||||||
|
thumb_name = f"{name}-{unique_id}-thumb.jpg"
|
||||||
|
thumb_path = UPLOAD_DIR / thumb_name
|
||||||
|
img.thumbnail((400, 400), Image.Resampling.LANCZOS)
|
||||||
|
img.save(thumb_path, 'JPEG', quality=80, optimize=True)
|
||||||
|
|
||||||
|
# Original löschen (verwenden nur optimierte Versionen)
|
||||||
|
original_path.unlink()
|
||||||
|
|
||||||
|
logger.info(f"Bild optimiert: {optimized_name} & {thumb_name}")
|
||||||
|
|
||||||
|
# Thumbnail-URL zurückgeben (wird als Standard verwendet)
|
||||||
|
return f"{URL_PREFIX}/static/uploads/{thumb_name}" if URL_PREFIX else f"/static/uploads/{thumb_name}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Fehler bei Bild-Optimierung: {e}")
|
||||||
|
# Fallback: Original verwenden
|
||||||
|
|
||||||
|
# Fallback wenn Pillow fehlt oder Fehler aufgetreten ist
|
||||||
|
return f"{URL_PREFIX}/static/uploads/{original_name}" if URL_PREFIX else f"/static/uploads/{original_name}"
|
||||||
|
|
||||||
|
|
||||||
def ensure_admin_user(db: sqlite3.Connection) -> None:
|
def ensure_admin_user(db: sqlite3.Connection) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user