import base64 from io import BytesIO from pathlib import Path from typing import Any, Callable from PIL import Image DecodeRunner = Callable[[Image.Image], str] SectionParser = Callable[[str], dict[str, Any]] DecodeBuilder = Callable[..., dict[str, Any]] class DecodePayloadError(ValueError): pass MAX_DECODE_IMAGE_EDGE = 640 def _strip_data_url(value: str) -> str: if "," in value and value.lstrip().startswith("data:"): return value.split(",", 1)[1] return value def _image_from_payload(payload: dict[str, Any]) -> tuple[Image.Image, str, str]: image_b64 = payload.get("image") if not isinstance(image_b64, str) or not image_b64.strip(): raise DecodePayloadError("Missing base64 image.") normalized_b64 = _strip_data_url(image_b64.strip()) mime = payload.get("mime") if not isinstance(mime, str) or not mime.startswith("image/"): mime = "image/jpeg" try: raw = base64.b64decode(normalized_b64, validate=True) image = Image.open(BytesIO(raw)).convert("RGB") image.thumbnail((MAX_DECODE_IMAGE_EDGE, MAX_DECODE_IMAGE_EDGE), Image.Resampling.LANCZOS) except Exception as exc: raise DecodePayloadError("Invalid image payload.") from exc return image, normalized_b64, mime def decode_payload_to_culture_decode( payload: dict[str, Any], *, run_decode: DecodeRunner, parse_sections: SectionParser, sections_to_decode: DecodeBuilder, ) -> dict[str, Any]: image, image_b64, mime = _image_from_payload(payload) raw = run_decode(image) sections = parse_sections(raw) decode = dict(sections_to_decode(sections, uploaded_b64=image_b64)) decode["image"] = f"{mime};base64,{image_b64}" if mime.startswith("data:") else f"data:{mime};base64,{image_b64}" decode.pop("_uploaded_b64", None) return decode def create_hackathon_app( gradio_demo: Any, decode_payload: Callable[[dict[str, Any]], dict[str, Any]], narrate_payload: Callable[[dict[str, Any]], dict[str, Any]] | None = None, *, static_dir: str = "dist", ) -> Any: from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles import gradio as gr app = FastAPI(title="Local in 30s Hackathon API") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) @app.get("/api/health") async def health() -> dict[str, str]: return {"status": "ok"} @app.post("/api/decode") async def decode_api(payload: dict[str, Any]) -> dict[str, Any]: try: return decode_payload(payload) except DecodePayloadError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=502, detail=f"AI decode failed: {exc}") from exc @app.post("/api/narrate") async def narrate_api(payload: dict[str, Any]) -> dict[str, Any]: if narrate_payload is None: return {"audio": None, "usedFallback": True} try: return narrate_payload(payload) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=502, detail=f"AI narration failed: {exc}") from exc dist = Path(static_dir) if dist.exists(): assets = dist / "assets" if assets.exists(): app.mount("/ui/assets", StaticFiles(directory=str(assets)), name="ui-assets") @app.get("/") async def react_home() -> FileResponse: standalone = dist / "standalone-preview.html" if standalone.exists(): return FileResponse(str(standalone)) return FileResponse(str(dist / "index.html")) @app.get("/ui") @app.get("/ui/") @app.get("/ui/{path:path}") async def react_ui(path: str = "") -> FileResponse: target = dist / path if path and target.exists() and target.is_file(): return FileResponse(str(target)) return FileResponse(str(dist / "index.html")) return gr.mount_gradio_app(app, gradio_demo, path="/gradio")