""" PDF → Manim Animation Pipeline Hugging Face Spaces — Gradio 6.x """ import asyncio import atexit import queue import threading import uuid import gradio as gr from queue_manager import JobQueue, State from pipeline import run_pipeline # ── Asyncio cleanup (suppresses "Invalid file descriptor" noise on shutdown) ── 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 job queue ────────────────────────────────────────────────────────── job_queue = JobQueue(max_workers=8, max_jobs=100) # ── Streaming pipeline ──────────────────────────────────────────────────────── def submit_and_stream(pdf_file, api_key: str): """ Generator — yields tuples: (status_md, code_str, code_visible, video_path, video_visible, zip_path, zip_visible) live-streamed to the Gradio UI. """ def _emit(status, code="", code_vis=False, video=None, vid_vis=False, zip_p=None, zip_vis=False): return ( status, code, gr.update(visible=code_vis), video, gr.update(visible=vid_vis), zip_p, gr.update(visible=zip_vis), ) # ── Validate ────────────────────────────────────────────────────────────── if pdf_file is None: yield _emit("❌ Please upload a PDF file.") return if not api_key or len(api_key) < 10: yield _emit("❌ Please enter a valid Gemini API key.") return if job_queue.is_full(): yield _emit("⚠️ Queue is full (max 100 jobs). Please try again shortly.") return job_id = uuid.uuid4().hex pdf_path = pdf_file.name job_queue.register(job_id) # Thread-safe update channel update_q: queue.Queue = queue.Queue() def status_cb(state: State, message: str = "", code: str | None = None): update_q.put((state, message, code)) result_holder: dict = {} def _run(): try: result = run_pipeline( job_id=job_id, pdf_path=pdf_path, gemini_api_key=api_key, status_cb=status_cb, ) result_holder.update(result) update_q.put((State.DONE, "✅ Render complete!", result.get("code"))) except Exception as exc: update_q.put((State.FAILED, f"❌ {exc}", None)) finally: update_q.put(None) # sentinel threading.Thread(target=_run, daemon=True).start() icons = { State.QUEUED: "⏳", State.RUNNING: "⚙️", State.DONE: "✅", State.FAILED: "❌", } code_so_far = "" yield _emit(f"⏳ **Queued** — Starting…\n\n*Job `{job_id}`*") while True: item = update_q.get() if item is None: break state, message, code = item if code: code_so_far = code status_text = ( f"{icons.get(state,'❓')} **{state.value.title()}** — {message}" f"\n\n*Job `{job_id}`*" ) is_done = state == State.DONE is_failed = state == State.FAILED yield _emit( status_text, code = code_so_far, code_vis = bool(code_so_far), video = result_holder.get("video_path") if is_done else None, vid_vis = is_done, zip_p = result_holder.get("zip_path") if is_done else None, zip_vis = is_done, ) # ── UI ──────────────────────────────────────────────────────────────────────── with gr.Blocks(title="PDF → Manim Video") as demo: gr.Markdown("# 🎬 PDF → Manim Animation Pipeline\nUpload a PDF and get a downloadable Manim animation.") saved_api_key = gr.BrowserState("") # persisted in browser localStorage with gr.Row(): # ── Left column: inputs ─────────────────────────────────────────────── with gr.Column(scale=1): pdf_input = gr.File(label="📄 Upload PDF", file_types=[".pdf"]) api_key_input = gr.Textbox( label="🔑 Gemini API Key", placeholder="AIza…", type="password", info="Saved in your browser — you only need to enter this once.", ) submit_btn = gr.Button("🚀 Generate Video", variant="primary") # ── Right column: outputs ───────────────────────────────────────────── 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, ) video_player = gr.Video( label="🎬 Rendered Animation", visible=False, interactive=False, ) zip_download = gr.File( label="⬇️ Download Artifacts (.py + .mp4)", visible=False, interactive=False, ) gr.Markdown( """ --- **Notes:** Processing typically takes 2–5 minutes depending on animation complexity. The artifacts ZIP contains the generated `.py` source and the rendered `.mp4`. Your API key is never stored server-side. Have fun! 🎬 If you liked it, feel free to share it with your friends and family. """ ) # ── Restore API key from browser on load ────────────────────────────────── demo.load(fn=lambda k: k, inputs=[saved_api_key], outputs=[api_key_input]) api_key_input.change(fn=lambda v: v, inputs=[api_key_input], outputs=[saved_api_key]) # ── Streaming submit ─────────────────────────────────────────────────────── submit_btn.click( fn=submit_and_stream, inputs=[pdf_input, api_key_input], outputs=[status_md, code_box, code_box, video_player, video_player, zip_download, zip_download], ) if __name__ == "__main__": demo.launch( theme=gr.themes.Soft(), ssr_mode=False, server_name="0.0.0.0", server_port=7860, )