| """Render generated Manim scenes in an isolated subprocess.""" |
|
|
| from __future__ import annotations |
|
|
| import shutil |
| import subprocess |
| import tempfile |
| import time |
| import uuid |
| from dataclasses import dataclass |
| from pathlib import Path |
| import re |
| import sys |
| from typing import Tuple |
|
|
|
|
| OUTPUT_DIR = Path("./output") |
| SCENE_CLASS_PATTERN = re.compile(r"class\s+(\w+)\s*\(\s*\w*Scene\w*\s*\):") |
|
|
|
|
| @dataclass |
| class RenderResult: |
| success: bool |
| video_path: str | None |
| info: str |
| errors: str |
|
|
|
|
| def find_scene_name(code: str) -> str: |
| """Extract the first Manim Scene class name from generated Python code.""" |
| match = SCENE_CLASS_PATTERN.search(code) |
| return match.group(1) if match else "MainScene" |
|
|
|
|
| def _manim_command_prefix() -> list[str]: |
| """Return a command prefix for Manim. |
| |
| Hugging Face Spaces occasionally installs the Python package correctly |
| without exposing the `manim` console script on PATH. `python -m manim` |
| still works in that case, so keep it as the final fallback. |
| """ |
| for command in ("manim", "manimce"): |
| if shutil.which(command): |
| return [command] |
| return [sys.executable, "-m", "manim"] |
|
|
|
|
| def render_code( |
| code: str, |
| timeout_seconds: int = 600, |
| output_dir: Path = OUTPUT_DIR, |
| quality: str = "-ql", |
| ) -> RenderResult: |
| """Render Manim code and return a structured result. |
| |
| This mirrors the robust approach used by manim-trainer: use a temporary |
| script and a temporary media directory to avoid collisions, then write only |
| the final MP4 into the app's output folder. |
| """ |
| if not code or not code.strip(): |
| return RenderResult(False, None, "", "No Manim code was provided.") |
|
|
| output_dir.mkdir(parents=True, exist_ok=True) |
| scene_name = find_scene_name(code) |
| video_name = f"{scene_name}_{uuid.uuid4().hex[:12]}_{int(time.time())}" |
| final_video_base = output_dir / video_name |
|
|
| temp_script_name = "" |
| try: |
| with tempfile.NamedTemporaryFile( |
| mode="w", |
| suffix=".py", |
| prefix="temp_scene_", |
| encoding="utf-8", |
| delete=False, |
| ) as temp_script: |
| temp_script.write(code) |
| temp_script.flush() |
| temp_script_name = temp_script.name |
|
|
| with tempfile.TemporaryDirectory(prefix="scivisual_manim_") as temp_media_dir: |
| command = _manim_command_prefix() + [ |
| temp_script_name, |
| scene_name, |
| quality, |
| "--format=mp4", |
| "--media_dir", |
| temp_media_dir, |
| "-o", |
| str(final_video_base.absolute()), |
| ] |
| result = subprocess.run( |
| command, |
| capture_output=True, |
| text=True, |
| timeout=timeout_seconds, |
| check=False, |
| ) |
|
|
| stdout = result.stdout.strip() |
| stderr = result.stderr.strip() |
| if result.returncode != 0: |
| error_log = stderr or stdout or f"Manim failed with return code {result.returncode}." |
| return RenderResult(False, None, stdout, error_log) |
|
|
| final_video_path = final_video_base.with_suffix(".mp4") |
| if final_video_path.exists(): |
| return RenderResult(True, str(final_video_path), stdout, stderr) |
|
|
| return RenderResult( |
| False, |
| None, |
| stdout, |
| f"Manim reported success, but no MP4 was found at {final_video_path}. STDERR: {stderr}", |
| ) |
| except FileNotFoundError as exc: |
| return RenderResult( |
| False, |
| None, |
| "", |
| "Manim could not be launched. Ensure the `manim` Python package is installed. " |
| f"Attempted fallback command: `{sys.executable} -m manim`. Details: {exc}", |
| ) |
| except subprocess.TimeoutExpired as exc: |
| stdout = exc.stdout or "" |
| stderr = exc.stderr or "" |
| return RenderResult( |
| False, |
| None, |
| stdout, |
| f"Rendering timed out after {timeout_seconds} seconds.\n\nSTDERR:\n{stderr}", |
| ) |
| except Exception as exc: |
| return RenderResult(False, None, "", f"Unexpected render failure: {type(exc).__name__}: {exc}") |
| finally: |
| if temp_script_name: |
| Path(temp_script_name).unlink(missing_ok=True) |
|
|
|
|
| def render_manim_scene(code: str, timeout_seconds: int = 600) -> Tuple[bool, str]: |
| """Compatibility wrapper returning the tuple shape used by the app.""" |
| result = render_code(code, timeout_seconds=timeout_seconds) |
| if result.success and result.video_path: |
| return True, result.video_path |
| return False, result.errors or result.info |
|
|