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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user