bhaveshgoel07 commited on
Commit
dd833dc
·
1 Parent(s): 0805c5b

Update to latest versions of all source files

Browse files
Files changed (3) hide show
  1. app.py.new +661 -0
  2. orchestrator.py +43 -276
  3. orchestrator.py.new +785 -0
app.py.new ADDED
@@ -0,0 +1,661 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ NeuroAnim Gradio Web Interface
4
+
5
+ A comprehensive web UI for generating educational STEM animations with:
6
+ - Topic input and configuration
7
+ - Real-time progress tracking
8
+ - Video preview and download
9
+ - Generated content display (narration, code, quiz)
10
+ - Error handling and logging
11
+ """
12
+
13
+ import asyncio
14
+ import logging
15
+ import os
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any, Dict, Optional, Tuple
19
+
20
+ import gradio as gr
21
+ from dotenv import load_dotenv
22
+
23
+ from orchestrator import NeuroAnimOrchestrator
24
+
25
+ load_dotenv()
26
+
27
+ # Set up logging
28
+ logging.basicConfig(
29
+ level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
30
+ )
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ def format_quiz_markdown(quiz_text: str) -> str:
35
+ """Format quiz text into a nice markdown display."""
36
+ if not quiz_text or quiz_text == "Not available":
37
+ return "❓ No quiz generated yet."
38
+
39
+ # If it's already formatted or looks good, return as is with some styling
40
+ formatted = f"## 📝 Assessment Questions\n\n{quiz_text}"
41
+
42
+ # Try to add some structure if it's plain text
43
+ lines = quiz_text.split("\n")
44
+ formatted_lines = []
45
+ question_num = 0
46
+
47
+ for line in lines:
48
+ line = line.strip()
49
+ if not line:
50
+ formatted_lines.append("")
51
+ continue
52
+
53
+ # Detect question patterns
54
+ if line.lower().startswith(("q:", "question", "q.", f"{question_num + 1}.")):
55
+ question_num += 1
56
+ formatted_lines.append(f"\n### Question {question_num}")
57
+ # Remove the question prefix
58
+ clean_line = line.split(":", 1)[-1].strip() if ":" in line else line
59
+ formatted_lines.append(f"**{clean_line}**\n")
60
+ elif line.lower().startswith(("a)", "b)", "c)", "d)", "a.", "b.", "c.", "d.")):
61
+ # Format multiple choice options
62
+ formatted_lines.append(f"- {line}")
63
+ elif line.lower().startswith(("answer:", "a:", "correct:")):
64
+ # Format answers
65
+ formatted_lines.append(f"\n> ✅ {line}\n")
66
+ else:
67
+ formatted_lines.append(line)
68
+
69
+ # If we detected structure, use the formatted version
70
+ if question_num > 0:
71
+ return "## 📝 Assessment Questions\n\n" + "\n".join(formatted_lines)
72
+
73
+ # Otherwise return with basic formatting
74
+ return formatted
75
+
76
+
77
+ class NeuroAnimApp:
78
+ """Main application class for Gradio interface."""
79
+
80
+ def __init__(self):
81
+ self.orchestrator: Optional[NeuroAnimOrchestrator] = None
82
+ self.current_task: Optional[asyncio.Task] = None
83
+ self.is_generating = False
84
+ self.event_loop: Optional[asyncio.AbstractEventLoop] = None
85
+ self.current_progress = None # Store progress callback for dynamic updates
86
+
87
+ async def initialize_orchestrator(self):
88
+ """Initialize the orchestrator if not already done."""
89
+ if self.orchestrator is None:
90
+ self.orchestrator = NeuroAnimOrchestrator()
91
+ await self.orchestrator.initialize()
92
+ logger.info("Orchestrator initialized successfully")
93
+
94
+ async def cleanup_orchestrator(self):
95
+ """Clean up orchestrator resources."""
96
+ if self.orchestrator is not None:
97
+ await self.orchestrator.cleanup()
98
+ self.orchestrator = None
99
+ logger.info("Orchestrator cleaned up")
100
+
101
+ def cleanup_event_loop(self):
102
+ """Clean up the event loop on application shutdown."""
103
+ if self.event_loop is not None and not self.event_loop.is_closed():
104
+ self.event_loop.close()
105
+ self.event_loop = None
106
+ logger.info("Event loop closed")
107
+
108
+ async def generate_animation_async(
109
+ self, topic: str, audience: str, duration: float, quality: str, progress=gr.Progress()
110
+ ) -> Dict[str, Any]:
111
+ """
112
+ Generate animation with progress tracking.
113
+
114
+ Args:
115
+ topic: STEM topic to animate
116
+ audience: Target audience level
117
+ duration: Animation duration in minutes
118
+ quality: Video quality (low, medium, high, production_quality)
119
+ progress: Gradio progress tracker
120
+
121
+ Returns:
122
+ Results dictionary with generated content
123
+ """
124
+ try:
125
+ self.is_generating = True
126
+
127
+ # Validate inputs
128
+ if not topic or len(topic.strip()) < 3:
129
+ return {
130
+ "success": False,
131
+ "error": "Please provide a valid topic (at least 3 characters)",
132
+ }
133
+
134
+ if duration < 0.5 or duration > 10:
135
+ return {
136
+ "success": False,
137
+ "error": "Duration must be between 0.5 and 10 minutes",
138
+ }
139
+
140
+ # Initialize orchestrator
141
+ progress(0.05, desc="Initializing system...")
142
+ await self.initialize_orchestrator()
143
+
144
+ # Generate unique filename
145
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
146
+ safe_topic = "".join(c if c.isalnum() else "_" for c in topic)[:30]
147
+ output_filename = f"{safe_topic}_{timestamp}.mp4"
148
+
149
+ # Map quality from UI to orchestrator format
150
+ quality_map = {
151
+ "Low (480p, faster)": "low",
152
+ "Medium (720p, balanced)": "medium",
153
+ "High (1080p, slower)": "high",
154
+ "Production (4K, slowest)": "production_quality",
155
+ }
156
+ quality_param = quality_map.get(quality, "medium")
157
+
158
+ # Map audience from UI to orchestrator format
159
+ audience_map = {
160
+ "elementary": "elementary",
161
+ "middle_school": "middle_school",
162
+ "high_school": "high_school",
163
+ "undergraduate": "college", # Map to 'college' for LLM compatibility
164
+ "phd": "graduate", # Map to 'graduate' for LLM compatibility
165
+ "general": "general",
166
+ }
167
+ audience_param = audience_map.get(audience, audience)
168
+
169
+ # Dynamic progress tracking with step-based updates
170
+ step_times = {} # Track step start times
171
+ step_index = [0] # Current step index
172
+
173
+ steps = [
174
+ (0.1, "Planning concept"),
175
+ (0.25, "Generating narration script"),
176
+ (0.40, "Creating Manim animation code"),
177
+ (0.55, "Rendering animation video"),
178
+ (0.75, "Generating audio narration"),
179
+ (0.90, "Merging video and audio"),
180
+ (0.95, "Creating quiz questions"),
181
+ ]
182
+
183
+ import time
184
+
185
+ def progress_callback(step_name: str, step_progress: float):
186
+ """Callback for orchestrator to report progress."""
187
+ # Find matching step
188
+ for idx, (prog, desc) in enumerate(steps):
189
+ if desc.lower() in step_name.lower():
190
+ step_index[0] = idx
191
+
192
+ # Track timing
193
+ current_time = time.time()
194
+ if step_name not in step_times:
195
+ step_times[step_name] = current_time
196
+ elapsed = current_time - step_times[step_name]
197
+
198
+ # Add timing info for long steps
199
+ if elapsed > 30: # Show message if step takes more than 30s
200
+ desc_with_time = f"{desc} (taking longer than usual, please wait...)"
201
+ else:
202
+ desc_with_time = f"{desc}..."
203
+
204
+ progress(prog, desc=desc_with_time)
205
+ return
206
+
207
+ # If no match, use the provided progress directly
208
+ progress(step_progress, desc=f"{step_name}...")
209
+
210
+ # Start generation with dynamic progress
211
+ result = await self.orchestrator.generate_animation(
212
+ topic=topic,
213
+ target_audience=audience_param,
214
+ animation_length_minutes=duration,
215
+ output_filename=output_filename,
216
+ quality=quality_param,
217
+ progress_callback=progress_callback,
218
+ )
219
+
220
+ progress(1.0, desc="Complete!")
221
+ logger.info("Async generation completed, returning result")
222
+
223
+ return result
224
+
225
+ except Exception as e:
226
+ logger.error(f"Generation failed: {e}", exc_info=True)
227
+ return {"success": False, "error": str(e)}
228
+ finally:
229
+ self.is_generating = False
230
+
231
+ def generate_animation_sync(
232
+ self, topic: str, audience: str, duration: float, quality: str, progress=gr.Progress()
233
+ ) -> Tuple[str, str, str, str, str, str]:
234
+ """
235
+ Synchronous wrapper for Gradio interface.
236
+
237
+ Returns:
238
+ Tuple of (video_path, status, narration, code, quiz, concept_plan)
239
+ """
240
+ try:
241
+ # Reuse existing event loop or create a persistent one
242
+ if self.event_loop is None or self.event_loop.is_closed():
243
+ self.event_loop = asyncio.new_event_loop()
244
+ asyncio.set_event_loop(self.event_loop)
245
+ logger.info("Created new persistent event loop")
246
+ else:
247
+ asyncio.set_event_loop(self.event_loop)
248
+ logger.info("Reusing existing event loop")
249
+
250
+ logger.info("Starting event loop execution...")
251
+ result = self.event_loop.run_until_complete(
252
+ self.generate_animation_async(topic, audience, duration, quality, progress)
253
+ )
254
+ logger.info("Event loop execution completed")
255
+ # DO NOT close the loop - keep it for subsequent generations
256
+
257
+ if result["success"]:
258
+ logger.info("Processing successful result...")
259
+ video_path = result["output_file"]
260
+ status = f"✅ **Animation Generated Successfully!**\n\n**Topic:** {result['topic']}\n**Audience:** {result['target_audience']}\n**Output:** {os.path.basename(video_path)}"
261
+ narration = result.get("narration", "Not available")
262
+ code = result.get("manim_code", "Not available")
263
+ quiz_raw = result.get("quiz", "Not available")
264
+ quiz = format_quiz_markdown(quiz_raw)
265
+ concept = result.get("concept_plan", "Not available")
266
+
267
+ logger.info(f"Returning result to Gradio: {video_path}")
268
+ return video_path, video_path, status, narration, code, quiz, concept
269
+ else:
270
+ error_msg = result.get("error", "Unknown error")
271
+ status = f"❌ **Generation Failed**\n\n{error_msg}"
272
+ return None, None, status, "", "", "", ""
273
+
274
+ except Exception as e:
275
+ logger.error(f"Sync wrapper error: {e}", exc_info=True)
276
+ status = f"💥 **Unexpected Error**\n\n{str(e)}"
277
+ return None, None, status, "", "", "", ""
278
+
279
+
280
+ def create_interface() -> gr.Blocks:
281
+ """Create the Gradio interface."""
282
+
283
+ app = NeuroAnimApp()
284
+
285
+ # Custom CSS for better styling
286
+ custom_css = """
287
+ .main-title {
288
+ text-align: center;
289
+ color: #2563eb;
290
+ font-size: 2.5em;
291
+ font-weight: bold;
292
+ margin-bottom: 0.5em;
293
+ }
294
+ .subtitle {
295
+ text-align: center;
296
+ color: #64748b;
297
+ font-size: 1.2em;
298
+ margin-bottom: 2em;
299
+ }
300
+ .status-box {
301
+ padding: 1em;
302
+ border-radius: 8px;
303
+ margin: 1em 0;
304
+ }
305
+ .gradio-container {
306
+ max-width: 1400px !important;
307
+ }
308
+ /* Video player styling */
309
+ video {
310
+ border-radius: 8px;
311
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
312
+ }
313
+ /* Quiz and content styling */
314
+ .markdown-text h2 {
315
+ color: #1e40af;
316
+ border-bottom: 2px solid #3b82f6;
317
+ padding-bottom: 0.5em;
318
+ margin-top: 1em;
319
+ }
320
+ .markdown-text h3 {
321
+ color: #1e293b;
322
+ margin-top: 1em;
323
+ }
324
+ .markdown-text blockquote {
325
+ background-color: #f0fdf4;
326
+ border-left: 4px solid #22c55e;
327
+ padding: 0.5em 1em;
328
+ margin: 1em 0;
329
+ }
330
+ /* Button styling */
331
+ .primary {
332
+ background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
333
+ }
334
+ /* Code block styling */
335
+ .code-container {
336
+ border-radius: 8px;
337
+ margin: 1em 0;
338
+ }
339
+ """
340
+
341
+ with gr.Blocks(title="NeuroAnim - STEM Animation Generator") as interface:
342
+ # Apply custom CSS
343
+ interface.css = custom_css
344
+ # Header
345
+ gr.HTML("""
346
+ <div class="main-title">🧠 NeuroAnim</div>
347
+ <div class="subtitle">AI-Powered Educational Animation Generator</div>
348
+ """)
349
+
350
+ with gr.Tabs() as tabs:
351
+ # Main Generation Tab
352
+ with gr.TabItem("🎬 Generate Animation", id=0):
353
+ gr.Markdown("""
354
+ ### Create Your Educational Animation
355
+ Enter a mathematical or scientific concept, and NeuroAnim will generate a complete animated video with narration and quiz questions.
356
+ """)
357
+
358
+ with gr.Row():
359
+ with gr.Column(scale=1):
360
+ # Input Section
361
+ gr.Markdown("#### 📝 Animation Configuration")
362
+
363
+ topic_input = gr.Textbox(
364
+ label="Topic / Concept",
365
+ placeholder="e.g., Pythagorean Theorem, Photosynthesis, Newton's Laws, etc.",
366
+ lines=2,
367
+ info="Enter the STEM concept you want to explain",
368
+ )
369
+
370
+ with gr.Row():
371
+ audience_input = gr.Dropdown(
372
+ label="Target Audience",
373
+ choices=[
374
+ "elementary",
375
+ "middle_school",
376
+ "high_school",
377
+ "undergraduate",
378
+ "phd",
379
+ "general",
380
+ ],
381
+ value="high_school",
382
+ info="Select the appropriate education level",
383
+ )
384
+
385
+ duration_input = gr.Slider(
386
+ label="Duration (minutes)",
387
+ minimum=0.5,
388
+ maximum=10,
389
+ value=2.0,
390
+ step=0.5,
391
+ info="Animation length",
392
+ )
393
+
394
+ quality_input = gr.Dropdown(
395
+ label="Video Quality",
396
+ choices=[
397
+ "Low (480p, faster)",
398
+ "Medium (720p, balanced)",
399
+ "High (1080p, slower)",
400
+ "Production (4K, slowest)",
401
+ ],
402
+ value="Medium (720p, balanced)",
403
+ info="Higher quality takes longer to render",
404
+ )
405
+
406
+ generate_btn = gr.Button(
407
+ "🚀 Generate Animation", variant="primary", size="lg"
408
+ )
409
+
410
+ status_output = gr.Markdown(
411
+ label="Status",
412
+ value="Ready to generate...",
413
+ elem_classes=["status-box"],
414
+ )
415
+
416
+ # Example inputs
417
+ gr.Markdown("#### 💡 Example Topics")
418
+ gr.Examples(
419
+ examples=[
420
+ ["Pythagorean Theorem", "high_school", 2.0, "Medium (720p, balanced)"],
421
+ ["Laws of Motion", "middle_school", 2.5, "Low (480p, faster)"],
422
+ ["Binary Numbers", "middle_school", 1.5, "Medium (720p, balanced)"],
423
+ ["Photosynthesis Process", "elementary", 2.0, "Low (480p, faster)"],
424
+ ["Quadratic Formula", "high_school", 3.0, "Medium (720p, balanced)"],
425
+ ["Circle Area Derivation", "undergraduate", 2.5, "High (1080p, slower)"],
426
+ ],
427
+ inputs=[topic_input, audience_input, duration_input, quality_input],
428
+ )
429
+
430
+ with gr.Column(scale=1):
431
+ # Output Section
432
+ gr.Markdown("#### 🎥 Generated Animation")
433
+
434
+ video_output = gr.Video(
435
+ label="Animation Video", height=400, interactive=False
436
+ )
437
+
438
+ # Download button
439
+ download_file = gr.File(
440
+ label="📥 Download Animation",
441
+ interactive=False,
442
+ visible=True,
443
+ )
444
+
445
+ gr.Markdown(
446
+ "**Tip:** Click the download button above or use the ⋮ menu on the video player"
447
+ )
448
+
449
+ # Additional outputs in accordion
450
+ with gr.Accordion("📄 View Generated Content", open=True):
451
+ with gr.Tabs():
452
+ with gr.TabItem("📖 Narration Script"):
453
+ narration_output = gr.Textbox(
454
+ label="Narration Text",
455
+ lines=8,
456
+ interactive=False,
457
+ )
458
+
459
+ with gr.TabItem("💻 Manim Code"):
460
+ code_output = gr.Code(
461
+ label="Generated Python Code",
462
+ language="python",
463
+ interactive=False,
464
+ lines=15,
465
+ )
466
+
467
+ with gr.TabItem("❓ Quiz Questions"):
468
+ quiz_output = gr.Markdown(
469
+ label="Assessment Questions",
470
+ value="Quiz will appear here after generation...",
471
+ )
472
+
473
+ with gr.TabItem("📋 Concept Plan"):
474
+ concept_output = gr.Textbox(
475
+ label="Educational Plan",
476
+ lines=10,
477
+ interactive=False,
478
+ )
479
+
480
+ # Connect the generate button
481
+ generate_btn.click(
482
+ fn=app.generate_animation_sync,
483
+ inputs=[topic_input, audience_input, duration_input, quality_input],
484
+ outputs=[
485
+ video_output,
486
+ download_file,
487
+ status_output,
488
+ narration_output,
489
+ code_output,
490
+ quiz_output,
491
+ concept_output,
492
+ ],
493
+ api_name="generate",
494
+ )
495
+
496
+ # About Tab
497
+ with gr.TabItem("ℹ️ About", id=1):
498
+ gr.Markdown("""
499
+ # About NeuroAnim
500
+
501
+ NeuroAnim is an AI-powered educational animation generator that creates engaging STEM content automatically.
502
+
503
+ ## 🎯 Features
504
+
505
+ - **🎨 Automatic Animation Generation**: Creates professional Manim animations from topic descriptions
506
+ - **🗣️ AI Narration**: Generates educational narration scripts tailored to your audience
507
+ - **🔊 Text-to-Speech**: Converts narration to high-quality audio with ElevenLabs or Hugging Face
508
+ - **📹 Video Production**: Renders and merges video with synchronized audio
509
+ - **❓ Quiz Generation**: Creates assessment questions to test understanding
510
+ - **🎓 Multi-Level Support**: Content appropriate for elementary through undergraduate levels
511
+
512
+ ## 🔧 Technology Stack
513
+
514
+ - **Manim Community Edition**: Mathematical animation engine
515
+ - **Hugging Face Models**: AI-powered content generation
516
+ - **ElevenLabs**: High-quality text-to-speech synthesis
517
+ - **MCP (Model Context Protocol)**: Modular server architecture
518
+ - **Gradio**: Interactive web interface
519
+
520
+ ## 🚀 How It Works
521
+
522
+ 1. **Concept Planning**: AI analyzes your topic and creates an educational plan
523
+ 2. **Script Writing**: Generates age-appropriate narration aligned with learning objectives
524
+ 3. **Code Generation**: Creates Manim Python code for visual representation
525
+ 4. **Rendering**: Executes Manim to produce the base animation
526
+ 5. **Audio Synthesis**: Converts narration to speech using TTS
527
+ 6. **Final Production**: Merges video and audio into complete animation
528
+ 7. **Assessment**: Generates quiz questions for the content
529
+
530
+ ## 📝 Tips for Best Results
531
+
532
+ - **Be Specific**: Instead of "math", try "solving linear equations" or "area of a circle"
533
+ - **Choose Right Audience**: Match the complexity level to your target viewers
534
+ - **Optimal Duration**: 1.5-3 minutes works best for most concepts
535
+ - **Review Generated Content**: Check the narration and code tabs to see what was created
536
+ - **Iterate**: If results aren't perfect, try rewording your topic or adjusting parameters
537
+
538
+ ## 🔑 Setup Requirements
539
+
540
+ To use NeuroAnim, you need:
541
+ - **Hugging Face API Key**: For AI content generation (required)
542
+ - **ElevenLabs API Key**: For high-quality TTS (optional, falls back to HF TTS)
543
+
544
+ Set these in your `.env` file:
545
+ ```bash
546
+ HUGGINGFACE_API_KEY=your_key_here
547
+ ELEVENLABS_API_KEY=your_key_here # Optional
548
+ ```
549
+
550
+ ## 📚 Example Use Cases
551
+
552
+ - **Teachers**: Create engaging lesson materials
553
+ - **Students**: Visualize complex concepts for better understanding
554
+ - **Content Creators**: Produce educational YouTube/social media content
555
+ - **Tutors**: Generate custom explanations for specific topics
556
+ - **Course Developers**: Build comprehensive educational video libraries
557
+
558
+ ## 🤝 Contributing
559
+
560
+ NeuroAnim is open source! Contributions are welcome:
561
+ - Report bugs or suggest features via GitHub Issues
562
+ - Submit pull requests with improvements
563
+ - Share your generated animations with the community
564
+
565
+ ## 📄 License
566
+
567
+ MIT License - Free to use for educational and commercial purposes
568
+
569
+ ---
570
+
571
+ Made with ❤️ for educational content creation
572
+ """)
573
+
574
+ # Settings Tab
575
+ with gr.TabItem("⚙️ Settings", id=2):
576
+ gr.Markdown("""
577
+ # System Configuration
578
+
579
+ Configure API keys and system settings here.
580
+ """)
581
+
582
+ with gr.Group():
583
+ gr.Markdown("### 🔑 API Keys")
584
+
585
+ hf_key_status = gr.Textbox(
586
+ label="Hugging Face API Key Status",
587
+ value="✅ Configured"
588
+ if os.getenv("HUGGINGFACE_API_KEY")
589
+ else "❌ Not Set",
590
+ interactive=False,
591
+ )
592
+
593
+ eleven_key_status = gr.Textbox(
594
+ label="ElevenLabs API Key Status",
595
+ value="✅ Configured"
596
+ if os.getenv("ELEVENLABS_API_KEY")
597
+ else "⚠️ Not Set (will use fallback TTS)",
598
+ interactive=False,
599
+ )
600
+
601
+ gr.Markdown("""
602
+ **To configure API keys:**
603
+ 1. Create a `.env` file in the project root
604
+ 2. Add your keys:
605
+ ```
606
+ HUGGINGFACE_API_KEY=your_hf_key
607
+ ELEVENLABS_API_KEY=your_elevenlabs_key
608
+ ```
609
+ 3. Restart the application
610
+ """)
611
+
612
+ with gr.Group():
613
+ gr.Markdown("### 📊 System Info")
614
+
615
+ system_info = gr.Textbox(
616
+ label="System Status",
617
+ value=f"""
618
+ Output Directory: {Path("outputs").absolute()}
619
+ Working Directory: Temporary (auto-created)
620
+ Manim Version: Community Edition
621
+ Default Quality: Medium (720p, 30fps)
622
+ """.strip(),
623
+ interactive=False,
624
+ lines=6,
625
+ )
626
+
627
+ return interface
628
+
629
+
630
+ def main():
631
+ """Launch the Gradio application."""
632
+
633
+ # Check for API keys
634
+ if not os.getenv("HUGGINGFACE_API_KEY"):
635
+ logger.warning("HUGGINGFACE_API_KEY not set! Generation will fail.")
636
+ print("\n⚠️ WARNING: HUGGINGFACE_API_KEY environment variable not set!")
637
+ print("Please set it in your .env file or environment.\n")
638
+
639
+ if not os.getenv("ELEVENLABS_API_KEY"):
640
+ logger.info("ELEVENLABS_API_KEY not set, will use fallback TTS")
641
+ print(
642
+ "\nℹ️ Note: ELEVENLABS_API_KEY not set. Using fallback TTS (may have lower quality).\n"
643
+ )
644
+
645
+ # Create outputs directory
646
+ Path("outputs").mkdir(exist_ok=True)
647
+
648
+ # Build and launch interface
649
+ interface = create_interface()
650
+
651
+ logger.info("Launching Gradio interface...")
652
+
653
+ interface.launch(
654
+ server_name="0.0.0.0",
655
+ server_port=7860,
656
+ share=False,
657
+ )
658
+
659
+
660
+ if __name__ == "__main__":
661
+ main()
orchestrator.py CHANGED
@@ -167,8 +167,6 @@ class NeuroAnimOrchestrator:
167
  target_audience: str = "general",
168
  animation_length_minutes: float = 2.0,
169
  output_filename: str = "animation.mp4",
170
- quality: str = "medium",
171
- progress_callback: Optional[callable] = None,
172
  ) -> Dict[str, Any]:
173
  """Complete animation generation pipeline."""
174
 
@@ -177,8 +175,6 @@ class NeuroAnimOrchestrator:
177
 
178
  # Step 1: Concept Planning
179
  logger.info("Step 1: Planning concept...")
180
- if progress_callback:
181
- progress_callback("Planning concept", 0.1)
182
  concept_result = await self.call_tool(
183
  self.creative_session,
184
  "plan_concept",
@@ -197,8 +193,6 @@ class NeuroAnimOrchestrator:
197
 
198
  # Step 2: Generate Narration
199
  logger.info("Step 2: Generating narration...")
200
- if progress_callback:
201
- progress_callback("Generating narration script", 0.25)
202
  narration_result = await self.call_tool(
203
  self.creative_session,
204
  "generate_narration",
@@ -215,21 +209,13 @@ class NeuroAnimOrchestrator:
215
  f"Narration generation failed: {narration_result['text']}"
216
  )
217
 
218
- # Clean narration text - remove title/prefix before TTS
219
- narration_text = self._clean_narration_text(narration_result["text"])
220
  logger.info("Narration generation completed")
221
- logger.info(f"Narration preview: {narration_text[:100]}...")
222
 
223
  # Step 3: Generate Manim Code with retry logic
224
  logger.info("Step 3: Generating Manim code...")
225
- if progress_callback:
226
- progress_callback("Creating Manim animation code", 0.40)
227
- target_duration_seconds = int(animation_length_minutes * 60)
228
  manim_code = await self._generate_and_validate_code(
229
- topic=topic,
230
- concept_plan=concept_plan,
231
- duration_seconds=target_duration_seconds,
232
- max_retries=3,
233
  )
234
  logger.info("Manim code generation completed and validated")
235
 
@@ -249,108 +235,33 @@ class NeuroAnimOrchestrator:
249
  scene_name = self._extract_scene_name(manim_code)
250
  logger.info(f"Scene name detected: {scene_name}")
251
 
252
- # Step 5: Render Animation with retry on runtime errors
253
  logger.info("Step 5: Rendering animation...")
254
- if progress_callback:
255
- progress_callback("Rendering animation video", 0.55)
256
- max_render_retries = 5
257
- video_file = None
258
-
259
- for render_attempt in range(max_render_retries):
260
- render_result = await self.call_tool(
261
- self.renderer_session,
262
- "render_manim_animation",
263
- {
264
- "scene_name": scene_name,
265
- "file_path": str(manim_file),
266
- "output_dir": str(self.work_dir),
267
- "quality": quality, # Use the quality parameter
268
- "format": "mp4",
269
- "frame_rate": 30,
270
- },
271
- )
272
 
273
- if not render_result["isError"]:
274
- # Success! Find the rendered file
275
- video_file = self._find_output_file(self.work_dir, scene_name, "mp4")
276
- if video_file:
277
- # Check video duration
278
- try:
279
- actual_duration = self._get_video_duration(video_file)
280
- logger.info(f"Rendered video duration: {actual_duration:.2f}s (Target: {target_duration_seconds}s)")
281
-
282
- if actual_duration < target_duration_seconds * 0.5:
283
- logger.warning(f"Video is too short ({actual_duration:.2f}s < {target_duration_seconds * 0.5}s). Forcing retry...")
284
- error_text = (
285
- f"The generated animation was TOO SHORT ({actual_duration:.1f}s). "
286
- f"The target duration is {target_duration_seconds}s. "
287
- "You MUST make the animation longer by adding more `self.wait()` calls "
288
- "and ensuring animations play slower (use run_time parameter)."
289
- )
290
- # Fall through to error handling logic below
291
- else:
292
- break
293
- except Exception as e:
294
- logger.warning(f"Could not verify video duration: {e}")
295
- break
296
- else:
297
- logger.warning("Render succeeded but could not find output file")
298
- if render_attempt < max_render_retries - 1:
299
- continue
300
-
301
- # Rendering failed - check if it's a runtime error we can fix
302
- error_text = render_result["text"]
303
- logger.warning(f"Render attempt {render_attempt + 1} failed: {error_text[:200]}...")
304
-
305
- # Check if this is a Manim runtime error (not a "no scene" error)
306
- if render_attempt < max_render_retries - 1 and (
307
- "TypeError" in error_text
308
- or "AttributeError" in error_text
309
- or "ValueError" in error_text
310
- or "KeyError" in error_text
311
- ):
312
- logger.info(f"Detected runtime error in Manim code. Regenerating code (attempt {render_attempt + 2}/{max_render_retries})...")
313
-
314
- # Regenerate code with error feedback
315
- runtime_error_msg = f"Runtime Error during Manim rendering:\n{error_text}\n\nPlease fix the code to be compatible with Manim version 0.19.0."
316
- manim_code = await self._generate_and_validate_code(
317
- topic=topic,
318
- concept_plan=concept_plan,
319
- duration_seconds=target_duration_seconds,
320
- max_retries=3, # Allow retries for syntax errors during fix
321
- previous_error=runtime_error_msg,
322
- previous_code=manim_code,
323
- )
324
-
325
- # Write the new code
326
- write_result = await self.call_tool(
327
- self.renderer_session,
328
- "write_manim_file",
329
- {"filepath": str(manim_file), "code": manim_code},
330
- )
331
-
332
- if write_result["isError"]:
333
- raise Exception(f"File writing failed: {write_result['text']}")
334
-
335
- # Extract scene name from new code
336
- scene_name = self._extract_scene_name(manim_code)
337
- logger.info(f"Regenerated code with scene: {scene_name}")
338
-
339
- # Loop will retry rendering with new code
340
- continue
341
- else:
342
- # Not a runtime error or out of retries
343
- raise Exception(f"Rendering failed: {error_text}")
344
-
345
  if not video_file:
346
- raise Exception("Could not find rendered video file after all attempts")
347
 
348
  logger.info(f"Animation rendered: {video_file}")
349
 
350
  # Step 6: Generate Speech Audio
351
  logger.info("Step 6: Generating speech audio...")
352
- if progress_callback:
353
- progress_callback("Generating audio narration", 0.75)
354
  audio_file = self.work_dir / "narration.mp3"
355
 
356
  # Use TTS generator with automatic fallback
@@ -380,8 +291,6 @@ class NeuroAnimOrchestrator:
380
 
381
  # Step 7: Merge Video and Audio
382
  logger.info("Step 7: Merging video and audio...")
383
- if progress_callback:
384
- progress_callback("Merging video and audio", 0.90)
385
  final_output = self.output_dir / output_filename
386
  merge_result = await self.call_tool(
387
  self.renderer_session,
@@ -398,8 +307,6 @@ class NeuroAnimOrchestrator:
398
 
399
  # Step 8: Generate Quiz
400
  logger.info("Step 8: Generating quiz...")
401
- if progress_callback:
402
- progress_callback("Creating quiz questions", 0.95)
403
  quiz_result = await self.call_tool(
404
  self.creative_session,
405
  "generate_quiz",
@@ -441,68 +348,28 @@ class NeuroAnimOrchestrator:
441
  "work_dir": str(self.work_dir) if self.work_dir else None,
442
  }
443
 
444
- def _clean_narration_text(self, text: str) -> str:
445
- """
446
- Clean narration text by removing title prefixes and formatting artifacts.
447
-
448
- The creative server returns text with prefixes like "Narration Script:\n\n"
449
- which should not be sent to TTS.
450
- """
451
- # Remove common prefixes
452
- prefixes_to_remove = [
453
- "Narration Script:",
454
- "Script:",
455
- "Narration:",
456
- "Text:",
457
- ]
458
-
459
- cleaned = text.strip()
460
-
461
- # Remove any of the prefixes (case-insensitive)
462
- for prefix in prefixes_to_remove:
463
- if cleaned.lower().startswith(prefix.lower()):
464
- cleaned = cleaned[len(prefix) :].strip()
465
- break
466
-
467
- # Remove leading newlines and whitespace
468
- cleaned = cleaned.lstrip("\n").strip()
469
-
470
- # Remove any markdown code block markers
471
- if cleaned.startswith("```"):
472
- lines = cleaned.split("\n")
473
- # Remove first line (opening ```)
474
- if len(lines) > 1:
475
- lines = lines[1:]
476
- # Remove last line if it's closing ```
477
- if lines and lines[-1].strip() == "```":
478
- lines = lines[:-1]
479
- cleaned = "\n".join(lines).strip()
480
-
481
- return cleaned
482
-
483
- def _extract_python_code(self, text: str) -> str:
484
  """Extract Python code from markdown response."""
485
  # Look for code blocks
486
- if "```python" in text:
487
- start = text.find("```python") + 9
488
- end = text.find("```", start)
489
  if end == -1:
490
- end = len(text)
491
- return text[start:end].strip()
492
- elif "```" in text:
493
- start = text.find("```") + 3
494
- end = text.find("```", start)
495
  if end == -1:
496
- end = len(text)
497
- return text[start:end].strip()
498
  else:
499
- return text.strip()
500
 
501
  async def _generate_and_validate_code(
502
  self,
503
  topic: str,
504
  concept_plan: str,
505
- duration_seconds: int = 60,
506
  max_retries: int = 3,
507
  previous_error: Optional[str] = None,
508
  previous_code: Optional[str] = None,
@@ -517,13 +384,11 @@ class NeuroAnimOrchestrator:
517
  "concept": topic,
518
  "scene_description": concept_plan,
519
  "visual_elements": ["text", "shapes", "animations"],
520
- "duration_seconds": duration_seconds,
521
  }
522
 
523
  # If this is a retry, include error feedback
524
- if previous_error:
525
- if previous_code:
526
- arguments["previous_code"] = previous_code
527
  arguments["error_message"] = previous_error
528
  logger.info(
529
  f"Retrying with error feedback: {previous_error[:100]}..."
@@ -540,7 +405,6 @@ class NeuroAnimOrchestrator:
540
  f"Code generation failed, retrying: {code_result['text']}"
541
  )
542
  previous_error = code_result["text"]
543
- # Keep previous_code if we had it, for better context in retry
544
  continue
545
  else:
546
  raise Exception(
@@ -565,25 +429,6 @@ class NeuroAnimOrchestrator:
565
  f"Generated code has syntax errors after {max_retries} attempts:\n{syntax_errors}"
566
  )
567
 
568
- # Validate that code contains a Scene class
569
- has_scene = self._validate_has_scene_class(manim_code)
570
- if not has_scene:
571
- if attempt < max_retries - 1:
572
- logger.warning(
573
- "No Scene class found in generated code, retrying..."
574
- )
575
- previous_error = (
576
- "Error: The generated code does not contain any Scene class. "
577
- "Please ensure you create a class that inherits from manim.Scene, "
578
- "manim.MovingCameraScene, or manim.ThreeDScene."
579
- )
580
- previous_code = manim_code
581
- continue
582
- else:
583
- raise Exception(
584
- f"Generated code does not contain a Scene class after {max_retries} attempts"
585
- )
586
-
587
  # Success!
588
  logger.info(f"Valid code generated on attempt {attempt + 1}")
589
  return manim_code
@@ -604,101 +449,23 @@ class NeuroAnimOrchestrator:
604
  ast.parse(code)
605
  return None
606
  except SyntaxError as e:
607
- # Build detailed error message with context
608
  error_msg = f"Line {e.lineno}: {e.msg}"
609
-
610
- # Show surrounding context (3 lines before and after)
611
- if e.lineno is not None:
612
- code_lines = code.split("\n")
613
- start_line = max(0, e.lineno - 4) # 3 lines before
614
- end_line = min(len(code_lines), e.lineno + 2) # 2 lines after
615
-
616
- error_msg += "\n\nContext:"
617
- for i in range(start_line, end_line):
618
- line_num = i + 1
619
- prefix = ">>> " if line_num == e.lineno else " "
620
- error_msg += f"\n{prefix}{line_num:3d} | {code_lines[i]}"
621
-
622
- # Add pointer for error line
623
- if line_num == e.lineno and e.offset:
624
- error_msg += f"\n {' ' * 4}{' ' * (e.offset - 1)}^"
625
-
626
  return error_msg
627
  except Exception as e:
628
  return f"Unexpected error during syntax validation: {str(e)}"
629
 
630
- def _validate_has_scene_class(self, code: str) -> bool:
631
- """Check if code contains at least one Scene class."""
632
- import re
633
-
634
- # Check for Scene class inheritance
635
- scene_patterns = [
636
- r"class\s+\w+\s*\(\s*Scene\s*\)",
637
- r"class\s+\w+\s*\(\s*MovingCameraScene\s*\)",
638
- r"class\s+\w+\s*\(\s*ThreeDScene\s*\)",
639
- r"class\s+\w+\s*\(\s*\w*Scene\s*\)",
640
- ]
641
-
642
- for pattern in scene_patterns:
643
- if re.search(pattern, code):
644
- return True
645
-
646
- # Also check using AST parsing as a backup
647
- try:
648
- tree = ast.parse(code)
649
- for node in ast.walk(tree):
650
- if isinstance(node, ast.ClassDef):
651
- # Check if any base class contains "Scene"
652
- for base in node.bases:
653
- if isinstance(base, ast.Name) and "Scene" in base.id:
654
- return True
655
- except Exception:
656
- pass
657
-
658
- return False
659
-
660
  def _extract_scene_name(self, code: str) -> str:
661
  """Extract scene class name from Manim code."""
662
  import re
663
 
664
- # Try multiple patterns to find Scene class
665
- patterns = [
666
- r"class\s+(\w+)\s*\(\s*Scene\s*\)", # class Name(Scene)
667
- r"class\s+(\w+)\s*\(\s*MovingCameraScene\s*\)", # class Name(MovingCameraScene)
668
- r"class\s+(\w+)\s*\(\s*ThreeDScene\s*\)", # class Name(ThreeDScene)
669
- r"class\s+(\w+)\s*\(\s*\w*Scene\s*\)", # class Name(AnyScene)
670
- ]
671
-
672
- for pattern in patterns:
673
- match = re.search(pattern, code)
674
- if match:
675
- scene_name = match.group(1)
676
- logger.info(f"Found scene class: {scene_name}")
677
- return scene_name
678
-
679
- # If no scene found, look for any class definition and warn
680
- any_class = re.search(r"class\s+(\w+)\s*\(", code)
681
- if any_class:
682
- class_name = any_class.group(1)
683
- logger.warning(
684
- f"Could not find Scene class, using first class found: {class_name}"
685
- )
686
- return class_name
687
-
688
- # Last resort - parse the AST to find classes
689
- try:
690
- tree = ast.parse(code)
691
- for node in ast.walk(tree):
692
- if isinstance(node, ast.ClassDef):
693
- logger.warning(
694
- f"Using first class from AST parsing: {node.name}"
695
- )
696
- return node.name
697
- except Exception as e:
698
- logger.error(f"Failed to parse code AST: {e}")
699
-
700
- # Absolute fallback
701
- logger.error("No scene class found in code! This will likely cause rendering to fail.")
702
  return "Scene" # fallback
703
 
704
  def _find_output_file(
 
167
  target_audience: str = "general",
168
  animation_length_minutes: float = 2.0,
169
  output_filename: str = "animation.mp4",
 
 
170
  ) -> Dict[str, Any]:
171
  """Complete animation generation pipeline."""
172
 
 
175
 
176
  # Step 1: Concept Planning
177
  logger.info("Step 1: Planning concept...")
 
 
178
  concept_result = await self.call_tool(
179
  self.creative_session,
180
  "plan_concept",
 
193
 
194
  # Step 2: Generate Narration
195
  logger.info("Step 2: Generating narration...")
 
 
196
  narration_result = await self.call_tool(
197
  self.creative_session,
198
  "generate_narration",
 
209
  f"Narration generation failed: {narration_result['text']}"
210
  )
211
 
212
+ narration_text = narration_result["text"]
 
213
  logger.info("Narration generation completed")
 
214
 
215
  # Step 3: Generate Manim Code with retry logic
216
  logger.info("Step 3: Generating Manim code...")
 
 
 
217
  manim_code = await self._generate_and_validate_code(
218
+ topic=topic, concept_plan=concept_plan, max_retries=3
 
 
 
219
  )
220
  logger.info("Manim code generation completed and validated")
221
 
 
235
  scene_name = self._extract_scene_name(manim_code)
236
  logger.info(f"Scene name detected: {scene_name}")
237
 
238
+ # Step 5: Render Animation
239
  logger.info("Step 5: Rendering animation...")
240
+ render_result = await self.call_tool(
241
+ self.renderer_session,
242
+ "render_manim_animation",
243
+ {
244
+ "scene_name": scene_name,
245
+ "file_path": str(manim_file),
246
+ "output_dir": str(self.work_dir),
247
+ "quality": "medium",
248
+ "format": "mp4",
249
+ "frame_rate": 30,
250
+ },
251
+ )
 
 
 
 
 
 
252
 
253
+ if render_result["isError"]:
254
+ raise Exception(f"Rendering failed: {render_result['text']}")
255
+
256
+ # Find rendered video file
257
+ video_file = self._find_output_file(self.work_dir, scene_name, "mp4")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  if not video_file:
259
+ raise Exception("Could not find rendered video file")
260
 
261
  logger.info(f"Animation rendered: {video_file}")
262
 
263
  # Step 6: Generate Speech Audio
264
  logger.info("Step 6: Generating speech audio...")
 
 
265
  audio_file = self.work_dir / "narration.mp3"
266
 
267
  # Use TTS generator with automatic fallback
 
291
 
292
  # Step 7: Merge Video and Audio
293
  logger.info("Step 7: Merging video and audio...")
 
 
294
  final_output = self.output_dir / output_filename
295
  merge_result = await self.call_tool(
296
  self.renderer_session,
 
307
 
308
  # Step 8: Generate Quiz
309
  logger.info("Step 8: Generating quiz...")
 
 
310
  quiz_result = await self.call_tool(
311
  self.creative_session,
312
  "generate_quiz",
 
348
  "work_dir": str(self.work_dir) if self.work_dir else None,
349
  }
350
 
351
+ def _extract_python_code(self, response_text: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  """Extract Python code from markdown response."""
353
  # Look for code blocks
354
+ if "```python" in response_text:
355
+ start = response_text.find("```python") + 9
356
+ end = response_text.find("```", start)
357
  if end == -1:
358
+ end = len(response_text)
359
+ return response_text[start:end].strip()
360
+ elif "```" in response_text:
361
+ start = response_text.find("```") + 3
362
+ end = response_text.find("```", start)
363
  if end == -1:
364
+ end = len(response_text)
365
+ return response_text[start:end].strip()
366
  else:
367
+ return response_text.strip()
368
 
369
  async def _generate_and_validate_code(
370
  self,
371
  topic: str,
372
  concept_plan: str,
 
373
  max_retries: int = 3,
374
  previous_error: Optional[str] = None,
375
  previous_code: Optional[str] = None,
 
384
  "concept": topic,
385
  "scene_description": concept_plan,
386
  "visual_elements": ["text", "shapes", "animations"],
 
387
  }
388
 
389
  # If this is a retry, include error feedback
390
+ if previous_error and previous_code:
391
+ arguments["previous_code"] = previous_code
 
392
  arguments["error_message"] = previous_error
393
  logger.info(
394
  f"Retrying with error feedback: {previous_error[:100]}..."
 
405
  f"Code generation failed, retrying: {code_result['text']}"
406
  )
407
  previous_error = code_result["text"]
 
408
  continue
409
  else:
410
  raise Exception(
 
429
  f"Generated code has syntax errors after {max_retries} attempts:\n{syntax_errors}"
430
  )
431
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  # Success!
433
  logger.info(f"Valid code generated on attempt {attempt + 1}")
434
  return manim_code
 
449
  ast.parse(code)
450
  return None
451
  except SyntaxError as e:
 
452
  error_msg = f"Line {e.lineno}: {e.msg}"
453
+ if e.text:
454
+ error_msg += f"\n {e.text.rstrip()}"
455
+ if e.offset:
456
+ error_msg += f"\n {' ' * (e.offset - 1)}^"
 
 
 
 
 
 
 
 
 
 
 
 
 
457
  return error_msg
458
  except Exception as e:
459
  return f"Unexpected error during syntax validation: {str(e)}"
460
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
  def _extract_scene_name(self, code: str) -> str:
462
  """Extract scene class name from Manim code."""
463
  import re
464
 
465
+ # Look for class definition that inherits from Scene, MovingCameraScene, etc.
466
+ match = re.search(r"class\s+(\w+)\s*\(\s*\w*Scene\s*\)", code)
467
+ if match:
468
+ return match.group(1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  return "Scene" # fallback
470
 
471
  def _find_output_file(
orchestrator.py.new ADDED
@@ -0,0 +1,785 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NeuroAnim Orchestrator
3
+
4
+ This script coordinates the entire STEM animation generation pipeline:
5
+ 1. Concept Planning
6
+ 2. Code Generation
7
+ 3. Rendering
8
+ 4. Vision-based Analysis
9
+ 5. Audio Generation
10
+ 6. Final Merging
11
+
12
+ It uses the MCP servers (renderer and creative) to accomplish these tasks.
13
+ """
14
+
15
+ import ast
16
+ import asyncio
17
+ import json
18
+ import logging
19
+ import os
20
+ import tempfile
21
+ from pathlib import Path
22
+ from typing import Any, Dict, List, Optional
23
+
24
+ import aiofiles
25
+ from dotenv import load_dotenv
26
+ from mcp import ClientSession, StdioServerParameters
27
+ from mcp.client.stdio import stdio_client
28
+
29
+ from utils.tts import TTSGenerator
30
+
31
+ load_dotenv()
32
+ # Set up logging
33
+ logging.basicConfig(
34
+ level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
35
+ )
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class NeuroAnimOrchestrator:
40
+ """Main orchestrator for NeuroAnim pipeline."""
41
+
42
+ def __init__(
43
+ self, hf_api_key: Optional[str] = None, elevenlabs_api_key: Optional[str] = None
44
+ ):
45
+ self.hf_api_key = hf_api_key or os.getenv("HUGGINGFACE_API_KEY")
46
+ self.elevenlabs_api_key = elevenlabs_api_key or os.getenv("ELEVENLABS_API_KEY")
47
+ self.renderer_session: Optional[ClientSession] = None
48
+ self.creative_session: Optional[ClientSession] = None
49
+
50
+ # Initialize TTS generator
51
+ self.tts_generator = TTSGenerator(
52
+ elevenlabs_api_key=self.elevenlabs_api_key,
53
+ hf_api_key=self.hf_api_key,
54
+ fallback_enabled=True,
55
+ )
56
+
57
+ # Context managers for MCP client connections
58
+ self._renderer_cm = None
59
+ self._creative_cm = None
60
+ self._renderer_streams = None
61
+ self._creative_streams = None
62
+
63
+ # Working directories
64
+ self.work_dir: Optional[Path] = None
65
+ self.output_dir: Optional[Path] = None
66
+
67
+ async def initialize(self):
68
+ """Initialize MCP server connections."""
69
+ # Set up working directories
70
+ self.work_dir = Path(tempfile.mkdtemp(prefix="neuroanim_work_"))
71
+ self.output_dir = Path("outputs")
72
+ self.output_dir.mkdir(exist_ok=True)
73
+
74
+ logger.info(f"Working directory: {self.work_dir}")
75
+ logger.info(f"Output directory: {self.output_dir}")
76
+
77
+ # Initialize renderer server
78
+ # stdio_client is an async context manager, must use async with
79
+ renderer_params = StdioServerParameters(
80
+ command="python", args=["mcp_servers/renderer.py"]
81
+ )
82
+
83
+ self._renderer_cm = stdio_client(renderer_params)
84
+ self._renderer_streams = await self._renderer_cm.__aenter__()
85
+ read_stream, write_stream = self._renderer_streams
86
+ self.renderer_session = ClientSession(read_stream, write_stream)
87
+ # Start background receive loop for the client session
88
+ await self.renderer_session.__aenter__()
89
+ await self.renderer_session.initialize()
90
+ logger.info("Renderer MCP server connected")
91
+
92
+ # Initialize creative server
93
+ creative_params = StdioServerParameters(
94
+ command="python",
95
+ args=["mcp_servers/creative.py"],
96
+ env={"HUGGINGFACE_API_KEY": self.hf_api_key} if self.hf_api_key else None,
97
+ )
98
+
99
+ self._creative_cm = stdio_client(creative_params)
100
+ self._creative_streams = await self._creative_cm.__aenter__()
101
+ read_stream, write_stream = self._creative_streams
102
+ self.creative_session = ClientSession(read_stream, write_stream)
103
+ # Start background receive loop for the client session
104
+ await self.creative_session.__aenter__()
105
+ await self.creative_session.initialize()
106
+ logger.info("Creative MCP server connected")
107
+
108
+ async def cleanup(self):
109
+ """Clean up resources."""
110
+ import shutil
111
+
112
+ # Close sessions first
113
+ if self.renderer_session:
114
+ try:
115
+ await self.renderer_session.__aexit__(None, None, None)
116
+ except (Exception, asyncio.CancelledError) as e:
117
+ logger.debug(f"Error closing renderer session: {e}")
118
+
119
+ if self.creative_session:
120
+ try:
121
+ await self.creative_session.__aexit__(None, None, None)
122
+ except (Exception, asyncio.CancelledError) as e:
123
+ logger.debug(f"Error closing creative session: {e}")
124
+
125
+ # Then close the stdio_client context managers with timeout
126
+ if self._renderer_cm:
127
+ try:
128
+ async with asyncio.timeout(2): # 2 second timeout
129
+ await self._renderer_cm.__aexit__(None, None, None)
130
+ except (Exception, asyncio.CancelledError, TimeoutError) as e:
131
+ logger.debug(f"Error closing renderer context manager: {e}")
132
+
133
+ if self._creative_cm:
134
+ try:
135
+ async with asyncio.timeout(2): # 2 second timeout
136
+ await self._creative_cm.__aexit__(None, None, None)
137
+ except (Exception, asyncio.CancelledError, TimeoutError) as e:
138
+ logger.debug(f"Error closing creative context manager: {e}")
139
+
140
+ # Clean up working directory
141
+ if self.work_dir and self.work_dir.exists():
142
+ try:
143
+ shutil.rmtree(self.work_dir)
144
+ logger.info(f"Cleaned up working directory: {self.work_dir}")
145
+ except Exception as e:
146
+ logger.warning(f"Failed to clean up working directory: {e}")
147
+
148
+ async def call_tool(
149
+ self, session: ClientSession, tool_name: str, arguments: Dict[str, Any]
150
+ ) -> Dict[str, Any]:
151
+ """Call a tool on an MCP server."""
152
+ result = await session.call_tool(tool_name, arguments)
153
+
154
+ if hasattr(result, "content") and result.content:
155
+ content = result.content[0]
156
+ if hasattr(content, "text"):
157
+ return {
158
+ "text": content.text,
159
+ "isError": getattr(result, "isError", False),
160
+ }
161
+
162
+ return {"text": str(result), "isError": False}
163
+
164
+ async def generate_animation(
165
+ self,
166
+ topic: str,
167
+ target_audience: str = "general",
168
+ animation_length_minutes: float = 2.0,
169
+ output_filename: str = "animation.mp4",
170
+ quality: str = "medium",
171
+ progress_callback: Optional[callable] = None,
172
+ ) -> Dict[str, Any]:
173
+ """Complete animation generation pipeline."""
174
+
175
+ try:
176
+ logger.info(f"Starting animation generation for: {topic}")
177
+
178
+ # Step 1: Concept Planning
179
+ logger.info("Step 1: Planning concept...")
180
+ if progress_callback:
181
+ progress_callback("Planning concept", 0.1)
182
+ concept_result = await self.call_tool(
183
+ self.creative_session,
184
+ "plan_concept",
185
+ {
186
+ "topic": topic,
187
+ "target_audience": target_audience,
188
+ "animation_length_minutes": animation_length_minutes,
189
+ },
190
+ )
191
+
192
+ if concept_result["isError"]:
193
+ raise Exception(f"Concept planning failed: {concept_result['text']}")
194
+
195
+ concept_plan = concept_result["text"]
196
+ logger.info("Concept planning completed")
197
+
198
+ # Step 2: Generate Narration
199
+ logger.info("Step 2: Generating narration...")
200
+ if progress_callback:
201
+ progress_callback("Generating narration script", 0.25)
202
+ narration_result = await self.call_tool(
203
+ self.creative_session,
204
+ "generate_narration",
205
+ {
206
+ "concept": topic,
207
+ "scene_description": concept_plan,
208
+ "target_audience": target_audience,
209
+ "duration_seconds": int(animation_length_minutes * 60),
210
+ },
211
+ )
212
+
213
+ if narration_result["isError"]:
214
+ raise Exception(
215
+ f"Narration generation failed: {narration_result['text']}"
216
+ )
217
+
218
+ # Clean narration text - remove title/prefix before TTS
219
+ narration_text = self._clean_narration_text(narration_result["text"])
220
+ logger.info("Narration generation completed")
221
+ logger.info(f"Narration preview: {narration_text[:100]}...")
222
+
223
+ # Step 3: Generate Manim Code with retry logic
224
+ logger.info("Step 3: Generating Manim code...")
225
+ if progress_callback:
226
+ progress_callback("Creating Manim animation code", 0.40)
227
+ target_duration_seconds = int(animation_length_minutes * 60)
228
+ manim_code = await self._generate_and_validate_code(
229
+ topic=topic,
230
+ concept_plan=concept_plan,
231
+ duration_seconds=target_duration_seconds,
232
+ max_retries=3,
233
+ )
234
+ logger.info("Manim code generation completed and validated")
235
+
236
+ # Step 4: Write Manim File
237
+ logger.info("Step 4: Writing Manim file...")
238
+ manim_file = self.work_dir / "animation.py"
239
+ write_result = await self.call_tool(
240
+ self.renderer_session,
241
+ "write_manim_file",
242
+ {"filepath": str(manim_file), "code": manim_code},
243
+ )
244
+
245
+ if write_result["isError"]:
246
+ raise Exception(f"File writing failed: {write_result['text']}")
247
+
248
+ # Extract scene name from code
249
+ scene_name = self._extract_scene_name(manim_code)
250
+ logger.info(f"Scene name detected: {scene_name}")
251
+
252
+ # Step 5: Render Animation with retry on runtime errors
253
+ logger.info("Step 5: Rendering animation...")
254
+ if progress_callback:
255
+ progress_callback("Rendering animation video", 0.55)
256
+ max_render_retries = 5
257
+ video_file = None
258
+
259
+ for render_attempt in range(max_render_retries):
260
+ render_result = await self.call_tool(
261
+ self.renderer_session,
262
+ "render_manim_animation",
263
+ {
264
+ "scene_name": scene_name,
265
+ "file_path": str(manim_file),
266
+ "output_dir": str(self.work_dir),
267
+ "quality": quality, # Use the quality parameter
268
+ "format": "mp4",
269
+ "frame_rate": 30,
270
+ },
271
+ )
272
+
273
+ if not render_result["isError"]:
274
+ # Success! Find the rendered file
275
+ video_file = self._find_output_file(self.work_dir, scene_name, "mp4")
276
+ if video_file:
277
+ # Check video duration
278
+ try:
279
+ actual_duration = self._get_video_duration(video_file)
280
+ logger.info(f"Rendered video duration: {actual_duration:.2f}s (Target: {target_duration_seconds}s)")
281
+
282
+ if actual_duration < target_duration_seconds * 0.5:
283
+ logger.warning(f"Video is too short ({actual_duration:.2f}s < {target_duration_seconds * 0.5}s). Forcing retry...")
284
+ error_text = (
285
+ f"The generated animation was TOO SHORT ({actual_duration:.1f}s). "
286
+ f"The target duration is {target_duration_seconds}s. "
287
+ "You MUST make the animation longer by adding more `self.wait()` calls "
288
+ "and ensuring animations play slower (use run_time parameter)."
289
+ )
290
+ # Fall through to error handling logic below
291
+ else:
292
+ break
293
+ except Exception as e:
294
+ logger.warning(f"Could not verify video duration: {e}")
295
+ break
296
+ else:
297
+ logger.warning("Render succeeded but could not find output file")
298
+ if render_attempt < max_render_retries - 1:
299
+ continue
300
+
301
+ # Rendering failed - check if it's a runtime error we can fix
302
+ error_text = render_result["text"]
303
+ logger.warning(f"Render attempt {render_attempt + 1} failed: {error_text[:200]}...")
304
+
305
+ # Check if this is a Manim runtime error (not a "no scene" error)
306
+ if render_attempt < max_render_retries - 1 and (
307
+ "TypeError" in error_text
308
+ or "AttributeError" in error_text
309
+ or "ValueError" in error_text
310
+ or "KeyError" in error_text
311
+ ):
312
+ logger.info(f"Detected runtime error in Manim code. Regenerating code (attempt {render_attempt + 2}/{max_render_retries})...")
313
+
314
+ # Regenerate code with error feedback
315
+ runtime_error_msg = f"Runtime Error during Manim rendering:\n{error_text}\n\nPlease fix the code to be compatible with Manim version 0.19.0."
316
+ manim_code = await self._generate_and_validate_code(
317
+ topic=topic,
318
+ concept_plan=concept_plan,
319
+ duration_seconds=target_duration_seconds,
320
+ max_retries=3, # Allow retries for syntax errors during fix
321
+ previous_error=runtime_error_msg,
322
+ previous_code=manim_code,
323
+ )
324
+
325
+ # Write the new code
326
+ write_result = await self.call_tool(
327
+ self.renderer_session,
328
+ "write_manim_file",
329
+ {"filepath": str(manim_file), "code": manim_code},
330
+ )
331
+
332
+ if write_result["isError"]:
333
+ raise Exception(f"File writing failed: {write_result['text']}")
334
+
335
+ # Extract scene name from new code
336
+ scene_name = self._extract_scene_name(manim_code)
337
+ logger.info(f"Regenerated code with scene: {scene_name}")
338
+
339
+ # Loop will retry rendering with new code
340
+ continue
341
+ else:
342
+ # Not a runtime error or out of retries
343
+ raise Exception(f"Rendering failed: {error_text}")
344
+
345
+ if not video_file:
346
+ raise Exception("Could not find rendered video file after all attempts")
347
+
348
+ logger.info(f"Animation rendered: {video_file}")
349
+
350
+ # Step 6: Generate Speech Audio
351
+ logger.info("Step 6: Generating speech audio...")
352
+ if progress_callback:
353
+ progress_callback("Generating audio narration", 0.75)
354
+ audio_file = self.work_dir / "narration.mp3"
355
+
356
+ # Use TTS generator with automatic fallback
357
+ try:
358
+ tts_result = await self.tts_generator.generate_speech(
359
+ text=narration_text, output_path=audio_file, voice="rachel"
360
+ )
361
+ logger.info(
362
+ f"Audio generated with {tts_result['provider']}: {audio_file}"
363
+ )
364
+
365
+ # Validate audio file
366
+ validation = self.tts_generator.validate_audio_file(audio_file)
367
+ if not validation["valid"]:
368
+ logger.warning(
369
+ f"Audio validation warning: {validation.get('error', 'Unknown issue')}"
370
+ )
371
+ logger.info("Audio file may have issues but continuing...")
372
+ else:
373
+ logger.info(
374
+ f"Audio validated: {validation.get('duration', 'N/A')}s, {validation.get('size', 0)} bytes"
375
+ )
376
+
377
+ except Exception as e:
378
+ logger.error(f"TTS generation failed: {e}")
379
+ raise Exception(f"Speech generation failed: {str(e)}")
380
+
381
+ # Step 7: Merge Video and Audio
382
+ logger.info("Step 7: Merging video and audio...")
383
+ if progress_callback:
384
+ progress_callback("Merging video and audio", 0.90)
385
+ final_output = self.output_dir / output_filename
386
+ merge_result = await self.call_tool(
387
+ self.renderer_session,
388
+ "merge_video_audio",
389
+ {
390
+ "video_file": str(video_file),
391
+ "audio_file": str(audio_file),
392
+ "output_file": str(final_output),
393
+ },
394
+ )
395
+
396
+ if merge_result["isError"]:
397
+ raise Exception(f"Merging failed: {merge_result['text']}")
398
+
399
+ # Step 8: Generate Quiz
400
+ logger.info("Step 8: Generating quiz...")
401
+ if progress_callback:
402
+ progress_callback("Creating quiz questions", 0.95)
403
+ quiz_result = await self.call_tool(
404
+ self.creative_session,
405
+ "generate_quiz",
406
+ {
407
+ "concept": topic,
408
+ "difficulty": "medium",
409
+ "num_questions": 3,
410
+ "question_types": ["multiple_choice"],
411
+ },
412
+ )
413
+
414
+ quiz_content = (
415
+ quiz_result["text"]
416
+ if not quiz_result["isError"]
417
+ else "Quiz generation failed"
418
+ )
419
+
420
+ # Return results
421
+ results = {
422
+ "success": True,
423
+ "topic": topic,
424
+ "target_audience": target_audience,
425
+ "concept_plan": concept_plan,
426
+ "narration": narration_text,
427
+ "manim_code": manim_code,
428
+ "output_file": str(final_output),
429
+ "quiz": quiz_content,
430
+ "work_dir": str(self.work_dir),
431
+ }
432
+
433
+ logger.info(f"Animation generation completed successfully: {final_output}")
434
+ return results
435
+
436
+ except Exception as e:
437
+ logger.error(f"Animation generation failed: {str(e)}")
438
+ return {
439
+ "success": False,
440
+ "error": str(e),
441
+ "work_dir": str(self.work_dir) if self.work_dir else None,
442
+ }
443
+
444
+ def _clean_narration_text(self, text: str) -> str:
445
+ """
446
+ Clean narration text by removing title prefixes and formatting artifacts.
447
+
448
+ The creative server returns text with prefixes like "Narration Script:\n\n"
449
+ which should not be sent to TTS.
450
+ """
451
+ # Remove common prefixes
452
+ prefixes_to_remove = [
453
+ "Narration Script:",
454
+ "Script:",
455
+ "Narration:",
456
+ "Text:",
457
+ ]
458
+
459
+ cleaned = text.strip()
460
+
461
+ # Remove any of the prefixes (case-insensitive)
462
+ for prefix in prefixes_to_remove:
463
+ if cleaned.lower().startswith(prefix.lower()):
464
+ cleaned = cleaned[len(prefix) :].strip()
465
+ break
466
+
467
+ # Remove leading newlines and whitespace
468
+ cleaned = cleaned.lstrip("\n").strip()
469
+
470
+ # Remove any markdown code block markers
471
+ if cleaned.startswith("```"):
472
+ lines = cleaned.split("\n")
473
+ # Remove first line (opening ```)
474
+ if len(lines) > 1:
475
+ lines = lines[1:]
476
+ # Remove last line if it's closing ```
477
+ if lines and lines[-1].strip() == "```":
478
+ lines = lines[:-1]
479
+ cleaned = "\n".join(lines).strip()
480
+
481
+ return cleaned
482
+
483
+ def _extract_python_code(self, text: str) -> str:
484
+ """Extract Python code from markdown response."""
485
+ # Look for code blocks
486
+ if "```python" in text:
487
+ start = text.find("```python") + 9
488
+ end = text.find("```", start)
489
+ if end == -1:
490
+ end = len(text)
491
+ return text[start:end].strip()
492
+ elif "```" in text:
493
+ start = text.find("```") + 3
494
+ end = text.find("```", start)
495
+ if end == -1:
496
+ end = len(text)
497
+ return text[start:end].strip()
498
+ else:
499
+ return text.strip()
500
+
501
+ async def _generate_and_validate_code(
502
+ self,
503
+ topic: str,
504
+ concept_plan: str,
505
+ duration_seconds: int = 60,
506
+ max_retries: int = 3,
507
+ previous_error: Optional[str] = None,
508
+ previous_code: Optional[str] = None,
509
+ ) -> str:
510
+ """Generate Manim code with retry logic for syntax errors."""
511
+ for attempt in range(max_retries):
512
+ try:
513
+ logger.info(f"Code generation attempt {attempt + 1}/{max_retries}")
514
+
515
+ # Build arguments for code generation
516
+ arguments = {
517
+ "concept": topic,
518
+ "scene_description": concept_plan,
519
+ "visual_elements": ["text", "shapes", "animations"],
520
+ "duration_seconds": duration_seconds,
521
+ }
522
+
523
+ # If this is a retry, include error feedback
524
+ if previous_error:
525
+ if previous_code:
526
+ arguments["previous_code"] = previous_code
527
+ arguments["error_message"] = previous_error
528
+ logger.info(
529
+ f"Retrying with error feedback: {previous_error[:100]}..."
530
+ )
531
+
532
+ # Generate code
533
+ code_result = await self.call_tool(
534
+ self.creative_session, "generate_manim_code", arguments
535
+ )
536
+
537
+ if code_result["isError"]:
538
+ if attempt < max_retries - 1:
539
+ logger.warning(
540
+ f"Code generation failed, retrying: {code_result['text']}"
541
+ )
542
+ previous_error = code_result["text"]
543
+ # Keep previous_code if we had it, for better context in retry
544
+ continue
545
+ else:
546
+ raise Exception(
547
+ f"Code generation failed: {code_result['text']}"
548
+ )
549
+
550
+ # Extract Python code from response
551
+ manim_code = self._extract_python_code(code_result["text"])
552
+
553
+ # Validate Python syntax
554
+ syntax_errors = self._validate_python_syntax(manim_code)
555
+ if syntax_errors:
556
+ if attempt < max_retries - 1:
557
+ logger.warning(
558
+ f"Syntax error detected, retrying: {syntax_errors}"
559
+ )
560
+ previous_error = f"Syntax Error:\n{syntax_errors}"
561
+ previous_code = manim_code
562
+ continue
563
+ else:
564
+ raise Exception(
565
+ f"Generated code has syntax errors after {max_retries} attempts:\n{syntax_errors}"
566
+ )
567
+
568
+ # Validate that code contains a Scene class
569
+ has_scene = self._validate_has_scene_class(manim_code)
570
+ if not has_scene:
571
+ if attempt < max_retries - 1:
572
+ logger.warning(
573
+ "No Scene class found in generated code, retrying..."
574
+ )
575
+ previous_error = (
576
+ "Error: The generated code does not contain any Scene class. "
577
+ "Please ensure you create a class that inherits from manim.Scene, "
578
+ "manim.MovingCameraScene, or manim.ThreeDScene."
579
+ )
580
+ previous_code = manim_code
581
+ continue
582
+ else:
583
+ raise Exception(
584
+ f"Generated code does not contain a Scene class after {max_retries} attempts"
585
+ )
586
+
587
+ # Success!
588
+ logger.info(f"Valid code generated on attempt {attempt + 1}")
589
+ return manim_code
590
+
591
+ except Exception as e:
592
+ if attempt < max_retries - 1:
593
+ logger.warning(f"Attempt {attempt + 1} failed: {str(e)}")
594
+ previous_error = str(e)
595
+ continue
596
+ else:
597
+ raise
598
+
599
+ raise Exception("Failed to generate valid code after all retries")
600
+
601
+ def _validate_python_syntax(self, code: str) -> Optional[str]:
602
+ """Validate Python code syntax. Returns error message if invalid, None if valid."""
603
+ try:
604
+ ast.parse(code)
605
+ return None
606
+ except SyntaxError as e:
607
+ # Build detailed error message with context
608
+ error_msg = f"Line {e.lineno}: {e.msg}"
609
+
610
+ # Show surrounding context (3 lines before and after)
611
+ if e.lineno is not None:
612
+ code_lines = code.split("\n")
613
+ start_line = max(0, e.lineno - 4) # 3 lines before
614
+ end_line = min(len(code_lines), e.lineno + 2) # 2 lines after
615
+
616
+ error_msg += "\n\nContext:"
617
+ for i in range(start_line, end_line):
618
+ line_num = i + 1
619
+ prefix = ">>> " if line_num == e.lineno else " "
620
+ error_msg += f"\n{prefix}{line_num:3d} | {code_lines[i]}"
621
+
622
+ # Add pointer for error line
623
+ if line_num == e.lineno and e.offset:
624
+ error_msg += f"\n {' ' * 4}{' ' * (e.offset - 1)}^"
625
+
626
+ return error_msg
627
+ except Exception as e:
628
+ return f"Unexpected error during syntax validation: {str(e)}"
629
+
630
+ def _validate_has_scene_class(self, code: str) -> bool:
631
+ """Check if code contains at least one Scene class."""
632
+ import re
633
+
634
+ # Check for Scene class inheritance
635
+ scene_patterns = [
636
+ r"class\s+\w+\s*\(\s*Scene\s*\)",
637
+ r"class\s+\w+\s*\(\s*MovingCameraScene\s*\)",
638
+ r"class\s+\w+\s*\(\s*ThreeDScene\s*\)",
639
+ r"class\s+\w+\s*\(\s*\w*Scene\s*\)",
640
+ ]
641
+
642
+ for pattern in scene_patterns:
643
+ if re.search(pattern, code):
644
+ return True
645
+
646
+ # Also check using AST parsing as a backup
647
+ try:
648
+ tree = ast.parse(code)
649
+ for node in ast.walk(tree):
650
+ if isinstance(node, ast.ClassDef):
651
+ # Check if any base class contains "Scene"
652
+ for base in node.bases:
653
+ if isinstance(base, ast.Name) and "Scene" in base.id:
654
+ return True
655
+ except Exception:
656
+ pass
657
+
658
+ return False
659
+
660
+ def _extract_scene_name(self, code: str) -> str:
661
+ """Extract scene class name from Manim code."""
662
+ import re
663
+
664
+ # Try multiple patterns to find Scene class
665
+ patterns = [
666
+ r"class\s+(\w+)\s*\(\s*Scene\s*\)", # class Name(Scene)
667
+ r"class\s+(\w+)\s*\(\s*MovingCameraScene\s*\)", # class Name(MovingCameraScene)
668
+ r"class\s+(\w+)\s*\(\s*ThreeDScene\s*\)", # class Name(ThreeDScene)
669
+ r"class\s+(\w+)\s*\(\s*\w*Scene\s*\)", # class Name(AnyScene)
670
+ ]
671
+
672
+ for pattern in patterns:
673
+ match = re.search(pattern, code)
674
+ if match:
675
+ scene_name = match.group(1)
676
+ logger.info(f"Found scene class: {scene_name}")
677
+ return scene_name
678
+
679
+ # If no scene found, look for any class definition and warn
680
+ any_class = re.search(r"class\s+(\w+)\s*\(", code)
681
+ if any_class:
682
+ class_name = any_class.group(1)
683
+ logger.warning(
684
+ f"Could not find Scene class, using first class found: {class_name}"
685
+ )
686
+ return class_name
687
+
688
+ # Last resort - parse the AST to find classes
689
+ try:
690
+ tree = ast.parse(code)
691
+ for node in ast.walk(tree):
692
+ if isinstance(node, ast.ClassDef):
693
+ logger.warning(
694
+ f"Using first class from AST parsing: {node.name}"
695
+ )
696
+ return node.name
697
+ except Exception as e:
698
+ logger.error(f"Failed to parse code AST: {e}")
699
+
700
+ # Absolute fallback
701
+ logger.error("No scene class found in code! This will likely cause rendering to fail.")
702
+ return "Scene" # fallback
703
+
704
+ def _find_output_file(
705
+ self, directory: Path, scene_name: str, extension: str
706
+ ) -> Optional[Path]:
707
+ """Find output file with given scene name and extension."""
708
+ for file in directory.glob(f"{scene_name}*.{extension}"):
709
+ return file
710
+ return None
711
+
712
+
713
+ async def main():
714
+ """Main function for running the orchestrator."""
715
+ import argparse
716
+
717
+ parser = argparse.ArgumentParser(description="NeuroAnim STEM Animation Generator")
718
+ parser.add_argument("topic", help="STEM topic for the animation")
719
+ parser.add_argument(
720
+ "--audience",
721
+ choices=["elementary", "middle_school", "high_school", "college", "general"],
722
+ default="general",
723
+ help="Target audience",
724
+ )
725
+ parser.add_argument(
726
+ "--duration", type=float, default=2.0, help="Animation duration in minutes"
727
+ )
728
+ parser.add_argument("--output", default="animation.mp4", help="Output filename")
729
+ parser.add_argument(
730
+ "--api-key", help="Hugging Face API key (or set HUGGINGFACE_API_KEY env var)"
731
+ )
732
+ parser.add_argument(
733
+ "--elevenlabs-key",
734
+ help="ElevenLabs API key (or set ELEVENLABS_API_KEY env var)",
735
+ )
736
+
737
+ args = parser.parse_args()
738
+
739
+ # Initialize and run orchestrator
740
+ orchestrator = NeuroAnimOrchestrator(
741
+ hf_api_key=args.api_key, elevenlabs_api_key=args.elevenlabs_key
742
+ )
743
+
744
+ try:
745
+ await orchestrator.initialize()
746
+
747
+ results = await orchestrator.generate_animation(
748
+ topic=args.topic,
749
+ target_audience=args.audience,
750
+ animation_length_minutes=args.duration,
751
+ output_filename=args.output,
752
+ )
753
+
754
+ if results["success"]:
755
+ print("\n🎉 Animation Generated Successfully!")
756
+ print(f"📹 Output file: {results['output_file']}")
757
+ print(f"🎯 Topic: {results['topic']}")
758
+ print(f"👥 Audience: {results['target_audience']}")
759
+ print(f"\n📝 Concept Plan:")
760
+ print(
761
+ results["concept_plan"][:500] + "..."
762
+ if len(results["concept_plan"]) > 500
763
+ else results["concept_plan"]
764
+ )
765
+ print(f"\n🎭 Narration:")
766
+ print(
767
+ results["narration"][:300] + "..."
768
+ if len(results["narration"]) > 300
769
+ else results["narration"]
770
+ )
771
+ print(f"\n📚 Quiz Questions:")
772
+ print(results["quiz"])
773
+ else:
774
+ print(f"\n❌ Animation Generation Failed: {results['error']}")
775
+
776
+ except KeyboardInterrupt:
777
+ print("\n⚠️ Process interrupted by user")
778
+ except Exception as e:
779
+ print(f"\n💥 Unexpected error: {str(e)}")
780
+ finally:
781
+ await orchestrator.cleanup()
782
+
783
+
784
+ if __name__ == "__main__":
785
+ asyncio.run(main())