Spaces:
Sleeping
Sleeping
| """ | |
| 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 |