feat: add card-renderer internal service (v1)

- New card-renderer FastAPI service (Python 3.11 + Pillow)
  - GET /health, GET /templates
  - POST /render (URL input)
  - POST /render/file (multipart upload)
  - POST /render/meta (dry-run layout metadata)
  - nova-artwork-v1 template: cover crop, gradient overlay, text, logo
  - SSRF-safe async image fetch with redirect validation
  - Smart center cover crop isolated for future YOLO focal-point support
  - Graceful font/logo fallback when assets are absent

- docker-compose.yml: add card-renderer service + healthcheck;
  extend gateway with CARD_RENDERER_URL and depends_on

- gateway/main.py: proxy endpoints under /cards/*
  - GET  /cards/templates
  - POST /cards/render
  - POST /cards/render/file
  - POST /cards/render/meta
  All protected by existing APIKeyMiddleware
This commit is contained in:
2026-03-31 10:39:29 +02:00
parent 613023de86
commit 58ee1b3bdd
12 changed files with 589 additions and 1 deletions

38
card-renderer/app/crop.py Normal file
View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from PIL import Image
def smart_cover_crop(
img: Image.Image,
target_w: int,
target_h: int,
) -> tuple[Image.Image, tuple[int, int, int, int]]:
"""Center-weighted cover crop that fills target_w × target_h exactly.
The crop box is returned alongside the cropped image so callers can
record it for metadata or later focal-point refinement.
"""
src_w, src_h = img.size
target_ratio = target_w / target_h
src_ratio = src_w / src_h
if src_ratio > target_ratio:
# Image is wider than needed — crop sides
crop_h = src_h
crop_w = int(crop_h * target_ratio)
left = max((src_w - crop_w) // 2, 0)
top = 0
else:
# Image is taller than needed — crop top/bottom
crop_w = src_w
crop_h = int(crop_w / target_ratio)
left = 0
top = max((src_h - crop_h) // 2, 0)
right = min(left + crop_w, src_w)
bottom = min(top + crop_h, src_h)
box = (left, top, right, bottom)
cropped = img.crop(box).resize((target_w, target_h), Image.LANCZOS)
return cropped, box