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)