|
|
from flask import Flask, request, jsonify, send_file |
|
|
from flask_cors import CORS |
|
|
import os |
|
|
import subprocess |
|
|
import tempfile |
|
|
import shutil |
|
|
from datetime import datetime |
|
|
import traceback |
|
|
import json |
|
|
|
|
|
app = Flask(__name__) |
|
|
CORS(app) |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
API_KEY = "rkmentormindzofficaltokenkey12345" |
|
|
|
|
|
import textwrap |
|
|
from manim import * |
|
|
|
|
|
def make_wrapped_paragraph(content, max_width, color, font, font_size, line_spacing, align_left=True): |
|
|
""" |
|
|
Build a vertically stacked group of Text lines that together form a paragraph. |
|
|
It splits content into lines that fit within max_width by measuring rendered width. |
|
|
Each line is a separate Text object joined into a VGroup and arranged downward. |
|
|
""" |
|
|
words = content.split() |
|
|
lines = [] |
|
|
current = "" |
|
|
|
|
|
|
|
|
temp = Text("", color=color, font=font, font_size=font_size) |
|
|
|
|
|
for w in words: |
|
|
test = w if not current else current + " " + w |
|
|
test_obj = Text(test, color=color, font=font, font_size=font_size) |
|
|
if test_obj.width <= max_width: |
|
|
current = test |
|
|
else: |
|
|
|
|
|
line = Text(current, color=color, font=font, font_size=font_size) |
|
|
lines.append(line) |
|
|
current = w |
|
|
if current: |
|
|
lines.append(Text(current, color=color, font=font, font_size=font_size)) |
|
|
|
|
|
if not lines: |
|
|
return VGroup() |
|
|
|
|
|
para = VGroup(*lines) |
|
|
|
|
|
para.arrange(DOWN, buff=line_spacing) |
|
|
if align_left: |
|
|
para = para.align_to(LEFT) |
|
|
return para.strip() |
|
|
|
|
|
def create_manim_script(problem_data, script_path): |
|
|
"""Generate Manim script from problem data with robust wrapping for title, text, and equations.""" |
|
|
|
|
|
|
|
|
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, |
|
|
"wrap_width": 12.0 |
|
|
}) |
|
|
|
|
|
slides = problem_data.get("slides", []) |
|
|
if not slides: |
|
|
raise ValueError("No slides provided in input data") |
|
|
|
|
|
slides_repr = repr(slides) |
|
|
|
|
|
|
|
|
wrap_width = float(settings.get("wrap_width", 12.0)) |
|
|
|
|
|
manim_code = f''' |
|
|
from manim import * |
|
|
import textwrap |
|
|
class GeneratedMathScene(Scene): |
|
|
def construct(self): |
|
|
# Scene 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)} |
|
|
wrap_width = {wrap_width} |
|
|
|
|
|
# Helper to wrap text into lines that fit within max width |
|
|
def make_wrapped_paragraph(content, color, font, font_size, line_spacing=0.2): |
|
|
lines = [] |
|
|
words = content.split() |
|
|
current = "" |
|
|
for w in words: |
|
|
test = w if not current else current + " " + w |
|
|
test_obj = Text(test, color=color, font=font, font_size=font_size) |
|
|
if test_obj.width <= wrap_width * 0.9: # a bit of padding |
|
|
current = test |
|
|
else: |
|
|
lines.append(Text(current, color=color, font=font, font_size=font_size)) |
|
|
current = w |
|
|
if current: |
|
|
lines.append(Text(current, color=color, font=font, font_size=font_size)) |
|
|
if not lines: |
|
|
return VGroup() |
|
|
para = VGroup(*lines).arrange(DOWN, buff=line_spacing) |
|
|
return para |
|
|
class GeneratedMathSceneInner(Scene): |
|
|
pass |
|
|
content_group = VGroup() |
|
|
current_y = 3.0 |
|
|
line_spacing = 0.8 |
|
|
slides = {slides_repr} |
|
|
|
|
|
# Build each slide |
|
|
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") |
|
|
|
|
|
if slide_type == "title": |
|
|
# Wrap title text |
|
|
title_text = content |
|
|
# Use paragraph wrapping to keep multi-line titles readable |
|
|
lines = [] |
|
|
if title_text: |
|
|
lines = [] |
|
|
# Reuse make_wrapped_paragraph by simulating a single paragraph |
|
|
lines_group = make_wrapped_paragraph(title_text, highlight_color, default_font, title_size, line_spacing=0.2) |
|
|
obj = lines_group if len(lines_group) > 0 else Text(title_text, color=highlight_color, font=default_font, font_size=title_size) |
|
|
else: |
|
|
obj = Text("", color=highlight_color, font=default_font, font_size=title_size) |
|
|
if obj.width > wrap_width: |
|
|
obj.scale_to_fit_width(wrap_width) |
|
|
|
|
|
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 |
|
|
|
|
|
elif slide_type == "text": |
|
|
# Use wrapping for normal text |
|
|
obj = make_wrapped_paragraph(content, default_color, default_font, text_size, line_spacing=0.25) |
|
|
|
|
|
elif slide_type == "equation": |
|
|
# Wrap long equations by splitting content into lines if needed |
|
|
# Heuristic: if content is too wide, create a multi-line TeX using \\ line breaks |
|
|
eq_content = content |
|
|
# Optional: insert line breaks at common math breakpoints if needed |
|
|
test = MathTex(eq_content, color=default_color, font_size=equation_size) |
|
|
if test.width > wrap_width: |
|
|
# naive wrap: insert line breaks at spaces near the middle |
|
|
parts = eq_content.split(" ") |
|
|
mid = len(parts)//2 |
|
|
line1 = " ".join(parts[:mid]) |
|
|
line2 = " ".join(parts[mid:]) |
|
|
wrapped_eq = f"{{line1}} \\\\\\\\ {{line2}}" |
|
|
obj = MathTex(wrapped_eq, color=default_color, font_size=equation_size) |
|
|
else: |
|
|
obj = MathTex(eq_content, color=default_color, font_size=equation_size) |
|
|
|
|
|
if obj.width > wrap_width: |
|
|
obj.scale_to_fit_width(wrap_width) |
|
|
|
|
|
if obj: |
|
|
# Position and animate |
|
|
obj.to_edge(LEFT, buff=0.3) |
|
|
obj.shift(UP * (current_y - obj.height/2)) |
|
|
|
|
|
obj_bottom = obj.get_bottom()[1] |
|
|
if obj_bottom < -3.5: |
|
|
scroll_amount = abs(obj_bottom - (-3.5)) + 0.3 |
|
|
self.play(content_group.animate.shift(UP * scroll_amount), run_time=0.5) |
|
|
current_y += scroll_amount |
|
|
obj.shift(UP * scroll_amount) |
|
|
obj.to_edge(LEFT, buff=0.3) |
|
|
|
|
|
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) |
|
|
# Decrease y for next item |
|
|
current_y -= (getattr(obj, "height", 0) + line_spacing) |
|
|
self.wait(0.3) |
|
|
|
|
|
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', encoding='utf-8') as f: |
|
|
f.write(manim_code) |
|
|
|
|
|
print(f"Generated script preview (first 500 chars):{manim_code[:500]}...") |
|
|
|
|
|
@app.route("/") |
|
|
def home(): |
|
|
return "Flask Manim Video Generator is Running" |
|
|
|
|
|
@app.route("/generate", methods=["POST", "OPTIONS"]) |
|
|
def generate_video(): |
|
|
|
|
|
if request.method == "OPTIONS": |
|
|
return '', 204 |
|
|
|
|
|
try: |
|
|
|
|
|
api_key = request.headers.get('X-API-KEY') |
|
|
if api_key and api_key != API_KEY: |
|
|
return jsonify({"error": "Invalid API key"}), 401 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
raw_body = request.data.decode("utf-8").strip() |
|
|
data = None |
|
|
|
|
|
if not raw_body: |
|
|
return jsonify({"error": "No input data provided"}), 400 |
|
|
|
|
|
|
|
|
if raw_body.startswith("{") or raw_body.startswith("["): |
|
|
|
|
|
try: |
|
|
data = json.loads(raw_body) |
|
|
print("✅ Detected valid JSON input.") |
|
|
except json.JSONDecodeError: |
|
|
|
|
|
data = None |
|
|
|
|
|
if data is None: |
|
|
print("⚙️ Detected raw string input (non-JSON). Parsing manually...") |
|
|
|
|
|
|
|
|
parts = raw_body.split("&&&") |
|
|
slides_part = parts[0].strip() |
|
|
extra_info = parts[1].strip() if len(parts) > 1 else "" |
|
|
|
|
|
try: |
|
|
slides = json.loads(slides_part) |
|
|
except json.JSONDecodeError as e: |
|
|
return jsonify({ |
|
|
"error": "Failed to parse slides JSON from string input", |
|
|
"details": str(e), |
|
|
"raw_snippet": slides_part[:200] |
|
|
}), 400 |
|
|
|
|
|
|
|
|
slides_json = [] |
|
|
for s in slides: |
|
|
if len(s) >= 4: |
|
|
slide_type, content, animation, duration = s |
|
|
slides_json.append({ |
|
|
"type": slide_type, |
|
|
"content": content, |
|
|
"animation": animation, |
|
|
"duration": duration |
|
|
}) |
|
|
|
|
|
data = { |
|
|
"slides": slides_json, |
|
|
"language": "Tamil" if "Tamil" in extra_info else "English", |
|
|
"explanation": extra_info, |
|
|
"video_settings": { |
|
|
"background_color": "#0f0f23", |
|
|
"text_color": "WHITE", |
|
|
"highlight_color": "YELLOW", |
|
|
"font": "CMU Serif" |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if "slides" not in data or not data["slides"]: |
|
|
return jsonify({"error": "No slides provided in request"}), 400 |
|
|
|
|
|
print(f"✅ Parsed {len(data['slides'])} slides successfully.") |
|
|
|
|
|
|
|
|
if "slides" not in data or not data["slides"]: |
|
|
return jsonify({"error": "No slides provided in request"}), 400 |
|
|
|
|
|
print(f"Received request with {len(data['slides'])} slides") |
|
|
|
|
|
|
|
|
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(data, script_path) |
|
|
print(f"Created Manim script at {script_path}") |
|
|
|
|
|
|
|
|
quality = request.args.get('quality', 'l') |
|
|
render_command = [ |
|
|
"manim", |
|
|
f"-q{quality}", |
|
|
"--disable_caching", |
|
|
"--media_dir", temp_work_dir, |
|
|
script_path, |
|
|
"GeneratedMathScene" |
|
|
] |
|
|
|
|
|
print(f"Running command: {' '.join(render_command)}") |
|
|
|
|
|
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 |
|
|
print(f"Manim rendering failed: {error_msg}") |
|
|
return jsonify({ |
|
|
"error": "Manim rendering failed", |
|
|
"details": error_msg |
|
|
}), 500 |
|
|
|
|
|
print("Manim rendering completed successfully") |
|
|
|
|
|
|
|
|
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): |
|
|
print(f"Video not found at expected path: {video_path}") |
|
|
return jsonify({ |
|
|
"error": "Video file not found after rendering", |
|
|
"expected_path": video_path |
|
|
}), 500 |
|
|
|
|
|
print(f"Video found at: {video_path}") |
|
|
|
|
|
|
|
|
output_filename = f"math_video_{timestamp}.mp4" |
|
|
output_path = os.path.join(MEDIA_DIR, output_filename) |
|
|
shutil.copy(video_path, output_path) |
|
|
print(f"Video copied to: {output_path}") |
|
|
|
|
|
|
|
|
try: |
|
|
shutil.rmtree(temp_work_dir) |
|
|
print("Cleaned up temp directory") |
|
|
except Exception as e: |
|
|
print(f"Failed to clean temp dir: {e}") |
|
|
|
|
|
|
|
|
return send_file( |
|
|
output_path, |
|
|
mimetype='video/mp4', |
|
|
as_attachment=False, |
|
|
download_name=output_filename |
|
|
) |
|
|
|
|
|
except subprocess.TimeoutExpired: |
|
|
print("Video rendering timeout") |
|
|
return jsonify({"error": "Video rendering timeout (120s)"}), 504 |
|
|
except Exception as e: |
|
|
print(f"Error: {str(e)}") |
|
|
traceback.print_exc() |
|
|
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) |