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

17
card-renderer/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
COPY card-renderer/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY card-renderer /app
ENV PYTHONUNBUFFERED=1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1 @@
# card-renderer app package

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

View 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

View 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
View 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

View File

@@ -0,0 +1,10 @@
# Card Renderer Assets
Place the following files here before building the image:
- `fonts/Inter-Regular.ttf` — body text
- `fonts/Inter-Bold.ttf` — title text
- `logo.png` — Skinbase Nova logo (RGBA, ~320×120 px)
The renderer will fall back to PIL's built-in font if TTF files are absent,
and will skip the logo overlay silently if `logo.png` is missing.

View File

@@ -0,0 +1 @@
# Put Inter-Bold.ttf here

164
card-renderer/main.py Normal file
View File

@@ -0,0 +1,164 @@
from __future__ import annotations
import json
from io import BytesIO
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import JSONResponse, Response
from app.image_io import ImageLoadError, load_image_from_bytes, load_image_from_url
from app.models import CardRenderRequest
from app.render import render_nova_artwork_v1
app = FastAPI(title="card-renderer", version="1.0.0")
# Supported templates (extend here as new templates are added)
_TEMPLATES = [
{
"key": "nova-artwork-v1",
"label": "Skinbase Nova Artwork Card v1",
"supports": ["url", "file"],
"recommended_sizes": [
{"width": 1200, "height": 630},
{"width": 1600, "height": 900},
{"width": 1080, "height": 1080},
],
}
]
@app.get("/health")
async def health():
return {"ok": True, "service": "card-renderer"}
@app.get("/templates")
async def templates():
return {"items": _TEMPLATES}
@app.post("/render")
async def render(payload: CardRenderRequest):
"""Render a card from a remote image URL. Returns binary image bytes."""
try:
image = await load_image_from_url(str(payload.image_url))
except ImageLoadError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Failed to fetch image: {exc}")
try:
rendered, _ = render_nova_artwork_v1(
source=image,
width=payload.width,
height=payload.height,
title=payload.title,
subtitle=payload.subtitle,
username=payload.username,
category=payload.category,
show_logo=payload.show_logo,
)
return _image_response(rendered, payload.output, payload.quality)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Render failed: {exc}")
@app.post("/render/file")
async def render_file(
file: UploadFile = File(...),
template: str = Form("nova-artwork-v1"),
width: int = Form(1200),
height: int = Form(630),
output: str = Form("webp"),
quality: int = Form(90),
title: str | None = Form(None),
subtitle: str | None = Form(None),
username: str | None = Form(None),
category: str | None = Form(None),
tags_json: str | None = Form(None),
show_logo: bool = Form(True),
):
"""Render a card from an uploaded image file. Returns binary image bytes."""
_ = json.loads(tags_json) if tags_json else [] # validate JSON early; unused in v1
try:
image = load_image_from_bytes(await file.read())
except ImageLoadError as exc:
raise HTTPException(status_code=400, detail=str(exc))
try:
rendered, _ = render_nova_artwork_v1(
source=image,
width=width,
height=height,
title=title,
subtitle=subtitle,
username=username,
category=category,
show_logo=show_logo,
)
return _image_response(rendered, output, quality)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Render failed: {exc}")
@app.post("/render/meta")
async def render_meta(payload: CardRenderRequest):
"""Return layout and crop metadata without producing an image (dry run)."""
try:
image = await load_image_from_url(str(payload.image_url))
except ImageLoadError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Failed to fetch image: {exc}")
try:
_, crop_box = render_nova_artwork_v1(
source=image,
width=payload.width,
height=payload.height,
title=payload.title,
subtitle=payload.subtitle,
username=payload.username,
category=payload.category,
show_logo=payload.show_logo,
)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Render failed: {exc}")
return JSONResponse(
{
"template": payload.template,
"width": payload.width,
"height": payload.height,
"crop_box": list(crop_box),
"safe_area": {
"left": 48,
"right": payload.width - 48,
"top": 36,
"bottom": payload.height - 36,
},
"theme": payload.theme,
}
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _image_response(img, output: str, quality: int) -> Response:
"""Encode a PIL image and return it as an HTTP Response."""
fmt = "JPEG" if output.lower() in ("jpg", "jpeg") else output.upper()
# JPEG does not support alpha
save_img = img.convert("RGB") if fmt == "JPEG" else img
buf = BytesIO()
save_kwargs: dict = {}
if fmt in ("WEBP", "JPEG"):
save_kwargs["quality"] = quality
save_img.save(buf, format=fmt, **save_kwargs)
media_type = "image/jpeg" if fmt == "JPEG" else f"image/{output.lower()}"
return Response(content=buf.getvalue(), media_type=media_type)

View File

@@ -0,0 +1,6 @@
fastapi==0.115.5
uvicorn[standard]==0.30.6
httpx==0.27.2
pillow==10.4.0
python-multipart==0.0.9
pydantic==2.9.2

View File

@@ -12,6 +12,7 @@ services:
- BLIP_URL=http://blip:8000
- YOLO_URL=http://yolo:8000
- QDRANT_SVC_URL=http://qdrant-svc:8000
- CARD_RENDERER_URL=http://card-renderer:8000
- API_KEY=${API_KEY}
- VISION_TIMEOUT=300
- MAX_IMAGE_BYTES=52428800
@@ -24,6 +25,26 @@ services:
condition: service_healthy
qdrant-svc:
condition: service_healthy
card-renderer:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5).read()"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
card-renderer:
build:
context: .
dockerfile: card-renderer/Dockerfile
environment:
- CARD_DEFAULT_FONT=/app/assets/fonts/Inter-Regular.ttf
- CARD_BOLD_FONT=/app/assets/fonts/Inter-Bold.ttf
- CARD_LOGO_PATH=/app/assets/logo.png
- CARD_MAX_IMAGE_BYTES=52428800
- CARD_DEFAULT_OUTPUT=webp
- CARD_DEFAULT_QUALITY=90
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5).read()"]
interval: 30s

View File

@@ -6,7 +6,7 @@ from typing import Any, Dict, Optional
import httpx
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Request
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, Response
from starlette.middleware.base import BaseHTTPMiddleware
from pydantic import BaseModel, Field
@@ -14,6 +14,7 @@ CLIP_URL = os.getenv("CLIP_URL", "http://clip:8000")
BLIP_URL = os.getenv("BLIP_URL", "http://blip:8000")
YOLO_URL = os.getenv("YOLO_URL", "http://yolo:8000")
QDRANT_SVC_URL = os.getenv("QDRANT_SVC_URL", "http://qdrant-svc:8000")
CARD_RENDERER_URL = os.getenv("CARD_RENDERER_URL", "http://card-renderer:8000")
VISION_TIMEOUT = float(os.getenv("VISION_TIMEOUT", "20"))
# API key (set via env var `API_KEY`). If not set, gateway will reject requests.
@@ -329,3 +330,89 @@ async def analyze_all_file(
clip_res, blip_res, yolo_res = await asyncio.gather(clip_task, blip_task, yolo_task)
return {"clip": clip_res, "blip": blip_res, "yolo": yolo_res}
# ---- Card renderer endpoints ----
@app.get("/cards/templates")
async def cards_templates():
"""List available card templates."""
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _get_json(client, f"{CARD_RENDERER_URL}/templates")
@app.post("/cards/render")
async def cards_render(payload: Dict[str, Any]):
"""Render a Nova card from a remote image URL. Returns binary image bytes."""
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
try:
resp = await client.post(f"{CARD_RENDERER_URL}/render", json=payload)
except httpx.RequestError as exc:
raise HTTPException(status_code=502, detail=f"card-renderer unreachable: {exc}")
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f"card-renderer error {resp.status_code}: {resp.text[:1000]}")
return Response(
content=resp.content,
media_type=resp.headers.get("content-type", "image/webp"),
)
@app.post("/cards/render/file")
async def cards_render_file(
file: UploadFile = File(...),
template: str = Form("nova-artwork-v1"),
width: int = Form(1200),
height: int = Form(630),
output: str = Form("webp"),
quality: int = Form(90),
title: Optional[str] = Form(None),
subtitle: Optional[str] = Form(None),
username: Optional[str] = Form(None),
category: Optional[str] = Form(None),
tags_json: Optional[str] = Form(None),
show_logo: bool = Form(True),
):
"""Render a Nova card from an uploaded image file. Returns binary image bytes."""
data = await file.read()
fields: Dict[str, Any] = {
"template": template,
"width": width,
"height": height,
"output": output,
"quality": quality,
"show_logo": show_logo,
}
if title is not None:
fields["title"] = title
if subtitle is not None:
fields["subtitle"] = subtitle
if username is not None:
fields["username"] = username
if category is not None:
fields["category"] = category
if tags_json is not None:
fields["tags_json"] = tags_json
upload_files = {"file": (file.filename or "image", data, file.content_type or "application/octet-stream")}
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
try:
resp = await client.post(
f"{CARD_RENDERER_URL}/render/file",
data={k: str(v) for k, v in fields.items()},
files=upload_files,
)
except httpx.RequestError as exc:
raise HTTPException(status_code=502, detail=f"card-renderer unreachable: {exc}")
if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f"card-renderer error {resp.status_code}: {resp.text[:1000]}")
return Response(
content=resp.content,
media_type=resp.headers.get("content-type", "image/webp"),
)
@app.post("/cards/render/meta")
async def cards_render_meta(payload: Dict[str, Any]):
"""Return crop and layout metadata for a card render (no image produced)."""
async with httpx.AsyncClient(timeout=VISION_TIMEOUT) as client:
return await _post_json(client, f"{CARD_RENDERER_URL}/render/meta", payload)