feat(fc2a): add Thumbnailer service
Generates 400px-max thumbnails: PNG with alpha preserved when source has
transparency, JPEG (q=85, progressive) otherwise. Storage layout is
/images/thumbs/{sha[:3]}/{sha}.{jpg|png} so each prefix bucket holds at
most ~4k files (16^3 buckets across all possible SHAs).
Video path uses ffmpeg's first-frame snapshot at ~5% offset; we have a
1-second floor for short clips. ffmpeg call has a 60s timeout. Video
tests are deferred to FC-2e's integration suite where transcoding is
also wired up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user