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:
164
card-renderer/main.py
Normal file
164
card-renderer/main.py
Normal 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)
|
||||
Reference in New Issue
Block a user