fix: Bugs created after the overhaul, performance and layout fixes
This commit is contained in:
125
backend/crm/thumbnails.py
Normal file
125
backend/crm/thumbnails.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Thumbnail generation for uploaded media files.
|
||||
|
||||
Supports:
|
||||
- Images (via Pillow): JPEG thumbnail at 300×300 max
|
||||
- Videos (via ffmpeg subprocess): extract first frame as JPEG
|
||||
- PDFs (via pdf2image + Poppler): render first page as JPEG
|
||||
|
||||
Returns None if the type is unsupported or if generation fails.
|
||||
"""
|
||||
import io
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
THUMB_SIZE = (220, 220) # small enough for gallery tiles; keeps files ~4-6 KB
|
||||
|
||||
|
||||
def _thumb_from_image(content: bytes) -> bytes | None:
|
||||
try:
|
||||
from PIL import Image, ImageOps
|
||||
img = Image.open(io.BytesIO(content))
|
||||
img = ImageOps.exif_transpose(img) # honour EXIF Orientation tag before resizing
|
||||
img = img.convert("RGB")
|
||||
img.thumbnail(THUMB_SIZE, Image.LANCZOS)
|
||||
out = io.BytesIO()
|
||||
# quality=55 + optimize=True + progressive encoding → ~4-6 KB for typical photos
|
||||
img.save(out, format="JPEG", quality=65, optimize=True, progressive=True)
|
||||
return out.getvalue()
|
||||
except Exception as e:
|
||||
logger.warning("Image thumbnail failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _thumb_from_video(content: bytes) -> bytes | None:
|
||||
"""
|
||||
Extract the first frame of a video as a JPEG thumbnail.
|
||||
|
||||
We write the video to a temp file instead of piping it to ffmpeg because
|
||||
most video containers (MP4, MOV, MKV …) store their index (moov atom) at
|
||||
an arbitrary offset and ffmpeg cannot seek on a pipe — causing rc≠0 with
|
||||
"moov atom not found" or similar errors when stdin is used.
|
||||
"""
|
||||
import tempfile
|
||||
import os
|
||||
try:
|
||||
# Write to a temp file so ffmpeg can seek freely
|
||||
with tempfile.NamedTemporaryFile(suffix=".video", delete=False) as tmp_in:
|
||||
tmp_in.write(content)
|
||||
tmp_in_path = tmp_in.name
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp_out:
|
||||
tmp_out_path = tmp_out.name
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y",
|
||||
"-i", tmp_in_path,
|
||||
"-vframes", "1",
|
||||
"-vf", f"scale={THUMB_SIZE[0]}:-2",
|
||||
"-q:v", "4", # JPEG quality 1-31 (lower = better); 4 ≈ ~80% quality
|
||||
tmp_out_path,
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
)
|
||||
if result.returncode == 0 and os.path.getsize(tmp_out_path) > 0:
|
||||
with open(tmp_out_path, "rb") as f:
|
||||
return f.read()
|
||||
logger.warning(
|
||||
"ffmpeg video thumb failed (rc=%s): %s",
|
||||
result.returncode,
|
||||
result.stderr[-400:].decode(errors="replace") if result.stderr else "",
|
||||
)
|
||||
return None
|
||||
finally:
|
||||
os.unlink(tmp_in_path)
|
||||
try:
|
||||
os.unlink(tmp_out_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
logger.warning("ffmpeg not found — video thumbnails unavailable")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Video thumbnail failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _thumb_from_pdf(content: bytes) -> bytes | None:
|
||||
try:
|
||||
from pdf2image import convert_from_bytes
|
||||
pages = convert_from_bytes(content, first_page=1, last_page=1, size=THUMB_SIZE)
|
||||
if not pages:
|
||||
return None
|
||||
out = io.BytesIO()
|
||||
pages[0].save(out, format="JPEG", quality=55, optimize=True, progressive=True)
|
||||
return out.getvalue()
|
||||
except ImportError:
|
||||
logger.warning("pdf2image not installed — PDF thumbnails unavailable")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("PDF thumbnail failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def generate_thumbnail(content: bytes, mime_type: str, filename: str) -> bytes | None:
|
||||
"""
|
||||
Generate a small JPEG thumbnail for the given file content.
|
||||
Returns JPEG bytes or None if unsupported / generation fails.
|
||||
"""
|
||||
mt = (mime_type or "").lower()
|
||||
fn = (filename or "").lower()
|
||||
|
||||
if mt.startswith("image/"):
|
||||
return _thumb_from_image(content)
|
||||
if mt.startswith("video/"):
|
||||
return _thumb_from_video(content)
|
||||
if mt == "application/pdf" or fn.endswith(".pdf"):
|
||||
return _thumb_from_pdf(content)
|
||||
|
||||
return None
|
||||
Reference in New Issue
Block a user