manim_mcp / renderer.py
euler314's picture
Upload 19 files
4deb6ce verified
Raw
History Blame Contribute Delete
11.9 kB
"""
Manim rendering engine - handles subprocess execution and video generation
"""
import asyncio
import os
import shutil
import logging
from datetime import datetime
from pathlib import Path
from typing import Tuple, Optional, List
from config import QUALITY_PRESETS, RENDER_TIMEOUT, BASE_RENDER_DIR
logger = logging.getLogger("manim_studio.renderer")
async def render_animation(
request_id: str,
code: str,
quality: str,
fps: Optional[int],
scene_name: str
) -> Tuple[bool, str, Optional[Path]]:
"""
Execute Manim rendering in subprocess
Args:
request_id: Unique identifier for this render
code: Python code containing Manim Scene
quality: Quality preset (4k, 2k, 1080p, 720p, 480p)
fps: Custom FPS (overrides quality preset)
scene_name: Name of Scene class to render
Returns:
Tuple of (success, message, output_path)
- success: True if render completed successfully
- message: Status message or error description
- output_path: Path to rendered MP4 file (None if failed)
"""
# Create request directory
request_dir = BASE_RENDER_DIR / request_id
request_dir.mkdir(parents=True, exist_ok=True)
source_file = request_dir / "source.py"
media_dir = request_dir / "media"
try:
# Write code to source file
source_file.write_text(code, encoding="utf-8")
logger.info(f"[{request_id}] Source file written: {source_file}")
# Build manim command
command = build_manim_command(
source_file=source_file,
quality=quality,
fps=fps,
scene_name=scene_name,
media_dir=media_dir
)
logger.info(f"[{request_id}] Executing command: {' '.join(command)}")
# Execute manim with timeout
success, message, stdout, stderr = await execute_manim_command(
command=command,
timeout=RENDER_TIMEOUT,
request_id=request_id
)
if not success:
logger.error(f"[{request_id}] Render failed: {message}")
# Log stdout and stderr for debugging
if stdout:
logger.error(f"[{request_id}] Manim stdout: {stdout[:1000]}")
if stderr:
logger.error(f"[{request_id}] Manim stderr: {stderr[:1000]}")
# Return detailed error message
error_details = f"{message}\n\nStdout: {stdout[:500]}\n\nStderr: {stderr[:500]}"
return False, error_details, None
# Find the output video file
logger.info(f"[{request_id}] Searching for output video in: {media_dir}")
# Debug: List all files created
if media_dir.exists():
logger.info(f"[{request_id}] Media directory exists, listing contents...")
for root, dirs, files in os.walk(media_dir):
logger.info(f"[{request_id}] Dir: {root}")
for f in files:
logger.info(f"[{request_id}] File: {f}")
else:
logger.error(f"[{request_id}] Media directory does NOT exist: {media_dir}")
output_video = find_output_video(media_dir, quality, fps)
if not output_video or not output_video.exists():
# Try to find ANY mp4 file in the media directory
mp4_files = list(media_dir.rglob("*.mp4")) if media_dir.exists() else []
if mp4_files:
logger.info(f"[{request_id}] Found MP4 files via fallback search: {mp4_files}")
output_video = mp4_files[0] # Use the first one found
else:
error_msg = f"Render completed but output video not found. Expected in {media_dir}"
logger.error(f"[{request_id}] {error_msg}")
return False, error_msg, None
# Copy to standardized location
standardized_output = request_dir / "output.mp4"
shutil.copy2(output_video, standardized_output)
logger.info(f"[{request_id}] Render completed successfully: {standardized_output}")
return True, "Render completed successfully", standardized_output
except Exception as e:
error_msg = f"Unexpected error during rendering: {str(e)}"
logger.exception(f"[{request_id}] {error_msg}")
return False, error_msg, None
def build_manim_command(
source_file: Path,
quality: str,
fps: Optional[int],
scene_name: str,
media_dir: Path
) -> List[str]:
"""
Build Manim CLI command with appropriate flags
Args:
source_file: Path to Python file with Manim code
quality: Quality preset key
fps: Custom FPS (None to use quality preset default)
scene_name: Scene class name to render
media_dir: Output directory for media files
Returns:
List of command arguments for subprocess execution
"""
# Get quality preset
preset = QUALITY_PRESETS.get(quality, QUALITY_PRESETS["720p"])
quality_flag = preset["flag"]
# Base command
command = [
"manim",
quality_flag,
"--media_dir", str(media_dir),
"--disable_caching", # Disable caching for clean renders
]
# Add custom FPS if specified
if fps is not None:
command.extend(["--fps", str(fps)])
# Add source file and scene name
command.append(str(source_file))
command.append(scene_name)
return command
async def execute_manim_command(
command: List[str],
timeout: int,
request_id: str
) -> Tuple[bool, str, str, str]:
"""
Execute Manim command as subprocess with timeout
Args:
command: Command arguments list
timeout: Maximum execution time in seconds
request_id: Request ID for logging
Returns:
Tuple of (success, message, stdout, stderr)
"""
async def _read_stream(stream, buffer, log_file, prefix):
while True:
chunk = await stream.read(1024)
if not chunk:
break
text = chunk.decode("utf-8", errors="replace")
buffer.append(text)
if log_file:
log_file.write(f"{prefix}{text}")
log_file.flush()
log_file = None
try:
# Set up environment for non-interactive Manim
import os
env = os.environ.copy()
env["TERM"] = "xterm-256color"
env["PYTHONUNBUFFERED"] = "1"
# Create subprocess
process = await asyncio.create_subprocess_exec(
*command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env
)
log_path = BASE_RENDER_DIR / request_id / "render.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
log_file = log_path.open("a", encoding="utf-8")
log_file.write(f"[{datetime.utcnow().isoformat()}] Starting manim render\n")
log_file.flush()
stdout_chunks: List[str] = []
stderr_chunks: List[str] = []
read_stdout = asyncio.create_task(
_read_stream(process.stdout, stdout_chunks, log_file, "")
)
read_stderr = asyncio.create_task(
_read_stream(process.stderr, stderr_chunks, log_file, "ERR: ")
)
# Wait for completion with timeout
try:
await asyncio.wait_for(process.wait(), timeout=timeout)
except asyncio.TimeoutError:
# Kill process on timeout
process.kill()
await process.wait()
error_msg = f"Render exceeded timeout of {timeout} seconds"
logger.error(f"[{request_id}] {error_msg}")
await asyncio.gather(read_stdout, read_stderr, return_exceptions=True)
if log_file:
log_file.write(f"[{datetime.utcnow().isoformat()}] {error_msg}\n")
log_file.flush()
return False, error_msg, "".join(stdout_chunks), "".join(stderr_chunks)
await asyncio.gather(read_stdout, read_stderr, return_exceptions=True)
stdout = "".join(stdout_chunks)
stderr = "".join(stderr_chunks)
# Check return code
if process.returncode == 0:
if log_file:
log_file.write(f"[{datetime.utcnow().isoformat()}] Manim finished successfully\n")
log_file.flush()
return True, "Command executed successfully", stdout, stderr
else:
error_msg = f"Manim command failed with exit code {process.returncode}"
if stderr:
error_msg += f"\n\nError output:\n{stderr}"
if log_file:
log_file.write(f"[{datetime.utcnow().isoformat()}] {error_msg}\n")
log_file.flush()
return False, error_msg, stdout, stderr
except Exception as e:
error_msg = f"Failed to execute command: {str(e)}"
logger.exception(f"[{request_id}] {error_msg}")
return False, error_msg, "", ""
finally:
if log_file:
log_file.close()
def find_output_video(media_dir: Path, quality: str, fps: Optional[int]) -> Optional[Path]:
"""
Locate the rendered video file in Manim's output directory structure
Manim creates videos in: media/videos/{source_file_name}/{quality_fps}/{scene_name}.mp4
Args:
media_dir: Base media directory
quality: Quality preset
fps: Custom FPS (None if using preset default)
Returns:
Path to output video file, or None if not found
"""
videos_dir = media_dir / "videos" / "source"
if not videos_dir.exists():
logger.warning(f"Videos directory not found: {videos_dir}")
return None
# Determine quality directory name
preset = QUALITY_PRESETS.get(quality, QUALITY_PRESETS["720p"])
if fps is not None:
# Custom FPS: e.g., "720p30"
resolution_height = preset["resolution"].split("x")[1]
quality_dir_name = f"{resolution_height}p{fps}"
else:
# Use preset FPS
resolution_height = preset["resolution"].split("x")[1]
preset_fps = preset["fps"]
quality_dir_name = f"{resolution_height}p{preset_fps}"
quality_dir = videos_dir / quality_dir_name
if not quality_dir.exists():
# Try to find any subdirectory (fallback)
logger.warning(f"Expected quality directory not found: {quality_dir}")
subdirs = list(videos_dir.iterdir())
if subdirs:
quality_dir = subdirs[0]
logger.info(f"Using alternative directory: {quality_dir}")
else:
return None
# Find .mp4 files in the quality directory
mp4_files = list(quality_dir.glob("*.mp4"))
if mp4_files:
# Return the first (and typically only) MP4 file
return mp4_files[0]
else:
logger.warning(f"No MP4 files found in: {quality_dir}")
return None
def cleanup_temp_files(request_dir: Path) -> None:
"""
Clean up temporary files created during rendering (keep only output.mp4)
Args:
request_dir: Directory containing render files
"""
try:
# Remove source.py
source_file = request_dir / "source.py"
if source_file.exists():
source_file.unlink()
# Remove media directory (contains intermediate files)
media_dir = request_dir / "media"
if media_dir.exists():
shutil.rmtree(media_dir)
logger.info(f"Cleaned up temporary files in {request_dir}")
except Exception as e:
logger.warning(f"Failed to cleanup temp files in {request_dir}: {e}")