AISCienceLab / video_generator.py
swapnakumbar12's picture
Create video_generator.py
6f41c63 verified
Raw
History Blame Contribute Delete
9.19 kB
"""
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