|
|
import gradio as gr |
|
|
import subprocess |
|
|
import os |
|
|
import tempfile |
|
|
import shutil |
|
|
from pathlib import Path |
|
|
import time |
|
|
import sys |
|
|
import re |
|
|
import manim |
|
|
import requests |
|
|
import json |
|
|
|
|
|
API_KEY = os.environ.get("API_KEY") |
|
|
|
|
|
|
|
|
def extract_manim_code(ai_text): |
|
|
""" |
|
|
Extract Manim Python code from AI text. |
|
|
- If a ```python block exists, extract it. |
|
|
- Otherwise, remove any leading text before 'import' or 'class Scene'. |
|
|
""" |
|
|
|
|
|
match = re.search(r"```python\s*(.*?)```", ai_text, re.DOTALL) |
|
|
if match: |
|
|
return match.group(1).strip() |
|
|
|
|
|
|
|
|
lines = ai_text.splitlines() |
|
|
for i, line in enumerate(lines): |
|
|
if line.strip().startswith(("from ", "import ", "class ")): |
|
|
return "\n".join(lines[i:]).strip() |
|
|
|
|
|
|
|
|
return ai_text.strip() |
|
|
|
|
|
|
|
|
def generate_code_from_prompt(prompt, progress=gr.Progress()): |
|
|
""" |
|
|
Calls the Fal AI Any LLM API to generate Python Manim code. |
|
|
""" |
|
|
progress(0, desc="Sending request to AI API...") |
|
|
|
|
|
api_url = "https://fal.run/fal-ai/any-llm" |
|
|
|
|
|
payload = { |
|
|
"prompt": prompt, |
|
|
"priority": "latency", |
|
|
"model": "anthropic/claude-sonnet-4.5", |
|
|
"system_prompt": f""" |
|
|
Write a Manim script that's in Python to visualize: {prompt}. FOCUS on producing working code. Always use Manim Community version 0.19 syntax. When creating an Axes object, do not use axis_color directly as a keyword argument. Instead, use axis_config= 'color': ... The class should be MyScene and end with self.wait(). Class name should be MyScene. End with self.wait() Only give me the python code so that I can directly put this into the manim project input. |
|
|
Avoid using deprecated or unavailable ManimCE methods like get_tangent_line. Construct tangent lines manually using slope and Line(...). |
|
|
You are a senior math educator and Manim Community v0.19 expert. |
|
|
Always ensure visuals are well spaced, readable, never overlapping. |
|
|
make sure video scene doesn't overlap and shown inside the canvas. |
|
|
Text should be placed carefully using `.animate.to_edge()`, `.next_to()`, or `.shift()`. |
|
|
Only include coordinate axes, graphs, tangent lines, or shapes if necessary. |
|
|
Always conclude with `self.wait()`. |
|
|
Use ManimCE v0.19 syntax. |
|
|
The scene class should always be named `MyScene`. |
|
|
""" |
|
|
} |
|
|
|
|
|
try: |
|
|
headers = { |
|
|
"Content-Type": "application/json", |
|
|
"Authorization": f"Key {API_KEY}" |
|
|
} |
|
|
response = requests.post(api_url, headers=headers, data=json.dumps(payload), timeout=60) |
|
|
response.raise_for_status() |
|
|
result = response.json() |
|
|
|
|
|
|
|
|
generated_text = result.get("output", "") |
|
|
if not generated_text: |
|
|
return "", "❌ No code generated from AI." |
|
|
|
|
|
|
|
|
cleaned_code = extract_manim_code(generated_text) |
|
|
|
|
|
progress(1.0, desc="✅ AI code generation complete!") |
|
|
return cleaned_code, f"✅ AI code generated successfully for: '{prompt}'" |
|
|
|
|
|
except Exception as e: |
|
|
return "", f"❌ Failed to generate code: {str(e)}" |
|
|
|
|
|
|
|
|
def edit_code_with_instruction(existing_code, instruction, progress=gr.Progress()): |
|
|
""" |
|
|
Takes existing Manim code and a user instruction (like 'move the text to the left'), |
|
|
and uses the AI model to modify the code accordingly. |
|
|
""" |
|
|
progress(0, desc="Sending edit request to AI API...") |
|
|
|
|
|
api_url = "https://fal.run/fal-ai/any-llm" |
|
|
|
|
|
system_prompt = f""" |
|
|
You are a Manim expert. You will receive existing Manim code and an instruction on how to modify it. |
|
|
Follow these rules: |
|
|
- Only modify what’s necessary. |
|
|
- Maintain compatibility with Manim Community v0.19. |
|
|
- Always return full corrected Python code in a ```python``` block. |
|
|
- The class name must remain unchanged. |
|
|
- Always end with self.wait(). |
|
|
""" |
|
|
|
|
|
payload = { |
|
|
"prompt": f"Instruction: {instruction}\n\nOriginal Code:\n```python\n{existing_code}\n```", |
|
|
"priority": "latency", |
|
|
"model": "anthropic/claude-sonnet-4.5", |
|
|
"system_prompt": system_prompt |
|
|
} |
|
|
|
|
|
try: |
|
|
headers = { |
|
|
"Content-Type": "application/json", |
|
|
"Authorization": f"Key {API_KEY}" |
|
|
} |
|
|
response = requests.post(api_url, headers=headers, data=json.dumps(payload), timeout=60) |
|
|
response.raise_for_status() |
|
|
result = response.json() |
|
|
|
|
|
ai_text = result.get("output", "") |
|
|
if not ai_text: |
|
|
return "", "❌ No edited code returned by AI." |
|
|
|
|
|
cleaned_code = extract_manim_code(ai_text) |
|
|
|
|
|
progress(1.0, desc="✅ Code edited successfully!") |
|
|
return cleaned_code, "✅ Code updated based on your instruction." |
|
|
|
|
|
except Exception as e: |
|
|
return "", f"❌ Failed to edit code: {str(e)}" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ManimAnimationGenerator: |
|
|
def __init__(self): |
|
|
self.temp_dir = None |
|
|
self.output_dir = None |
|
|
|
|
|
def setup_directories(self): |
|
|
"""Setup temporary directories for Manim execution""" |
|
|
self.temp_dir = tempfile.mkdtemp() |
|
|
self.output_dir = os.path.join(self.temp_dir, "media", "videos", "480p15") |
|
|
os.makedirs(self.output_dir, exist_ok=True) |
|
|
return self.temp_dir |
|
|
|
|
|
def cleanup_directories(self): |
|
|
"""Clean up temporary directories""" |
|
|
if self.temp_dir and os.path.exists(self.temp_dir): |
|
|
shutil.rmtree(self.temp_dir) |
|
|
self.temp_dir = None |
|
|
self.output_dir = None |
|
|
|
|
|
def validate_manim_code(self, code): |
|
|
"""Basic validation of Manim code""" |
|
|
required_imports = ["from manim import *", "import manim"] |
|
|
has_import = any(imp in code for imp in required_imports) |
|
|
|
|
|
if not has_import: |
|
|
return False, "Code must include 'from manim import *' or 'import manim'" |
|
|
|
|
|
if "class" not in code: |
|
|
return False, "Code must contain at least one class definition" |
|
|
|
|
|
if "Scene" not in code: |
|
|
return False, "Class must inherit from Scene or a Scene subclass" |
|
|
|
|
|
return True, "Code validation passed" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def execute_manim_code(self, code, quality="low", format_type="gif"): |
|
|
"""Execute Manim code and return the generated animation""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
code = extract_manim_code(code) |
|
|
|
|
|
try: |
|
|
is_valid, message = self.validate_manim_code(code) |
|
|
if not is_valid: |
|
|
return None, f"❌ Validation Error: {message}", "" |
|
|
|
|
|
temp_dir = self.setup_directories() |
|
|
python_file = os.path.join(temp_dir, "animation.py") |
|
|
with open(python_file, "w") as f: |
|
|
f.write(code) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class_name = self.extract_class_name(code) |
|
|
if not class_name: |
|
|
self.cleanup_directories() |
|
|
return None, "❌ Error: Could not find a valid Scene class in the code", "" |
|
|
|
|
|
quality_map = {"low": "-ql", "medium": "-qm", "high": "-qh"} |
|
|
quality_flag = quality_map.get(quality, "-ql") |
|
|
format_flag = "--format=gif" if format_type == "gif" else "" |
|
|
|
|
|
cmd = [sys.executable, "-m", "manim", quality_flag, python_file, class_name] |
|
|
if format_flag: |
|
|
cmd.append(format_flag) |
|
|
|
|
|
result = subprocess.run( |
|
|
cmd, |
|
|
cwd=temp_dir, |
|
|
capture_output=True, |
|
|
text=True, |
|
|
timeout=120, |
|
|
) |
|
|
|
|
|
if result.returncode != 0: |
|
|
error_msg = f"❌ Manim execution failed:\n{result.stderr}" |
|
|
self.cleanup_directories() |
|
|
return None, error_msg, result.stdout |
|
|
|
|
|
output_file = self.find_output_file(temp_dir, class_name, format_type) |
|
|
if not output_file: |
|
|
self.cleanup_directories() |
|
|
return None, "❌ Error: Could not find generated animation file", result.stdout |
|
|
|
|
|
permanent_file = f"/tmp/{class_name}_{int(time.time())}.{format_type}" |
|
|
shutil.copy2(output_file, permanent_file) |
|
|
|
|
|
success_msg = f"✅ Animation generated successfully!" |
|
|
self.cleanup_directories() |
|
|
return permanent_file, success_msg, result.stdout |
|
|
|
|
|
except subprocess.TimeoutExpired: |
|
|
self.cleanup_directories() |
|
|
return None, "❌ Error: Animation generation timed out (2 minutes)", "" |
|
|
except Exception as e: |
|
|
self.cleanup_directories() |
|
|
return None, f"❌ An unexpected error occurred: {str(e)}", "" |
|
|
|
|
|
def extract_class_name(self, code): |
|
|
lines = code.split('\n') |
|
|
for line in lines: |
|
|
if line.strip().startswith('class ') and 'Scene' in line: |
|
|
return line.strip().split('class ')[1].split('(')[0].strip() |
|
|
return None |
|
|
|
|
|
def find_output_file(self, temp_dir, class_name, format_type): |
|
|
for root, _, files in os.walk(temp_dir): |
|
|
for file in files: |
|
|
if file.startswith(class_name) and file.endswith(f".{format_type}"): |
|
|
return os.path.join(root, file) |
|
|
return None |
|
|
|
|
|
|
|
|
|
|
|
generator = ManimAnimationGenerator() |
|
|
|
|
|
example_codes = { |
|
|
"Simple Square": '''from manim import * |
|
|
class CreateSquare(Scene): |
|
|
def construct(self): |
|
|
square = Square(side_length=2).set_fill(BLUE, opacity=0.5) |
|
|
self.play(Create(square)) |
|
|
self.play(square.animate.rotate(PI/2)) |
|
|
self.wait()''', |
|
|
|
|
|
"Moving Circle": '''from manim import * |
|
|
class MovingCircle(Scene): |
|
|
def construct(self): |
|
|
circle = Circle().set_fill(RED, opacity=0.5) |
|
|
self.play(Create(circle)) |
|
|
self.play(circle.animate.shift(RIGHT * 2)) |
|
|
self.wait()''', |
|
|
|
|
|
"Text Animation": '''from manim import * |
|
|
class TextAnimation(Scene): |
|
|
def construct(self): |
|
|
text = Text("Hello, Manim!", font_size=48) |
|
|
self.play(Write(text)) |
|
|
self.wait()''' |
|
|
} |
|
|
|
|
|
|
|
|
def generate_animation(code, quality, format_type, progress=gr.Progress()): |
|
|
"""Main function to generate animation from code.""" |
|
|
if not code.strip(): |
|
|
return None, "❌ Please enter or generate some Manim code.", "" |
|
|
|
|
|
progress(0.1, desc="Starting animation generation...") |
|
|
|
|
|
progress(0.3, desc="Executing Manim code...") |
|
|
result_path, status_msg, logs = generator.execute_manim_code(code, quality, format_type) |
|
|
|
|
|
if result_path: |
|
|
progress(1.0, desc="Animation Ready!") |
|
|
return result_path, status_msg, logs |
|
|
else: |
|
|
return None, status_msg, logs |
|
|
|
|
|
|
|
|
def generate_full_process(prompt, quality, format_type): |
|
|
"""Generate Manim code and render video with live, user-friendly updates.""" |
|
|
|
|
|
|
|
|
yield None, "🤖 Thinking... generating Manim code based on your prompt.", "", "" |
|
|
|
|
|
|
|
|
code, msg = generate_code_from_prompt(prompt) |
|
|
if not code: |
|
|
yield None, f"⚠️ Couldn't generate code. {msg}", "", "" |
|
|
return |
|
|
|
|
|
|
|
|
yield None, "🧠 Manim code ready — preparing render environment.", code, "" |
|
|
|
|
|
|
|
|
yield None, "🎬 Rendering animation... this may take a moment.", code, "" |
|
|
result_path, status_msg, logs = generator.execute_manim_code(code, quality, format_type) |
|
|
|
|
|
|
|
|
if result_path: |
|
|
yield result_path, "✅ Rendering complete! Previewing your animation...", code, logs |
|
|
else: |
|
|
yield None, f"❌ Something went wrong while rendering. Details: {status_msg}", code, logs |
|
|
|
|
|
def edit_and_render(existing_code, instruction, quality, format_type, progress=gr.Progress()): |
|
|
edited_code, status = edit_code_with_instruction(existing_code, instruction, progress) |
|
|
if not edited_code.strip(): |
|
|
return None, status, existing_code, "" |
|
|
result_path, render_status, logs = generator.execute_manim_code(edited_code, quality, format_type) |
|
|
return result_path, f"{status}\n{render_status}", edited_code, logs |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_example(example_name): |
|
|
"""Load example code into the code editor.""" |
|
|
return example_codes.get(example_name, "") |
|
|
|
|
|
|
|
|
|
|
|
css = """ |
|
|
/* Fix the height of the code input and add scrollbar */ |
|
|
.code-input textarea { |
|
|
height: 400px !important; |
|
|
max-height: 400px !important; |
|
|
min-height: 400px !important; |
|
|
overflow-y: auto !important; |
|
|
resize: none !important; |
|
|
} |
|
|
|
|
|
/* Ensure the parent container doesn't expand */ |
|
|
.code-input { |
|
|
height: 400px !important; |
|
|
max-height: 400px !important; |
|
|
} |
|
|
|
|
|
/* Style the scrollbar for better visibility */ |
|
|
.code-input textarea::-webkit-scrollbar { |
|
|
width: 8px; |
|
|
} |
|
|
|
|
|
.code-input textarea::-webkit-scrollbar-track { |
|
|
background: #f1f1f1; |
|
|
border-radius: 4px; |
|
|
} |
|
|
|
|
|
.code-input textarea::-webkit-scrollbar-thumb { |
|
|
background: #888; |
|
|
border-radius: 4px; |
|
|
} |
|
|
|
|
|
.code-input textarea::-webkit-scrollbar-thumb:hover { |
|
|
background: #555; |
|
|
} |
|
|
""" |
|
|
|
|
|
with gr.Blocks(theme=gr.themes.Soft(), |
|
|
css=css, |
|
|
title="AI Math Animation Generator") as app: |
|
|
gr.Markdown("# 🎬 AI-Powered Manim Animation Generator") |
|
|
gr.Markdown("Describe the animation you want, generate the code with AI, and render the video!") |
|
|
gr.Markdown("<small>Powered by Claude 4.5 Sonnet</small>") |
|
|
|
|
|
with gr.Row(): |
|
|
with gr.Column(scale=2): |
|
|
gr.Markdown("### 1. Generate Code with AI") |
|
|
prompt_input = gr.Textbox( |
|
|
label="Describe your animation", |
|
|
placeholder="e.g., explain bubble sort algorithm", |
|
|
lines=2 |
|
|
) |
|
|
|
|
|
|
|
|
generate_anim_btn = gr.Button("🎬 Generate & Render Animation", variant="primary") |
|
|
|
|
|
gr.Examples( |
|
|
examples=["explain (a+b)^2", "sum of 1 to n", "explain bubble sort algorithm", "explain dfs", "a guy shaking hands with a horse"], |
|
|
inputs=[prompt_input], |
|
|
) |
|
|
|
|
|
gr.Markdown("### 2. Edit & Render Code") |
|
|
code_input = gr.Code( |
|
|
label="Manim Code", |
|
|
language="python", |
|
|
lines=15, |
|
|
value=example_codes["Simple Square"], |
|
|
elem_classes=["code-input"] |
|
|
) |
|
|
|
|
|
edit_instruction = gr.Textbox( |
|
|
label="Describe what you want to fix or change", |
|
|
placeholder="e.g., move the circle to the left, make text smaller", |
|
|
lines=2 |
|
|
) |
|
|
edit_code_btn = gr.Button("✏️ Edit Code with AI", variant="secondary") |
|
|
|
|
|
with gr.Row(): |
|
|
quality = gr.Dropdown(choices=["low", "medium", "high"], value="low", label="Quality",visible=False) |
|
|
format_type = gr.Dropdown(choices=["gif", "mp4"], value="mp4", label="Format", visible=False) |
|
|
|
|
|
|
|
|
rerender_btn = gr.Button("🎥 Re-render Animation") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with gr.Column(scale=2): |
|
|
gr.Markdown("### 3. View Your Animation") |
|
|
output_video = gr.Video(label="Generated Animation") |
|
|
status_output = gr.Textbox(label="Status", lines=2, max_lines=5) |
|
|
logs_output = gr.Textbox(label="Manim Logs", lines=10, max_lines=9, visible=False) |
|
|
|
|
|
with gr.Row(): |
|
|
show_logs_btn = gr.Button("Show Logs", size="sm") |
|
|
hide_logs_btn = gr.Button("Hide Logs", size="sm") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
generate_anim_btn.click( |
|
|
fn=generate_full_process, |
|
|
inputs=[prompt_input, quality, format_type], |
|
|
outputs=[output_video, status_output, code_input, logs_output], |
|
|
) |
|
|
|
|
|
rerender_btn.click( |
|
|
fn=generate_animation, |
|
|
inputs=[code_input, quality, format_type], |
|
|
outputs=[output_video, status_output, logs_output] |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
edit_code_btn.click( |
|
|
fn=edit_and_render, |
|
|
inputs=[code_input, edit_instruction, quality, format_type], |
|
|
outputs=[output_video, status_output, code_input, logs_output] |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
show_logs_btn.click(fn=lambda: gr.update(visible=True), outputs=[logs_output]) |
|
|
hide_logs_btn.click(fn=lambda: gr.update(visible=False), outputs=[logs_output]) |
|
|
|
|
|
if __name__ == "__main__": |
|
|
app.launch(mcp_server=True, debug=True, share=True) |