""" video_generator.py Generates animated Manim science experiment videos (free, CPU, no GPU needed). Flow: Title card -> Materials -> Steps -> Safety & Outcome -> End card Audio narration merged via ffmpeg. """ import re import sys import subprocess import tempfile import textwrap from pathlib import Path def _safe(s: str, n: int = 52) -> str: s = s.replace('"', "'").replace("\\", " ") return textwrap.shorten(s, width=n, placeholder="...") if len(s) > n else s def _bullets(raw: str, max_items: int = 7) -> list: parts = re.split(r"[,.]", raw) return [_safe(p.strip()) for p in parts if p.strip()][:max_items] def _build_script(exp: dict) -> str: title = _safe(exp["title"], 48) chapter = "Chapter {} . Page {}".format(exp["chapter"], exp["page"]) mats = _bullets(exp["materials"], 7) steps = _bullets(exp["steps"], 6) safety = _safe(exp["safety"], 65) outcome = _safe(exp["outcome"], 65) header = ( "from manim import *\n" 'config.background_color = "#0f0c29"\n' "config.pixel_height = 720\n" "config.pixel_width = 1280\n" "config.frame_rate = 24\n" "\n" 'PURPLE = "#7c3aed"\n' 'LAVENDER = "#a5b4fc"\n' 'WHITE = "#ffffff"\n' 'YELLOW = "#fbbf24"\n' 'GREEN = "#34d399"\n' 'RED = "#f87171"\n' 'CYAN = "#67e8f9"\n' ) title_card = ( "\n" " def title_card(self):\n" " badge = RoundedRectangle(corner_radius=0.3, width=3.6, height=0.55,\n" " fill_color=PURPLE, fill_opacity=1, stroke_width=0)\n" " badge.to_edge(UP, buff=0.55)\n" ' badge_t = Text("AI Science Lab", font_size=20, color=WHITE).move_to(badge)\n' " title = Text({t}, font_size=30, color=WHITE, weight=BOLD, width=11)\n" " title.next_to(badge, DOWN, buff=0.4)\n" " ch = Text({c}, font_size=20, color=LAVENDER)\n" " ch.next_to(title, DOWN, buff=0.28)\n" " line = Line(LEFT*5, RIGHT*5, color=PURPLE, stroke_width=3)\n" " line.next_to(ch, DOWN, buff=0.3)\n" ' icon = Text("\\U0001f52c", font_size=70).next_to(line, DOWN, buff=0.35)\n' " self.play(FadeIn(badge), Write(badge_t), run_time=0.55)\n" " self.play(Write(title), run_time=1.1)\n" " self.play(FadeIn(ch), Create(line), run_time=0.6)\n" " self.play(FadeIn(icon, scale=0.5), run_time=0.6)\n" " self.wait(1.4)\n" " self._clear()\n" ).format(t=repr(title), c=repr(chapter)) materials_card = ( "\n" " def materials_card(self):\n" " materials = {m}\n" ' header = Text("Materials and Apparatus", font_size=28, color=YELLOW, weight=BOLD)\n' " header.to_edge(UP, buff=0.5)\n" " ul = Line(LEFT*5.5, RIGHT*5.5, color=YELLOW, stroke_width=2)\n" " ul.next_to(header, DOWN, buff=0.12)\n" " self.play(Write(header), Create(ul), run_time=0.6)\n" " grp = VGroup()\n" " for item in materials:\n" " dot = Dot(radius=0.08, color=PURPLE)\n" " txt = Text(item, font_size=21, color=WHITE)\n" " grp.add(VGroup(dot, txt).arrange(RIGHT, buff=0.18))\n" " grp.arrange(DOWN, aligned_edge=LEFT, buff=0.27)\n" " grp.next_to(ul, DOWN, buff=0.3).shift(LEFT*0.4)\n" " for row in grp:\n" " self.play(FadeIn(row, shift=RIGHT*0.25), run_time=0.3)\n" " self.wait(1.4)\n" " self._clear()\n" ).format(m=repr(mats)) steps_card = ( "\n" " def steps_card(self):\n" " steps = {s}\n" ' header = Text("Procedure Steps", font_size=28, color=CYAN, weight=BOLD)\n' " header.to_edge(UP, buff=0.5)\n" " ul = Line(LEFT*5.5, RIGHT*5.5, color=CYAN, stroke_width=2)\n" " ul.next_to(header, DOWN, buff=0.12)\n" " self.play(Write(header), Create(ul), run_time=0.6)\n" " grp = VGroup()\n" " for i, step in enumerate(steps):\n" " circ = Circle(radius=0.21, fill_color=PURPLE, fill_opacity=1, stroke_width=0)\n" " num = Text(str(i+1), font_size=15, color=WHITE).move_to(circ)\n" " txt = Text(step, font_size=20, color=WHITE)\n" " grp.add(VGroup(VGroup(circ, num), txt).arrange(RIGHT, buff=0.22))\n" " grp.arrange(DOWN, aligned_edge=LEFT, buff=0.28)\n" " grp.next_to(ul, DOWN, buff=0.32).shift(LEFT*0.25)\n" " for row in grp:\n" " self.play(FadeIn(row, shift=UP*0.12), run_time=0.38)\n" " self.wait(1.4)\n" " self._clear()\n" ).format(s=repr(steps)) outcome_card = ( "\n" " def outcome_card(self):\n" ' s_lbl = Text("Safety Precaution", font_size=24, color=RED, weight=BOLD)\n' " s_box = SurroundingRectangle(s_lbl, color=RED, buff=0.18,\n" " corner_radius=0.18, stroke_width=2)\n" " s_txt = Text({sf}, font_size=18, color=WHITE, width=10)\n" " s_grp = VGroup(VGroup(s_box, s_lbl), s_txt).arrange(DOWN, buff=0.18)\n" " s_grp.to_edge(UP, buff=0.5)\n" " self.play(Create(s_box), Write(s_lbl), run_time=0.55)\n" " self.play(FadeIn(s_txt), run_time=0.45)\n" " self.wait(0.9)\n" ' o_lbl = Text("Expected Outcome", font_size=24, color=GREEN, weight=BOLD)\n' " o_box = SurroundingRectangle(o_lbl, color=GREEN, buff=0.18,\n" " corner_radius=0.18, stroke_width=2)\n" " o_txt = Text({oc}, font_size=19, color=WHITE, width=10)\n" " o_grp = VGroup(VGroup(o_box, o_lbl), o_txt).arrange(DOWN, buff=0.18)\n" " o_grp.next_to(s_grp, DOWN, buff=0.45)\n" " self.play(Create(o_box), Write(o_lbl), run_time=0.55)\n" " self.play(FadeIn(o_txt), run_time=0.45)\n" " self.wait(0.8)\n" ' end1 = Text("AI Science Lab", font_size=26, color=LAVENDER, weight=BOLD)\n' ' end2 = Text("NCERT Class 10 . Keep Experimenting!", font_size=17, color=LAVENDER)\n' " end = VGroup(end1, end2).arrange(DOWN, buff=0.18)\n" " end.next_to(o_grp, DOWN, buff=0.45)\n" " self.play(FadeIn(end, scale=0.8), run_time=0.65)\n" " self.wait(2.0)\n" " self._clear()\n" ).format(sf=repr(safety), oc=repr(outcome)) class_body = ( "\nclass ExperimentVideo(Scene):\n" " def construct(self):\n" " self.title_card()\n" " self.materials_card()\n" " self.steps_card()\n" " self.outcome_card()\n" "\n" " def _clear(self):\n" " self.play(*[FadeOut(m) for m in self.mobjects], run_time=0.45)\n" + title_card + materials_card + steps_card + outcome_card ) return header + class_body def render_video(exp: dict, output_dir: str = None): """Render Manim animation to MP4. Returns file path or None on failure.""" if output_dir is None: output_dir = tempfile.mkdtemp() output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) script_path = output_dir / "scene.py" script_path.write_text(_build_script(exp), encoding="utf-8") cmd = [ sys.executable, "-m", "manim", str(script_path), "ExperimentVideo", "-o", "experiment_video", "--media_dir", str(output_dir), "-q", "m", "--disable_caching", ] try: result = subprocess.run( cmd, capture_output=True, text=True, timeout=180, cwd=str(output_dir) ) if result.returncode != 0: print("Manim STDERR:", result.stderr[-2000:]) return None except subprocess.TimeoutExpired: print("Manim render timed out (180 s)") return None except Exception as e: print("Manim error:", e) return None for mp4 in output_dir.rglob("*.mp4"): return str(mp4) return None def add_audio_to_video(video_path: str, audio_path: str, output_path: str) -> str: """Merge audio + video with ffmpeg. Falls back to silent video on any error.""" try: cmd = [ "ffmpeg", "-y", "-i", video_path, "-i", audio_path, "-c:v", "copy", "-c:a", "aac", "-shortest", output_path, ] r = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if r.returncode == 0 and Path(output_path).exists(): return output_path except Exception as e: print("ffmpeg merge error:", e) return video_path