programmersd commited on
Commit
fa3da87
·
1 Parent(s): e65b972
Files changed (7) hide show
  1. .gitignore +11 -0
  2. README.md +27 -37
  3. app.py +135 -79
  4. packages.txt +14 -0
  5. pipeline.py +37 -50
  6. requirements.txt +14 -5
  7. utils.py +7 -17
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+
5
+ # Media
6
+ media/videos/
7
+ *.mp4
8
+ *.mov
9
+
10
+ # System files
11
+ .DS_Store
README.md CHANGED
@@ -10,9 +10,6 @@ app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- This Hugging Face Space lets users upload PDFs, generates Manim animations via Gemini LLM, renders a video, and sends it via email or uploads to Catbox.moe.
14
-
15
- =======
16
  # 🎬 PDF → Manim Animation Pipeline
17
 
18
  A Hugging Face Spaces Gradio app that converts any PDF into an animated Manim video and delivers it to your inbox.
@@ -21,7 +18,7 @@ A Hugging Face Spaces Gradio app that converts any PDF into an animated Manim vi
21
 
22
  ## 🚀 Quick Start
23
 
24
- ### 1. Clone / Upload to Hugging Face Spaces
25
 
26
  Create a new Space on [huggingface.co/spaces](https://huggingface.co/spaces) with:
27
  - **SDK:** Gradio
@@ -35,10 +32,10 @@ Upload all files in this folder.
35
 
36
  In your Space settings → **Secrets**, add:
37
 
38
- | Secret Name | Value |
39
- |-----------------|------------------------------------------|
40
- | `SMTP_EMAIL` | Your Gmail address (e.g. `you@gmail.com`) |
41
- | `SMTP_PASSWORD` | Gmail App Password (16-char, not your login password) |
42
 
43
  **How to get a Gmail App Password:**
44
  1. Enable 2-Step Verification on your Google account.
@@ -53,10 +50,10 @@ In your Space settings → **Secrets**, add:
53
 
54
  1. Open your Space URL.
55
  2. Upload a PDF file.
56
- 3. Enter your email address.
57
- 4. Enter your **Gemini API key** (get one free at [aistudio.google.com](https://aistudio.google.com)).
58
  5. Click **Generate Video**.
59
- 6. Refresh the status panel until done the video will arrive in your inbox!
60
 
61
  ---
62
 
@@ -70,7 +67,7 @@ Extract Text (pypdf)
70
 
71
 
72
  Gemini 2.5 Flash (google-genai)
73
- generates OutputVideo(Scene) Manim code
74
 
75
  Manim Render (-qm, 1280×720, 30fps)
76
 
@@ -78,7 +75,6 @@ Manim Render (-qm, 1280×720, 30fps)
78
  Video < 24MB? ──YES──▶ Email Attachment
79
  │ │
80
  NO │
81
- │ │
82
  ▼ │
83
  Catbox.moe Upload │
84
  │ │
@@ -93,13 +89,13 @@ Cleanup temp files
93
 
94
  ## ⚙️ Configuration
95
 
96
- | Setting | Default | Notes |
97
- |---------|---------|-------|
98
- | Max concurrent jobs | 100 | Set in `app.py` → `JobQueue(max_jobs=100)` |
99
- | Thread workers | 8 | Set in `app.py` → `JobQueue(max_workers=8)` |
100
- | Manim quality | `-qm` (720p30) | Change in `pipeline.py` |
101
- | Render timeout | 600s | Change in `pipeline.py` → `_render_manim` |
102
- | Catbox retries | 5 | Change in `utils.py` |
103
 
104
  ---
105
 
@@ -107,29 +103,23 @@ Cleanup temp files
107
 
108
  ```
109
  .
110
- ├── app.py # Gradio UI + job submission
111
- ├── pipeline.py # Full job pipeline orchestrator
112
- ├── queue_manager.py # Thread-pool job queue with status tracking
113
  ├── utils.py # PDF, Gemini, email, Catbox helpers
114
  ├── requirements.txt # Python dependencies
115
- ├── README.md # This file
116
- ── media/ # Manim render outputs (auto-created)
117
- │ └── videos/
118
- │ └── <job_id>/
119
- │ └── 720p30/
120
- │ └── OutputVideo.mp4
121
- └── jobs/ # Per-job Manim scripts (auto-created, auto-deleted)
122
- └── <job_id>/
123
- └── <job_id>.py
124
  ```
125
 
126
  ---
127
 
128
  ## 🔒 Security Notes
129
 
130
- - Gemini API keys are passed per-request and never stored.
 
131
  - SMTP credentials live only in HF Secrets (env vars).
132
- - All temporary files are deleted after pipeline completion.
133
  - Each job uses a UUID to prevent path collisions.
134
 
135
  ---
@@ -139,8 +129,8 @@ Cleanup temp files
139
  | Issue | Fix |
140
  |-------|-----|
141
  | `SMTP_EMAIL not set` | Add the secret in HF Space settings |
 
142
  | Render timeout | Use a higher-tier Space hardware |
143
  | Gemini API error | Check your API key and quota |
144
- | Empty PDF | Ensure the PDF has selectable text (not a scanned image) |
145
- | Large video not emailed | Catbox fallback will be used automatically |
146
- >>>>>>> d5995c3 (Initial commit of PDF → Manim → LLM app)
 
10
  pinned: false
11
  ---
12
 
 
 
 
13
  # 🎬 PDF → Manim Animation Pipeline
14
 
15
  A Hugging Face Spaces Gradio app that converts any PDF into an animated Manim video and delivers it to your inbox.
 
18
 
19
  ## 🚀 Quick Start
20
 
21
+ ### 1. Upload to Hugging Face Spaces
22
 
23
  Create a new Space on [huggingface.co/spaces](https://huggingface.co/spaces) with:
24
  - **SDK:** Gradio
 
32
 
33
  In your Space settings → **Secrets**, add:
34
 
35
+ | Secret Name | Value |
36
+ |-----------------|--------------------------------------------|
37
+ | `SMTP_EMAIL` | Your Gmail address (e.g. `you@gmail.com`) |
38
+ | `SMTP_PASSWORD` | Gmail App Password (16-char, not login pw) |
39
 
40
  **How to get a Gmail App Password:**
41
  1. Enable 2-Step Verification on your Google account.
 
50
 
51
  1. Open your Space URL.
52
  2. Upload a PDF file.
53
+ 3. Enter your email address *(saved in browser — no need to re-enter)*.
54
+ 4. Enter your **Gemini API key** *(saved in browser)* — get one free at [aistudio.google.com](https://aistudio.google.com).
55
  5. Click **Generate Video**.
56
+ 6. Watch live status updates the generated Manim code appears in-app, and the video arrives in your inbox!
57
 
58
  ---
59
 
 
67
 
68
 
69
  Gemini 2.5 Flash (google-genai)
70
+ generated code shown live in UI
71
 
72
  Manim Render (-qm, 1280×720, 30fps)
73
 
 
75
  Video < 24MB? ──YES──▶ Email Attachment
76
  │ │
77
  NO │
 
78
  ▼ │
79
  Catbox.moe Upload │
80
  │ │
 
89
 
90
  ## ⚙️ Configuration
91
 
92
+ | Setting | Default | Location |
93
+ |--------------------|--------------|---------------------------------|
94
+ | Max concurrent jobs| 100 | `app.py` → `JobQueue(max_jobs=)`|
95
+ | Thread workers | 8 | `app.py` → `JobQueue(max_workers=)`|
96
+ | Manim quality | `-qm` 720p30 | `pipeline.py` → `_render_manim` |
97
+ | Render timeout | 600s | `pipeline.py` → `_render_manim` |
98
+ | Catbox retries | 5 | `utils.py` |
99
 
100
  ---
101
 
 
103
 
104
  ```
105
  .
106
+ ├── app.py # Gradio UI + streaming status + BrowserState persistence
107
+ ├── pipeline.py # Job pipeline orchestrator
108
+ ├── queue_manager.py # Thread-pool queue with state tracking
109
  ├── utils.py # PDF, Gemini, email, Catbox helpers
110
  ├── requirements.txt # Python dependencies
111
+ ├── packages.txt # System packages (LaTeX, ffmpeg, cairo…)
112
+ ── README.md # This file
 
 
 
 
 
 
 
113
  ```
114
 
115
  ---
116
 
117
  ## 🔒 Security Notes
118
 
119
+ - Gemini API keys are passed per-request and **never stored server-side**.
120
+ - Email/API key persisted in **browser localStorage** (client only).
121
  - SMTP credentials live only in HF Secrets (env vars).
122
+ - All temporary files deleted after pipeline completion.
123
  - Each job uses a UUID to prevent path collisions.
124
 
125
  ---
 
129
  | Issue | Fix |
130
  |-------|-----|
131
  | `SMTP_EMAIL not set` | Add the secret in HF Space settings |
132
+ | `latex not found` | Ensure `packages.txt` is in your Space root |
133
  | Render timeout | Use a higher-tier Space hardware |
134
  | Gemini API error | Check your API key and quota |
135
+ | Empty PDF | PDF must have selectable text (not scanned image) |
136
+ | Large video | Catbox fallback used automatically |
 
app.py CHANGED
@@ -1,78 +1,117 @@
1
  """
2
- PDF → Manim Animation → Video → Email Pipeline
3
- Hugging Face Spaces Gradio App
4
  """
5
 
6
- import gradio as gr
 
 
7
  import threading
8
  import uuid
9
- import time
10
  from pathlib import Path
11
 
12
- from queue_manager import JobQueue, JobStatus
 
 
13
  from pipeline import run_pipeline
14
 
15
- # ── Global queue ────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
16
  job_queue = JobQueue(max_workers=8, max_jobs=100)
17
 
18
 
19
- # ── Helpers ──────────────────────────────────────────────────────────────────
20
- def validate_inputs(pdf_file, email: str, api_key: str) -> str | None:
 
 
 
 
 
 
21
  if pdf_file is None:
22
- return "Please upload a PDF file."
 
23
  if not email or "@" not in email:
24
- return "Please enter a valid email address."
 
25
  if not api_key or len(api_key) < 10:
26
- return "Please enter a valid Gemini API key."
27
- return None
28
-
29
-
30
- def submit_job(pdf_file, email: str, api_key: str):
31
- """Validate, enqueue, and return a job_id."""
32
- err = validate_inputs(pdf_file, email, api_key)
33
- if err:
34
- return gr.update(value=err, visible=True), gr.update(visible=False), ""
35
-
36
  if job_queue.is_full():
37
- return (
38
- gr.update(value="⚠️ Queue is full (max 100 jobs). Please try again later.", visible=True),
39
- gr.update(visible=False),
40
- "",
41
- )
42
 
43
  job_id = uuid.uuid4().hex
44
- pdf_path = pdf_file.name # temp path from Gradio
45
-
46
- job_queue.enqueue(
47
- job_id=job_id,
48
- fn=run_pipeline,
49
- kwargs={
50
- "job_id": job_id,
51
- "pdf_path": pdf_path,
52
- "email": email,
53
- "gemini_api_key": api_key,
54
- },
55
- )
56
-
57
- return (
58
- gr.update(value="", visible=False),
59
- gr.update(visible=True),
60
- job_id,
61
- )
62
-
63
-
64
- def poll_status(job_id: str):
65
- """Return a status string for the given job_id."""
66
- if not job_id:
67
- return "—"
68
- status = job_queue.get_status(job_id)
69
- if status is None:
70
- return "Unknown job ID."
71
- return status.display()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
 
74
  # ── UI ────────────────────────────────────────────────────────────────────────
75
  with gr.Blocks(title="PDF → Manim Video") as demo:
 
76
  gr.Markdown(
77
  """
78
  # 🎬 PDF → Manim Animation Pipeline
@@ -80,50 +119,67 @@ with gr.Blocks(title="PDF → Manim Video") as demo:
80
  """
81
  )
82
 
 
 
 
 
83
  with gr.Row():
84
  with gr.Column(scale=1):
85
  pdf_input = gr.File(label="📄 Upload PDF", file_types=[".pdf"])
86
- email_input = gr.Textbox(label="📧 Your Email", placeholder="you@example.com")
 
 
 
87
  api_key_input = gr.Textbox(
88
  label="🔑 Gemini API Key",
89
- placeholder="AIza...",
90
  type="password",
91
  )
92
  submit_btn = gr.Button("🚀 Generate Video", variant="primary")
93
 
94
  with gr.Column(scale=1):
95
- error_box = gr.Markdown(visible=False, elem_id="error_box")
96
- status_panel = gr.Markdown("### Job Status\n—", visible=False)
97
- job_id_state = gr.State("")
98
- refresh_btn = gr.Button("🔄 Refresh Status", visible=False)
99
-
100
- # ── Events ────────────────────────────────────────────────────────────────
101
- submit_btn.click(
102
- fn=submit_job,
103
- inputs=[pdf_input, email_input, api_key_input],
104
- outputs=[error_box, status_panel, job_id_state],
105
- ).then(
106
- fn=lambda jid: gr.update(visible=bool(jid)),
107
- inputs=[job_id_state],
108
- outputs=[refresh_btn],
109
- )
110
-
111
- refresh_btn.click(
112
- fn=lambda jid: gr.update(value=f"### Job Status\n{poll_status(jid)}"),
113
- inputs=[job_id_state],
114
- outputs=[status_panel],
115
- )
116
 
117
  gr.Markdown(
118
  """
119
  ---
120
  **Notes:**
121
  - Processing typically takes 2–5 minutes.
122
- - The video will be emailed to you once rendered.
123
- - Your Gemini API key is used only for this request and never stored.
124
  - Requires `SMTP_EMAIL` and `SMTP_PASSWORD` environment secrets on the Space.
125
  """
126
  )
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  if __name__ == "__main__":
129
- demo.launch(theme=gr.themes.Soft())
 
 
 
 
 
 
1
  """
2
+ PDF → Manim Animation Pipeline
3
+ Hugging Face Spaces Gradio 6.x
4
  """
5
 
6
+ import asyncio
7
+ import atexit
8
+ import queue
9
  import threading
10
  import uuid
 
11
  from pathlib import Path
12
 
13
+ import gradio as gr
14
+
15
+ from queue_manager import JobQueue, State
16
  from pipeline import run_pipeline
17
 
18
+ # ── Asyncio cleanup fix (suppresses Invalid file descriptor errors) ───────────
19
+ def _cleanup_event_loop():
20
+ try:
21
+ loop = asyncio.get_event_loop()
22
+ if not loop.is_closed():
23
+ loop.close()
24
+ except Exception:
25
+ pass
26
+
27
+ atexit.register(_cleanup_event_loop)
28
+
29
+ # ── Global queue ──────────────────────────────────────────────────────────────
30
  job_queue = JobQueue(max_workers=8, max_jobs=100)
31
 
32
 
33
+ # ── Pipeline streaming generator ──────────────────────────────────────────────
34
+ def submit_and_stream(pdf_file, email: str, api_key: str):
35
+ """
36
+ Generator — yields (status_md, code_text, code_visible) tuples live.
37
+ Drives the entire UI update without any polling button.
38
+ """
39
+
40
+ # ── Validate ──────────────────────────────────────────────────────────────
41
  if pdf_file is None:
42
+ yield "Please upload a PDF file.", "", gr.update(visible=False)
43
+ return
44
  if not email or "@" not in email:
45
+ yield "Please enter a valid email address.", "", gr.update(visible=False)
46
+ return
47
  if not api_key or len(api_key) < 10:
48
+ yield "Please enter a valid Gemini API key.", "", gr.update(visible=False)
49
+ return
 
 
 
 
 
 
 
 
50
  if job_queue.is_full():
51
+ yield "⚠️ Queue is full (max 100 jobs). Please try again shortly.", "", gr.update(visible=False)
52
+ return
 
 
 
53
 
54
  job_id = uuid.uuid4().hex
55
+ pdf_path = pdf_file.name
56
+
57
+ # Thread-safe channel for status + code updates
58
+ update_q: queue.Queue = queue.Queue()
59
+
60
+ def status_cb(state: State, message: str = "", code: str | None = None):
61
+ update_q.put((state, message, code))
62
+
63
+ def _run():
64
+ try:
65
+ run_pipeline(
66
+ job_id=job_id,
67
+ pdf_path=pdf_path,
68
+ email=email,
69
+ gemini_api_key=api_key,
70
+ status_cb=status_cb,
71
+ )
72
+ update_q.put((State.DONE, "Video sent to your inbox! 🎉", None))
73
+ except Exception as exc:
74
+ update_q.put((State.FAILED, str(exc), None))
75
+ finally:
76
+ update_q.put(None) # sentinel
77
+
78
+ thread = threading.Thread(target=_run, daemon=True)
79
+ thread.start()
80
+
81
+ code_so_far = ""
82
+ yield f"⏳ **Queued** — Starting…\n\n*Job ID: `{job_id}`*", "", gr.update(visible=False)
83
+
84
+ while True:
85
+ item = update_q.get()
86
+ if item is None:
87
+ break
88
+
89
+ state, message, code = item
90
+
91
+ icons = {
92
+ State.QUEUED: "⏳",
93
+ State.RUNNING: "⚙️",
94
+ State.UPLOADING: "☁️",
95
+ State.SENDING: "📧",
96
+ State.DONE: "✅",
97
+ State.FAILED: "❌",
98
+ }
99
+ icon = icons.get(state, "❓")
100
+ status_text = f"{icon} **{state.value.title()}** — {message}\n\n*Job ID: `{job_id}`*"
101
+
102
+ if code is not None:
103
+ code_so_far = code
104
+
105
+ yield (
106
+ status_text,
107
+ code_so_far,
108
+ gr.update(visible=bool(code_so_far)),
109
+ )
110
 
111
 
112
  # ── UI ────────────────────────────────────────────────────────────────────────
113
  with gr.Blocks(title="PDF → Manim Video") as demo:
114
+
115
  gr.Markdown(
116
  """
117
  # 🎬 PDF → Manim Animation Pipeline
 
119
  """
120
  )
121
 
122
+ # Persistent browser-side storage (survives page refresh)
123
+ saved_email = gr.BrowserState("")
124
+ saved_api_key = gr.BrowserState("")
125
+
126
  with gr.Row():
127
  with gr.Column(scale=1):
128
  pdf_input = gr.File(label="📄 Upload PDF", file_types=[".pdf"])
129
+ email_input = gr.Textbox(
130
+ label="📧 Your Email",
131
+ placeholder="you@example.com",
132
+ )
133
  api_key_input = gr.Textbox(
134
  label="🔑 Gemini API Key",
135
+ placeholder="AIza",
136
  type="password",
137
  )
138
  submit_btn = gr.Button("🚀 Generate Video", variant="primary")
139
 
140
  with gr.Column(scale=1):
141
+ status_md = gr.Markdown("*Submit a job to see live status here.*")
142
+ code_box = gr.Code(
143
+ label="📝 Generated Manim Code",
144
+ language="python",
145
+ visible=False,
146
+ interactive=False,
147
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
  gr.Markdown(
150
  """
151
  ---
152
  **Notes:**
153
  - Processing typically takes 2–5 minutes.
154
+ - The video will be emailed once rendered; large files use a Catbox.moe link.
155
+ - Your Gemini API key is used only for this request and never stored server-side.
156
  - Requires `SMTP_EMAIL` and `SMTP_PASSWORD` environment secrets on the Space.
157
  """
158
  )
159
 
160
+ # ── Restore persisted values on page load ─────────────────────────────────
161
+ demo.load(
162
+ fn=lambda e, k: (e, k),
163
+ inputs=[saved_email, saved_api_key],
164
+ outputs=[email_input, api_key_input],
165
+ )
166
+
167
+ # ── Save values to browser storage whenever they change ───────────────────
168
+ email_input.change(fn=lambda v: v, inputs=[email_input], outputs=[saved_email])
169
+ api_key_input.change(fn=lambda v: v, inputs=[api_key_input], outputs=[saved_api_key])
170
+
171
+ # ── Streaming job submission ───────────────────────────────────────────────
172
+ submit_btn.click(
173
+ fn=submit_and_stream,
174
+ inputs=[pdf_input, email_input, api_key_input],
175
+ outputs=[status_md, code_box, code_box],
176
+ )
177
+
178
+
179
  if __name__ == "__main__":
180
+ demo.launch(
181
+ theme=gr.themes.Soft(),
182
+ ssr_mode=False,
183
+ server_name="0.0.0.0",
184
+ server_port=7860,
185
+ )
packages.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Full LaTeX distribution for Manim
2
+ texlive-full
3
+
4
+ # Video and rendering dependencies
5
+ ffmpeg
6
+ ghostscript
7
+
8
+ # Tools for DVI/TeX output
9
+ dvipng
10
+ dvisvgm
11
+
12
+ # Graphics libraries
13
+ libcairo2-dev
14
+ libpango1.0-dev
pipeline.py CHANGED
@@ -7,8 +7,8 @@ from __future__ import annotations
7
  import os
8
  import shutil
9
  import subprocess
10
- import tempfile
11
  import textwrap
 
12
  from pathlib import Path
13
  from typing import Callable
14
 
@@ -21,9 +21,8 @@ from utils import (
21
  sanitize_manim_code,
22
  )
23
 
24
- # Base directory for all render outputs
25
  MEDIA_ROOT = Path("media/videos")
26
- JOBS_ROOT = Path("jobs")
27
 
28
 
29
  def run_pipeline(
@@ -31,56 +30,54 @@ def run_pipeline(
31
  pdf_path: str,
32
  email: str,
33
  gemini_api_key: str,
34
- status_cb: Callable[[State, str], None],
35
  ) -> None:
36
- """
37
- Full pipeline for one job. Raises on unrecoverable errors.
38
- """
39
 
40
- job_dir = JOBS_ROOT / job_id
41
  job_dir.mkdir(parents=True, exist_ok=True)
42
 
43
- script_path = job_dir / f"{job_id}.py"
44
- video_path = MEDIA_ROOT / job_id / "720p30" / "OutputVideo.mp4"
45
-
46
  try:
47
- # ── 1. Extract PDF text ──────────────────────────────────────────────
48
- status_cb(State.RUNNING, "📖 Extracting text from PDF…")
49
  pdf_text = extract_pdf_text(pdf_path)
50
  if not pdf_text.strip():
51
- raise ValueError("PDF appears to be empty or unreadable.")
52
 
53
- # ── 2. Generate Manim code via Gemini ────────────────────────────────
54
- status_cb(State.RUNNING, "🤖 Generating Manim animation code with Gemini…")
55
- prompt = _build_prompt(pdf_text, job_id)
56
- raw_code = generate_manim_code(prompt, gemini_api_key)
57
  manim_code = sanitize_manim_code(raw_code, job_id)
58
 
 
 
 
59
  script_path.write_text(manim_code, encoding="utf-8")
60
 
61
- # ── 3. Render with Manim ─────────────────────────────────────────────
62
- status_cb(State.RUNNING, "🎬 Rendering animation (this may take a few minutes)…")
63
  _render_manim(script_path, job_id)
64
 
65
  if not video_path.exists():
66
  raise FileNotFoundError(f"Rendered video not found at {video_path}")
67
 
68
- # ── 4. Email (with Catbox fallback) ──────────────────────────────────
69
- status_cb(State.SENDING, "📧 Sending video to your inbox…")
70
  _deliver_video(str(video_path), email, status_cb)
71
 
72
  finally:
73
- # ── 5. Cleanup ───────────────────────────────────────────────────────
74
  _cleanup(job_dir, video_path)
75
 
76
 
77
  # ── Helpers ───────────────────────────────────────────────────────────────────
78
 
79
- def _build_prompt(pdf_text: str, job_id: str) -> str:
80
- truncated = pdf_text[:12_000] # keep within context limits
81
  return textwrap.dedent(f"""
82
  You are an expert Manim animator. Given the following document content,
83
- create a concise, visually engaging Manim animation that summarizes the
84
  key ideas. Use ONLY the class name `OutputVideo` extending `Scene`.
85
 
86
  Requirements:
@@ -88,6 +85,7 @@ def _build_prompt(pdf_text: str, job_id: str) -> str:
88
  - Use only standard Manim Community v0.18+ API.
89
  - Output ONLY valid Python code. No explanations, no markdown fences.
90
  - Keep runtime under 90 seconds.
 
91
  - Animations should be clear, readable, and professional.
92
 
93
  Document content:
@@ -97,40 +95,33 @@ def _build_prompt(pdf_text: str, job_id: str) -> str:
97
  """).strip()
98
 
99
 
100
- def _render_manim(script_path: Path, job_id: str) -> None:
101
- """Run manim render with retries."""
102
  cmd = [
103
  "manim",
104
  str(script_path),
105
  "OutputVideo",
106
  "-qm",
107
  "--media_dir", "media",
 
108
  ]
109
- max_retries = 2
110
  for attempt in range(max_retries + 1):
111
- result = subprocess.run(
112
- cmd,
113
- capture_output=True,
114
- text=True,
115
- timeout=600, # 10 min hard limit
116
- )
117
  if result.returncode == 0:
118
  return
119
- if attempt == max_retries:
120
- raise RuntimeError(
121
- f"Manim render failed after {max_retries + 1} attempts.\n"
122
- f"STDERR: {result.stderr[-2000:]}"
123
- )
124
- import time
125
- time.sleep(5 * (attempt + 1))
126
 
127
 
128
  def _deliver_video(
129
  video_path: str,
130
  email: str,
131
- status_cb: Callable[[State, str], None],
132
  ) -> None:
133
- """Try email attachment; fall back to Catbox link."""
134
  file_size_mb = Path(video_path).stat().st_size / (1024 * 1024)
135
 
136
  if file_size_mb <= 24:
@@ -138,14 +129,10 @@ def _deliver_video(
138
  send_video_email(email, video_path)
139
  return
140
  except Exception as exc:
141
- status_cb(State.UPLOADING, f"⚠️ Email attachment failed ({exc}); trying Catbox…")
142
  else:
143
- status_cb(
144
- State.UPLOADING,
145
- f"📦 Video is {file_size_mb:.1f} MB — uploading to Catbox for link delivery…",
146
- )
147
 
148
- # Catbox fallback
149
  url = upload_to_catbox(video_path)
150
  send_video_email(email, video_path=None, catbox_url=url)
151
 
 
7
  import os
8
  import shutil
9
  import subprocess
 
10
  import textwrap
11
+ import time
12
  from pathlib import Path
13
  from typing import Callable
14
 
 
21
  sanitize_manim_code,
22
  )
23
 
 
24
  MEDIA_ROOT = Path("media/videos")
25
+ JOBS_ROOT = Path("jobs")
26
 
27
 
28
  def run_pipeline(
 
30
  pdf_path: str,
31
  email: str,
32
  gemini_api_key: str,
33
+ status_cb: Callable[[State, str, str | None], None],
34
  ) -> None:
35
+ job_dir = JOBS_ROOT / job_id
36
+ script_path = job_dir / f"{job_id}.py"
37
+ video_path = MEDIA_ROOT / job_id / "720p30" / "OutputVideo.mp4"
38
 
 
39
  job_dir.mkdir(parents=True, exist_ok=True)
40
 
 
 
 
41
  try:
42
+ # 1. Extract PDF
43
+ status_cb(State.RUNNING, "📖 Extracting text from PDF…", None)
44
  pdf_text = extract_pdf_text(pdf_path)
45
  if not pdf_text.strip():
46
+ raise ValueError("PDF appears empty or has no selectable text.")
47
 
48
+ # 2. Generate Manim code via Gemini
49
+ status_cb(State.RUNNING, "🤖 Generating Manim code with Gemini…", None)
50
+ prompt = _build_prompt(pdf_text)
51
+ raw_code = generate_manim_code(prompt, gemini_api_key)
52
  manim_code = sanitize_manim_code(raw_code, job_id)
53
 
54
+ # Surface generated code to UI
55
+ status_cb(State.RUNNING, "✏️ Manim code generated — starting render…", manim_code)
56
+
57
  script_path.write_text(manim_code, encoding="utf-8")
58
 
59
+ # 3. Render
60
+ status_cb(State.RUNNING, "🎬 Rendering animation (this may take a few minutes)…", None)
61
  _render_manim(script_path, job_id)
62
 
63
  if not video_path.exists():
64
  raise FileNotFoundError(f"Rendered video not found at {video_path}")
65
 
66
+ # 4. Deliver
67
+ status_cb(State.SENDING, "📧 Sending video to your inbox…", None)
68
  _deliver_video(str(video_path), email, status_cb)
69
 
70
  finally:
 
71
  _cleanup(job_dir, video_path)
72
 
73
 
74
  # ── Helpers ───────────────────────────────────────────────────────────────────
75
 
76
+ def _build_prompt(pdf_text: str) -> str:
77
+ truncated = pdf_text[:12_000]
78
  return textwrap.dedent(f"""
79
  You are an expert Manim animator. Given the following document content,
80
+ create a concise, visually engaging Manim animation that summarises the
81
  key ideas. Use ONLY the class name `OutputVideo` extending `Scene`.
82
 
83
  Requirements:
 
85
  - Use only standard Manim Community v0.18+ API.
86
  - Output ONLY valid Python code. No explanations, no markdown fences.
87
  - Keep runtime under 90 seconds.
88
+ - Avoid custom LaTeX; prefer Text() over MathTex() where possible.
89
  - Animations should be clear, readable, and professional.
90
 
91
  Document content:
 
95
  """).strip()
96
 
97
 
98
+ def _render_manim(script_path: Path, job_id: str, max_retries: int = 2) -> None:
 
99
  cmd = [
100
  "manim",
101
  str(script_path),
102
  "OutputVideo",
103
  "-qm",
104
  "--media_dir", "media",
105
+ "--disable_caching",
106
  ]
 
107
  for attempt in range(max_retries + 1):
108
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
 
 
 
 
 
109
  if result.returncode == 0:
110
  return
111
+ if attempt < max_retries:
112
+ time.sleep(5 * (attempt + 1))
113
+
114
+ raise RuntimeError(
115
+ f"Manim render failed after {max_retries + 1} attempts.\n"
116
+ f"STDERR: {result.stderr[-3000:]}"
117
+ )
118
 
119
 
120
  def _deliver_video(
121
  video_path: str,
122
  email: str,
123
+ status_cb: Callable,
124
  ) -> None:
 
125
  file_size_mb = Path(video_path).stat().st_size / (1024 * 1024)
126
 
127
  if file_size_mb <= 24:
 
129
  send_video_email(email, video_path)
130
  return
131
  except Exception as exc:
132
+ status_cb(State.UPLOADING, f"⚠️ Attachment failed ({exc}) uploading to Catbox…", None)
133
  else:
134
+ status_cb(State.UPLOADING, f"📦 {file_size_mb:.1f} MB video — uploading to Catbox…", None)
 
 
 
135
 
 
136
  url = upload_to_catbox(video_path)
137
  send_video_email(email, video_path=None, catbox_url=url)
138
 
requirements.txt CHANGED
@@ -1,5 +1,14 @@
1
- gradio
2
- google-genai
3
- manim
4
- pypdf
5
- requests
 
 
 
 
 
 
 
 
 
 
1
+ # Full LaTeX distribution for Manim
2
+ texlive-full
3
+
4
+ # Video and rendering dependencies
5
+ ffmpeg
6
+ ghostscript
7
+
8
+ # Tools for DVI/TeX output
9
+ dvipng
10
+ dvisvgm
11
+
12
+ # Graphics libraries
13
+ libcairo2-dev
14
+ libpango1.0-dev
utils.py CHANGED
@@ -33,36 +33,26 @@ def extract_pdf_text(pdf_path: str) -> str:
33
 
34
  # ── Gemini LLM ────────────────────────────────────────────────────────────────
35
 
36
- def generate_manim_code(prompt_text: str, api_key: str):
37
- """Stream Manim code from Gemini 3 Flash Preview and return it as a string."""
38
  client = genai.Client(api_key=api_key)
39
- model = "gemini-3-flash-preview"
40
-
41
  contents = [
42
- types.Content(
43
- role="user",
44
- parts=[types.Part.from_text(text=prompt_text)]
45
- )
46
  ]
47
-
48
  config = types.GenerateContentConfig(
49
- thinking_config=types.ThinkingConfig(
50
- thinking_level="HIGH"
51
- )
52
  )
53
 
54
  code = ""
55
  for chunk in client.models.generate_content_stream(
56
- model=model,
57
- contents=contents,
58
- config=config
59
  ):
60
  if chunk.text:
61
  code += chunk.text
62
- print(chunk.text, end="") # optional live streaming output
63
-
64
  return code
65
 
 
66
  def sanitize_manim_code(raw: str, job_id: str) -> str:
67
  """
68
  Strip markdown fences, ensure correct imports and class name,
 
33
 
34
  # ── Gemini LLM ────────────────────────────────────────────────────────────────
35
 
36
+ def generate_manim_code(prompt_text: str, api_key: str) -> str:
37
+ """Stream Manim code from Gemini."""
38
  client = genai.Client(api_key=api_key)
39
+ model = "gemini-2.5-flash-preview-05-20" # latest stable flash model
 
40
  contents = [
41
+ types.Content(role="user", parts=[types.Part.from_text(prompt_text)])
 
 
 
42
  ]
 
43
  config = types.GenerateContentConfig(
44
+ thinking_config=types.ThinkingConfig(thinking_budget=8192)
 
 
45
  )
46
 
47
  code = ""
48
  for chunk in client.models.generate_content_stream(
49
+ model=model, contents=contents, config=config
 
 
50
  ):
51
  if chunk.text:
52
  code += chunk.text
 
 
53
  return code
54
 
55
+
56
  def sanitize_manim_code(raw: str, job_id: str) -> str:
57
  """
58
  Strip markdown fences, ensure correct imports and class name,