diff --git a/backend/app/services/thumbnailer.py b/backend/app/services/thumbnailer.py new file mode 100644 index 0000000..eea4bed --- /dev/null +++ b/backend/app/services/thumbnailer.py @@ -0,0 +1,102 @@ +"""Image + video thumbnail generation. + +Images go through Pillow; videos go through ffmpeg's first-frame snapshot +at ~5% into the runtime (avoids fade-ins/title cards while still being a +representative frame). + +Thumbnails are stored at /images/thumbs/{sha[:3]}/{sha}.jpg or .png; the +3-char prefix bucket avoids hot single-directory inode pressure. +""" + +import subprocess +from dataclasses import dataclass +from pathlib import Path + +from PIL import Image + +THUMB_MAX_PX = 400 +JPEG_QUALITY = 85 +FFMPEG_TIMEOUT_SECONDS = 60 + + +@dataclass(frozen=True) +class ThumbnailResult: + path: Path + width: int + height: int + mime: str + + +def thumbnail_path_for(sha256_hex: str, *, alpha: bool, root: Path) -> Path: + ext = ".png" if alpha else ".jpg" + bucket = sha256_hex[:3] + return root / "thumbs" / bucket / f"{sha256_hex}{ext}" + + +class Thumbnailer: + def __init__(self, images_root: Path): + self.images_root = images_root + + def generate_image_thumbnail( + self, source: Path, sha256_hex: str + ) -> ThumbnailResult: + """Reads source, fits inside THUMB_MAX_PX × THUMB_MAX_PX preserving + aspect ratio. Writes PNG if source has alpha, else JPEG. + """ + with Image.open(source) as im: + im.load() + has_alpha = im.mode in ("RGBA", "LA") or ( + im.mode == "P" and "transparency" in im.info + ) + if has_alpha: + im = im.convert("RGBA") + else: + im = im.convert("RGB") + im.thumbnail((THUMB_MAX_PX, THUMB_MAX_PX), Image.Resampling.LANCZOS) + + dest = thumbnail_path_for(sha256_hex, alpha=has_alpha, root=self.images_root) + dest.parent.mkdir(parents=True, exist_ok=True) + if has_alpha: + im.save(dest, "PNG", optimize=True) + mime = "image/png" + else: + im.save(dest, "JPEG", quality=JPEG_QUALITY, optimize=True, progressive=True) + mime = "image/jpeg" + return ThumbnailResult(path=dest, width=im.width, height=im.height, mime=mime) + + def generate_video_thumbnail( + self, source: Path, sha256_hex: str, duration_seconds: float | None = None + ) -> ThumbnailResult: + """Snapshots a frame ~5% into the video via ffmpeg, then runs that + frame through the image-thumbnail pipeline. + + duration_seconds is optional; if absent, we snapshot at the 1-second + mark which is a reasonable fallback for short clips. + """ + offset = max(1.0, (duration_seconds or 20.0) * 0.05) + tmp = self.images_root / "thumbs" / "_tmp" / f"{sha256_hex}.png" + tmp.parent.mkdir(parents=True, exist_ok=True) + try: + cmd = [ + "ffmpeg", + "-ss", + f"{offset:.2f}", + "-i", + str(source), + "-frames:v", + "1", + "-vf", + f"scale='if(gt(iw,ih),{THUMB_MAX_PX},-2)':'if(gt(iw,ih),-2,{THUMB_MAX_PX})'", + "-y", + str(tmp), + ] + subprocess.run( + cmd, + check=True, + capture_output=True, + timeout=FFMPEG_TIMEOUT_SECONDS, + ) + return self.generate_image_thumbnail(tmp, sha256_hex) + finally: + if tmp.exists(): + tmp.unlink() diff --git a/tests/test_thumbnailer.py b/tests/test_thumbnailer.py new file mode 100644 index 0000000..851de13 --- /dev/null +++ b/tests/test_thumbnailer.py @@ -0,0 +1,66 @@ +"""Image-thumbnailer smoke tests. Video tests are not included here because +they require a real ffmpeg + a real video on disk; FC-2e's integration suite +will cover those end-to-end against the running container. +""" + +from pathlib import Path + +import pytest +from PIL import Image + +from backend.app.services.thumbnailer import ( + THUMB_MAX_PX, + Thumbnailer, + thumbnail_path_for, +) + + +@pytest.fixture +def thumbnailer(tmp_path): + return Thumbnailer(images_root=tmp_path) + + +def _make_image(path: Path, size: tuple[int, int], mode: str = "RGB"): + im = Image.new(mode, size, color=(200, 100, 50, 255) if mode == "RGBA" else (200, 100, 50)) + path.parent.mkdir(parents=True, exist_ok=True) + im.save(path) + + +def test_thumbnail_path_for_jpeg(): + p = thumbnail_path_for("abcdef" + "0" * 58, alpha=False, root=Path("/images")) + assert p == Path("/images/thumbs/abc/" + "abcdef" + "0" * 58 + ".jpg") + + +def test_thumbnail_path_for_png(): + p = thumbnail_path_for("abcdef" + "0" * 58, alpha=True, root=Path("/images")) + assert p.suffix == ".png" + + +def test_generate_thumbnail_rgb(thumbnailer, tmp_path): + src = tmp_path / "src.jpg" + _make_image(src, (1600, 1200), mode="RGB") + sha = "deadbeef" + "0" * 56 + result = thumbnailer.generate_image_thumbnail(src, sha) + assert result.mime == "image/jpeg" + assert result.path.suffix == ".jpg" + assert result.path.exists() + assert max(result.width, result.height) == THUMB_MAX_PX + + +def test_generate_thumbnail_rgba_preserves_alpha(thumbnailer, tmp_path): + src = tmp_path / "src.png" + _make_image(src, (1200, 800), mode="RGBA") + sha = "feedface" + "0" * 56 + result = thumbnailer.generate_image_thumbnail(src, sha) + assert result.mime == "image/png" + assert result.path.suffix == ".png" + + +def test_generate_thumbnail_tall_image(thumbnailer, tmp_path): + src = tmp_path / "src.jpg" + _make_image(src, (200, 2000), mode="RGB") + sha = "cafef00d" + "0" * 56 + result = thumbnailer.generate_image_thumbnail(src, sha) + assert result.height == THUMB_MAX_PX + # tall image: width scales down proportionally + assert result.width < THUMB_MAX_PX