| """
|
| ManimStudio - FastAPI application for rendering Manim animations
|
| Designed for ChatGPT GPT Actions integration on Hugging Face Spaces
|
| """
|
|
|
| import asyncio
|
| import hashlib
|
| import json
|
| import logging
|
| import os
|
| import shutil
|
| import uuid
|
| from datetime import datetime, timedelta, timezone
|
| from pathlib import Path
|
| from typing import Optional, Tuple
|
|
|
| from fastapi import FastAPI, HTTPException, BackgroundTasks
|
| from fastapi.responses import FileResponse
|
| from fastapi.middleware.cors import CORSMiddleware
|
| from fastapi.openapi.utils import get_openapi
|
|
|
| from models import RenderRequest, RenderResponse, HealthResponse, StatusResponse
|
| from validators import validate_code_full
|
| from renderer import render_animation, cleanup_temp_files
|
| from converter import convert_all_formats, check_ffmpeg_available
|
| from cleanup import cleanup_service
|
| from config import BASE_RENDER_DIR, HOST, PORT, QUALITY_PRESETS
|
| from mcp_integration import mcp_router
|
|
|
|
|
| logging.basicConfig(
|
| level=logging.INFO,
|
| format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| handlers=[logging.StreamHandler()]
|
| )
|
| logger = logging.getLogger("manim_studio")
|
|
|
|
|
| app = FastAPI(
|
| title="ManimStudio API",
|
| description="Render mathematical animations using Manim library. Designed for ChatGPT GPT Actions integration.",
|
| version="1.0.0",
|
| docs_url="/docs",
|
| redoc_url="/redoc",
|
| )
|
|
|
|
|
| app.add_middleware(
|
| CORSMiddleware,
|
| allow_origins=["*"],
|
| allow_credentials=True,
|
| allow_methods=["*"],
|
| allow_headers=["*"],
|
| )
|
|
|
|
|
| app.include_router(mcp_router)
|
|
|
|
|
| active_renders = set()
|
| active_mcp_renders: dict[str, str] = {}
|
|
|
|
|
| @app.on_event("startup")
|
| async def startup_event():
|
| """Initialize application on startup"""
|
| logger.info("Starting ManimStudio API...")
|
|
|
|
|
| BASE_RENDER_DIR.mkdir(parents=True, exist_ok=True)
|
| logger.info(f"Render directory: {BASE_RENDER_DIR}")
|
|
|
|
|
| try:
|
| process = await asyncio.create_subprocess_exec(
|
| "manim", "--version",
|
| stdout=asyncio.subprocess.PIPE,
|
| stderr=asyncio.subprocess.PIPE
|
| )
|
| stdout, _ = await process.communicate()
|
| version = stdout.decode().strip()
|
| logger.info(f"Manim installed: {version}")
|
| except FileNotFoundError:
|
| logger.error("Manim not found! Please install manim package.")
|
|
|
|
|
| ffmpeg_available = await check_ffmpeg_available()
|
| logger.info(f"FFmpeg available: {ffmpeg_available}")
|
|
|
|
|
| asyncio.create_task(cleanup_service.start())
|
| logger.info("Cleanup service started")
|
|
|
| logger.info("ManimStudio API ready!")
|
|
|
|
|
| @app.on_event("shutdown")
|
| async def shutdown_event():
|
| """Cleanup on application shutdown"""
|
| logger.info("Shutting down ManimStudio API...")
|
| cleanup_service.stop()
|
| await asyncio.sleep(1)
|
| logger.info("Shutdown complete")
|
|
|
|
|
| @app.get("/.well-known/openai-apps-challenge", tags=["verification"])
|
| async def openai_verification():
|
| """Domain verification for ChatGPT Apps SDK"""
|
| from fastapi.responses import PlainTextResponse
|
| return PlainTextResponse("WiYDk9HI73fbvsut_q8ddTlXT_T9DPvixQXncps9VOI")
|
|
|
|
|
| @app.get("/", tags=["info"])
|
| async def root():
|
| """API information and version"""
|
| return {
|
| "name": "ManimStudio API",
|
| "version": "1.0.0",
|
| "description": "Render Manim mathematical animations via API for ChatGPT GPT Actions",
|
| "endpoints": {
|
| "health": "/health",
|
| "render": "/render (POST)",
|
| "test-render": "/test-render (GET)",
|
| "media": "/media/{request_id}/{filename}",
|
| "status": "/status/{request_id}",
|
| "docs": "/docs"
|
| },
|
| "repository": "https://github.com/yu314-coder/manim_app-new-ui"
|
| }
|
|
|
|
|
| @app.get("/debug/files/{request_id}", tags=["testing"])
|
| async def debug_files(request_id: str):
|
| """
|
| Debug endpoint to list all files in a request directory
|
| """
|
| request_dir = BASE_RENDER_DIR / request_id
|
|
|
| if not request_dir.exists():
|
| return {
|
| "error": f"Request directory not found: {request_id}",
|
| "base_dir": str(BASE_RENDER_DIR),
|
| "exists": False
|
| }
|
|
|
| file_tree = {}
|
| for root, dirs, files in os.walk(request_dir):
|
| rel_path = os.path.relpath(root, request_dir)
|
| file_tree[rel_path] = {
|
| "dirs": dirs,
|
| "files": files
|
| }
|
|
|
| return {
|
| "request_id": request_id,
|
| "directory": str(request_dir),
|
| "exists": True,
|
| "file_tree": file_tree
|
| }
|
|
|
|
|
| @app.get("/test-render", tags=["testing"])
|
| async def test_render():
|
| """
|
| Test rendering with a simple scene (no LaTeX)
|
| This helps diagnose Manim issues
|
| """
|
| request_id = str(uuid.uuid4())
|
|
|
|
|
|
|
| test_code = """from manim import *
|
|
|
| class SimpleCircle(Scene):
|
| def construct(self):
|
| circle = Circle()
|
| circle.set_fill(BLUE, opacity=0.5)
|
| circle.set_stroke(BLUE_E, width=4)
|
| self.play(Create(circle))
|
| self.wait(1)
|
| """
|
|
|
| try:
|
| logger.info(f"[{request_id}] Running test render...")
|
|
|
| success, message, output_mp4 = await render_animation(
|
| request_id=request_id,
|
| code=test_code,
|
| quality="480p",
|
| fps=15,
|
| scene_name="SimpleCircle"
|
| )
|
|
|
| if success and output_mp4:
|
|
|
| base_url = os.getenv("SPACE_HOST", "https://euler314-manim-mcp.hf.space")
|
| if not base_url.startswith("http"):
|
| base_url = f"https://{base_url}"
|
|
|
| url = f"{base_url}/media/{request_id}/output.mp4"
|
|
|
|
|
| await cleanup_service.register_render(
|
| request_id=request_id,
|
| formats=["mp4"],
|
| size_bytes=output_mp4.stat().st_size if output_mp4.exists() else 0
|
| )
|
|
|
| return {
|
| "status": "success",
|
| "message": "Test render completed",
|
| "request_id": request_id,
|
| "url": url,
|
| "file_exists": output_mp4.exists(),
|
| "file_size": output_mp4.stat().st_size if output_mp4.exists() else 0
|
| }
|
| else:
|
|
|
| request_dir = BASE_RENDER_DIR / request_id
|
| file_tree = {}
|
| if request_dir.exists():
|
| for root, dirs, files in os.walk(request_dir):
|
| rel_path = os.path.relpath(root, request_dir)
|
| file_tree[rel_path] = {"dirs": dirs, "files": files}
|
|
|
| return {
|
| "status": "error",
|
| "message": message,
|
| "request_id": request_id,
|
| "debug_info": {
|
| "request_dir": str(request_dir),
|
| "request_dir_exists": request_dir.exists(),
|
| "file_tree": file_tree
|
| }
|
| }
|
|
|
| except Exception as e:
|
| logger.exception(f"[{request_id}] Test render failed")
|
| return {
|
| "status": "error",
|
| "message": str(e),
|
| "request_id": request_id
|
| }
|
|
|
|
|
| @app.get("/health", response_model=HealthResponse, tags=["info"])
|
| async def health_check():
|
| """System health check endpoint"""
|
| try:
|
|
|
| process = await asyncio.create_subprocess_exec(
|
| "manim", "--version",
|
| stdout=asyncio.subprocess.PIPE,
|
| stderr=asyncio.subprocess.PIPE
|
| )
|
| stdout, _ = await process.communicate()
|
| manim_version = stdout.decode().strip().split('\n')[0]
|
|
|
|
|
| ffmpeg_available = await check_ffmpeg_available()
|
|
|
|
|
| disk_usage = shutil.disk_usage(BASE_RENDER_DIR)
|
| disk_space_mb = disk_usage.free / (1024 * 1024)
|
|
|
|
|
| stats = await cleanup_service.get_stats()
|
|
|
| return HealthResponse(
|
| status="healthy",
|
| manim_version=manim_version,
|
| ffmpeg_available=ffmpeg_available,
|
| disk_space_mb=disk_space_mb,
|
| active_renders=len(active_renders)
|
| )
|
| except Exception as e:
|
| logger.exception("Health check failed")
|
| return HealthResponse(
|
| status="unhealthy",
|
| manim_version="unknown",
|
| ffmpeg_available=False,
|
| disk_space_mb=0.0,
|
| active_renders=0
|
| )
|
|
|
|
|
| @app.post("/render", response_model=RenderResponse, tags=["rendering"])
|
| async def render_animation_endpoint(
|
| request: RenderRequest,
|
| background_tasks: BackgroundTasks
|
| ):
|
| """
|
| Render a Manim animation from Python code
|
|
|
| Accepts Manim Scene code and renders it to video format(s).
|
| Returns URLs to download the rendered files.
|
| Files expire after 1 hour.
|
| """
|
|
|
| request_id = str(uuid.uuid4())
|
| logger.info(f"[{request_id}] New render request: quality={request.quality}, format={request.output_format}")
|
|
|
| try:
|
|
|
| is_valid, error_msg, scene_name = validate_code_full(
|
| request.code,
|
| request.scene_name
|
| )
|
|
|
| if not is_valid:
|
| logger.warning(f"[{request_id}] Validation failed: {error_msg}")
|
| return RenderResponse(
|
| request_id=request_id,
|
| status="error",
|
| message="Code validation failed",
|
| urls=None,
|
| expires_at=None,
|
| error_details=error_msg
|
| )
|
|
|
|
|
| if request.quality not in QUALITY_PRESETS:
|
| return RenderResponse(
|
| request_id=request_id,
|
| status="error",
|
| message="Invalid quality preset",
|
| urls=None,
|
| expires_at=None,
|
| error_details=f"Quality must be one of: {', '.join(QUALITY_PRESETS.keys())}"
|
| )
|
|
|
|
|
| active_renders.add(request_id)
|
|
|
| try:
|
|
|
| logger.info(f"[{request_id}] Starting render: scene={scene_name}")
|
| success, message, output_mp4 = await render_animation(
|
| request_id=request_id,
|
| code=request.code,
|
| quality=request.quality,
|
| fps=request.fps,
|
| scene_name=scene_name
|
| )
|
|
|
| if not success or not output_mp4:
|
| logger.error(f"[{request_id}] Render failed: {message}")
|
| return RenderResponse(
|
| request_id=request_id,
|
| status="error",
|
| message="Rendering failed",
|
| urls=None,
|
| expires_at=None,
|
| error_details=message
|
| )
|
|
|
|
|
| formats_to_convert = []
|
| if request.output_format == "all":
|
| formats_to_convert = ["gif", "webm"]
|
| elif request.output_format != "mp4":
|
| formats_to_convert = [request.output_format]
|
|
|
| if formats_to_convert:
|
| logger.info(f"[{request_id}] Converting to formats: {formats_to_convert}")
|
| conversion_results = await convert_all_formats(
|
| input_mp4=output_mp4,
|
| output_formats=formats_to_convert,
|
| request_id=request_id
|
| )
|
| else:
|
| conversion_results = {"mp4": output_mp4}
|
|
|
|
|
| base_url = os.getenv("SPACE_HOST", "http://localhost:7860")
|
| if not base_url.startswith("http"):
|
| base_url = f"https://{base_url}"
|
|
|
| urls = {}
|
| available_formats = []
|
|
|
| for fmt, path in conversion_results.items():
|
| if path and path.exists():
|
| urls[fmt] = f"{base_url}/media/{request_id}/output.{fmt}"
|
| available_formats.append(fmt)
|
| else:
|
| logger.warning(f"[{request_id}] Format {fmt} conversion failed or file missing")
|
|
|
| if not urls:
|
| return RenderResponse(
|
| request_id=request_id,
|
| status="error",
|
| message="No output files generated",
|
| urls=None,
|
| expires_at=None,
|
| error_details="All format conversions failed"
|
| )
|
|
|
|
|
| total_size = sum(
|
| path.stat().st_size
|
| for path in conversion_results.values()
|
| if path and path.exists()
|
| )
|
|
|
| await cleanup_service.register_render(
|
| request_id=request_id,
|
| formats=available_formats,
|
| size_bytes=total_size
|
| )
|
|
|
|
|
| background_tasks.add_task(
|
| cleanup_temp_files,
|
| BASE_RENDER_DIR / request_id
|
| )
|
|
|
|
|
| expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
|
|
| logger.info(f"[{request_id}] Render completed successfully: {available_formats}")
|
|
|
| return RenderResponse(
|
| request_id=request_id,
|
| status="success",
|
| message=f"Animation rendered successfully ({scene_name})",
|
| urls=urls,
|
| expires_at=expires_at.isoformat(),
|
| error_details=None
|
| )
|
|
|
| finally:
|
|
|
| active_renders.discard(request_id)
|
|
|
| except Exception as e:
|
| logger.exception(f"[{request_id}] Unexpected error")
|
| active_renders.discard(request_id)
|
| return RenderResponse(
|
| request_id=request_id,
|
| status="error",
|
| message="Internal server error",
|
| urls=None,
|
| expires_at=None,
|
| error_details=str(e)
|
| )
|
|
|
|
|
| @app.options("/media/{request_id}/{filename}", tags=["media"])
|
| async def download_file_options(request_id: str, filename: str):
|
| """Handle CORS preflight requests for media files"""
|
| return JSONResponse(
|
| content={},
|
| headers={
|
| "Access-Control-Allow-Origin": "*",
|
| "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
|
| "Access-Control-Allow-Headers": "*",
|
| }
|
| )
|
|
|
|
|
| @app.get("/media/{request_id}/{filename}", tags=["media"])
|
| async def download_file(request_id: str, filename: str):
|
| """
|
| Download a rendered video file
|
|
|
| Files are available for 1 hour after rendering.
|
| """
|
|
|
| try:
|
| uuid.UUID(request_id)
|
| except ValueError:
|
| raise HTTPException(status_code=400, detail="Invalid request ID format")
|
|
|
|
|
| if not filename.startswith("output."):
|
| raise HTTPException(status_code=400, detail="Invalid filename")
|
|
|
| file_extension = filename.split(".")[-1]
|
| if file_extension not in ["mp4", "gif", "webm"]:
|
| raise HTTPException(status_code=400, detail="Invalid file format")
|
|
|
|
|
| if await cleanup_service.check_if_expired(request_id):
|
| raise HTTPException(
|
| status_code=404,
|
| detail="File not found or expired. Files are only available for 1 hour after rendering."
|
| )
|
|
|
|
|
| file_path = BASE_RENDER_DIR / request_id / filename
|
|
|
| if not file_path.exists():
|
| raise HTTPException(status_code=404, detail="File not found")
|
|
|
|
|
| media_type_map = {
|
| "mp4": "video/mp4",
|
| "gif": "image/gif",
|
| "webm": "video/webm"
|
| }
|
| media_type = media_type_map.get(file_extension, "application/octet-stream")
|
|
|
| return FileResponse(
|
| path=file_path,
|
| media_type=media_type,
|
| filename=filename,
|
| headers={
|
| "Cache-Control": "public, max-age=3600",
|
| "Content-Disposition": f'inline; filename="{filename}"',
|
| "Access-Control-Allow-Origin": "*",
|
| "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
|
| "Access-Control-Allow-Headers": "*"
|
| }
|
| )
|
|
|
|
|
| @app.get("/status/{request_id}", response_model=StatusResponse, tags=["info"])
|
| async def get_render_status(request_id: str):
|
| """
|
| Check the status of a render request
|
|
|
| Returns information about available files and download URLs.
|
| """
|
|
|
| try:
|
| uuid.UUID(request_id)
|
| except ValueError:
|
| raise HTTPException(status_code=400, detail="Invalid request ID format")
|
|
|
|
|
| if await cleanup_service.check_if_expired(request_id):
|
| return StatusResponse(
|
| request_id=request_id,
|
| status="expired",
|
| files_available=None,
|
| urls=None,
|
| expires_at=None
|
| )
|
|
|
|
|
| render_info = await cleanup_service.get_render_info(request_id)
|
|
|
| if not render_info:
|
| render_dir = BASE_RENDER_DIR / request_id
|
| if render_dir.exists():
|
| return StatusResponse(
|
| request_id=request_id,
|
| status="processing",
|
| files_available=None,
|
| urls=None,
|
| expires_at=None,
|
| log_tail=get_render_log_tail(request_id)
|
| )
|
| return StatusResponse(
|
| request_id=request_id,
|
| status="not_found",
|
| files_available=None,
|
| urls=None,
|
| expires_at=None
|
| )
|
|
|
|
|
| render_dir = BASE_RENDER_DIR / request_id
|
| if not render_dir.exists():
|
| return StatusResponse(
|
| request_id=request_id,
|
| status="not_found",
|
| files_available=None,
|
| urls=None,
|
| expires_at=None
|
| )
|
|
|
|
|
| base_url = os.getenv("SPACE_HOST", "http://localhost:7860")
|
| if not base_url.startswith("http"):
|
| base_url = f"https://{base_url}"
|
|
|
| urls = {
|
| fmt: f"{base_url}/media/{request_id}/output.{fmt}"
|
| for fmt in render_info["formats"]
|
| }
|
|
|
| expires_at = datetime.fromtimestamp(render_info["expires_at"], tz=timezone.utc)
|
|
|
| return StatusResponse(
|
| request_id=request_id,
|
| status="ready",
|
| files_available=render_info["formats"],
|
| urls=urls,
|
| expires_at=expires_at.isoformat(),
|
| log_tail=None
|
| )
|
|
|
|
|
|
|
|
|
|
|
| def custom_openapi():
|
| """Customize OpenAPI schema for ChatGPT GPT Actions"""
|
| if app.openapi_schema:
|
| return app.openapi_schema
|
|
|
| openapi_schema = get_openapi(
|
| title="ManimStudio API",
|
| version="1.0.0",
|
| description=(
|
| "Render mathematical animations using the Manim library. "
|
| "Submit Python code containing Manim Scene classes and receive "
|
| "download URLs for rendered videos in MP4, GIF, or WebM formats."
|
| ),
|
| routes=app.routes,
|
| )
|
|
|
|
|
| openapi_schema["info"]["x-chatgpt"] = {
|
| "description_for_model": (
|
| "Use this API to render mathematical animations using Manim. "
|
| "Users provide Python code containing Scene classes that define animations. "
|
| "The API validates the code, renders the animation, and returns URLs to download "
|
| "the video in MP4, GIF, or WebM format. Files expire after 1 hour. "
|
| "Support quality presets: 4k, 2k, 1080p, 720p, 480p."
|
| ),
|
| "description_for_human": "Render beautiful mathematical animations with Manim",
|
| "legal_info_url": "https://github.com/ManimCommunity/manim/blob/main/LICENSE"
|
| }
|
|
|
| app.openapi_schema = openapi_schema
|
| return app.openapi_schema
|
|
|
|
|
| app.openapi = custom_openapi
|
|
|
|
|
| def get_render_log_tail(request_id: str, max_chars: int = 2000) -> Optional[str]:
|
| log_path = BASE_RENDER_DIR / request_id / "render.log"
|
| if not log_path.exists():
|
| return None
|
|
|
| try:
|
| max_bytes = max_chars * 4
|
| with log_path.open("rb") as handle:
|
| handle.seek(0, os.SEEK_END)
|
| size = handle.tell()
|
| read_size = min(size, max_bytes)
|
| handle.seek(-read_size, os.SEEK_END)
|
| data = handle.read()
|
| text = data.decode("utf-8", errors="replace").replace("\r", "\n")
|
| if len(text) > max_chars:
|
| text = text[-max_chars:]
|
| return text.strip()
|
| except OSError:
|
| return None
|
|
|
|
|
| def get_render_status_path(request_id: str) -> Path:
|
| return BASE_RENDER_DIR / request_id / "render_status.json"
|
|
|
|
|
| def write_render_status(request_id: str, status: str, message: str = "") -> None:
|
| status_path = get_render_status_path(request_id)
|
| status_path.parent.mkdir(parents=True, exist_ok=True)
|
| payload = {
|
| "status": status,
|
| "message": message,
|
| "updated_at": datetime.now(timezone.utc).isoformat()
|
| }
|
| status_path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
|
| def read_render_status(request_id: str) -> Optional[dict]:
|
| status_path = get_render_status_path(request_id)
|
| if not status_path.exists():
|
| return None
|
| try:
|
| return json.loads(status_path.read_text(encoding="utf-8"))
|
| except json.JSONDecodeError:
|
| return None
|
|
|
|
|
| def compute_render_fingerprint(arguments: dict, scene_name: str) -> str:
|
| payload = {
|
| "code": arguments.get("code", ""),
|
| "quality": arguments.get("quality", "720p"),
|
| "fps": arguments.get("fps"),
|
| "output_format": arguments.get("output_format", "mp4"),
|
| "scene_name": scene_name
|
| }
|
| data = json.dumps(payload, sort_keys=True, ensure_ascii=True)
|
| return hashlib.sha256(data.encode("utf-8")).hexdigest()
|
|
|
|
|
| async def run_mcp_render(
|
| request_id: str,
|
| arguments: dict,
|
| scene_name: str
|
| ) -> Tuple[bool, str, dict]:
|
| success, message, output_mp4, render_logs = await render_animation_with_logs(
|
| request_id=request_id,
|
| code=arguments["code"],
|
| quality=arguments.get("quality", "720p"),
|
| fps=arguments.get("fps"),
|
| scene_name=scene_name
|
| )
|
|
|
| if not success or not output_mp4:
|
| error_message = f"Rendering failed:\n\n{message}"
|
| if render_logs:
|
| error_message += f"\n\nManim Output (last 500 chars):\n```\n{render_logs[-500:]}\n```"
|
| return False, error_message, {}
|
|
|
|
|
| formats_to_convert = []
|
| output_format = arguments.get("output_format", "mp4")
|
|
|
| if output_format == "all":
|
| formats_to_convert = ["gif", "webm"]
|
| elif output_format != "mp4":
|
| formats_to_convert = [output_format]
|
|
|
| conversion_results = {"mp4": output_mp4}
|
| if formats_to_convert:
|
| conversion_results.update(await convert_all_formats(
|
| input_mp4=output_mp4,
|
| output_formats=formats_to_convert,
|
| request_id=request_id
|
| ))
|
|
|
|
|
| base_url = os.getenv("SPACE_HOST", "https://euler314-manim-mcp.hf.space")
|
| if not base_url.startswith("http"):
|
| base_url = f"https://{base_url}"
|
|
|
| urls = {}
|
| available_formats = []
|
|
|
| for fmt, path in conversion_results.items():
|
| if path and path.exists():
|
| urls[fmt] = f"{base_url}/media/{request_id}/output.{fmt}"
|
| available_formats.append(fmt)
|
|
|
| if not urls:
|
| return False, "No output files generated. All format conversions failed.", {}
|
|
|
|
|
| total_size = sum(
|
| path.stat().st_size
|
| for path in conversion_results.values()
|
| if path and path.exists()
|
| )
|
|
|
| await cleanup_service.register_render(
|
| request_id=request_id,
|
| formats=available_formats,
|
| size_bytes=total_size
|
| )
|
|
|
|
|
| cleanup_temp_files(BASE_RENDER_DIR / request_id)
|
|
|
| return True, "Render completed successfully", urls
|
|
|
|
|
| async def render_animation_with_logs(
|
| request_id: str,
|
| code: str,
|
| quality: str,
|
| fps: Optional[int],
|
| scene_name: str
|
| ) -> Tuple[bool, str, Optional[Path], str]:
|
| """
|
| Wrapper around render_animation that also captures and returns logs
|
|
|
| Returns:
|
| Tuple of (success, message, output_path, logs)
|
| """
|
| success, message, output_path = await render_animation(
|
| request_id=request_id,
|
| code=code,
|
| quality=quality,
|
| fps=fps,
|
| scene_name=scene_name
|
| )
|
|
|
|
|
| logs = ""
|
| if not success and "Stdout:" in message:
|
|
|
| logs = message
|
|
|
| return success, message, output_path, logs
|
|
|
|
|
|
|
|
|
|
|
|
|
| async def render_animation_for_mcp(arguments: dict) -> dict:
|
| """
|
| Helper function to handle rendering for MCP/ChatGPT Apps SDK
|
| Returns MCP-formatted response with content array
|
| """
|
| request_id = str(uuid.uuid4())
|
|
|
| try:
|
|
|
| is_valid, error_msg, scene_name = validate_code_full(
|
| arguments.get("code", ""),
|
| arguments.get("scene_name")
|
| )
|
|
|
| if not is_valid:
|
| return {
|
| "content": [
|
| {
|
| "type": "text",
|
| "text": f"""Code validation failed:
|
|
|
| {error_msg}"""
|
| }
|
| ],
|
| "isError": True
|
| }
|
|
|
| fingerprint = compute_render_fingerprint(arguments, scene_name)
|
| existing_request_id = active_mcp_renders.get(fingerprint)
|
| if existing_request_id:
|
| return {
|
| "content": [{
|
| "type": "text",
|
| "text": f"""Render already running.
|
|
|
| Request ID: `{existing_request_id}`
|
|
|
| Use `check_render_status` to see progress and get the output when ready."""
|
| }],
|
| "isError": False,
|
| "structuredContent": {
|
| "request_id": existing_request_id,
|
| "status": "processing"
|
| }
|
| }
|
|
|
| is_async = bool(arguments.get("async", False))
|
| if is_async:
|
| request_id = str(uuid.uuid4())
|
| active_mcp_renders[fingerprint] = request_id
|
| write_render_status(request_id, "processing", "Render started")
|
|
|
| async def _run_background():
|
| try:
|
| success, message, _urls = await run_mcp_render(
|
| request_id=request_id,
|
| arguments=arguments,
|
| scene_name=scene_name
|
| )
|
| if success:
|
| write_render_status(request_id, "ready", message)
|
| else:
|
| write_render_status(request_id, "error", message)
|
| finally:
|
| active_mcp_renders.pop(fingerprint, None)
|
|
|
| asyncio.create_task(_run_background())
|
|
|
| return {
|
| "content": [{
|
| "type": "text",
|
| "text": f"""Render started.
|
|
|
| Request ID: `{request_id}`
|
|
|
| Use `check_render_status` to see progress and get the output when ready."""
|
| }],
|
| "isError": False,
|
| "structuredContent": {
|
| "request_id": request_id,
|
| "status": "processing"
|
| }
|
| }
|
|
|
| active_mcp_renders[fingerprint] = request_id
|
| write_render_status(request_id, "processing", "Render started")
|
|
|
| try:
|
| success, message, urls = await run_mcp_render(
|
| request_id=request_id,
|
| arguments=arguments,
|
| scene_name=scene_name
|
| )
|
| finally:
|
| active_mcp_renders.pop(fingerprint, None)
|
|
|
| if not success:
|
| write_render_status(request_id, "error", message)
|
| return {
|
| "content": [
|
| {
|
| "type": "text",
|
| "text": f"""Rendering failed:
|
|
|
| {message}"""
|
| }
|
| ],
|
| "isError": True
|
| }
|
|
|
| write_render_status(request_id, "ready", message)
|
|
|
|
|
| expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
|
|
|
|
| url_list = "\n".join([f" - {fmt.upper()}: {url}" for fmt, url in urls.items()])
|
| log_tail = get_render_log_tail(request_id)
|
| log_block = ""
|
| if log_tail:
|
| log_block = f"""
|
| Recent output:
|
| ```
|
| {log_tail}
|
| ```"""
|
|
|
| response_text = f"""Animation rendered successfully.
|
|
|
| Scene: {scene_name}
|
| Quality: {arguments.get('quality', '720p')}
|
| Request ID: `{request_id}`
|
|
|
| Download URLs:
|
| {url_list}
|
|
|
| Files expire at: {expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')}
|
| {log_block}
|
|
|
| Your animation is ready. Use the video player below or download via the links.
|
| """
|
|
|
|
|
| resource_base_url = os.getenv("SPACE_HOST", "https://euler314-manim-mcp.hf.space")
|
| if not resource_base_url.startswith("http"):
|
| resource_base_url = f"https://{resource_base_url}"
|
|
|
| content_items = [
|
| {
|
| "type": "text",
|
| "text": response_text
|
| }
|
| ]
|
|
|
| mp4_url = urls.get("mp4")
|
| if mp4_url:
|
| content_items.append({
|
| "type": "resource",
|
| "resource": {
|
| "uri": mp4_url,
|
| "mimeType": "video/mp4"
|
| }
|
| })
|
|
|
|
|
| return {
|
| "content": content_items + [{
|
| "type": "resource",
|
| "resource": {
|
| "uri": f"{resource_base_url}/mcp/resources/video-player",
|
| "mimeType": "text/html",
|
| "text": response_text
|
| }
|
| }],
|
| "isError": False,
|
| "_meta": {
|
| "openai/outputTemplate": f"{resource_base_url}/mcp/resources/video-player"
|
| },
|
| "structuredContent": {
|
| "urls": urls,
|
| "scene_name": scene_name,
|
| "quality": arguments.get("quality", "720p"),
|
| "request_id": request_id,
|
| "expires_at": expires_at.isoformat(),
|
| "log_tail": log_tail,
|
| "status": "ready"
|
| }
|
| }
|
|
|
| except Exception as e:
|
| logger.exception(f"MCP render error: {e}")
|
| return {
|
| "content": [
|
| {
|
| "type": "text",
|
| "text": f"""Unexpected error:
|
|
|
| {str(e)}"""
|
| }
|
| ],
|
| "isError": True
|
| }
|
|
|
|
|
| async def get_render_status_for_mcp(arguments: dict) -> dict:
|
| """
|
| Check render status for MCP/ChatGPT - allows debugging
|
|
|
| Returns detailed information about a render request
|
| """
|
| request_id = arguments.get("request_id", "")
|
|
|
| try:
|
|
|
| try:
|
| uuid.UUID(request_id)
|
| except ValueError:
|
| return {
|
| "content": [{
|
| "type": "text",
|
| "text": f"Invalid request ID format: {request_id}"
|
| }],
|
| "isError": True
|
| }
|
|
|
|
|
| if await cleanup_service.check_if_expired(request_id):
|
| return {
|
| "content": [{
|
| "type": "text",
|
| "text": f"""Render expired.
|
|
|
| Request ID: `{request_id}`
|
|
|
| This render has expired (files are deleted after 1 hour). You'll need to re-render the animation."""
|
| }],
|
| "isError": False,
|
| "structuredContent": {
|
| "request_id": request_id,
|
| "status": "expired"
|
| }
|
| }
|
|
|
| status_info = read_render_status(request_id)
|
| if status_info:
|
| status = status_info.get("status")
|
| message = status_info.get("message", "")
|
| log_tail = get_render_log_tail(request_id)
|
| log_block = ""
|
| if log_tail:
|
| log_block = f"""
|
|
|
| Recent output:
|
| ```
|
| {log_tail}
|
| ```"""
|
|
|
| if status == "error":
|
| return {
|
| "content": [{
|
| "type": "text",
|
| "text": f"""Render failed.
|
|
|
| Request ID: `{request_id}`
|
|
|
| {message}{log_block}"""
|
| }],
|
| "isError": True,
|
| "structuredContent": {
|
| "request_id": request_id,
|
| "status": "error",
|
| "log_tail": log_tail
|
| }
|
| }
|
| if status == "processing":
|
| return {
|
| "content": [{
|
| "type": "text",
|
| "text": f"""Render in progress.
|
|
|
| Request ID: `{request_id}`
|
|
|
| The render is still processing. Please wait and try again.{log_block}"""
|
| }],
|
| "isError": False,
|
| "structuredContent": {
|
| "request_id": request_id,
|
| "status": "processing",
|
| "log_tail": log_tail
|
| }
|
| }
|
|
|
|
|
| render_info = await cleanup_service.get_render_info(request_id)
|
|
|
| if not render_info:
|
|
|
| render_dir = BASE_RENDER_DIR / request_id
|
| if render_dir.exists():
|
| log_tail = get_render_log_tail(request_id)
|
| log_block = ""
|
| if log_tail:
|
| log_block = f"""
|
|
|
| Recent output:
|
| ```
|
| {log_tail}
|
| ```"""
|
| return {
|
| "content": [{
|
| "type": "text",
|
| "text": f"""Render in progress.
|
|
|
| Request ID: `{request_id}`
|
|
|
| The render is still processing. Please wait and try again.{log_block}"""
|
| }],
|
| "isError": False,
|
| "structuredContent": {
|
| "request_id": request_id,
|
| "status": "processing",
|
| "log_tail": log_tail
|
| }
|
| }
|
| else:
|
| return {
|
| "content": [{
|
| "type": "text",
|
| "text": f"""Render not found.
|
|
|
| Request ID: `{request_id}`
|
|
|
| No render found with this ID. It may have expired or never existed."""
|
| }],
|
| "isError": True,
|
| "structuredContent": {
|
| "request_id": request_id,
|
| "status": "not_found"
|
| }
|
| }
|
|
|
|
|
| base_url = os.getenv("SPACE_HOST", "https://euler314-manim-mcp.hf.space")
|
| if not base_url.startswith("http"):
|
| base_url = f"https://{base_url}"
|
|
|
| urls = {
|
| fmt: f"{base_url}/media/{request_id}/output.{fmt}"
|
| for fmt in render_info["formats"]
|
| }
|
|
|
| expires_at = datetime.fromtimestamp(render_info["expires_at"], tz=timezone.utc)
|
| time_remaining = expires_at - datetime.now(timezone.utc)
|
| minutes_remaining = int(time_remaining.total_seconds() / 60)
|
|
|
| url_list = "\n".join([f" - {fmt.upper()}: {url}" for fmt, url in urls.items()])
|
| log_tail = get_render_log_tail(request_id)
|
| log_block = ""
|
| if log_tail:
|
| log_block = f"""
|
| Recent output:
|
| ```
|
| {log_tail}
|
| ```"""
|
|
|
| status_text = f"""Render ready.
|
|
|
| Request ID: `{request_id}`
|
| Available Formats: {', '.join(render_info['formats'])}
|
| File Size: {render_info.get('size_bytes', 0) / 1024 / 1024:.2f} MB
|
| Expires In: {minutes_remaining} minutes
|
|
|
| Download URLs:
|
| {url_list}
|
|
|
| Expiration: {expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')}
|
| {log_block}
|
| """
|
|
|
| content_items = [{
|
| "type": "text",
|
| "text": status_text
|
| }]
|
|
|
| mp4_url = urls.get("mp4")
|
| if mp4_url:
|
| content_items.append({
|
| "type": "resource",
|
| "resource": {
|
| "uri": mp4_url,
|
| "mimeType": "video/mp4"
|
| }
|
| })
|
|
|
| return {
|
| "content": content_items,
|
| "isError": False,
|
| "structuredContent": {
|
| "request_id": request_id,
|
| "status": "ready",
|
| "urls": urls,
|
| "expires_at": expires_at.isoformat(),
|
| "log_tail": log_tail
|
| }
|
| }
|
|
|
| except Exception as e:
|
| logger.exception(f"Error checking render status: {e}")
|
| return {
|
| "content": [{
|
| "type": "text",
|
| "text": f"Error checking status: {str(e)}"
|
| }],
|
| "isError": True
|
| }
|
|
|
|
|
| async def poll_render_status_for_mcp(arguments: dict) -> dict:
|
| """
|
| Wait for a short interval, then return a fresh status snapshot.
|
| """
|
| interval_s = arguments.get("interval_s", 10)
|
| if not isinstance(interval_s, int) or interval_s < 1:
|
| interval_s = 10
|
| if interval_s > 60:
|
| interval_s = 60
|
|
|
| await asyncio.sleep(interval_s)
|
| return await get_render_status_for_mcp(arguments)
|
|
|
| if __name__ == "__main__":
|
| import uvicorn
|
| uvicorn.run(app, host=HOST, port=PORT)
|
|
|