Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import json | |
| import os | |
| import re | |
| import subprocess | |
| import uuid | |
| from pathlib import Path | |
| from typing import Any, Dict | |
| from fastapi import BackgroundTasks, FastAPI, HTTPException | |
| from fastapi.responses import FileResponse, HTMLResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from pydantic import BaseModel, Field | |
| APP_DIR = Path(__file__).resolve().parent | |
| TEMPLATES_DIR = APP_DIR / "templates" | |
| RENDERS_DIR = APP_DIR / "renders" | |
| TEMPLATES_DIR.mkdir(parents=True, exist_ok=True) | |
| RENDERS_DIR.mkdir(parents=True, exist_ok=True) | |
| app = FastAPI(title="Video Template API (FFmpeg)", version="0.1.0") | |
| app.mount("/static", StaticFiles(directory=str(APP_DIR / "static")), name="static") | |
| def home() -> Any: | |
| return (APP_DIR / "static" / "index.html").read_text(encoding="utf-8") | |
| def _safe_id(s: str) -> str: | |
| if not re.fullmatch(r"[a-zA-Z0-9_\-]{1,80}", s): | |
| raise HTTPException(400, "Invalid id (use letters, numbers, _ or -, max 80 chars)") | |
| return s | |
| def _template_path(tpl_id: str) -> Path: | |
| return TEMPLATES_DIR / f"{tpl_id}.json" | |
| def _load_template(tpl_id: str) -> Dict[str, Any]: | |
| p = _template_path(tpl_id) | |
| if not p.exists(): | |
| raise HTTPException(404, "Template not found") | |
| return json.loads(p.read_text(encoding="utf-8")) | |
| def _save_template(tpl_id: str, data: Dict[str, Any]) -> None: | |
| p = _template_path(tpl_id) | |
| p.write_text(json.dumps(data, indent=2), encoding="utf-8") | |
| class RenderRequest(BaseModel): | |
| template_id: str | |
| variables: Dict[str, Any] = Field(default_factory=dict) | |
| # In-memory job store (starter). For persistence, push outputs to a dataset repo. | |
| JOBS: Dict[str, Dict[str, Any]] = {} | |
| def list_templates() -> Any: | |
| items = [] | |
| for p in sorted(TEMPLATES_DIR.glob("*.json")): | |
| try: | |
| obj = json.loads(p.read_text(encoding="utf-8")) | |
| items.append({"id": obj.get("id", p.stem), "name": obj.get("name", "")}) | |
| except Exception: | |
| items.append({"id": p.stem, "name": "(invalid json)"}) | |
| return items | |
| def get_template(tpl_id: str) -> Any: | |
| tpl_id = _safe_id(tpl_id) | |
| return _load_template(tpl_id) | |
| def upsert_template(tpl_id: str, body: Dict[str, Any]) -> Any: | |
| tpl_id = _safe_id(tpl_id) | |
| body = dict(body) | |
| body["id"] = tpl_id | |
| body.setdefault("name", tpl_id) | |
| for k in ["width", "height", "fps", "duration_sec"]: | |
| if k not in body: | |
| raise HTTPException(400, f"Missing '{k}' in template") | |
| _save_template(tpl_id, body) | |
| return {"ok": True, "id": tpl_id} | |
| def _render_job(job_id: str, req: RenderRequest) -> None: | |
| try: | |
| JOBS[job_id]["status"] = "running" | |
| tpl = _load_template(_safe_id(req.template_id)) | |
| w = int(tpl["width"]) | |
| h = int(tpl["height"]) | |
| fps = int(tpl["fps"]) | |
| dur = float(tpl["duration_sec"]) | |
| bg = str(tpl.get("bg_color", "#111827")) | |
| text = tpl.get("text", {}) | |
| raw_text = str(text.get("value", "{name}")) | |
| name = str(req.variables.get("name", "Friend")) | |
| final_text = raw_text.replace("{name}", name) | |
| x = int(text.get("x", 80)) | |
| y = int(text.get("y", h - 120)) | |
| fontsize = int(text.get("fontsize", 48)) | |
| out_path = RENDERS_DIR / f"{job_id}.mp4" | |
| fontfile = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" | |
| vf = ( | |
| f"drawtext=fontfile='{fontfile}':" | |
| f"text='{final_text}':" | |
| f"x={x}:y={y}:fontsize={fontsize}:fontcolor=white" | |
| ) | |
| cmd = [ | |
| "ffmpeg", "-y", | |
| "-f", "lavfi", "-i", f"color=c={bg}:s={w}x{h}:r={fps}", | |
| "-t", str(dur), | |
| "-vf", vf, | |
| "-c:v", "libx264", | |
| "-pix_fmt", "yuv420p", | |
| str(out_path), | |
| ] | |
| JOBS[job_id]["detail"] = "Rendering…" | |
| proc = subprocess.run(cmd, capture_output=True, text=True) | |
| if proc.returncode != 0: | |
| raise RuntimeError(proc.stderr[-2000:] or "ffmpeg failed") | |
| JOBS[job_id]["status"] = "done" | |
| JOBS[job_id]["detail"] = "Done" | |
| JOBS[job_id]["output_path"] = str(out_path) | |
| except Exception as e: | |
| JOBS[job_id]["status"] = "error" | |
| JOBS[job_id]["detail"] = str(e) | |
| def render(req: RenderRequest, background: BackgroundTasks) -> Any: | |
| tpl_id = _safe_id(req.template_id) | |
| _load_template(tpl_id) | |
| job_id = uuid.uuid4().hex | |
| JOBS[job_id] = {"job_id": job_id, "status": "queued", "detail": "Queued"} | |
| background.add_task(_render_job, job_id, req) | |
| return {"job_id": job_id, "status": "queued"} | |
| def job_status(job_id: str) -> Any: | |
| job = JOBS.get(job_id) | |
| if not job: | |
| raise HTTPException(404, "Job not found") | |
| return {k: v for k, v in job.items() if k != "output_path"} | |
| def job_download(job_id: str) -> Any: | |
| job = JOBS.get(job_id) | |
| if not job: | |
| raise HTTPException(404, "Job not found") | |
| if job.get("status") != "done": | |
| raise HTTPException(400, "Job not finished") | |
| path = job.get("output_path") | |
| if not path or not os.path.exists(path): | |
| raise HTTPException(404, "Output missing (Space may have restarted)") | |
| return FileResponse(path, media_type="video/mp4", filename=f"{job_id}.mp4") | |