""" 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 # Configure logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[logging.StreamHandler()] ) logger = logging.getLogger("manim_studio") # Create FastAPI application 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", ) # Add CORS middleware app.add_middleware( CORSMiddleware, allow_origins=["*"], # Adjust for production allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Include MCP router for ChatGPT Apps SDK integration app.include_router(mcp_router) # Track active renders (for concurrency limiting) 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...") # Create base render directory BASE_RENDER_DIR.mkdir(parents=True, exist_ok=True) logger.info(f"Render directory: {BASE_RENDER_DIR}") # Check Manim installation 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.") # Check FFmpeg installation ffmpeg_available = await check_ffmpeg_available() logger.info(f"FFmpeg available: {ffmpeg_available}") # Start cleanup service 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) # Give cleanup service time to stop 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()) # Simple scene without LaTeX or complex operations # MUST use self.play() to generate video frames 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", # Lowest quality for speed fps=15, scene_name="SimpleCircle" ) if success and output_mp4: # Generate URL 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" # Register with cleanup 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: # Debug: List what files were created 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: # Get Manim version 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] # Check FFmpeg ffmpeg_available = await check_ffmpeg_available() # Get disk space disk_usage = shutil.disk_usage(BASE_RENDER_DIR) disk_space_mb = disk_usage.free / (1024 * 1024) # Get cleanup stats 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. """ # Generate unique request ID request_id = str(uuid.uuid4()) logger.info(f"[{request_id}] New render request: quality={request.quality}, format={request.output_format}") try: # Step 1: Validate code 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 ) # Step 2: Validate quality preset 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())}" ) # Step 3: Add to active renders active_renders.add(request_id) try: # Step 4: Render animation 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 ) # Step 5: Convert to requested formats 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} # Step 6: Generate download URLs 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" ) # Step 7: Calculate file sizes and register with cleanup service 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 ) # Step 8: Schedule cleanup of temporary files (keep only outputs) background_tasks.add_task( cleanup_temp_files, BASE_RENDER_DIR / request_id ) # Calculate expiration time 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: # Remove from active renders 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. """ # Validate request_id format (UUID only, prevent path traversal) try: uuid.UUID(request_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid request ID format") # Validate filename (only allow output.{mp4,gif,webm}) 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") # Check if render exists and is not expired 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." ) # Construct file path file_path = BASE_RENDER_DIR / request_id / filename if not file_path.exists(): raise HTTPException(status_code=404, detail="File not found") # Determine media type 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. """ # Validate request_id try: uuid.UUID(request_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid request ID format") # Check if expired 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 ) # Get render info 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 ) # Check if files exist 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 ) # Generate URLs 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 ) # Custom OpenAPI schema for ChatGPT integration 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, ) # Add ChatGPT-specific metadata 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, {} # Convert to requested formats 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 )) # Generate download URLs 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.", {} # Register with cleanup service 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 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 ) # Extract logs from the message if it contains stdout/stderr logs = "" if not success and "Stdout:" in message: # Message already contains the logs from renderer.py 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: # Validate code 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) # Calculate expiration expires_at = datetime.now(timezone.utc) + timedelta(hours=1) # Format response for ChatGPT 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. """ # Get base URL for resources 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 with both text content and UI component 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: # Validate request_id format try: uuid.UUID(request_id) except ValueError: return { "content": [{ "type": "text", "text": f"Invalid request ID format: {request_id}" }], "isError": True } # Check if expired 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 } } # Get render info render_info = await cleanup_service.get_render_info(request_id) if not render_info: # Check if files exist on disk (might not be in metadata yet) 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" } } # Render exists - provide details 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)