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:
1
card-renderer/app/__init__.py
Normal file
1
card-renderer/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# card-renderer app package
|
||||
38
card-renderer/app/crop.py
Normal file
38
card-renderer/app/crop.py
Normal 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
|
||||
99
card-renderer/app/image_io.py
Normal file
99
card-renderer/app/image_io.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from io import BytesIO
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
from PIL import Image
|
||||
|
||||
DEFAULT_MAX_BYTES = 52_428_800 # 50 MB
|
||||
_MAX_REDIRECTS = 3
|
||||
|
||||
|
||||
class ImageLoadError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _validate_public_url(url: str) -> str:
|
||||
"""Raise ImageLoadError if the URL is not a safe public http/https address.
|
||||
|
||||
Prevents SSRF by rejecting private, loopback, link-local, and reserved IPs.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ImageLoadError("Only http and https URLs are allowed")
|
||||
if not parsed.hostname:
|
||||
raise ImageLoadError("URL must include a hostname")
|
||||
|
||||
hostname = parsed.hostname.strip().lower()
|
||||
if hostname in {"localhost", "127.0.0.1", "::1"}:
|
||||
raise ImageLoadError("Localhost URLs are not allowed")
|
||||
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
try:
|
||||
resolved = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM)
|
||||
except socket.gaierror as exc:
|
||||
raise ImageLoadError(f"Cannot resolve host: {exc}") from exc
|
||||
|
||||
for entry in resolved:
|
||||
ip = ipaddress.ip_address(entry[4][0])
|
||||
if (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_multicast
|
||||
or ip.is_reserved
|
||||
or ip.is_unspecified
|
||||
):
|
||||
raise ImageLoadError("URLs resolving to private or reserved addresses are not allowed")
|
||||
|
||||
return url
|
||||
|
||||
|
||||
async def load_image_from_url(
|
||||
url: str,
|
||||
max_bytes: int = DEFAULT_MAX_BYTES,
|
||||
timeout: float = 60.0,
|
||||
) -> Image.Image:
|
||||
"""Fetch an image from a validated public URL and return it as a PIL Image (RGBA)."""
|
||||
validated = _validate_public_url(url)
|
||||
|
||||
current = validated
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
|
||||
for _ in range(_MAX_REDIRECTS + 1):
|
||||
resp = await client.get(current)
|
||||
|
||||
if 300 <= resp.status_code < 400:
|
||||
location = resp.headers.get("location")
|
||||
if not location:
|
||||
raise ImageLoadError("Redirect missing Location header")
|
||||
current = _validate_public_url(urljoin(current, location))
|
||||
continue
|
||||
|
||||
resp.raise_for_status()
|
||||
|
||||
content_type = (resp.headers.get("content-type") or "").lower()
|
||||
if content_type and not content_type.startswith("image/"):
|
||||
raise ImageLoadError(f"URL does not point to an image: {content_type}")
|
||||
|
||||
data = resp.content
|
||||
if len(data) > max_bytes:
|
||||
raise ImageLoadError(f"Image exceeds maximum allowed size ({max_bytes} bytes)")
|
||||
|
||||
return _decode(data)
|
||||
|
||||
raise ImageLoadError(f"Too many redirects (>{_MAX_REDIRECTS})")
|
||||
|
||||
|
||||
def load_image_from_bytes(data: bytes) -> Image.Image:
|
||||
"""Decode raw bytes into a PIL Image (RGBA)."""
|
||||
return _decode(data)
|
||||
|
||||
|
||||
def _decode(data: bytes) -> Image.Image:
|
||||
try:
|
||||
return Image.open(BytesIO(data)).convert("RGBA")
|
||||
except Exception as exc:
|
||||
raise ImageLoadError(f"Cannot decode image: {exc}") from exc
|
||||
31
card-renderer/app/models.py
Normal file
31
card-renderer/app/models.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
|
||||
|
||||
class CardRenderRequest(BaseModel):
|
||||
template: str = Field(default="nova-artwork-v1")
|
||||
width: int = Field(default=1200, ge=100, le=4000)
|
||||
height: int = Field(default=630, ge=100, le=4000)
|
||||
output: Literal["png", "jpeg", "jpg", "webp"] = "webp"
|
||||
quality: int = Field(default=90, ge=1, le=100)
|
||||
image_url: HttpUrl
|
||||
title: str | None = None
|
||||
subtitle: str | None = None
|
||||
username: str | None = None
|
||||
category: str | None = None
|
||||
tags: list[str] = []
|
||||
show_logo: bool = True
|
||||
show_avatar: bool = False
|
||||
avatar_url: HttpUrl | None = None
|
||||
theme: Literal["dark", "light", "nova"] = "dark"
|
||||
|
||||
|
||||
class CardMetaResponse(BaseModel):
|
||||
template: str
|
||||
width: int
|
||||
height: int
|
||||
crop_box: list[int]
|
||||
safe_area: dict
|
||||
theme: str
|
||||
113
card-renderer/app/render.py
Normal file
113
card-renderer/app/render.py
Normal file
@@ -0,0 +1,113 @@
|
||||
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
|
||||
Reference in New Issue
Block a user