Spaces:
Running
Running
| # # app.py | |
| # import os | |
| # import uuid | |
| # import shutil | |
| # import tempfile | |
| # import asyncio | |
| # from pathlib import Path | |
| # from fastapi import FastAPI, File, UploadFile, Form, HTTPException | |
| # from fastapi.responses import FileResponse, JSONResponse | |
| # from fastapi.middleware.cors import CORSMiddleware | |
| # # local imports | |
| # from task_queue import TaskQueue, TaskStatus | |
| # from core.pipeline import process_image_pipeline # your real pipeline | |
| # # App config | |
| # APP_NAME = "manim_render_service" | |
| # TMP_ROOT = Path(tempfile.gettempdir()) / APP_NAME | |
| # TASKS_DIR = TMP_ROOT / "tasks" | |
| # OUTPUTS_DIR = TMP_ROOT / "outputs" | |
| # TASKS_DIR.mkdir(parents=True, exist_ok=True) | |
| # OUTPUTS_DIR.mkdir(parents=True, exist_ok=True) | |
| # # instantiate queue (file-backed) | |
| # queue = TaskQueue(base_dir=TMP_ROOT, max_workers=os.cpu_count() or 2) | |
| # app = FastAPI(title="Manim Render Service") | |
| # app.add_middleware( | |
| # CORSMiddleware, | |
| # allow_origins=["*"], # change in prod | |
| # allow_credentials=True, | |
| # allow_methods=["*"], | |
| # allow_headers=["*"], | |
| # ) | |
| # @app.on_event("startup") | |
| # async def startup_event(): | |
| # # start background workers (non-blocking) | |
| # await queue.start(processor=process_image_pipeline) | |
| # @app.on_event("shutdown") | |
| # async def shutdown_event(): | |
| # await queue.stop() | |
| # def _make_task_dir(task_id: str) -> Path: | |
| # p = TASKS_DIR / task_id | |
| # p.mkdir(parents=True, exist_ok=True) | |
| # return p | |
| # def _secure_filename(filename: str) -> str: | |
| # # Minimal safe filename normalizer | |
| # return "".join(c for c in filename if c.isalnum() or c in "._-").strip("_") | |
| # @app.post("/render", status_code=202) | |
| # async def submit_render( | |
| # image: UploadFile = File(...), | |
| # style: str = Form("fade-in"), | |
| # quality: str = Form("final"), # preview or final | |
| # ): | |
| # # Basic validation | |
| # if image.content_type.split("/")[0] != "image": | |
| # raise HTTPException(status_code=400, detail="Uploaded file must be an image.") | |
| # task_id = uuid.uuid4().hex | |
| # task_dir = _make_task_dir(task_id) | |
| # # Save upload to tmp task directory | |
| # safe_name = _secure_filename(image.filename or f"{task_id}.png") | |
| # uploaded_path = task_dir / safe_name | |
| # try: | |
| # with uploaded_path.open("wb") as f: | |
| # content = await image.read() | |
| # # Limit size for safety (example: 25 MB) | |
| # if len(content) > 25 * 1024 * 1024: | |
| # raise HTTPException(status_code=413, detail="File too large (max 25MB).") | |
| # f.write(content) | |
| # finally: | |
| # await image.close() | |
| # # Compose metadata | |
| # meta = { | |
| # "task_id": task_id, | |
| # "input_image": str(uploaded_path), | |
| # "style": style, | |
| # "quality": quality, | |
| # "task_dir": str(task_dir), | |
| # } | |
| # # Enqueue the task | |
| # queue.enqueue(meta) | |
| # return JSONResponse({"task_id": task_id, "status": "queued"}) | |
| # @app.get("/status/{task_id}") | |
| # async def status(task_id: str): | |
| # st = queue.get_status(task_id) | |
| # if st is None: | |
| # raise HTTPException(status_code=404, detail="Task not found") | |
| # return JSONResponse({"task_id": task_id, "status": st.name}) | |
| # @app.get("/result/{task_id}") | |
| # async def result(task_id: str): | |
| # info = queue.get_task_info(task_id) | |
| # if info is None: | |
| # raise HTTPException(status_code=404, detail="Task not found") | |
| # status = queue.get_status(task_id) | |
| # if status != TaskStatus.COMPLETED: | |
| # return JSONResponse({"task_id": task_id, "status": status.name}) | |
| # output_path = Path(info.get("output_path", "")) | |
| # if not output_path.exists(): | |
| # raise HTTPException(status_code=404, detail="Output not found on disk") | |
| # return FileResponse(path=str(output_path), filename=output_path.name, media_type="video/mp4") | |
| # @app.delete("/task/{task_id}") | |
| # async def delete_task(task_id: str): | |
| # info = queue.get_task_info(task_id) | |
| # if info: | |
| # # attempt cleanup | |
| # task_dir = Path(info.get("task_dir", "")) | |
| # if task_dir.exists(): | |
| # shutil.rmtree(task_dir, ignore_errors=True) | |
| # queue.remove_task(task_id) | |
| # return JSONResponse({"task_id": task_id, "status": "removed"}) | |
| # else: | |
| # raise HTTPException(status_code=404, detail="Task not found") | |
| # app.py | |
| import os | |
| import uuid | |
| import shutil | |
| import tempfile | |
| import asyncio | |
| from pathlib import Path | |
| from fastapi import FastAPI, File, UploadFile, Form, HTTPException | |
| from fastapi.responses import FileResponse, JSONResponse | |
| from fastapi.middleware.cors import CORSMiddleware | |
| import subprocess | |
| # local imports | |
| from task_queue import TaskQueue, TaskStatus | |
| from core.pipeline import process_image_pipeline # your real pipeline | |
| from fastapi.responses import FileResponse, JSONResponse, Response | |
| from fastapi.responses import JSONResponse, Response, FileResponse | |
| from fastapi import HTTPException | |
| from pathlib import Path | |
| import base64 | |
| import base64 | |
| from pathlib import Path | |
| from moviepy.video.io.VideoFileClip import VideoFileClip | |
| # -------------------------------------------------- | |
| # Logging Setup | |
| # -------------------------------------------------- | |
| import logging | |
| logging.basicConfig( | |
| level=logging.DEBUG, | |
| format="π [%(asctime)s] [%(levelname)s] %(message)s", | |
| datefmt="%H:%M:%S", | |
| ) | |
| logger = logging.getLogger("manim_render_service") | |
| # -------------------------------------------------- | |
| # App config | |
| # -------------------------------------------------- | |
| APP_NAME = "manim_render_service" | |
| TMP_ROOT = Path("tmp")/ APP_NAME | |
| TASKS_DIR = TMP_ROOT / "tasks" | |
| OUTPUTS_DIR = TMP_ROOT / "outputs" | |
| TASKS_DIR.mkdir(parents=True, exist_ok=True) | |
| OUTPUTS_DIR.mkdir(parents=True, exist_ok=True) | |
| queue = TaskQueue(base_dir=TMP_ROOT, max_workers=os.cpu_count() or 2) | |
| app = FastAPI(title="Manim Render Service") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # -------------------------------------------------- | |
| # Lifecycle Events | |
| # -------------------------------------------------- | |
| async def startup_event(): | |
| logger.info("π Starting up backend...") | |
| logger.debug(f"Temporary root: {TMP_ROOT}") | |
| await queue.start(processor=process_image_pipeline) | |
| logger.info("β Queue system initialized and worker started.") | |
| async def shutdown_event(): | |
| logger.info("π§Ή Shutting down backend...") | |
| await queue.stop() | |
| logger.info("π Queue stopped gracefully.") | |
| # -------------------------------------------------- | |
| # Helpers | |
| # -------------------------------------------------- | |
| def _make_task_dir(task_id: str) -> Path: | |
| p = TASKS_DIR / task_id | |
| p.mkdir(parents=True, exist_ok=True) | |
| logger.debug(f"π Created task directory: {p}") | |
| return p | |
| def _secure_filename(filename: str) -> str: | |
| safe = "".join(c for c in filename if c.isalnum() or c in "._-").strip("_") | |
| logger.debug(f"π Secured filename: {filename} β {safe}") | |
| return safe | |
| # -------------------------------------------------- | |
| # Routes | |
| # -------------------------------------------------- | |
| async def submit_render( | |
| image: UploadFile = File(...), | |
| style: str = Form("fade-in"), | |
| quality: str = Form("final"), | |
| ): | |
| logger.info(f"π¨ Received new render request | style={style}, quality={quality}") | |
| logger.debug(f"Uploaded file info: {image.filename}, type={image.content_type}") | |
| if image.content_type.split("/")[0] != "image": | |
| logger.error("β Invalid file type, not an image.") | |
| raise HTTPException(status_code=400, detail="Uploaded file must be an image.") | |
| task_id = uuid.uuid4().hex | |
| task_dir = _make_task_dir(task_id) | |
| logger.info(f"π Generated Task ID: {task_id}") | |
| safe_name = _secure_filename(image.filename or f"{task_id}.png") | |
| uploaded_path = task_dir / safe_name | |
| logger.debug(f"π Saving upload to {uploaded_path}") | |
| try: | |
| with uploaded_path.open("wb") as f: | |
| content = await image.read() | |
| logger.debug(f"π¦ File size: {len(content)/1024:.2f} KB") | |
| if len(content) > 25 * 1024 * 1024: | |
| logger.warning("β οΈ Upload too large (>25MB). Rejecting.") | |
| raise HTTPException(status_code=413, detail="File too large (max 25MB).") | |
| f.write(content) | |
| finally: | |
| await image.close() | |
| logger.debug("π Image file closed after writing.") | |
| meta = { | |
| "task_id": task_id, | |
| "input_image": str(uploaded_path), | |
| "style": style, | |
| "quality": quality, | |
| "task_dir": str(task_dir), | |
| } | |
| logger.debug(f"π§Ύ Task metadata: {meta}") | |
| queue.enqueue(meta) | |
| logger.info(f"π€ Task {task_id} successfully enqueued.") | |
| return JSONResponse({"task_id": task_id, "status": "queued"}) | |
| async def status(task_id: str): | |
| logger.debug(f"Status check for task: {task_id}") | |
| st = queue.get_status(task_id) | |
| if st is None: | |
| logger.warning(f"Task {task_id} not found") | |
| raise HTTPException(status_code=404, detail="Task not found") | |
| # Get additional info | |
| task_info = queue.get_task_info(task_id) | |
| logger.info(f"Task {task_id} status: {st.name} | info: {task_info}") | |
| return JSONResponse({ | |
| "task_id": task_id, | |
| "status": st.name, | |
| "details": task_info | |
| }) | |
| # async def result(task_id: str): | |
| # logger.debug(f"π¦ Fetching result for task: {task_id}") | |
| # info = queue.get_task_info(task_id) | |
| # if info is None: | |
| # logger.warning(f"β οΈ Task info not found for {task_id}") | |
| # raise HTTPException(status_code=404, detail="Task not found") | |
| # status = queue.get_status(task_id) | |
| # logger.debug(f"π Task {task_id} current status: {status.name}") | |
| # if status != TaskStatus.COMPLETED: | |
| # logger.info(f"β³ Task {task_id} still in progress ({status.name})") | |
| # return JSONResponse({"task_id": task_id, "status": status.name}) | |
| # output_path = Path(info.get("output_path", "")) | |
| # logger.debug(f"π§© Checking output path: {output_path}") | |
| # # if not output_path.exists(): | |
| # # logger.error(f"β Output file missing for task {task_id}") | |
| # # raise HTTPException(status_code=404, detail="Output not found on disk") | |
| # info = queue.get_task_info(task_id) | |
| # output_bytes = info.get("output_bytes") | |
| # if output_bytes: | |
| # logger.info(f"π¬ Returning in-memory video for task {task_id}") | |
| # return Response(content=output_bytes, media_type="video") | |
| # # fallback to disk if memory missing | |
| # if not output_path.exists(): | |
| # logger.error(f"β Output file missing for task {task_id}") | |
| # raise HTTPException(status_code=404, detail="Output not found on disk") | |
| # logger.info(f"π¬ Returning result video from disk for task {task_id}") | |
| # return FileResponse( | |
| # path=str(output_path), filename=output_path.name, media_type="video" | |
| # ) | |
| async def result(task_id: str): | |
| logger.debug(f"π¦ Fetching result for task: {task_id}") | |
| info = queue.get_task_info(task_id) | |
| if info is None: | |
| logger.warning(f"β οΈ Task info not found for {task_id}") | |
| raise HTTPException(status_code=404, detail="Task not found") | |
| status = queue.get_status(task_id) | |
| logger.debug(f"π Task {task_id} current status: {status.name}") | |
| if status != TaskStatus.COMPLETED: | |
| logger.info(f"β³ Task {task_id} still in progress ({status.name})") | |
| return JSONResponse({"task_id": task_id, "status": status.name}) | |
| info = queue.get_task_info(task_id) | |
| output_path = Path(info.get("output_path", "")) # MOV path | |
| if not output_path.exists(): | |
| logger.error(f"β Output file missing for task {task_id}") | |
| raise HTTPException(status_code=404, detail="Output not found") | |
| # Convert MOV to WEBM with alpha if needed | |
| webm_path = output_path.with_suffix(".webm") | |
| if output_path.suffix.lower() == ".mov" and not webm_path.exists(): | |
| try: | |
| logger.info(f"ποΈ Converting .mov β .webm (keeping transparency)...") | |
| cmd = [ | |
| "ffmpeg", | |
| "-y", | |
| "-i", str(output_path), | |
| "-c:v", "libvpx-vp9", | |
| "-pix_fmt", "yuva420p", # keep alpha channel | |
| "-b:v", "4M", | |
| "-auto-alt-ref", "0", | |
| str(webm_path) | |
| ] | |
| subprocess.run(cmd, check=True, capture_output=True) | |
| logger.info(f"β Converted successfully β {webm_path}") | |
| except Exception as e: | |
| logger.error(f"β οΈ MOVβWEBM conversion failed: {e}") | |
| raise HTTPException(status_code=500, detail=f"Conversion failed: {e}") | |
| # Read both MOV and WEBM as bytes | |
| mov_bytes = output_path.read_bytes() | |
| webm_bytes = webm_path.read_bytes() | |
| logger.info(f"β Returning both MOV + WEBM for task {task_id}") | |
| return JSONResponse({ | |
| "task_id": task_id, | |
| "status": "COMPLETED", | |
| "results": [ | |
| { | |
| "format": "mov", | |
| "data": base64.b64encode(mov_bytes).decode("utf-8"), | |
| }, | |
| { | |
| "format": "webm", | |
| "data": base64.b64encode(webm_bytes).decode("utf-8"), | |
| }, | |
| ], | |
| }) | |
| async def delete_task(task_id: str): | |
| logger.info(f"π Request to delete task: {task_id}") | |
| info = queue.get_task_info(task_id) | |
| if info: | |
| task_dir = Path(info.get("task_dir", "")) | |
| if task_dir.exists(): | |
| logger.debug(f"π§Ή Removing directory: {task_dir}") | |
| shutil.rmtree(task_dir, ignore_errors=True) | |
| queue.remove_task(task_id) | |
| logger.info(f"β Task {task_id} removed successfully.") | |
| return JSONResponse({"task_id": task_id, "status": "removed"}) | |
| else: | |
| logger.warning(f"β οΈ Task {task_id} not found for deletion.") | |
| raise HTTPException(status_code=404, detail="Task not found") | |
| def home(): | |
| return {"status": "Your Manim backend is running!"} | |