devendergarg14 commited on
Commit
3f1ca1c
·
verified ·
1 Parent(s): 39a9065

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +179 -70
app.py CHANGED
@@ -11,36 +11,65 @@ import glob
11
  # ---------------------------------------------------------
12
 
13
  def modify_animation_times(code: str, factor: float = 1.0, for_precheck: bool = False) -> str:
 
 
 
 
 
14
  if for_precheck:
 
15
  code = re.sub(r"self\.wait\s*\([^)]*\)", "self.wait(0)", code)
16
  code = re.sub(r"run_time\s*=\s*[^,)]+", "run_time=0.01", code)
17
  return code
 
 
18
  MIN_RUN_TIME = 0.1
 
19
  def scale_match(m, is_wait):
20
  try:
21
  val = float(m.group(2))
22
  new_val = val * factor
23
  final_val = new_val if is_wait else max(new_val, MIN_RUN_TIME)
24
  return f"{m.group(1)}{final_val:.3f}"
25
- except ValueError: return m.group(0)
 
 
26
  code = re.sub(r"(run_time\s*=\s*)(\d+\.?\d*)", lambda m: scale_match(m, False), code)
27
  code = re.sub(r"(self\.wait\s*\(\s*)(\d+\.?\d*)", lambda m: scale_match(m, True), code)
28
  return code
29
 
30
  def run_manim_pre_check(code_str: str) -> (bool, str):
 
 
 
 
 
 
 
31
  fast_code = modify_animation_times(code_str, for_precheck=True)
32
- with open("scene_pre_check.py", "w", encoding="utf-8") as f: f.write(fast_code)
 
 
 
33
  cmd = ["manim", "-ql", "--progress_bar", "none", "--disable_caching", "scene_pre_check.py", "GenScene", "-s", "-o", "pre_check_output"]
 
34
  try:
35
  process = subprocess.run(cmd, capture_output=True, timeout=30, check=False)
36
- if process.returncode == 0: return True, "Pre-check successful."
37
- else: return False, f"⚠️ ERROR: Pre-check failed.\n\n--- ERROR LOG ---\n{process.stderr.decode('utf-8', 'ignore')}"
 
 
 
 
 
38
  except subprocess.TimeoutExpired:
 
39
  return True, "⚠️ Warning: Pre-check timed out. Proceeding to full render..."
40
 
41
  def cleanup_media_directory():
42
- if os.path.exists('media'):
43
- try: shutil.rmtree('media')
 
44
  except OSError: pass
45
 
46
  def make_even(n):
@@ -49,46 +78,87 @@ def make_even(n):
49
  def get_resolution_flags(orientation, quality):
50
  qual_map = {"Preview (360p)": 360, "480p": 480, "720p": 720, "1080p": 1080, "4k": 2160}
51
  base_h = qual_map.get(quality, 1080)
52
- width, height = (make_even(base_h * (16/9)), make_even(base_h)) if orientation == "Landscape (16:9)" else (make_even(base_h), make_even(base_h * (16/9)))
 
 
 
53
  return f"{width},{height}"
54
 
55
  def run_manim(code_str, orientation, quality, timeout):
 
 
 
 
 
56
  timeout_sec = float(timeout) if timeout and float(timeout) > 0 else None
 
 
57
  with open("scene.py", "w", encoding="utf-8") as f: f.write(code_str)
58
- timestamp, output_filename = int(time.time()), f"video_{int(time.time())}.mp4"
 
 
59
  res_str = get_resolution_flags(orientation, quality)
60
  frame_rate_flags = ["--frame_rate", "15"] if quality == "Preview (360p)" else []
61
- cmd = ["manim", "--resolution", res_str, *frame_rate_flags, "--disable_caching", "--progress_bar", "none", "scene.py", "GenScene", "-o", output_filename]
62
 
 
 
 
 
 
63
  try:
64
  process = subprocess.run(cmd, capture_output=True, timeout=timeout_sec, check=False)
65
- full_logs = f"--- STDOUT ---\n{process.stdout.decode('utf-8', 'ignore')}\n\n--- STDERR ---\n{process.stderr.decode('utf-8', 'ignore')}"
66
- if process.returncode != 0: print(f"❌ Render Failed (Error Code: {process.returncode})", flush=True)
 
 
 
 
 
 
67
  except subprocess.TimeoutExpired as e:
68
- full_logs = f"--- STDOUT ---\n{e.stdout.decode('utf-8', 'ignore') if e.stdout else ''}\n\n--- STDERR ---\n{e.stderr.decode('utf-8', 'ignore') if e.stderr else ''}"
69
- print(f"⌛ Render timed out. Attempting recovery...", flush=True)
70
- path_matches = re.findall(r"movie file written in\s*'([^']+?)'", full_logs, flags=re.DOTALL)
 
 
 
 
 
 
71
  if path_matches:
72
- partial_dir = os.path.dirname("".join(path_matches[-1].split()))
73
- if os.path.exists(partial_dir):
74
- chunks = sorted(glob.glob(os.path.join(partial_dir, 'uncached_*.mp4')), key=lambda f: int(re.search(r'(\d+)\.mp4$', f).group(1)))
75
- if chunks:
76
- list_path = os.path.join(partial_dir, "file_list.txt")
77
- with open(list_path, 'w') as f:
78
- for chunk in chunks: f.write(f"file '{os.path.abspath(chunk)}'\n")
79
- combined_path = os.path.join(os.path.dirname(partial_dir), f"combined_partial_{timestamp}.mp4")
80
- ffmpeg_cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_path, "-c", "copy", combined_path]
81
- ffmpeg_proc = subprocess.run(ffmpeg_cmd, capture_output=True, text=True, check=False)
82
- if ffmpeg_proc.returncode == 0 and os.path.exists(combined_path):
83
- return combined_path, f"⚠️ WARNING: Render Timed Out. Recovered partial video.\n\n{full_logs}", True
84
- print("❌ Recovery failed.", flush=True)
85
-
86
- media_base = os.path.join("media", "videos", "scene")
87
- if os.path.exists(media_base):
88
- for root, _, files in os.walk(media_base):
 
 
 
 
 
 
 
 
 
 
89
  if output_filename in files:
90
- return os.path.join(root, output_filename), f"✅ Rendering Successful\n\n{full_logs}", True
91
- return None, f" Failure: Video file not created.\n\n{full_logs}", False
 
 
 
92
 
93
  # ---------------------------------------------------------
94
  # 2. Main API Function
@@ -97,60 +167,61 @@ def run_manim(code_str, orientation, quality, timeout):
97
  def render_video_from_code(code, orientation, quality, timeout, preview_factor):
98
  try:
99
  is_valid, logs = run_manim_pre_check(code)
100
- if not is_valid: return None, logs, gr.Button(visible=True)
 
 
101
  cleanup_media_directory()
102
- if not code or "from manim import" not in code: return None, "Error: No valid code.", gr.Button(visible=False)
103
- code_to_render = modify_animation_times(code, factor=float(preview_factor) or 0.5) if quality == "Preview (360p)" else code
 
 
 
 
 
 
104
  video_path, logs, success = run_manim(code_to_render, orientation, quality, timeout)
105
  return video_path, logs, gr.Button(visible=not success)
106
  except Exception as e:
107
  return None, f"Rendering failed: {str(e)}", gr.Button(visible=True)
108
 
109
  # ---------------------------------------------------------
110
- # NEW: Audio Merging Functions (with RENAMING FIX)
111
  # ---------------------------------------------------------
112
 
113
- def ensure_file_extension(file_path: str, expected_ext: str) -> str:
114
- """
115
- Checks if a file has an extension. If not, renames it to include one.
116
- This is CRITICAL for ffprobe to work with Gradio API's extension-less 'blob' files.
117
- """
118
- if not file_path or not os.path.exists(file_path):
119
- return file_path # Return original path if it's invalid, let downstream fail
120
-
121
- _, current_ext = os.path.splitext(file_path)
122
- if not current_ext:
123
- new_path = file_path + expected_ext
124
- print(f"🔧 Renaming extension-less file '{os.path.basename(file_path)}' to '{os.path.basename(new_path)}'", flush=True)
125
- shutil.move(file_path, new_path)
126
- return new_path
127
- return file_path
128
-
129
  def get_media_duration(file_path):
130
- cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", file_path]
 
 
 
 
 
 
 
131
  try:
132
  result = subprocess.run(cmd, capture_output=True, text=True, check=True)
133
  return float(result.stdout.strip())
134
- except (subprocess.CalledProcessError, FileNotFoundError) as e:
135
- print(f"⚠️ Could not get duration for {file_path}. Error: {e}", flush=True)
136
  return None
137
 
138
  def build_atempo_filter(factor):
 
139
  filters = []
 
140
  while factor > 100.0:
141
- filters.append("atempo=100.0"); factor /= 100.0
 
142
  while factor < 0.5:
143
- filters.append("atempo=0.5"); factor /= 0.5
 
 
144
  if 0.5 <= factor <= 100.0:
145
  filters.append(f"atempo={factor}")
 
146
  return ",".join(filters)
147
 
148
  def merge_audio_to_video(video_path, audio_path):
149
- # --- FIX APPLIED HERE ---
150
- video_path = ensure_file_extension(video_path, ".mp4")
151
- # Gradio's gr.Audio component often saves to .wav by default
152
- audio_path = ensure_file_extension(audio_path, ".wav")
153
-
154
  if not video_path or not audio_path:
155
  return None, "Error: Please provide both a video and an audio file."
156
 
@@ -160,36 +231,62 @@ def merge_audio_to_video(video_path, audio_path):
160
 
161
  if video_duration is None or audio_duration is None:
162
  return None, "Error: Could not determine media durations. Check server logs."
 
163
  if video_duration == 0:
164
  return None, "Error: Input video has zero duration."
165
 
166
  speed_factor = audio_duration / video_duration
167
  atempo_filter = build_atempo_filter(speed_factor)
168
 
 
 
 
169
  output_dir = "temp_outputs"
170
  os.makedirs(output_dir, exist_ok=True)
171
- output_path = os.path.join(output_dir, f"merged_video_{int(time.time())}.mp4")
 
172
 
173
- ffmpeg_cmd = ["ffmpeg", "-y", "-i", video_path, "-i", audio_path, "-c:v", "copy", "-filter:a", atempo_filter, "-map", "0:v:0", "-map", "1:a:0", "-shortest", output_path]
 
 
 
 
 
 
 
 
 
 
 
174
 
175
  process = subprocess.run(ffmpeg_cmd, capture_output=True, text=True)
176
 
177
  if process.returncode != 0:
178
  error_message = f"FFmpeg Error:\n{process.stderr}"
 
179
  return None, error_message
180
 
 
181
  return output_path, "✅ Audio merged successfully!"
182
 
183
  # ---------------------------------------------------------
184
  # 3. Gradio Interface
185
  # ---------------------------------------------------------
186
 
187
- with gr.Blocks(title="Manim AI Tools") as demo:
 
 
 
 
 
 
 
 
 
188
  with gr.Tab("🎬 Manim Video Renderer"):
189
- gr.Markdown("## Create Animations with Manim")
190
  with gr.Row():
191
  with gr.Column(scale=1):
192
- code_input = gr.Code(label="Python Code", language="python", value="from manim import *\n\nclass GenScene(Scene):\n def construct(self):\n c = Circle()\n self.play(Create(c))", visible=True)
193
  orientation_opt = gr.Radio(choices=["Landscape (16:9)", "Portrait (9:16)"], value="Portrait (9:16)", label="Orientation", visible=True)
194
  quality_opt = gr.Dropdown(choices=["Preview (360p)", "480p", "720p", "1080p", "4k"], value="Preview (360p)", label="Quality", visible=True)
195
  timeout_input = gr.Number(label="Render Timeout (seconds)", value=60, visible=True)
@@ -199,7 +296,13 @@ with gr.Blocks(title="Manim AI Tools") as demo:
199
  video_output = gr.Video(label="Result")
200
  status_output = gr.Textbox(label="Status/Logs")
201
  fix_btn_output = gr.Button("Fix Error & Re-render", variant="stop", visible=True)
202
- render_btn.click(fn=render_video_from_code, inputs=[code_input, orientation_opt, quality_opt, timeout_input, preview_speed_factor_input], outputs=[video_output, status_output, fix_btn_output], api_name="render")
 
 
 
 
 
 
203
 
204
  with gr.Tab("🎤 Add Audio to Video"):
205
  gr.Markdown("## Merge Audio into Video")
@@ -212,7 +315,13 @@ with gr.Blocks(title="Manim AI Tools") as demo:
212
  with gr.Column():
213
  video_output_audio_tab = gr.Video(label="Merged Video")
214
  status_audio_tab = gr.Textbox(label="Status")
215
- merge_audio_btn.click(fn=merge_audio_to_video, inputs=[video_input_audio_tab, audio_input_audio_tab], outputs=[video_output_audio_tab, status_audio_tab], api_name="add_audio")
 
 
 
 
 
 
216
 
217
  if __name__ == "__main__":
218
  demo.launch(server_name="0.0.0.0", server_port=7860)
 
11
  # ---------------------------------------------------------
12
 
13
  def modify_animation_times(code: str, factor: float = 1.0, for_precheck: bool = False) -> str:
14
+ """
15
+ Modifies wait() and run_time in the code.
16
+ 1. Pre-Check: Zero latency (wait(0), run_time=0.01).
17
+ 2. Preview: Scales time by factor.
18
+ """
19
  if for_precheck:
20
+ print("⚡ Optimizing code for Pre-check (Zero Latency)...", flush=True)
21
  code = re.sub(r"self\.wait\s*\([^)]*\)", "self.wait(0)", code)
22
  code = re.sub(r"run_time\s*=\s*[^,)]+", "run_time=0.01", code)
23
  return code
24
+
25
+ print(f"⚡ Scaling animation times by a factor of {factor} for preview.", flush=True)
26
  MIN_RUN_TIME = 0.1
27
+
28
  def scale_match(m, is_wait):
29
  try:
30
  val = float(m.group(2))
31
  new_val = val * factor
32
  final_val = new_val if is_wait else max(new_val, MIN_RUN_TIME)
33
  return f"{m.group(1)}{final_val:.3f}"
34
+ except ValueError:
35
+ return m.group(0)
36
+
37
  code = re.sub(r"(run_time\s*=\s*)(\d+\.?\d*)", lambda m: scale_match(m, False), code)
38
  code = re.sub(r"(self\.wait\s*\(\s*)(\d+\.?\d*)", lambda m: scale_match(m, True), code)
39
  return code
40
 
41
  def run_manim_pre_check(code_str: str) -> (bool, str):
42
+ """
43
+ Runs Manim with '-s'.
44
+ - Speed Hack applied.
45
+ - Soft Pass: Timeouts return True.
46
+ - Errors return False.
47
+ """
48
+ print("🕵️ Running fast pre-check with 'manim -s'...", flush=True)
49
  fast_code = modify_animation_times(code_str, for_precheck=True)
50
+
51
+ with open("scene_pre_check.py", "w", encoding="utf-8") as f:
52
+ f.write(fast_code)
53
+
54
  cmd = ["manim", "-ql", "--progress_bar", "none", "--disable_caching", "scene_pre_check.py", "GenScene", "-s", "-o", "pre_check_output"]
55
+
56
  try:
57
  process = subprocess.run(cmd, capture_output=True, timeout=30, check=False)
58
+ if process.returncode == 0:
59
+ print(" Pre-check passed.", flush=True)
60
+ return True, "Pre-check successful."
61
+ else:
62
+ stderr_log = process.stderr.decode('utf-8', 'ignore')
63
+ print(f"❌ Pre-check failed.\n{stderr_log}", flush=True)
64
+ return False, f"⚠️ ERROR: Your code failed the pre-check.\n\n--- ERROR LOG ---\n{stderr_log}"
65
  except subprocess.TimeoutExpired:
66
+ print("⌛ Pre-check timed out (30s). Soft Pass.", flush=True)
67
  return True, "⚠️ Warning: Pre-check timed out. Proceeding to full render..."
68
 
69
  def cleanup_media_directory():
70
+ media_dir = 'media'
71
+ if os.path.exists(media_dir):
72
+ try: shutil.rmtree(media_dir)
73
  except OSError: pass
74
 
75
  def make_even(n):
 
78
  def get_resolution_flags(orientation, quality):
79
  qual_map = {"Preview (360p)": 360, "480p": 480, "720p": 720, "1080p": 1080, "4k": 2160}
80
  base_h = qual_map.get(quality, 1080)
81
+ if orientation == "Landscape (16:9)":
82
+ width, height = make_even(base_h * (16/9)), make_even(base_h)
83
+ else:
84
+ width, height = make_even(base_h), make_even(base_h * (16/9))
85
  return f"{width},{height}"
86
 
87
  def run_manim(code_str, orientation, quality, timeout):
88
+ """
89
+ Executes Manim.
90
+ - RECOVERY: Partial stitching ONLY happens inside 'except subprocess.TimeoutExpired'.
91
+ - ERRORS: Standard errors do NOT trigger recovery.
92
+ """
93
  timeout_sec = float(timeout) if timeout and float(timeout) > 0 else None
94
+ print(f"🎬 Starting Full Render: {orientation} @ {quality} (Timeout: {timeout_sec}s)...", flush=True)
95
+
96
  with open("scene.py", "w", encoding="utf-8") as f: f.write(code_str)
97
+
98
+ timestamp = int(time.time())
99
+ output_filename = f"video_{timestamp}.mp4"
100
  res_str = get_resolution_flags(orientation, quality)
101
  frame_rate_flags = ["--frame_rate", "15"] if quality == "Preview (360p)" else []
 
102
 
103
+ cmd = ["manim", "--resolution", res_str, *frame_rate_flags, "--disable_caching",
104
+ "--progress_bar", "none", "scene.py", "GenScene", "-o", output_filename]
105
+
106
+ full_logs = ""
107
+
108
  try:
109
  process = subprocess.run(cmd, capture_output=True, timeout=timeout_sec, check=False)
110
+
111
+ stdout_log = process.stdout.decode('utf-8', 'ignore')
112
+ stderr_log = process.stderr.decode('utf-8', 'ignore')
113
+ full_logs = f"--- MANIM STDOUT ---\n{stdout_log}\n\n--- MANIM STDERR ---\n{stderr_log}"
114
+
115
+ if process.returncode != 0:
116
+ print(f"❌ Render Failed (Process Error). Return Code: {process.returncode}", flush=True)
117
+
118
  except subprocess.TimeoutExpired as e:
119
+ print(f" Render timed out after {timeout_sec} seconds. Attempting recovery...", flush=True)
120
+ stdout_log = e.stdout.decode('utf-8', 'ignore') if e.stdout else ""
121
+ stderr_log = e.stderr.decode('utf-8', 'ignore') if e.stderr else ""
122
+ timeout_logs = f"--- MANIM STDOUT ---\n{stdout_log}\n\n--- MANIM STDERR ---\n{stderr_log}"
123
+ full_logs = timeout_logs
124
+
125
+ combined_log = stdout_log + "\n" + stderr_log
126
+ path_matches = re.findall(r"movie file written in\s*'([^']+?)'", combined_log, flags=re.DOTALL)
127
+
128
  if path_matches:
129
+ partial_files_dir = os.path.dirname("".join(path_matches[-1].split()))
130
+
131
+ if os.path.exists(partial_files_dir):
132
+ partial_files = sorted(glob.glob(os.path.join(partial_files_dir, 'uncached_*.mp4')),
133
+ key=lambda f: int(re.search(r'(\d+)\.mp4$', f).group(1)))
134
+
135
+ if partial_files:
136
+ print(f"⚡ Found {len(partial_files)} partial chunks. Stitching...", flush=True)
137
+ list_file_path = os.path.join(partial_files_dir, "file_list.txt")
138
+ with open(list_file_path, 'w') as f:
139
+ for pf in partial_files:
140
+ f.write(f"file '{os.path.abspath(pf)}'\n")
141
+
142
+ combined_video_path = os.path.join(os.path.dirname(partial_files_dir), f"combined_partial_{timestamp}.mp4")
143
+ ffmpeg_cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_file_path, "-c", "copy", combined_video_path]
144
+
145
+ ffmpeg_process = subprocess.run(ffmpeg_cmd, capture_output=True, text=True, check=False)
146
+
147
+ if ffmpeg_process.returncode == 0 and os.path.exists(combined_video_path):
148
+ print(f"✅ Recovery Successful: {combined_video_path}", flush=True)
149
+ return combined_video_path, f"⚠️ WARNING: Render Timed Out. Recovered partial video.\n\n{timeout_logs}", True
150
+
151
+ print("❌ Recovery failed or no partial files found.", flush=True)
152
+
153
+ media_video_base = os.path.join("media", "videos", "scene")
154
+ if os.path.exists(media_video_base):
155
+ for root, _, files in os.walk(media_video_base):
156
  if output_filename in files:
157
+ found_video_path = os.path.join(root, output_filename)
158
+ print(f" Video Render Success: {found_video_path}", flush=True)
159
+ return found_video_path, f"✅ Rendering Successful\n\n{full_logs}", True
160
+
161
+ return None, f"❌ Failure: Video file was not created.\n\n{full_logs}", False
162
 
163
  # ---------------------------------------------------------
164
  # 2. Main API Function
 
167
  def render_video_from_code(code, orientation, quality, timeout, preview_factor):
168
  try:
169
  is_valid, logs = run_manim_pre_check(code)
170
+ if not is_valid:
171
+ return None, logs, gr.Button(visible=True)
172
+
173
  cleanup_media_directory()
174
+ if not code or "from manim import" not in code:
175
+ return None, "Error: No valid code.", gr.Button(visible=False)
176
+
177
+ if quality == "Preview (360p)":
178
+ code_to_render = modify_animation_times(code, factor=float(preview_factor) or 0.5, for_precheck=False)
179
+ else:
180
+ code_to_render = code
181
+
182
  video_path, logs, success = run_manim(code_to_render, orientation, quality, timeout)
183
  return video_path, logs, gr.Button(visible=not success)
184
  except Exception as e:
185
  return None, f"Rendering failed: {str(e)}", gr.Button(visible=True)
186
 
187
  # ---------------------------------------------------------
188
+ # NEW: Audio Merging Functions
189
  # ---------------------------------------------------------
190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  def get_media_duration(file_path):
192
+ """Uses ffprobe to get the duration of a media file."""
193
+ cmd = [
194
+ "ffprobe",
195
+ "-v", "error",
196
+ "-show_entries", "format=duration",
197
+ "-of", "default=noprint_wrappers=1:nokey=1",
198
+ file_path
199
+ ]
200
  try:
201
  result = subprocess.run(cmd, capture_output=True, text=True, check=True)
202
  return float(result.stdout.strip())
203
+ except (subprocess.CalledProcessError, FileNotFoundError):
204
+ print(f"⚠️ Could not get duration for {file_path}. Is ffprobe installed?", flush=True)
205
  return None
206
 
207
  def build_atempo_filter(factor):
208
+ """Builds a chained atempo filter for FFmpeg to handle extreme speed changes."""
209
  filters = []
210
+ # FFmpeg's atempo filter is limited to the range [0.5, 100.0]
211
  while factor > 100.0:
212
+ filters.append("atempo=100.0")
213
+ factor /= 100.0
214
  while factor < 0.5:
215
+ filters.append("atempo=0.5")
216
+ factor /= 0.5
217
+
218
  if 0.5 <= factor <= 100.0:
219
  filters.append(f"atempo={factor}")
220
+
221
  return ",".join(filters)
222
 
223
  def merge_audio_to_video(video_path, audio_path):
224
+ """Merges audio into a video, stretching the audio to match the video's duration."""
 
 
 
 
225
  if not video_path or not audio_path:
226
  return None, "Error: Please provide both a video and an audio file."
227
 
 
231
 
232
  if video_duration is None or audio_duration is None:
233
  return None, "Error: Could not determine media durations. Check server logs."
234
+
235
  if video_duration == 0:
236
  return None, "Error: Input video has zero duration."
237
 
238
  speed_factor = audio_duration / video_duration
239
  atempo_filter = build_atempo_filter(speed_factor)
240
 
241
+ print(f"Video Duration: {video_duration}s, Audio Duration: {audio_duration}s, Speed Factor: {speed_factor}", flush=True)
242
+ print(f"Applying FFmpeg atempo filter: '{atempo_filter}'", flush=True)
243
+
244
  output_dir = "temp_outputs"
245
  os.makedirs(output_dir, exist_ok=True)
246
+ timestamp = int(time.time())
247
+ output_path = os.path.join(output_dir, f"merged_video_{timestamp}.mp4")
248
 
249
+ ffmpeg_cmd = [
250
+ "ffmpeg",
251
+ "-y", # Overwrite output file if it exists
252
+ "-i", video_path, # Input video
253
+ "-i", audio_path, # Input audio
254
+ "-c:v", "copy", # Copy video stream (no re-encoding, preserves quality)
255
+ "-filter:a", atempo_filter, # Apply speed change to audio
256
+ "-map", "0:v:0", # Select video from the first input
257
+ "-map", "1:a:0", # Select audio from the second input
258
+ "-shortest", # Ensure output duration matches the shortest stream (video)
259
+ output_path
260
+ ]
261
 
262
  process = subprocess.run(ffmpeg_cmd, capture_output=True, text=True)
263
 
264
  if process.returncode != 0:
265
  error_message = f"FFmpeg Error:\n{process.stderr}"
266
+ print(error_message, flush=True)
267
  return None, error_message
268
 
269
+ print(f"✅ Audio merged successfully: {output_path}", flush=True)
270
  return output_path, "✅ Audio merged successfully!"
271
 
272
  # ---------------------------------------------------------
273
  # 3. Gradio Interface
274
  # ---------------------------------------------------------
275
 
276
+ DEFAULT_CODE = """from manim import *
277
+
278
+ class GenScene(Scene):
279
+ def construct(self):
280
+ c = Circle(color=BLUE, fill_opacity=0.5)
281
+ self.play(Create(c))
282
+ self.wait(1)
283
+ """
284
+
285
+ with gr.Blocks(title="Manim Render API") as demo:
286
  with gr.Tab("🎬 Manim Video Renderer"):
 
287
  with gr.Row():
288
  with gr.Column(scale=1):
289
+ code_input = gr.Code(label="Python Code", language="python", value=DEFAULT_CODE, visible=True)
290
  orientation_opt = gr.Radio(choices=["Landscape (16:9)", "Portrait (9:16)"], value="Portrait (9:16)", label="Orientation", visible=True)
291
  quality_opt = gr.Dropdown(choices=["Preview (360p)", "480p", "720p", "1080p", "4k"], value="Preview (360p)", label="Quality", visible=True)
292
  timeout_input = gr.Number(label="Render Timeout (seconds)", value=60, visible=True)
 
296
  video_output = gr.Video(label="Result")
297
  status_output = gr.Textbox(label="Status/Logs")
298
  fix_btn_output = gr.Button("Fix Error & Re-render", variant="stop", visible=True)
299
+
300
+ render_btn.click(
301
+ fn=render_video_from_code,
302
+ inputs=[code_input, orientation_opt, quality_opt, timeout_input, preview_speed_factor_input],
303
+ outputs=[video_output, status_output, fix_btn_output],
304
+ api_name="render"
305
+ )
306
 
307
  with gr.Tab("🎤 Add Audio to Video"):
308
  gr.Markdown("## Merge Audio into Video")
 
315
  with gr.Column():
316
  video_output_audio_tab = gr.Video(label="Merged Video")
317
  status_audio_tab = gr.Textbox(label="Status")
318
+
319
+ merge_audio_btn.click(
320
+ fn=merge_audio_to_video,
321
+ inputs=[video_input_audio_tab, audio_input_audio_tab],
322
+ outputs=[video_output_audio_tab, status_audio_tab],
323
+ api_name="add_audio"
324
+ )
325
 
326
  if __name__ == "__main__":
327
  demo.launch(server_name="0.0.0.0", server_port=7860)