| """
|
| 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)
|
| """
|
|
|
| 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:
|
|
|
| source_file.write_text(code, encoding="utf-8")
|
| logger.info(f"[{request_id}] Source file written: {source_file}")
|
|
|
|
|
| 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)}")
|
|
|
|
|
| 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}")
|
|
|
| if stdout:
|
| logger.error(f"[{request_id}] Manim stdout: {stdout[:1000]}")
|
| if stderr:
|
| logger.error(f"[{request_id}] Manim stderr: {stderr[:1000]}")
|
|
|
|
|
| error_details = f"{message}\n\nStdout: {stdout[:500]}\n\nStderr: {stderr[:500]}"
|
| return False, error_details, None
|
|
|
|
|
| logger.info(f"[{request_id}] Searching for output video in: {media_dir}")
|
|
|
|
|
| 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():
|
|
|
| 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]
|
| 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
|
|
|
|
|
| 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
|
| """
|
|
|
| preset = QUALITY_PRESETS.get(quality, QUALITY_PRESETS["720p"])
|
| quality_flag = preset["flag"]
|
|
|
|
|
| command = [ |
| "manim", |
| quality_flag, |
| "--media_dir", str(media_dir), |
| "--disable_caching", |
| ] |
|
|
|
|
| if fps is not None:
|
| command.extend(["--fps", str(fps)])
|
|
|
|
|
| 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: |
| |
| import os |
| env = os.environ.copy() |
| env["TERM"] = "xterm-256color" |
| env["PYTHONUNBUFFERED"] = "1" |
|
|
| |
| 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: ") |
| ) |
|
|
| |
| try: |
| await asyncio.wait_for(process.wait(), timeout=timeout) |
| except asyncio.TimeoutError: |
| |
| 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) |
|
|
| |
| 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
|
|
|
|
|
| preset = QUALITY_PRESETS.get(quality, QUALITY_PRESETS["720p"])
|
| if fps is not None:
|
|
|
| resolution_height = preset["resolution"].split("x")[1]
|
| quality_dir_name = f"{resolution_height}p{fps}"
|
| else:
|
|
|
| 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():
|
|
|
| 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
|
|
|
|
|
| mp4_files = list(quality_dir.glob("*.mp4"))
|
|
|
| if mp4_files:
|
|
|
| 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:
|
|
|
| source_file = request_dir / "source.py"
|
| if source_file.exists():
|
| source_file.unlink()
|
|
|
|
|
| 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}")
|
|
|