diff --git a/card-renderer/Dockerfile b/card-renderer/Dockerfile new file mode 100644 index 0000000..5b5d201 --- /dev/null +++ b/card-renderer/Dockerfile @@ -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"] diff --git a/card-renderer/app/__init__.py b/card-renderer/app/__init__.py new file mode 100644 index 0000000..8265c23 --- /dev/null +++ b/card-renderer/app/__init__.py @@ -0,0 +1 @@ +# card-renderer app package diff --git a/card-renderer/app/crop.py b/card-renderer/app/crop.py new file mode 100644 index 0000000..724a55c --- /dev/null +++ b/card-renderer/app/crop.py @@ -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 diff --git a/card-renderer/app/image_io.py b/card-renderer/app/image_io.py new file mode 100644 index 0000000..fbd65c3 --- /dev/null +++ b/card-renderer/app/image_io.py @@ -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 diff --git a/card-renderer/app/models.py b/card-renderer/app/models.py new file mode 100644 index 0000000..b8bd886 --- /dev/null +++ b/card-renderer/app/models.py @@ -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 diff --git a/card-renderer/app/render.py b/card-renderer/app/render.py new file mode 100644 index 0000000..283bf6e --- /dev/null +++ b/card-renderer/app/render.py @@ -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 diff --git a/card-renderer/assets/README.md b/card-renderer/assets/README.md new file mode 100644 index 0000000..ea44566 --- /dev/null +++ b/card-renderer/assets/README.md @@ -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. diff --git a/card-renderer/assets/fonts/.gitkeep b/card-renderer/assets/fonts/.gitkeep new file mode 100644 index 0000000..7e09f26 --- /dev/null +++ b/card-renderer/assets/fonts/.gitkeep @@ -0,0 +1 @@ +# Put Inter-Bold.ttf here diff --git a/card-renderer/main.py b/card-renderer/main.py new file mode 100644 index 0000000..41e3bc2 --- /dev/null +++ b/card-renderer/main.py @@ -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) diff --git a/card-renderer/requirements.txt b/card-renderer/requirements.txt new file mode 100644 index 0000000..87df502 --- /dev/null +++ b/card-renderer/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 84c3786..b1fa79a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/gateway/main.py b/gateway/main.py index 221fc1b..67f7d23 100644 --- a/gateway/main.py +++ b/gateway/main.py @@ -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)