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:
17
card-renderer/Dockerfile
Normal file
17
card-renderer/Dockerfile
Normal 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"]
|
||||||
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
|
||||||
10
card-renderer/assets/README.md
Normal file
10
card-renderer/assets/README.md
Normal 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.
|
||||||
1
card-renderer/assets/fonts/.gitkeep
Normal file
1
card-renderer/assets/fonts/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Put Inter-Bold.ttf here
|
||||||
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)
|
||||||
6
card-renderer/requirements.txt
Normal file
6
card-renderer/requirements.txt
Normal 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
|
||||||
@@ -12,6 +12,7 @@ services:
|
|||||||
- BLIP_URL=http://blip:8000
|
- BLIP_URL=http://blip:8000
|
||||||
- YOLO_URL=http://yolo:8000
|
- YOLO_URL=http://yolo:8000
|
||||||
- QDRANT_SVC_URL=http://qdrant-svc:8000
|
- QDRANT_SVC_URL=http://qdrant-svc:8000
|
||||||
|
- CARD_RENDERER_URL=http://card-renderer:8000
|
||||||
- API_KEY=${API_KEY}
|
- API_KEY=${API_KEY}
|
||||||
- VISION_TIMEOUT=300
|
- VISION_TIMEOUT=300
|
||||||
- MAX_IMAGE_BYTES=52428800
|
- MAX_IMAGE_BYTES=52428800
|
||||||
@@ -24,6 +25,26 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
qdrant-svc:
|
qdrant-svc:
|
||||||
condition: service_healthy
|
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:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5).read()"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5).read()"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Any, Dict, Optional
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Request
|
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 starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from pydantic import BaseModel, Field
|
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")
|
BLIP_URL = os.getenv("BLIP_URL", "http://blip:8000")
|
||||||
YOLO_URL = os.getenv("YOLO_URL", "http://yolo:8000")
|
YOLO_URL = os.getenv("YOLO_URL", "http://yolo:8000")
|
||||||
QDRANT_SVC_URL = os.getenv("QDRANT_SVC_URL", "http://qdrant-svc: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"))
|
VISION_TIMEOUT = float(os.getenv("VISION_TIMEOUT", "20"))
|
||||||
|
|
||||||
# API key (set via env var `API_KEY`). If not set, gateway will reject requests.
|
# 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)
|
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}
|
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