""" PDF → Manim Animation Pipeline Hugging Face Spaces — Gradio 6.x """ import asyncio import atexit import queue import threading import uuid from pathlib import Path import gradio as gr from queue_manager import JobQueue, State from pipeline import run_pipeline # ── Asyncio cleanup fix (suppresses Invalid file descriptor errors) ─────────── def _cleanup_event_loop(): try: loop = asyncio.get_event_loop() if not loop.is_closed(): loop.close() except Exception: pass atexit.register(_cleanup_event_loop) # ── Global queue ────────────────────────────────────────────────────────────── job_queue = JobQueue(max_workers=8, max_jobs=100) # ── Pipeline streaming generator ────────────────────────────────────────────── def submit_and_stream(pdf_file, email: str, api_key: str): """ Generator — yields (status_md, code_text, code_visible) tuples live. Drives the entire UI update without any polling button. """ # ── Validate ────────────────────────────────────────────────────────────── if pdf_file is None: yield "❌ Please upload a PDF file.", "", gr.update(visible=False) return if not email or "@" not in email: yield "❌ Please enter a valid email address.", "", gr.update(visible=False) return if not api_key or len(api_key) < 10: yield "❌ Please enter a valid Gemini API key.", "", gr.update(visible=False) return if job_queue.is_full(): yield "⚠️ Queue is full (max 100 jobs). Please try again shortly.", "", gr.update(visible=False) return job_id = uuid.uuid4().hex pdf_path = pdf_file.name # Thread-safe channel for status + code updates update_q: queue.Queue = queue.Queue() def status_cb(state: State, message: str = "", code: str | None = None): update_q.put((state, message, code)) def _run(): try: run_pipeline( job_id=job_id, pdf_path=pdf_path, email=email, gemini_api_key=api_key, status_cb=status_cb, ) update_q.put((State.DONE, "Video sent to your inbox! 🎉", None)) except Exception as exc: update_q.put((State.FAILED, str(exc), None)) finally: update_q.put(None) # sentinel thread = threading.Thread(target=_run, daemon=True) thread.start() code_so_far = "" yield f"⏳ **Queued** — Starting…\n\n*Job ID: `{job_id}`*", "", gr.update(visible=False) while True: item = update_q.get() if item is None: break state, message, code = item icons = { State.QUEUED: "⏳", State.RUNNING: "⚙️", State.UPLOADING: "☁️", State.SENDING: "📧", State.DONE: "✅", State.FAILED: "❌", } icon = icons.get(state, "❓") status_text = f"{icon} **{state.value.title()}** — {message}\n\n*Job ID: `{job_id}`*" if code is not None: code_so_far = code yield ( status_text, code_so_far, gr.update(visible=bool(code_so_far)), ) # ── UI ──────────────────────────────────────────────────────────────────────── with gr.Blocks(title="PDF → Manim Video") as demo: gr.Markdown( """ # 🎬 PDF → Manim Animation Pipeline Upload a PDF, enter your details, and receive an animated video in your inbox. """ ) # Persistent browser-side storage (survives page refresh) saved_email = gr.BrowserState("") saved_api_key = gr.BrowserState("") with gr.Row(): with gr.Column(scale=1): pdf_input = gr.File(label="📄 Upload PDF", file_types=[".pdf"]) email_input = gr.Textbox( label="📧 Your Email", placeholder="you@example.com", ) api_key_input = gr.Textbox( label="🔑 Gemini API Key", placeholder="AIza…", type="password", ) submit_btn = gr.Button("🚀 Generate Video", variant="primary") with gr.Column(scale=1): status_md = gr.Markdown("*Submit a job to see live status here.*") code_box = gr.Code( label="📝 Generated Manim Code", language="python", visible=False, interactive=False, ) gr.Markdown( """ --- **Notes:** - Processing typically takes 2–5 minutes. - The video will be emailed once rendered; large files use a Catbox.moe link. - Your Gemini API key is used only for this request and never stored server-side. - Requires `SMTP_EMAIL` and `SMTP_PASSWORD` environment secrets on the Space. """ ) # ── Restore persisted values on page load ───────────────────────────────── demo.load( fn=lambda e, k: (e, k), inputs=[saved_email, saved_api_key], outputs=[email_input, api_key_input], ) # ── Save values to browser storage whenever they change ─────────────────── email_input.change(fn=lambda v: v, inputs=[email_input], outputs=[saved_email]) api_key_input.change(fn=lambda v: v, inputs=[api_key_input], outputs=[saved_api_key]) # ── Streaming job submission ─────────────────────────────────────────────── submit_btn.click( fn=submit_and_stream, inputs=[pdf_input, email_input, api_key_input], outputs=[status_md, code_box, code_box], ) if __name__ == "__main__": demo.launch( theme=gr.themes.Soft(), ssr_mode=False, server_name="0.0.0.0", server_port=7860, )