- 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
114 lines
3.4 KiB
Python
114 lines
3.4 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
from app.crop import smart_cover_crop
|
|
|
|
# Asset paths — configurable via env so they can be overridden in compose.
|
|
ASSETS = Path(os.getenv("CARD_ASSETS_DIR", "/app/assets"))
|
|
FONT_REGULAR = os.getenv("CARD_DEFAULT_FONT", str(ASSETS / "fonts" / "Inter-Regular.ttf"))
|
|
FONT_BOLD = os.getenv("CARD_BOLD_FONT", str(ASSETS / "fonts" / "Inter-Bold.ttf"))
|
|
LOGO_PATH = os.getenv("CARD_LOGO_PATH", str(ASSETS / "logo.png"))
|
|
|
|
|
|
def _load_font(path: str, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
"""Load a TrueType font, falling back to PIL default if the file is absent."""
|
|
try:
|
|
return ImageFont.truetype(path, size=size)
|
|
except (OSError, IOError):
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def _truncate_text(text: str, max_chars: int) -> str:
|
|
"""Truncate text with an ellipsis if it exceeds max_chars."""
|
|
if len(text) <= max_chars:
|
|
return text
|
|
return text[: max_chars - 1] + "\u2026"
|
|
|
|
|
|
def _draw_gradient_overlay(base: Image.Image) -> None:
|
|
"""Paint a bottom-weighted dark gradient over base (mutates in-place)."""
|
|
w, h = base.size
|
|
overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(overlay)
|
|
for y in range(h):
|
|
# alpha ramps from 0 at top to ~200 at bottom
|
|
alpha = int(200 * (y / h) ** 1.5)
|
|
draw.line((0, y, w, y), fill=(0, 0, 0, alpha))
|
|
base.alpha_composite(overlay)
|
|
|
|
|
|
def render_nova_artwork_v1(
|
|
source: Image.Image,
|
|
width: int,
|
|
height: int,
|
|
title: Optional[str],
|
|
subtitle: Optional[str],
|
|
username: Optional[str],
|
|
category: Optional[str],
|
|
show_logo: bool,
|
|
) -> tuple[Image.Image, tuple[int, int, int, int]]:
|
|
"""Render the nova-artwork-v1 card template.
|
|
|
|
Returns (rendered_image_RGBA, crop_box).
|
|
"""
|
|
canvas, crop_box = smart_cover_crop(source, width, height)
|
|
canvas = canvas.convert("RGBA")
|
|
|
|
_draw_gradient_overlay(canvas)
|
|
draw = ImageDraw.Draw(canvas)
|
|
|
|
pad_x = 48
|
|
bottom = height - 48
|
|
|
|
title_font = _load_font(FONT_BOLD, 54)
|
|
meta_font = _load_font(FONT_REGULAR, 28)
|
|
small_font = _load_font(FONT_REGULAR, 22)
|
|
|
|
if category:
|
|
draw.text(
|
|
(pad_x, bottom - 156),
|
|
_truncate_text(category.upper(), 40),
|
|
font=small_font,
|
|
fill=(220, 220, 220, 235),
|
|
)
|
|
|
|
if title:
|
|
draw.text(
|
|
(pad_x, bottom - 116),
|
|
_truncate_text(title, 60),
|
|
font=title_font,
|
|
fill=(255, 255, 255, 255),
|
|
)
|
|
|
|
meta_parts = [p for p in [subtitle, username] if p]
|
|
if meta_parts:
|
|
draw.text(
|
|
(pad_x, bottom - 44),
|
|
" \u2022 ".join(_truncate_text(p, 40) for p in meta_parts),
|
|
font=meta_font,
|
|
fill=(235, 235, 235, 245),
|
|
)
|
|
|
|
if show_logo:
|
|
_composite_logo(canvas, width)
|
|
|
|
return canvas, crop_box
|
|
|
|
|
|
def _composite_logo(canvas: Image.Image, canvas_width: int) -> None:
|
|
"""Overlay the logo image in the top-right corner. Fails silently if absent."""
|
|
try:
|
|
logo = Image.open(LOGO_PATH).convert("RGBA")
|
|
logo.thumbnail((160, 60), Image.LANCZOS)
|
|
x = canvas_width - logo.width - 40
|
|
y = 36
|
|
canvas.alpha_composite(logo, (x, y))
|
|
except Exception:
|
|
# Logo is optional — missing or unreadable file is not an error
|
|
pass
|