bhaveshgoel07 commited on
Commit
06c6f39
·
1 Parent(s): dd833dc

Fix: Replace old files with correct latest versions and remove .new backups

Browse files
Files changed (3) hide show
  1. app.py.new +0 -661
  2. orchestrator.py +276 -43
  3. orchestrator.py.new +0 -785
app.py.new DELETED
@@ -1,661 +0,0 @@
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,6 +167,8 @@ class NeuroAnimOrchestrator:
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,6 +177,8 @@ class NeuroAnimOrchestrator:
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,6 +197,8 @@ class NeuroAnimOrchestrator:
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,13 +215,21 @@ class NeuroAnimOrchestrator:
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,33 +249,108 @@ class NeuroAnimOrchestrator:
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,6 +380,8 @@ class NeuroAnimOrchestrator:
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,6 +398,8 @@ class NeuroAnimOrchestrator:
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,28 +441,68 @@ class NeuroAnimOrchestrator:
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,11 +517,13 @@ class NeuroAnimOrchestrator:
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,6 +540,7 @@ class NeuroAnimOrchestrator:
405
  f"Code generation failed, retrying: {code_result['text']}"
406
  )
407
  previous_error = code_result["text"]
 
408
  continue
409
  else:
410
  raise Exception(
@@ -429,6 +565,25 @@ class NeuroAnimOrchestrator:
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,23 +604,101 @@ class NeuroAnimOrchestrator:
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(
 
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
 
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
 
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
  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
  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
 
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
 
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
  "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
  "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
  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
  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
  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(
orchestrator.py.new DELETED
@@ -1,785 +0,0 @@
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())