rocky1410 commited on
Commit
2b86483
·
verified ·
1 Parent(s): 20531ef

Upload 6 files

Browse files
Files changed (6) hide show
  1. .dockerignore +10 -0
  2. .gitignore +9 -0
  3. Dockerfile +47 -0
  4. app.py +568 -0
  5. render.yaml +9 -0
  6. requirements.txt +4 -0
.dockerignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .gitignore
3
+ __pycache__
4
+ *.pyc
5
+ *.pyo
6
+ .env
7
+ .venv
8
+ venv
9
+ *.md
10
+ .dockerignore
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ .env
5
+ .venv/
6
+ venv/
7
+ .DS_Store
8
+ *.log
9
+ media/
Dockerfile ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Base Image
2
+ FROM python:3.11-slim
3
+
4
+ # Set environment variables
5
+ ENV PYTHONDONTWRITEBYTECODE=1
6
+ ENV PYTHONUNBUFFERED=1
7
+ ENV DEBIAN_FRONTEND=noninteractive
8
+
9
+ # Install System Dependencies
10
+ RUN apt-get update && apt-get install -y --no-install-recommends \
11
+ build-essential \
12
+ libcairo2-dev \
13
+ libpango1.0-dev \
14
+ libglib2.0-dev \
15
+ ffmpeg \
16
+ pkg-config \
17
+ # LaTeX packages
18
+ texlive-latex-base \
19
+ texlive-latex-extra \
20
+ texlive-fonts-recommended \
21
+ texlive-fonts-extra \
22
+ texlive-science \
23
+ texlive-latex-recommended \
24
+ latexmk \
25
+ # Required for dvi to svg/png conversion
26
+ dvisvgm \
27
+ dvipng \
28
+ # Additional tools
29
+ cm-super \
30
+ && apt-get clean \
31
+ && rm -rf /var/lib/apt/lists/*
32
+
33
+ # Work Directory
34
+ WORKDIR /app
35
+
36
+ # Install Python Dependencies
37
+ COPY requirements.txt .
38
+ RUN pip install --no-cache-dir -r requirements.txt
39
+
40
+ # Copy App
41
+ COPY . .
42
+
43
+ # Expose Streamlit Port
44
+ EXPOSE 8501
45
+
46
+ # Run App
47
+ CMD sh -c "streamlit run app.py --server.port=${PORT:-8501} --server.address=0.0.0.0 --server.headless=true --server.fileWatcherType=none"
app.py ADDED
@@ -0,0 +1,568 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ast
2
+ import os
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ import time
8
+ import uuid
9
+ from pathlib import Path
10
+
11
+ import streamlit as st
12
+
13
+ # --- Configuration ---
14
+ TIMEOUT_SECONDS = 600 # 10 minutes
15
+
16
+ # Known Manim Scene base classes
17
+ SCENE_BASE_CLASSES = {
18
+ "Scene",
19
+ "ThreeDScene",
20
+ "MovingCameraScene",
21
+ "ZoomedScene",
22
+ "VectorScene",
23
+ "LinearTransformationScene",
24
+ "SampleSpaceScene",
25
+ }
26
+
27
+ st.set_page_config(
28
+ page_title="Manim Render Studio",
29
+ page_icon="🎬",
30
+ layout="wide",
31
+ initial_sidebar_state="expanded",
32
+ )
33
+
34
+ st.markdown(
35
+ """
36
+ <style>
37
+ @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600&family=Playfair+Display:wght@600;700&display=swap');
38
+
39
+ .stApp {
40
+ background: radial-gradient(circle at 10% 20%, rgba(255, 244, 235, 0.9), transparent 50%),
41
+ linear-gradient(180deg, #f7f8fb 0%, #eef1f6 100%);
42
+ color: #1f2a37;
43
+ }
44
+ .main .block-container {
45
+ padding-top: 2rem;
46
+ padding-bottom: 2.5rem;
47
+ max-width: 1200px;
48
+ }
49
+ body, div, p, label, input, textarea {
50
+ font-family: 'Manrope', sans-serif;
51
+ }
52
+ h1, h2, h3 {
53
+ font-family: 'Playfair Display', serif;
54
+ font-weight: 700;
55
+ color: #1f2a37;
56
+ }
57
+ .stButton>button {
58
+ background: linear-gradient(135deg, #ff6b6b, #ff4b4b);
59
+ color: white;
60
+ border-radius: 10px;
61
+ padding: 0.6rem 2.2rem;
62
+ font-weight: 600;
63
+ border: none;
64
+ transition: all 0.3s ease;
65
+ box-shadow: 0 10px 20px rgba(255, 75, 75, 0.2);
66
+ }
67
+ .stButton>button:hover {
68
+ transform: translateY(-1px);
69
+ box-shadow: 0 12px 24px rgba(255, 75, 75, 0.3);
70
+ }
71
+ .stTextArea textarea {
72
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
73
+ border-radius: 12px;
74
+ border: 1px solid #e0e0e0;
75
+ background-color: #fbfbfd;
76
+ }
77
+ div[data-testid="stExpander"] {
78
+ border: none;
79
+ box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
80
+ background-color: white;
81
+ border-radius: 12px;
82
+ }
83
+ </style>
84
+ """,
85
+ unsafe_allow_html=True,
86
+ )
87
+
88
+
89
+ # --- Helper Functions ---
90
+ def create_temp_dir() -> Path:
91
+ """Creates a unique temporary directory for this render session."""
92
+ unique_id = uuid.uuid4().hex[:8]
93
+ temp_dir = Path(tempfile.gettempdir()) / f"manim_{unique_id}"
94
+ temp_dir.mkdir(parents=True, exist_ok=True)
95
+ return temp_dir
96
+
97
+
98
+ def cleanup_temp_dir(temp_dir: Path) -> None:
99
+ """Safely removes the temporary directory."""
100
+ try:
101
+ if temp_dir.exists():
102
+ shutil.rmtree(temp_dir)
103
+ except Exception:
104
+ pass # Ignore cleanup errors
105
+
106
+
107
+ def find_video_file(output_dir: Path, output_name: str = "output.mp4") -> Path | None:
108
+ """Recursively finds the mp4 file in the output directory."""
109
+ # First try the expected output path
110
+ expected_paths = [
111
+ output_dir / "videos" / "scene" / "480p15" / output_name,
112
+ output_dir / "videos" / "scene" / "720p30" / output_name,
113
+ output_dir / "videos" / "scene" / "1080p60" / output_name,
114
+ output_dir / "videos" / "scene" / "1440p60" / output_name,
115
+ output_dir / "videos" / "scene" / "2160p60" / output_name,
116
+ output_dir / output_name,
117
+ ]
118
+
119
+ for path in expected_paths:
120
+ if path.exists():
121
+ return path
122
+
123
+ # Fallback: search for any mp4 file
124
+ mp4_files = list(output_dir.glob("**/*.mp4"))
125
+ valid_files = [f for f in mp4_files if "partial_movie_files" not in str(f)]
126
+ if valid_files:
127
+ return max(valid_files, key=os.path.getmtime)
128
+ return None
129
+
130
+
131
+ def get_quality_flag(quality_label: str) -> str:
132
+ mapping = {
133
+ "Low (480p, Fast)": "-ql",
134
+ "Medium (720p, Standard)": "-qm",
135
+ "High (1080p, HD)": "-qh",
136
+ "Extra High (1440p)": "-qp",
137
+ "4K (2160p)": "-qk",
138
+ }
139
+ return mapping.get(quality_label, "-qm")
140
+
141
+
142
+ def extract_scene_classes(source_code: str) -> list[str]:
143
+ """Extract all classes that inherit from any Scene type."""
144
+ try:
145
+ tree = ast.parse(source_code)
146
+ scene_classes: list[str] = []
147
+ for node in ast.walk(tree):
148
+ if not isinstance(node, ast.ClassDef):
149
+ continue
150
+ for base in node.bases:
151
+ base_name = None
152
+ if isinstance(base, ast.Name):
153
+ base_name = base.id
154
+ elif isinstance(base, ast.Attribute):
155
+ base_name = base.attr
156
+ if base_name and (base_name in SCENE_BASE_CLASSES or "Scene" in base_name):
157
+ scene_classes.append(node.name)
158
+ break
159
+ if scene_classes:
160
+ return scene_classes
161
+ except SyntaxError:
162
+ pass
163
+
164
+ # Fallback: regex-based detection
165
+ pattern = r"class\s+(\w+)\s*\([^)]*(?:Scene|ThreeDScene|MovingCameraScene)[^)]*\)"
166
+ matches = re.findall(pattern, source_code)
167
+ return matches if matches else []
168
+
169
+
170
+ def count_animations(source_code: str) -> int:
171
+ """Count the number of self.play() and self.wait() calls in the code."""
172
+ # Count self.play( and self.wait( calls
173
+ play_count = len(re.findall(r"self\.play\s*\(", source_code))
174
+ wait_count = len(re.findall(r"self\.wait\s*\(", source_code))
175
+ return max(play_count + wait_count, 1) # At least 1 to avoid division by zero
176
+
177
+
178
+ def ensure_manim_import(code: str) -> str:
179
+ """Ensures the code has manim import if it uses manim classes."""
180
+ if "from manim import" in code or "import manim" in code:
181
+ return code
182
+ manim_indicators = ["Scene", "Circle", "Square", "Tex", "MathTex", "Create", "Write"]
183
+ if any(indicator in code for indicator in manim_indicators):
184
+ return "from manim import *\n\n" + code
185
+ return code
186
+
187
+
188
+ class RenderProgressTracker:
189
+ """Tracks render progress in real-time based on manim output."""
190
+
191
+ # Render stages with their progress weight
192
+ STAGES = {
193
+ "init": (0, 5, "Initializing..."),
194
+ "parsing": (5, 10, "Parsing scene..."),
195
+ "latex": (10, 20, "Compiling LaTeX..."),
196
+ "rendering": (20, 85, "Rendering animations..."),
197
+ "combining": (85, 95, "Combining video segments..."),
198
+ "writing": (95, 99, "Writing final video..."),
199
+ "done": (100, 100, "Complete!"),
200
+ }
201
+
202
+ def __init__(self, total_animations: int):
203
+ self.total_animations = total_animations
204
+ self.current_animation = 0
205
+ self.current_stage = "init"
206
+ self.current_animation_progress = 0
207
+ self.last_status = ""
208
+
209
+ def parse_line(self, line: str) -> tuple[int, str]:
210
+ """
211
+ Parse a line of manim output and return (progress_percent, status_message).
212
+ """
213
+ line_lower = line.lower().strip()
214
+
215
+ # Skip empty lines
216
+ if not line_lower:
217
+ return self._calculate_progress(), self.last_status
218
+
219
+ # Detect stage transitions
220
+ if "error" in line_lower or "traceback" in line_lower:
221
+ return self._calculate_progress(), f"Error: {line[:50]}..."
222
+
223
+ # LaTeX compilation
224
+ if "tex" in line_lower and ("writing" in line_lower or "compiling" in line_lower):
225
+ self.current_stage = "latex"
226
+ self.last_status = "Compiling LaTeX..."
227
+ return self._calculate_progress(), self.last_status
228
+
229
+ # Animation detection - look for "Animation X:" pattern
230
+ anim_match = re.search(r"animation\s+(\d+)", line_lower)
231
+ if anim_match:
232
+ self.current_stage = "rendering"
233
+ self.current_animation = int(anim_match.group(1))
234
+
235
+ # Extract animation name if present
236
+ name_match = re.search(r"animation\s+\d+\s*:\s*(\w+)", line, re.IGNORECASE)
237
+ anim_name = name_match.group(1) if name_match else "animation"
238
+
239
+ self.last_status = f"Rendering {anim_name} ({self.current_animation}/{self.total_animations})"
240
+
241
+ # Progress percentage within current animation
242
+ percent_match = re.search(r"(\d+)%", line)
243
+ if percent_match and self.current_stage == "rendering":
244
+ self.current_animation_progress = int(percent_match.group(1))
245
+ return self._calculate_progress(), self.last_status
246
+
247
+ # Combining/concatenating partial movies
248
+ if "partial movie" in line_lower or "combining" in line_lower or "concatenat" in line_lower:
249
+ self.current_stage = "combining"
250
+ self.last_status = "Combining video segments..."
251
+ return self._calculate_progress(), self.last_status
252
+
253
+ # Writing final file
254
+ if ("writing" in line_lower or "saved" in line_lower) and "tex" not in line_lower:
255
+ self.current_stage = "writing"
256
+ self.last_status = "Writing final video..."
257
+ return self._calculate_progress(), self.last_status
258
+
259
+ # File ready
260
+ if "file ready" in line_lower or "movie ready" in line_lower:
261
+ self.current_stage = "done"
262
+ self.last_status = "Complete!"
263
+ return 100, self.last_status
264
+
265
+ # Scene initialization
266
+ if "scene" in line_lower and self.current_stage == "init":
267
+ self.current_stage = "parsing"
268
+ self.last_status = "Parsing scene..."
269
+
270
+ return self._calculate_progress(), self.last_status or "Processing..."
271
+
272
+ def _calculate_progress(self) -> int:
273
+ """Calculate overall progress based on current stage and animation."""
274
+ stage_start, stage_end, _ = self.STAGES.get(self.current_stage, (0, 5, ""))
275
+
276
+ if self.current_stage == "rendering" and self.total_animations > 0:
277
+ # Calculate progress within rendering stage based on animations
278
+ render_start, render_end, _ = self.STAGES["rendering"]
279
+ render_range = render_end - render_start
280
+
281
+ # Progress = completed animations + current animation progress
282
+ completed_progress = (self.current_animation - 1) / self.total_animations
283
+ current_progress = (self.current_animation_progress / 100) / self.total_animations
284
+
285
+ total_anim_progress = completed_progress + current_progress
286
+ return int(render_start + (render_range * total_anim_progress))
287
+
288
+ return stage_start
289
+
290
+
291
+ def run_manim_with_progress(
292
+ cmd: list,
293
+ cwd: str,
294
+ timeout: int,
295
+ total_animations: int,
296
+ progress_bar,
297
+ status_text,
298
+ ) -> tuple[bool, str]:
299
+ """
300
+ Run manim command with real-time progress updates.
301
+ Returns (success: bool, log_output: str).
302
+ """
303
+ log_lines: list[str] = []
304
+ tracker = RenderProgressTracker(total_animations)
305
+
306
+ # Log the command being run
307
+ log_lines.append(f"Command: {' '.join(cmd)}\n")
308
+ log_lines.append(f"Working directory: {cwd}\n")
309
+ log_lines.append("-" * 50 + "\n")
310
+
311
+ status_text.text("Starting render...")
312
+ progress_bar.progress(0)
313
+
314
+ try:
315
+ process = subprocess.Popen(
316
+ cmd,
317
+ stdout=subprocess.PIPE,
318
+ stderr=subprocess.STDOUT,
319
+ text=True,
320
+ cwd=cwd,
321
+ bufsize=1,
322
+ universal_newlines=True,
323
+ )
324
+
325
+ start_time = time.time()
326
+ last_progress = 0
327
+
328
+ while True:
329
+ # Check timeout
330
+ elapsed = time.time() - start_time
331
+ if elapsed > timeout:
332
+ process.kill()
333
+ raise subprocess.TimeoutExpired(cmd, timeout)
334
+
335
+ if process.stdout is None:
336
+ break
337
+
338
+ line = process.stdout.readline()
339
+ if not line and process.poll() is not None:
340
+ break
341
+
342
+ if line:
343
+ log_lines.append(line)
344
+ progress, status = tracker.parse_line(line)
345
+
346
+ # Only update if progress increased (avoid flickering)
347
+ if progress > last_progress:
348
+ last_progress = progress
349
+ progress_bar.progress(min(progress, 100))
350
+
351
+ if status:
352
+ status_text.text(status)
353
+
354
+ # Wait for process to complete and get return code
355
+ return_code = process.wait()
356
+
357
+ # Log the return code
358
+ log_lines.append("-" * 50 + "\n")
359
+ log_lines.append(f"Process exited with code: {return_code}\n")
360
+
361
+ # Final progress
362
+ if return_code == 0:
363
+ progress_bar.progress(100)
364
+ status_text.text("Render complete!")
365
+ else:
366
+ status_text.text("Render failed!")
367
+
368
+ return (return_code == 0, "".join(log_lines))
369
+
370
+ except subprocess.TimeoutExpired:
371
+ log_lines.append(f"\nProcess timed out after {timeout} seconds\n")
372
+ raise
373
+ except Exception as e:
374
+ log_lines.append(f"\nException: {str(e)}\n")
375
+ return (False, "".join(log_lines))
376
+
377
+
378
+ # --- UI Layout ---
379
+
380
+ with st.sidebar:
381
+ st.title("Settings")
382
+
383
+ quality = st.selectbox(
384
+ "Render Quality",
385
+ [
386
+ "Low (480p, Fast)",
387
+ "Medium (720p, Standard)",
388
+ "High (1080p, HD)",
389
+ "Extra High (1440p)",
390
+ "4K (2160p)",
391
+ ],
392
+ index=1,
393
+ )
394
+
395
+ st.info(
396
+ """
397
+ **Instructions:**
398
+ 1. Paste your Manim code on the right.
399
+ 2. Select the Scene class to render.
400
+ 3. Click 'Render Scene'.
401
+ """
402
+ )
403
+
404
+ st.markdown("---")
405
+ st.markdown("Made with Manim & Streamlit")
406
+
407
+ st.title("Manim Render Studio")
408
+ st.markdown("### Paste your code below and bring your math to life.")
409
+
410
+ # Default code with LaTeX example
411
+ default_code = '''from manim import *
412
+
413
+ class DemoScene(Scene):
414
+ def construct(self):
415
+ # LaTeX formula
416
+ formula = MathTex(r"e^{i\\pi} + 1 = 0", font_size=72)
417
+ formula.set_color(BLUE)
418
+
419
+ # Title
420
+ title = Text("Euler's Identity", font_size=48)
421
+ title.next_to(formula, UP, buff=0.8)
422
+
423
+ # Animate
424
+ self.play(Write(title))
425
+ self.play(Write(formula))
426
+ self.wait(1)
427
+
428
+ # Transform
429
+ circle = Circle(color=PINK, fill_opacity=0.5)
430
+ self.play(ReplacementTransform(formula, circle))
431
+ self.wait(0.5)
432
+ '''
433
+
434
+ code_input = st.text_area("Python Code", value=default_code, height=400)
435
+
436
+ # Auto-detect scene classes
437
+ scene_candidates = extract_scene_classes(code_input)
438
+
439
+ if scene_candidates:
440
+ scene_name = st.selectbox("Scene Class (auto-detected)", scene_candidates)
441
+ else:
442
+ scene_name = st.text_input(
443
+ "Scene Class Name",
444
+ value="DemoScene",
445
+ help="Could not auto-detect. Enter the class name manually.",
446
+ )
447
+
448
+ col1, col2 = st.columns([1, 4])
449
+ with col1:
450
+ render_button = st.button("Render Scene")
451
+
452
+ if render_button:
453
+ if not code_input.strip():
454
+ st.error("Please enter some Manim code.")
455
+ elif not scene_name:
456
+ st.error("Please specify a Scene class name.")
457
+ else:
458
+ # Prepare code (add import if missing)
459
+ final_code = ensure_manim_import(code_input)
460
+
461
+ # Check for syntax errors first
462
+ try:
463
+ ast.parse(final_code)
464
+ except SyntaxError as e:
465
+ st.error(f"Syntax error in your code at line {e.lineno}: {e.msg}")
466
+ st.stop()
467
+
468
+ # Count animations for progress tracking
469
+ total_animations = count_animations(final_code)
470
+
471
+ # Create unique temp directory for this render
472
+ temp_dir = create_temp_dir()
473
+ script_path = temp_dir / "scene.py"
474
+
475
+ # UI elements for progress (defined outside try for cleanup)
476
+ status_text = st.empty()
477
+ progress_bar = st.progress(0)
478
+
479
+ try:
480
+ # Write code to file
481
+ with open(script_path, "w", encoding="utf-8") as f:
482
+ f.write(final_code)
483
+
484
+ # Construct Command
485
+ quality_flag = get_quality_flag(quality)
486
+ cmd = [
487
+ "manim",
488
+ str(script_path),
489
+ scene_name,
490
+ "-o",
491
+ "output.mp4",
492
+ "--media_dir",
493
+ str(temp_dir),
494
+ quality_flag,
495
+ "--disable_caching",
496
+ "-v", "INFO", # Verbose output for progress tracking
497
+ ]
498
+
499
+ # Run render with progress tracking
500
+ success, log_output = run_manim_with_progress(
501
+ cmd=cmd,
502
+ cwd=str(temp_dir),
503
+ timeout=TIMEOUT_SECONDS,
504
+ total_animations=total_animations,
505
+ progress_bar=progress_bar,
506
+ status_text=status_text,
507
+ )
508
+
509
+ # Clear progress UI
510
+ status_text.empty()
511
+ progress_bar.empty()
512
+
513
+ # Find video file
514
+ video_file = find_video_file(temp_dir)
515
+
516
+ if success and video_file and video_file.exists():
517
+ # SUCCESS - Show video and download
518
+ st.success("Render Successful!")
519
+
520
+ # Read video bytes BEFORE cleanup
521
+ video_bytes = video_file.read_bytes()
522
+
523
+ # Video preview
524
+ st.video(video_bytes)
525
+
526
+ # Download button
527
+ st.download_button(
528
+ label="Download Video",
529
+ data=video_bytes,
530
+ file_name=f"{scene_name}.mp4",
531
+ mime="video/mp4",
532
+ )
533
+
534
+ # Logs (collapsed)
535
+ with st.expander("Render Logs"):
536
+ st.code(log_output, language="text")
537
+
538
+ elif success and not video_file:
539
+ # Render succeeded but no video found
540
+ all_files = list(temp_dir.rglob("*"))
541
+ debug_info = "\n\nFiles in temp directory:\n"
542
+ for f in all_files:
543
+ if f.is_file():
544
+ debug_info += f" {f}\n"
545
+
546
+ st.error("Render completed but video file was not found.")
547
+ with st.expander("Render Logs", expanded=True):
548
+ st.code(log_output + debug_info, language="text")
549
+ else:
550
+ # FAILED - Show error and logs
551
+ st.error("Render Failed!")
552
+ with st.expander("Render Logs", expanded=True):
553
+ st.code(log_output, language="text")
554
+
555
+ except subprocess.TimeoutExpired:
556
+ status_text.empty()
557
+ progress_bar.empty()
558
+ st.error(f"Render timed out after {TIMEOUT_SECONDS // 60} minutes.")
559
+ except Exception as e:
560
+ status_text.empty()
561
+ progress_bar.empty()
562
+ st.error(f"An unexpected error occurred: {str(e)}")
563
+ import traceback
564
+ with st.expander("Error Details", expanded=True):
565
+ st.code(traceback.format_exc(), language="text")
566
+ finally:
567
+ # Always cleanup temp directory
568
+ cleanup_temp_dir(temp_dir)
render.yaml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ - type: web
3
+ name: manim-render-studio
4
+ runtime: docker
5
+ plan: starter
6
+ healthCheckPath: /_stcore/health
7
+ envVars:
8
+ - key: PYTHONUNBUFFERED
9
+ value: "1"
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # Core dependencies
2
+ manim>=0.18.0
3
+ streamlit>=1.28.0
4
+ watchdog>=3.0.0