Update app.py
Browse files
app.py
CHANGED
@@ -2,7 +2,7 @@ import asyncio
|
|
2 |
import os
|
3 |
import shutil
|
4 |
import subprocess
|
5 |
-
import tempfile
|
6 |
from typing import List
|
7 |
import logging
|
8 |
|
@@ -95,26 +95,87 @@ def generate_video_outline(concept: str) -> VideoOutline:
|
|
95 |
|
96 |
def create_video_from_code(code: str, chapter_number: int) -> str:
|
97 |
"""Creates a video from Manim code and returns the video file path using subprocess.Popen."""
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
temp_file.write(code)
|
102 |
-
temp_file_name = temp_file.name
|
103 |
|
104 |
-
process = None
|
105 |
try:
|
106 |
-
#
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
113 |
|
114 |
if process.returncode == 0:
|
115 |
logging.info(f"Manim execution successful for chapter {chapter_number}.")
|
116 |
logging.debug(f"Manim stdout:\n{stdout}")
|
117 |
logging.debug(f"Manim stderr:\n{stderr}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
118 |
else:
|
119 |
error_msg = f"Manim execution failed for chapter {chapter_number} with return code {process.returncode}:\nStdout:\n{stdout}\nStderr:\n{stderr}"
|
120 |
logging.error(str(error_msg).split('\n')[-1])
|
@@ -128,41 +189,32 @@ def create_video_from_code(code: str, chapter_number: int) -> str:
|
|
128 |
except FileNotFoundError:
|
129 |
logging.error("Error: The 'manim' command was not found. Ensure Manim is installed and in your system's PATH.")
|
130 |
raise
|
|
|
|
|
|
|
131 |
finally:
|
132 |
# Ensure the temporary file is deleted after Manim tries to use it
|
133 |
-
if os.path.exists(temp_file_name):
|
134 |
os.remove(temp_file_name)
|
135 |
logging.info(f"Deleted temporary Manim script: {temp_file_name}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
136 |
|
137 |
-
#
|
138 |
-
# The
|
139 |
-
|
140 |
-
|
141 |
-
class_name = match.group(1)
|
142 |
-
video_file_name = f"{class_name}.mp4"
|
143 |
-
# Manim renders to a specific default output path if not specified
|
144 |
-
# Adjust this path if your Manim configuration or command differs
|
145 |
-
manim_output_dir = "./media/videos/temp/480p15/"
|
146 |
-
# Ensure the output directory exists
|
147 |
-
os.makedirs(manim_output_dir, exist_ok=True)
|
148 |
-
video_file_path = os.path.join(manim_output_dir, video_file_name)
|
149 |
-
|
150 |
-
# In some cases, Manim might output to the current directory before moving it.
|
151 |
-
# We need to make sure we're checking the correct path where Manim places the final video.
|
152 |
-
# A more robust solution might involve parsing Manim's stdout for the actual path.
|
153 |
-
# For now, let's assume the default output path.
|
154 |
-
if not os.path.exists(video_file_path):
|
155 |
-
logging.warning(f"Expected Manim output at {video_file_path} but not found. Checking current directory for {video_file_name}.")
|
156 |
-
# Fallback check in current directory, although not typical for default Manim behavior
|
157 |
-
if os.path.exists(video_file_name):
|
158 |
-
video_file_path = video_file_name
|
159 |
-
logging.info(f"Found video in current directory: {video_file_name}")
|
160 |
-
else:
|
161 |
-
raise FileNotFoundError(f"Manim output video file '{video_file_name}' not found at '{video_file_path}' or current directory after successful execution.")
|
162 |
-
|
163 |
-
return video_file_path
|
164 |
-
else:
|
165 |
-
raise ValueError(f"Could not extract class name from Manim code for chapter {chapter_number}")
|
166 |
|
167 |
async def generate_video(concept: str):
|
168 |
"""Generates a video explanation for a given concept using Manim with error correction."""
|
@@ -170,7 +222,9 @@ async def generate_video(concept: str):
|
|
170 |
outline = generate_video_outline(concept)
|
171 |
logging.info(f"Video outline: {outline}")
|
172 |
|
173 |
-
|
|
|
|
|
174 |
for i, chapter in enumerate(outline.chapters):
|
175 |
logging.info(f"Processing chapter {i + 1}: {chapter.title}")
|
176 |
manim_code = generate_manim_code(chapter)
|
@@ -178,14 +232,24 @@ async def generate_video(concept: str):
|
|
178 |
|
179 |
success = False
|
180 |
attempts = 0
|
181 |
-
max_attempts = 2
|
182 |
|
183 |
while attempts < max_attempts and not success:
|
184 |
try:
|
185 |
st.info(f"Attempting to render chapter {i + 1}: {chapter.title} (Attempt {attempts + 1}/{max_attempts})")
|
186 |
-
|
187 |
-
|
188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
189 |
success = True
|
190 |
except subprocess.CalledProcessError as e:
|
191 |
attempts += 1
|
@@ -196,14 +260,10 @@ async def generate_video(concept: str):
|
|
196 |
except ValueError as e:
|
197 |
logging.error(f"Error processing Manim code for chapter {i + 1}: {e}")
|
198 |
st.error(f"Error processing chapter {i + 1}: {e}")
|
199 |
-
return None
|
200 |
except FileNotFoundError as e:
|
201 |
-
|
202 |
-
|
203 |
-
st.error(f"Manim rendering failed to produce video for chapter {i + 1}. Please check Manim installation and configuration.")
|
204 |
-
else:
|
205 |
-
logging.error(f"Manim not found or output path issue: {e}")
|
206 |
-
st.error("Manim not found. Please ensure it's installed and in your PATH, or check Manim's output configuration.")
|
207 |
return None
|
208 |
except subprocess.TimeoutExpired:
|
209 |
attempts += 1
|
@@ -211,6 +271,11 @@ async def generate_video(concept: str):
|
|
211 |
st.warning(f"Manim rendering timed out for chapter {i + 1}. Attempting to fix code...")
|
212 |
manim_code = fix_manim_code(f"Manim process timed out.", manim_code)
|
213 |
logging.debug(f"Fixed Manim code (Attempt {attempts}):\n{manim_code}")
|
|
|
|
|
|
|
|
|
|
|
214 |
|
215 |
if not success:
|
216 |
logging.error(f"Failed to generate video for chapter {i + 1} after {max_attempts} attempts. Skipping chapter.")
|
@@ -219,17 +284,17 @@ async def generate_video(concept: str):
|
|
219 |
|
220 |
# Combine the video files
|
221 |
final_video_path = None
|
222 |
-
if
|
223 |
logging.info("Combining video files...")
|
224 |
st.info("Combining all chapter videos...")
|
|
|
225 |
try:
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
clips.append(VideoFileClip(vf))
|
230 |
else:
|
231 |
-
logging.warning(f"Skipping non-existent video file: {
|
232 |
-
st.warning(f"Skipping missing chapter video: {
|
233 |
|
234 |
if clips:
|
235 |
final_video_path = f"final_video.mp4"
|
@@ -244,53 +309,18 @@ async def generate_video(concept: str):
|
|
244 |
except Exception as e:
|
245 |
logging.error(f"Error combining video files: {e}")
|
246 |
st.error(f"Error combining video files: {e}")
|
247 |
-
|
248 |
-
|
249 |
-
|
250 |
-
|
251 |
-
|
252 |
-
|
253 |
-
|
254 |
-
|
255 |
-
|
256 |
-
|
257 |
-
|
258 |
-
|
259 |
-
|
260 |
-
|
261 |
-
|
262 |
-
except Exception as e:
|
263 |
-
logging.error(f"Error deleting empty Manim temp output directory {temp_manim_output_dir}: {e}")
|
264 |
-
else:
|
265 |
-
logging.warning("No video files to combine.")
|
266 |
-
st.info("No video files were successfully generated.")
|
267 |
-
|
268 |
-
return final_video_path
|
269 |
-
|
270 |
-
def main():
|
271 |
-
st.title("Explanatory Video Generator")
|
272 |
-
concept = st.text_input("Enter the concept for the video:")
|
273 |
-
|
274 |
-
if st.button("Generate Video"):
|
275 |
-
if concept:
|
276 |
-
with st.spinner("Generating video... This might take a few minutes."):
|
277 |
-
final_video_file = asyncio.run(generate_video(concept))
|
278 |
-
if final_video_file and os.path.exists(final_video_file):
|
279 |
-
st.video(final_video_file)
|
280 |
-
# Provide a download button for the video
|
281 |
-
with open(final_video_file, "rb") as file:
|
282 |
-
st.download_button(
|
283 |
-
label="Download Video",
|
284 |
-
data=file,
|
285 |
-
file_name=os.path.basename(final_video_file),
|
286 |
-
mime="video/mp4"
|
287 |
-
)
|
288 |
-
elif final_video_file:
|
289 |
-
st.error("Error: Final video file not found after generation.")
|
290 |
-
else:
|
291 |
-
st.info("Video generation process completed without creating a final video.")
|
292 |
-
else:
|
293 |
-
st.warning("Please enter a concept.")
|
294 |
-
|
295 |
-
if __name__ == "__main__":
|
296 |
-
main()
|
|
|
2 |
import os
|
3 |
import shutil
|
4 |
import subprocess
|
5 |
+
import tempfile
|
6 |
from typing import List
|
7 |
import logging
|
8 |
|
|
|
95 |
|
96 |
def create_video_from_code(code: str, chapter_number: int) -> str:
|
97 |
"""Creates a video from Manim code and returns the video file path using subprocess.Popen."""
|
98 |
+
temp_dir = None
|
99 |
+
temp_file_name = None
|
100 |
+
video_file_path = None
|
|
|
|
|
101 |
|
|
|
102 |
try:
|
103 |
+
# Create a temporary directory for Manim's output
|
104 |
+
temp_dir = tempfile.mkdtemp()
|
105 |
+
logging.info(f"Created temporary directory for Manim output: {temp_dir}")
|
106 |
+
|
107 |
+
# Use tempfile to create a temporary Python file within the temporary directory
|
108 |
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False, dir=temp_dir) as temp_file:
|
109 |
+
temp_file.write(code)
|
110 |
+
temp_file_name = temp_file.name
|
111 |
+
logging.info(f"Created temporary Manim script: {temp_file_name}")
|
112 |
+
|
113 |
+
process = None
|
114 |
+
# Manim command needs to specify the output directory
|
115 |
+
# The -o flag followed by a filename will output the video to that specific path relative to the current working directory,
|
116 |
+
# or an absolute path if provided.
|
117 |
+
# Alternatively, we can use -o with just the class name and specify the output directory using --output_directory
|
118 |
+
# For simplicity, we'll try to explicitly specify the output path if the Manim version supports it,
|
119 |
+
# or we'll ensure the manim command is executed from within the temporary directory.
|
120 |
+
|
121 |
+
# We need to extract the class name to get Manim's default filename
|
122 |
+
match = re.search(r"class\s+(\w+)\(Scene\):", code)
|
123 |
+
if not match:
|
124 |
+
raise ValueError(f"Could not extract class name from Manim code for chapter {chapter_number}")
|
125 |
+
class_name = match.group(1)
|
126 |
+
expected_output_filename = f"{class_name}.mp4"
|
127 |
+
|
128 |
+
# Construct the command. We will run manim from the temporary directory.
|
129 |
+
# Manim by default creates media/videos/<resolution>/<filename>.mp4
|
130 |
+
# We want it in our temp_dir. The easiest way is to change the CWD.
|
131 |
+
# So the manim command becomes `manim <script_name> -ql --media_dir <temp_dir>`
|
132 |
+
# This will put the videos inside <temp_dir>/videos/<resolution>/
|
133 |
+
command = [
|
134 |
+
"manim",
|
135 |
+
os.path.basename(temp_file_name), # Just the filename, as we're changing CWD
|
136 |
+
"-ql",
|
137 |
+
"--disable_caching",
|
138 |
+
"--media_dir", # Specify the media directory directly
|
139 |
+
temp_dir
|
140 |
+
]
|
141 |
+
logging.info(f"Executing Manim command in '{temp_dir}': {' '.join(command)}")
|
142 |
+
|
143 |
+
# Execute Manim from within the temporary directory
|
144 |
+
process = subprocess.Popen(
|
145 |
+
command,
|
146 |
+
stdout=subprocess.PIPE,
|
147 |
+
stderr=subprocess.PIPE,
|
148 |
+
cwd=temp_dir, # Crucial: set the current working directory for the subprocess
|
149 |
+
shell=True,
|
150 |
+
text=True
|
151 |
+
)
|
152 |
+
stdout, stderr = process.communicate(timeout=120)
|
153 |
|
154 |
if process.returncode == 0:
|
155 |
logging.info(f"Manim execution successful for chapter {chapter_number}.")
|
156 |
logging.debug(f"Manim stdout:\n{stdout}")
|
157 |
logging.debug(f"Manim stderr:\n{stderr}")
|
158 |
+
# Manim 0.16.0+ changed output paths slightly.
|
159 |
+
# It usually creates a structure like <media_dir>/videos/<quality>/<scene_name>.mp4
|
160 |
+
# We explicitly set media_dir to temp_dir, so the video should be in:
|
161 |
+
# temp_dir/videos/temp/480p15/<class_name>.mp4
|
162 |
+
# Or if --output_file is used, it directly goes to temp_dir/<filename>.
|
163 |
+
# With --media_dir, it will stick to its internal folder structure.
|
164 |
+
manim_rendered_video_subdir = "videos/temp/480p15" # This is Manim's internal structure
|
165 |
+
video_file_path_in_temp = os.path.join(temp_dir, manim_rendered_video_subdir, expected_output_filename)
|
166 |
+
|
167 |
+
if not os.path.exists(video_file_path_in_temp):
|
168 |
+
logging.error(f"Manim output file not found at expected path: {video_file_path_in_temp}")
|
169 |
+
# As a fallback, try to find it directly in temp_dir if Manim changed its behavior or a custom flag was added
|
170 |
+
fallback_path = os.path.join(temp_dir, expected_output_filename)
|
171 |
+
if os.path.exists(fallback_path):
|
172 |
+
logging.info(f"Found video at fallback path: {fallback_path}")
|
173 |
+
video_file_path = fallback_path
|
174 |
+
else:
|
175 |
+
raise FileNotFoundError(f"Manim output video file '{expected_output_filename}' not found at '{video_file_path_in_temp}' or '{fallback_path}' after successful execution. Check Manim's output configuration.")
|
176 |
+
else:
|
177 |
+
video_file_path = video_file_path_in_temp
|
178 |
+
|
179 |
else:
|
180 |
error_msg = f"Manim execution failed for chapter {chapter_number} with return code {process.returncode}:\nStdout:\n{stdout}\nStderr:\n{stderr}"
|
181 |
logging.error(str(error_msg).split('\n')[-1])
|
|
|
189 |
except FileNotFoundError:
|
190 |
logging.error("Error: The 'manim' command was not found. Ensure Manim is installed and in your system's PATH.")
|
191 |
raise
|
192 |
+
except Exception as e:
|
193 |
+
logging.error(f"An unexpected error occurred during video creation: {e}")
|
194 |
+
raise
|
195 |
finally:
|
196 |
# Ensure the temporary file is deleted after Manim tries to use it
|
197 |
+
if temp_file_name and os.path.exists(temp_file_name):
|
198 |
os.remove(temp_file_name)
|
199 |
logging.info(f"Deleted temporary Manim script: {temp_file_name}")
|
200 |
+
# Clean up the entire temporary directory created for Manim output
|
201 |
+
# It's important to do this AFTER the video file has been moved or processed
|
202 |
+
if temp_dir and os.path.exists(temp_dir):
|
203 |
+
try:
|
204 |
+
# Need to wait until the video is processed/moved by the calling function.
|
205 |
+
# For now, let's assume the video_file_path returned will be copied.
|
206 |
+
# If we don't copy, we need to defer this cleanup.
|
207 |
+
# Since the final combination happens in `generate_video`,
|
208 |
+
# it's better to clean up the `temp_dir` there after all clips are loaded.
|
209 |
+
# For now, we'll only clean up the script itself.
|
210 |
+
pass # Shifting temp_dir cleanup to generate_video for safer handling
|
211 |
+
except Exception as e:
|
212 |
+
logging.error(f"Error cleaning up temporary directory {temp_dir}: {e}")
|
213 |
|
214 |
+
# The video_file_path now points to a file within temp_dir.
|
215 |
+
# The calling function (generate_video) will need to load this using moviepy,
|
216 |
+
# and then we can clean up the temp_dir after all clips are loaded.
|
217 |
+
return video_file_path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
218 |
|
219 |
async def generate_video(concept: str):
|
220 |
"""Generates a video explanation for a given concept using Manim with error correction."""
|
|
|
222 |
outline = generate_video_outline(concept)
|
223 |
logging.info(f"Video outline: {outline}")
|
224 |
|
225 |
+
video_files_paths = [] # Renamed for clarity, these are paths in temp dirs
|
226 |
+
temp_dirs_to_clean = [] # To keep track of temporary directories to delete
|
227 |
+
|
228 |
for i, chapter in enumerate(outline.chapters):
|
229 |
logging.info(f"Processing chapter {i + 1}: {chapter.title}")
|
230 |
manim_code = generate_manim_code(chapter)
|
|
|
232 |
|
233 |
success = False
|
234 |
attempts = 0
|
235 |
+
max_attempts = 2
|
236 |
|
237 |
while attempts < max_attempts and not success:
|
238 |
try:
|
239 |
st.info(f"Attempting to render chapter {i + 1}: {chapter.title} (Attempt {attempts + 1}/{max_attempts})")
|
240 |
+
# create_video_from_code will return the path within a temp dir
|
241 |
+
video_file_path_for_chapter = create_video_from_code(manim_code, i + 1)
|
242 |
+
video_files_paths.append(video_file_path_for_chapter)
|
243 |
+
# We need to extract the base temp directory for cleanup later
|
244 |
+
# It's usually the parent of 'videos/temp/480p15'
|
245 |
+
# Find the root temp directory path from video_file_path_for_chapter
|
246 |
+
# Example: /tmp/tmpxyz/videos/temp/480p15/MyScene.mp4 -> /tmp/tmpxyz
|
247 |
+
# This assumes temp_dir is directly below the media/videos structure
|
248 |
+
root_temp_dir = os.path.abspath(os.path.join(video_file_path_for_chapter, os.pardir, os.pardir, os.pardir))
|
249 |
+
if root_temp_dir not in temp_dirs_to_clean:
|
250 |
+
temp_dirs_to_clean.append(root_temp_dir)
|
251 |
+
|
252 |
+
logging.info(f"Video file created for chapter {i + 1}: {video_file_path_for_chapter}")
|
253 |
success = True
|
254 |
except subprocess.CalledProcessError as e:
|
255 |
attempts += 1
|
|
|
260 |
except ValueError as e:
|
261 |
logging.error(f"Error processing Manim code for chapter {i + 1}: {e}")
|
262 |
st.error(f"Error processing chapter {i + 1}: {e}")
|
263 |
+
return None
|
264 |
except FileNotFoundError as e:
|
265 |
+
logging.error(f"Manim or output file not found for chapter {i + 1}: {e}")
|
266 |
+
st.error(f"Manim or its output file was not found for chapter {i + 1}: {e}. Please ensure Manim is installed and configured correctly.")
|
|
|
|
|
|
|
|
|
267 |
return None
|
268 |
except subprocess.TimeoutExpired:
|
269 |
attempts += 1
|
|
|
271 |
st.warning(f"Manim rendering timed out for chapter {i + 1}. Attempting to fix code...")
|
272 |
manim_code = fix_manim_code(f"Manim process timed out.", manim_code)
|
273 |
logging.debug(f"Fixed Manim code (Attempt {attempts}):\n{manim_code}")
|
274 |
+
except Exception as e:
|
275 |
+
logging.error(f"An unexpected error occurred during chapter {i + 1} processing: {e}")
|
276 |
+
st.error(f"An unexpected error occurred for chapter {i + 1}: {e}")
|
277 |
+
return None
|
278 |
+
|
279 |
|
280 |
if not success:
|
281 |
logging.error(f"Failed to generate video for chapter {i + 1} after {max_attempts} attempts. Skipping chapter.")
|
|
|
284 |
|
285 |
# Combine the video files
|
286 |
final_video_path = None
|
287 |
+
if video_files_paths:
|
288 |
logging.info("Combining video files...")
|
289 |
st.info("Combining all chapter videos...")
|
290 |
+
clips = []
|
291 |
try:
|
292 |
+
for vf_path in video_files_paths:
|
293 |
+
if os.path.exists(vf_path):
|
294 |
+
clips.append(VideoFileClip(vf_path))
|
|
|
295 |
else:
|
296 |
+
logging.warning(f"Skipping non-existent video file for concatenation: {vf_path}")
|
297 |
+
st.warning(f"Skipping missing chapter video for final combination: {vf_path.split('/')[-1]}")
|
298 |
|
299 |
if clips:
|
300 |
final_video_path = f"final_video.mp4"
|
|
|
309 |
except Exception as e:
|
310 |
logging.error(f"Error combining video files: {e}")
|
311 |
st.error(f"Error combining video files: {e}")
|
312 |
+
finally:
|
313 |
+
# Clean up all temporary directories after all clips are loaded and combined
|
314 |
+
for clip in clips: # Ensure all clips are closed
|
315 |
+
try:
|
316 |
+
clip.close()
|
317 |
+
except Exception as close_e:
|
318 |
+
logging.warning(f"Error closing clip: {close_e}")
|
319 |
+
|
320 |
+
for temp_dir_path in temp_dirs_to_clean:
|
321 |
+
try:
|
322 |
+
if os.path.exists(temp_dir_path):
|
323 |
+
shutil.rmtree(temp_dir_path) # Recursively remove the temporary directory
|
324 |
+
logging.info(f"Deleted temporary directory: {temp_dir_path}")
|
325 |
+
except Exception as e:
|
326 |
+
logging.error(f"Error deleting temporary directory {temp_dir_path}: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|