| from flask import Flask, request, jsonify, send_file |
| from werkzeug.utils import secure_filename |
| import os |
| import subprocess |
| import tempfile |
| import shutil |
| from datetime import datetime |
| import traceback |
| import json |
|
|
| app = Flask(__name__) |
|
|
| |
| BASE_DIR = "/app" |
| MEDIA_DIR = os.path.join(BASE_DIR, "media") |
| TEMP_DIR = os.path.join(BASE_DIR, "temp") |
| os.makedirs(MEDIA_DIR, exist_ok=True) |
| os.makedirs(TEMP_DIR, exist_ok=True) |
|
|
| def create_manim_script(problem_data, script_path): |
| """Generate Manim script from problem data""" |
| |
| |
| settings = problem_data.get("video_settings", { |
| "background_color": "#0f0f23", |
| "text_color": "WHITE", |
| "highlight_color": "YELLOW", |
| "font": "CMU Serif", |
| "text_size": 36, |
| "equation_size": 42, |
| "title_size": 48 |
| }) |
| |
| slides = problem_data.get("slides", []) |
| |
| if not slides: |
| raise ValueError("No slides provided in input data") |
| |
| |
| manim_code = f''' |
| from manim import * |
| |
| class GeneratedMathScene(Scene): |
| def construct(self): |
| # Settings |
| self.camera.background_color = "{settings.get('background_color', '#0f0f23')}" |
| default_color = {settings.get('text_color', 'WHITE')} |
| highlight_color = {settings.get('highlight_color', 'YELLOW')} |
| default_font = "{settings.get('font', 'CMU Serif')}" |
| text_size = {settings.get('text_size', 36)} |
| equation_size = {settings.get('equation_size', 42)} |
| title_size = {settings.get('title_size', 48)} |
| |
| # Content group for scrolling |
| content_group = VGroup() |
| current_y = 3.0 |
| line_spacing = 0.8 |
| screen_bottom = -3.5 |
| |
| # Process slides |
| slides = {json.dumps(slides)} |
| |
| for idx, slide in enumerate(slides): |
| obj = None |
| content = slide.get("content", "") |
| animation = slide.get("animation", "write_left") |
| duration = slide.get("duration", 1.0) |
| slide_type = slide.get("type", "text") |
| |
| # Create title (centered) |
| if slide_type == "title": |
| obj = Text(content, color=highlight_color, font=default_font, font_size=title_size) |
| obj.move_to(ORIGIN) |
| self.play(FadeIn(obj), run_time=duration * 0.8) |
| self.wait(duration * 0.3) |
| self.play(FadeOut(obj), run_time=duration * 0.3) |
| continue |
| |
| # Create text |
| elif slide_type == "text": |
| obj = Text(content, color=default_color, font=default_font, font_size=text_size) |
| |
| # Create equation |
| elif slide_type == "equation": |
| obj = MathTex(content, color=default_color, font_size=equation_size) |
| |
| if obj: |
| # Position at left edge |
| obj.to_edge(LEFT, buff=0.3) |
| obj.shift(UP * (current_y - obj.height/2)) |
| |
| # Check if scrolling needed |
| obj_bottom = obj.get_bottom()[1] |
| if obj_bottom < screen_bottom: |
| scroll_amount = abs(obj_bottom - screen_bottom) + 0.3 |
| self.play(content_group.animate.shift(UP * scroll_amount), run_time=0.5) |
| current_y += scroll_amount |
| obj.shift(UP * scroll_amount) |
| |
| # Animations |
| if animation == "write_left": |
| self.play(Write(obj), run_time=duration) |
| elif animation == "fade_in": |
| self.play(FadeIn(obj), run_time=duration) |
| elif animation == "highlight_left": |
| self.play(Write(obj), run_time=duration * 0.6) |
| self.play(obj.animate.set_color(highlight_color), run_time=duration * 0.4) |
| else: |
| self.play(Write(obj), run_time=duration) |
| |
| content_group.add(obj) |
| current_y -= (obj.height + line_spacing) |
| self.wait(0.3) |
| |
| # Highlight final answer |
| if len(content_group) > 0: |
| final_box = SurroundingRectangle(content_group[-1], color=highlight_color, buff=0.2) |
| self.play(Create(final_box), run_time=0.8) |
| self.wait(1.5) |
| ''' |
| |
| with open(script_path, 'w') as f: |
| f.write(manim_code) |
|
|
| @app.route('/', methods=['GET']) |
| def home(): |
| """Health check endpoint""" |
| return jsonify({ |
| "status": "running", |
| "message": "Manim Video Generation API", |
| "endpoints": { |
| "/generate": "POST - Generate video from JSON", |
| "/health": "GET - Health check" |
| } |
| }) |
|
|
| @app.route('/health', methods=['GET']) |
| def health(): |
| """Health check""" |
| return jsonify({"status": "healthy"}) |
|
|
| @app.route('/generate', methods=['POST']) |
| def generate_video(): |
| """ |
| Generate Manim video from JSON input |
| |
| Expected JSON format: |
| { |
| "video_settings": { |
| "background_color": "#0f0f23", |
| "text_color": "WHITE", |
| "highlight_color": "YELLOW", |
| "font": "CMU Serif", |
| "text_size": 36, |
| "equation_size": 42, |
| "title_size": 48 |
| }, |
| "slides": [ |
| { |
| "type": "title", |
| "content": "Your Title", |
| "animation": "fade_in", |
| "duration": 1.0 |
| }, |
| { |
| "type": "text", |
| "content": "Your text content", |
| "animation": "write_left", |
| "duration": 0.8 |
| }, |
| { |
| "type": "equation", |
| "content": "x = \\\\frac{-b \\\\pm \\\\sqrt{b^2 - 4ac}}{2a}", |
| "animation": "write_left", |
| "duration": 1.2 |
| } |
| ] |
| } |
| """ |
| try: |
| |
| if not request.json: |
| return jsonify({"error": "No JSON data provided"}), 400 |
| |
| problem_data = request.json |
| |
| |
| if "slides" not in problem_data or not problem_data["slides"]: |
| return jsonify({"error": "No slides provided in request"}), 400 |
| |
| |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| temp_work_dir = os.path.join(TEMP_DIR, f"manim_{timestamp}") |
| os.makedirs(temp_work_dir, exist_ok=True) |
| |
| |
| script_path = os.path.join(temp_work_dir, "scene.py") |
| create_manim_script(problem_data, script_path) |
| |
| |
| quality = request.args.get('quality', 'l') |
| render_command = [ |
| "manim", |
| f"-q{quality}", |
| "--disable_caching", |
| "--media_dir", temp_work_dir, |
| script_path, |
| "GeneratedMathScene" |
| ] |
| |
| result = subprocess.run( |
| render_command, |
| capture_output=True, |
| text=True, |
| cwd=temp_work_dir, |
| timeout=120 |
| ) |
| |
| if result.returncode != 0: |
| error_msg = result.stderr or result.stdout |
| return jsonify({ |
| "error": "Manim rendering failed", |
| "details": error_msg |
| }), 500 |
| |
| |
| quality_map = {'l': '480p15', 'm': '720p30', 'h': '1080p60'} |
| video_quality = quality_map.get(quality, '480p15') |
| |
| video_path = os.path.join( |
| temp_work_dir, |
| "videos", |
| "scene", |
| video_quality, |
| "GeneratedMathScene.mp4" |
| ) |
| |
| if not os.path.exists(video_path): |
| return jsonify({ |
| "error": "Video file not found after rendering", |
| "expected_path": video_path |
| }), 500 |
| |
| |
| output_filename = f"math_video_{timestamp}.mp4" |
| output_path = os.path.join(MEDIA_DIR, output_filename) |
| shutil.copy(video_path, output_path) |
| |
| |
| try: |
| shutil.rmtree(temp_work_dir) |
| except: |
| pass |
| |
| |
| return send_file( |
| output_path, |
| mimetype='video/mp4', |
| as_attachment=True, |
| download_name=output_filename |
| ) |
| |
| except subprocess.TimeoutExpired: |
| return jsonify({"error": "Video rendering timeout (120s)"}), 504 |
| except Exception as e: |
| return jsonify({ |
| "error": str(e), |
| "traceback": traceback.format_exc() |
| }), 500 |
|
|
| if __name__ == '__main__': |
| |
| port = int(os.environ.get('PORT', 7860)) |
| app.run(host='0.0.0.0', port=port, debug=False) |