Skip to content

Commit 8acf950

Browse files
feat: introduce PDF-to-animated-video pipeline in AI Assistant
Users can now attach one or multiple PDFs directly inside the AI tab and automatically convert slides into animated Manim scenes. Includes: - Multi-PDF attachment system - Structured slide extraction (titles, bullets, math equations) - AI-generated animation plans - Automatic scene generation - Direct conversion into node-based workflows chore: switch AI model from gemini-3-pro-preview to gemini-3.1-flash-lite-preview
1 parent 7f164ef commit 8acf950

16 files changed

+1523
-26
lines changed

README.md

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,23 @@
88

99
## 🚀 Key Features
1010

11+
### 📄 Convert Slides into Animated Math Videos
12+
- What it does: Upload one or multiple PDFs and the AI turns each slide into animated Manim scenes and a ready-to-edit node graph.
13+
- Who it's for: Students, teachers, and YouTube educators who need clear animated explanations quickly.
14+
- Why it's powerful: It converts static slides into structured animations automatically, saving hours of manual scene building.
15+
- How it works: PDFs are parsed into slide structure, sent to the AI for an animation plan, then converted into Manim code and nodes.
16+
1117
### 🎬 Node-Based Visual Workflow
1218
- **Visual Editor:** Drag-and-drop Mobjects and Animations with intuitive wiring
1319
- **Infinite Canvas:** Pan and zoom freely to manage large node graphs
1420
- **Live Preview:** Real-time static previews of individual nodes
1521
- **Smart Connections:** Automatic wire validation and scene synchronization
1622

23+
### 🤖 AI Features
24+
- **Prompt-to-Manim Code:** Describe animations in plain English and get runnable Manim code
25+
- **PDF Slide Animation:** Attach PDFs in the AI tab and generate animated scenes automatically
26+
- **AI Voiceover Studio:** TTS generation with multi-voice support and duration syncing
27+
1728
### 🎯 Tooltips
1829

1930
* Every button, panel, and action now includes a clear tooltip
@@ -33,15 +44,6 @@ This makes the app easier to learn, faster to use, and far more intuitive overal
3344
- VGroup code automatically generated: `group_1 = VGroup(circle_1, square_1)`
3445
- Groups shown in expandable tree view
3546

36-
### 🤖 Gemini AI Code Generation
37-
- Describe animations in plain English — AI generates Manim code
38-
- AI code parsed into editable nodes with correct wiring
39-
- Streaming responses with real-time feedback
40-
41-
### 🎙️ AI-Powered Voiceover Studio
42-
- Gemini TTS Integration with multi-voice support (Zephyr, Puck, Fenrir, etc.)
43-
- Auto-sync animation duration to audio length
44-
4547
### 🐙 GitHub Snippet Loader
4648
- Clone any GitHub repository into `~/.efficientmanim/snippets/`
4749
- Browse `.py` files; double-click to load into AI panel as snippet
@@ -135,7 +137,7 @@ python main.py
135137

136138
Manual install:
137139
```bash
138-
pip install manim PySide6 google-genai pydub requests numpy
140+
pip install manim PySide6 google-genai pydub requests numpy pdfplumber regex
139141
```
140142

141143
---
@@ -186,4 +188,4 @@ View the resulting output, demonstrating final rendering in the node canvas.
186188

187189
Made with lots of ❤️💚💙 by Soumalya a.k.a. @pro-grammer-SD
188190

189-
Discussions: https://www.reddit.com/r/manim/comments/1qck0ji/i_built_a_nodebased_manim_ide_with_ai_assistance/
191+
Discussions: https://www.reddit.com/r/manim/comments/1qck0ji/i_built_a_nodebased_manim_ide_with_ai_assistance/

core/ai_slides_to_manim.py

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
from __future__ import annotations
2+
# -*- coding: utf-8 -*-
3+
4+
import re
5+
from typing import Any
6+
7+
8+
def sanitize_latex(eq: str) -> str:
9+
s = "" if eq is None else str(eq)
10+
s = s.replace("\r\n", "\n").replace("\r", "\n")
11+
s = s.replace('"""', "").replace("'''", "")
12+
s = s.strip()
13+
if s.startswith("$$") and s.endswith("$$") and len(s) >= 4:
14+
s = s[2:-2].strip()
15+
elif s.startswith("$") and s.endswith("$") and len(s) >= 2:
16+
s = s[1:-1].strip()
17+
s = s.replace("\\[", "").replace("\\]", "")
18+
s = s.replace("\n", " ")
19+
s = re.sub(r"\\begin\s*\{[^}]*\}", "", s)
20+
s = re.sub(r"\\end\s*\{[^}]*\}", "", s)
21+
s = re.sub(r"\\text\s*\{([^}]*)\}", r"\1", s)
22+
s = re.sub(r"\\mathrm\s*\{([^}]*)\}", r"\1", s)
23+
s = re.sub(r"\\mathbf\s*\{([^}]*)\}", r"\1", s)
24+
s = re.sub(r"\\displaystyle\b", "", s)
25+
s = re.sub(r"\\left\b", "", s)
26+
s = re.sub(r"\\right\b", "", s)
27+
s = s.replace("\\,", " ")
28+
s = s.replace("\\;", " ")
29+
s = s.replace("\\:", " ")
30+
s = s.replace("\\!", " ")
31+
s = s.replace("\\times", "\\cdot")
32+
s = s.replace("\\ldots", "\\cdots")
33+
s = s.replace("\\dfrac", "\\frac")
34+
s = s.replace("\\tfrac", "\\frac")
35+
s = re.sub(r"\\\\+", " ", s)
36+
s = re.sub(r"\s+", " ", s).strip()
37+
while s.endswith("\\"):
38+
s = s[:-1].rstrip()
39+
return s
40+
41+
42+
def _is_safe_latex(eq: str) -> bool:
43+
if not eq:
44+
return False
45+
if len(eq) > 200:
46+
return False
47+
lowered = eq.lower()
48+
banned_tokens = [
49+
"\\begin",
50+
"\\end",
51+
"\\text",
52+
"\\label",
53+
"\\tag",
54+
"\\verb",
55+
"\\href",
56+
"\\url",
57+
"\\include",
58+
"\\input",
59+
"\\write",
60+
"\\def",
61+
"\\newcommand",
62+
"\\newenvironment",
63+
"\\usepackage",
64+
"\\require",
65+
"\\matrix",
66+
"\\array",
67+
"\\align",
68+
"\\cases",
69+
]
70+
for token in banned_tokens:
71+
if token in lowered:
72+
return False
73+
if "$" in eq or "\n" in eq:
74+
return False
75+
if eq.count("{") != eq.count("}"):
76+
return False
77+
if eq.count("(") != eq.count(")"):
78+
return False
79+
if eq.count("[") != eq.count("]"):
80+
return False
81+
82+
allowed_commands = {
83+
"frac",
84+
"cdot",
85+
"cdots",
86+
"binom",
87+
"sqrt",
88+
"alpha",
89+
"beta",
90+
"gamma",
91+
"delta",
92+
"epsilon",
93+
"zeta",
94+
"eta",
95+
"theta",
96+
"iota",
97+
"kappa",
98+
"lambda",
99+
"mu",
100+
"nu",
101+
"xi",
102+
"pi",
103+
"rho",
104+
"sigma",
105+
"tau",
106+
"phi",
107+
"chi",
108+
"psi",
109+
"omega",
110+
"Gamma",
111+
"Delta",
112+
"Theta",
113+
"Lambda",
114+
"Xi",
115+
"Pi",
116+
"Sigma",
117+
"Phi",
118+
"Psi",
119+
"Omega",
120+
}
121+
commands = re.findall(r"\\[A-Za-z]+", eq)
122+
for cmd in commands:
123+
if cmd[1:] not in allowed_commands:
124+
return False
125+
if re.search(r"\\[^A-Za-z]", eq):
126+
return False
127+
128+
allowed_chars = set(
129+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 +-*/=()[]{}.,:;|!<>^_"
130+
)
131+
for ch in eq:
132+
if ch == "\\":
133+
continue
134+
if ch not in allowed_chars:
135+
return False
136+
137+
return True
138+
139+
140+
def _latex_raw(text: str) -> str:
141+
safe = text.replace("\r\n", "\n").replace("\r", "\n")
142+
safe = safe.replace("\n", " ")
143+
safe = safe.replace('"', '\\"')
144+
safe = safe.rstrip("\\")
145+
return f'r"{safe}"'
146+
147+
148+
def _py_str(text: str) -> str:
149+
return repr(text)
150+
151+
152+
def _equation_code(eq: str, font_size: int = 34) -> str:
153+
sanitized = sanitize_latex(eq)
154+
if _is_safe_latex(sanitized):
155+
return f"MathTex({_latex_raw(sanitized)}, font_size={font_size})"
156+
display = f"Equation: {sanitized}".strip()
157+
if not display:
158+
display = "Equation"
159+
return f"Text({_py_str(display)}, font_size={font_size})"
160+
161+
162+
def _normalize_derivation(deriv: Any) -> list[dict]:
163+
if isinstance(deriv, dict):
164+
deriv = [deriv]
165+
if not isinstance(deriv, list):
166+
return []
167+
steps = []
168+
for item in deriv:
169+
if isinstance(item, dict):
170+
eq = str(item.get("equation") or "").strip()
171+
exp = str(item.get("explanation") or "").strip()
172+
if eq:
173+
steps.append({"equation": eq, "explanation": exp})
174+
else:
175+
eq = str(item).strip()
176+
if eq:
177+
steps.append({"equation": eq, "explanation": ""})
178+
return steps
179+
180+
181+
class SlidesToManim:
182+
"""Generates runnable Manim code from structured slide JSON."""
183+
184+
@staticmethod
185+
def generate_code(
186+
slide_deck: dict | list, scene_name: str = "GeneratedScene"
187+
) -> str:
188+
if isinstance(slide_deck, dict):
189+
slides = slide_deck.get("slides") or []
190+
else:
191+
slides = slide_deck
192+
193+
lines: list[str] = []
194+
lines.append("from manim import *")
195+
lines.append("")
196+
lines.append("class GeneratedScene(Scene):")
197+
lines.append(" def construct(self):")
198+
lines.append(' self.camera.background_color = "#ffffff"')
199+
lines.append(" self.wait(0.1)")
200+
lines.append("")
201+
202+
for idx, slide in enumerate(slides, start=1):
203+
if not isinstance(slide, dict):
204+
continue
205+
title = str(slide.get("title") or "").strip()
206+
bullets = slide.get("bullets") or []
207+
equations = slide.get("equations") or []
208+
deriv = slide.get("derivation_steps") or []
209+
210+
bullets = [str(b).strip() for b in bullets if str(b).strip()]
211+
equations = [str(e).strip() for e in equations if str(e).strip()]
212+
deriv = _normalize_derivation(deriv)
213+
214+
lines.append(f" # Slide {idx}")
215+
216+
slide_objects: list[str] = []
217+
218+
if title:
219+
lines.append(f" title = Text({_py_str(title)}, font_size=46)")
220+
lines.append(" title.to_edge(UP)")
221+
lines.append(" self.play(FadeIn(title))")
222+
slide_objects.append("title")
223+
224+
if bullets:
225+
bullet_items = [
226+
f"Text({_py_str('- ' + b)}, font_size=30)" for b in bullets
227+
]
228+
lines.append(f" bullets = VGroup({', '.join(bullet_items)})")
229+
lines.append(
230+
" bullets.arrange(DOWN, aligned_edge=LEFT, buff=0.25)"
231+
)
232+
if title:
233+
lines.append(
234+
" bullets.next_to(title, DOWN, aligned_edge=LEFT, buff=0.5)"
235+
)
236+
else:
237+
lines.append(" bullets.to_edge(LEFT)")
238+
lines.append(" self.play(FadeIn(bullets))")
239+
slide_objects.append("bullets")
240+
241+
if equations:
242+
eq_items = [_equation_code(eq, font_size=34) for eq in equations]
243+
lines.append(f" equations = VGroup({', '.join(eq_items)})")
244+
lines.append(" equations.arrange(DOWN, buff=0.4)")
245+
if bullets:
246+
lines.append(" equations.next_to(bullets, RIGHT, buff=1.0)")
247+
elif title:
248+
lines.append(" equations.next_to(title, DOWN, buff=0.6)")
249+
lines.append(" equations.to_edge(RIGHT)")
250+
lines.append(" self.play(FadeIn(equations))")
251+
slide_objects.append("equations")
252+
253+
if deriv:
254+
deriv_items = []
255+
for step in deriv:
256+
eq = step.get("equation") or ""
257+
if str(eq).strip():
258+
deriv_items.append(_equation_code(str(eq), font_size=34))
259+
if deriv_items:
260+
lines.append(f" deriv_steps = [{', '.join(deriv_items)}]")
261+
if title:
262+
lines.append(
263+
" deriv_steps[0].next_to(title, DOWN, buff=0.8)"
264+
)
265+
elif bullets:
266+
lines.append(
267+
" deriv_steps[0].next_to(bullets, DOWN, buff=0.8)"
268+
)
269+
else:
270+
lines.append(" deriv_steps[0].move_to(ORIGIN)")
271+
lines.append(" current_step = deriv_steps[0]")
272+
lines.append(" self.play(FadeIn(current_step))")
273+
lines.append(" for next_step in deriv_steps[1:]:")
274+
lines.append(" next_step.move_to(current_step)")
275+
lines.append(
276+
" self.play(FadeOut(current_step), FadeIn(next_step))"
277+
)
278+
lines.append(" current_step = next_step")
279+
slide_objects.append("current_step")
280+
281+
lines.append(" self.wait(0.5)")
282+
283+
if slide_objects:
284+
lines.append(
285+
f" self.play(FadeOut(VGroup({', '.join(slide_objects)})))"
286+
)
287+
lines.append("")
288+
289+
return "\n".join(lines).rstrip() + "\n"

core/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from PySide6.QtCore import QObject, QSettings, QStandardPaths, Signal
1010

1111
APP_NAME = "EfficientManim"
12-
APP_VERSION = "2.0.5"
12+
APP_VERSION = "2.1.0"
1313
PROJECT_EXT = ".efp" # EfficientManim Project (Zip)
1414
LIGHT_MODE = True
1515

0 commit comments

Comments
 (0)