manim_mcp / app.py
euler314's picture
Upload app.py
4b63dbf verified
Raw
History Blame Contribute Delete
39.7 kB
"""
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)