"""SciVisual-Agent Hugging Face Space entrypoint."""
from __future__ import annotations
import html
import traceback
from typing import Tuple
import gradio as gr
from generator import build_default_agent
try:
import spaces
except ImportError: # Local development fallback when not running on HF ZeroGPU.
class _SpacesFallback:
@staticmethod
def GPU(*args, **kwargs):
def decorator(func):
return func
return decorator
spaces = _SpacesFallback()
agent = None
custom_css = """
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap');
:root {
--cyber-bg: #0b0f19;
--cyber-panel: rgba(15, 24, 42, 0.72);
--cyber-panel-strong: rgba(4, 10, 20, 0.86);
--cyber-cyan: #00ffcc;
--cyber-pink: #ff0055;
--cyber-blue: #3b82f6;
--cyber-text: #e6fff9;
--cyber-muted: #8aa8b3;
--cyber-line: rgba(0, 255, 204, 0.35);
}
* {
box-sizing: border-box;
letter-spacing: 0;
}
body,
.gradio-container {
min-height: 100vh;
margin: 0;
color: var(--cyber-text) !important;
font-family: 'Share Tech Mono', 'Courier New', monospace !important;
background:
linear-gradient(rgba(0, 255, 204, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 255, 204, 0.035) 1px, transparent 1px),
radial-gradient(circle at 18% 18%, rgba(0, 255, 204, 0.16), transparent 28%),
radial-gradient(circle at 82% 10%, rgba(255, 0, 85, 0.11), transparent 30%),
#0b0f19 !important;
background-size: 34px 34px, 34px 34px, auto, auto, auto !important;
}
.gradio-container {
max-width: none !important;
padding: 0 !important;
}
footer,
.footer,
.built-with {
display: none !important;
}
#sci-shell {
margin: 0 auto;
padding: 18px 0 26px;
}
.cyber-hero {
position: relative;
min-height: 128px;
padding: 18px 22px;
border: 1px solid var(--cyber-line);
background: linear-gradient(135deg, rgba(0, 255, 204, 0.12), rgba(255, 0, 85, 0.06) 52%, rgba(59, 130, 246, 0.1));
box-shadow: 0 0 34px rgba(0, 255, 204, 0.12), inset 0 0 24px rgba(0, 255, 204, 0.08);
overflow: hidden;
}
.cyber-hero::before {
content: "";
position: absolute;
inset: 0;
background: repeating-linear-gradient(0deg, transparent 0 9px, rgba(0, 255, 204, 0.045) 10px);
pointer-events: none;
animation: scan 6s linear infinite;
}
@keyframes scan {
from { transform: translateY(-18px); }
to { transform: translateY(18px); }
}
.brand-row {
position: relative;
display: flex;
justify-content: space-between;
gap: 18px;
align-items: flex-start;
}
.kicker {
color: var(--cyber-cyan);
font-size: 13px;
text-transform: uppercase;
}
.cyber-title {
margin: 4px 0 8px;
color: #ffffff;
font-size: clamp(34px, 5vw, 74px);
line-height: 0.92;
text-shadow: 0 0 16px rgba(0, 255, 204, 0.75), 2px 0 0 rgba(255, 0, 85, 0.55);
}
.cyber-subtitle {
max-width: 900px;
margin: 0;
color: var(--cyber-muted);
font-size: 16px;
}
.status-pill {
flex: 0 0 auto;
min-width: 210px;
padding: 10px 12px;
border: 1px solid rgba(255, 0, 85, 0.55);
background: rgba(255, 0, 85, 0.08);
color: var(--cyber-text);
box-shadow: 0 0 18px rgba(255, 0, 85, 0.18);
}
.status-pill.ready {
border-color: rgba(0, 255, 204, 0.6);
background: rgba(0, 255, 204, 0.08);
box-shadow: 0 0 18px rgba(0, 255, 204, 0.18);
}
.panel-title {
margin: 0 0 12px;
color: var(--cyber-cyan);
font-size: 14px;
text-transform: uppercase;
border-bottom: 1px solid var(--cyber-line);
padding-bottom: 8px;
}
.dashboard-grid {
gap: 18px !important;
margin-top: 18px;
}
.panel-left,
.panel-right {
position: relative;
padding: 16px !important;
border: 1px solid var(--cyber-line);
background: var(--cyber-panel);
backdrop-filter: blur(18px);
box-shadow: 0 0 30px rgba(0, 255, 204, 0.10), inset 0 0 24px rgba(255, 255, 255, 0.025);
}
.panel-right {
min-height: 710px;
}
.panel-left::after,
.panel-right::after {
content: "";
position: absolute;
top: -1px;
right: 16px;
width: 70px;
height: 2px;
background: var(--cyber-pink);
box-shadow: 0 0 12px var(--cyber-pink);
}
.module-strip {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.module-strip span {
display: block;
padding: 8px;
border: 1px solid rgba(0, 255, 204, 0.22);
background: rgba(0, 0, 0, 0.22);
color: var(--cyber-muted);
font-size: 12px;
text-align: center;
}
.viewport-frame {
position: relative;
margin-bottom: 12px;
padding: 10px;
border: 1px solid rgba(0, 255, 204, 0.45);
background: linear-gradient(180deg, rgba(0, 255, 204, 0.05), rgba(255, 0, 85, 0.035));
box-shadow: inset 0 0 22px rgba(0, 255, 204, 0.08);
}
.viewport-frame::after {
content: "";
position: absolute;
inset: 10px;
pointer-events: none;
border: 1px solid rgba(255, 0, 85, 0.26);
}
.gr-form,
.block,
.form,
.wrap,
.contain {
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
}
label,
.label-wrap span {
color: var(--cyber-cyan) !important;
font-family: 'Share Tech Mono', 'Courier New', monospace !important;
text-transform: uppercase;
}
textarea,
input,
select,
.input,
.gr-text-input,
.gradio-dropdown,
.token,
.wrap textarea {
color: var(--cyber-text) !important;
background: rgba(2, 6, 16, 0.88) !important;
border: 1px solid rgba(0, 255, 204, 0.48) !important;
border-radius: 0 !important;
box-shadow: inset 0 0 14px rgba(0, 255, 204, 0.06), 0 0 12px rgba(0, 255, 204, 0.08) !important;
font-family: 'Share Tech Mono', 'Courier New', monospace !important;
}
textarea:focus,
input:focus {
border-color: var(--cyber-pink) !important;
box-shadow: 0 0 18px rgba(255, 0, 85, 0.25), inset 0 0 16px rgba(0, 255, 204, 0.08) !important;
}
button,
.gr-button {
min-height: 44px !important;
border: 1px solid var(--cyber-cyan) !important;
border-radius: 0 !important;
color: #001713 !important;
background: linear-gradient(90deg, var(--cyber-cyan), #78ffe7) !important;
box-shadow: 0 0 18px rgba(0, 255, 204, 0.35) !important;
font-family: 'Share Tech Mono', 'Courier New', monospace !important;
font-weight: 700 !important;
text-transform: uppercase !important;
transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease !important;
}
button:hover,
.gr-button:hover {
transform: translateY(-1px);
filter: brightness(1.08);
box-shadow: 0 0 26px rgba(0, 255, 204, 0.55) !important;
}
#clear-btn button {
color: var(--cyber-text) !important;
border-color: var(--cyber-pink) !important;
background: rgba(255, 0, 85, 0.12) !important;
box-shadow: 0 0 16px rgba(255, 0, 85, 0.22) !important;
}
#terminal textarea {
min-height: 220px !important;
color: #9fffe9 !important;
background:
linear-gradient(rgba(0, 255, 204, 0.03) 50%, rgba(255, 0, 85, 0.025) 50%),
rgba(0, 0, 0, 0.72) !important;
background-size: 100% 8px !important;
font-size: 13px !important;
}
#source-code textarea {
min-height: 430px !important;
color: #d9fff8 !important;
font-size: 13px !important;
overflow-y: scroll !important;
}
.tabs,
.tab-nav,
.tabitem {
background: transparent !important;
border-color: rgba(0, 255, 204, 0.25) !important;
}
.tab-nav button {
color: var(--cyber-text) !important;
background: rgba(3, 10, 20, 0.78) !important;
border: 1px solid rgba(0, 255, 204, 0.26) !important;
}
.tab-nav button.selected {
color: #001713 !important;
background: var(--cyber-cyan) !important;
}
#render-video {
border: 0 !important;
background: #020610 !important;
}
#render-video video {
width: 100% !important;
max-height: 560px !important;
object-fit: contain !important;
background: #020610 !important;
}
.examples-row {
margin-top: 12px;
color: var(--cyber-muted);
font-size: 12px;
}
#quick-examples .wrap {
gap: 8px !important;
}
#quick-examples .wrap label {
position: relative !important;
min-height: 38px !important;
padding: 9px 40px 9px 12px !important;
border: 1px solid rgba(0, 255, 204, 0.22) !important;
background: rgba(2, 6, 16, 0.58) !important;
color: var(--cyber-muted) !important;
transition: border-color 140ms ease, box-shadow 140ms ease, color 140ms ease, background 140ms ease !important;
}
#quick-examples .wrap label:hover {
border-color: rgba(0, 255, 204, 0.55) !important;
color: var(--cyber-text) !important;
}
#quick-examples .wrap label:has(input[type="radio"]:checked) {
border-color: var(--cyber-cyan) !important;
background: linear-gradient(90deg, rgba(0, 255, 204, 0.16), rgba(255, 0, 85, 0.08)) !important;
color: #ffffff !important;
box-shadow: 0 0 18px rgba(0, 255, 204, 0.20), inset 0 0 18px rgba(0, 255, 204, 0.06) !important;
}
#quick-examples .wrap label:has(input[type="radio"]:checked)::after {
content: "✓";
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--cyber-cyan);
font-size: 18px;
text-shadow: 0 0 10px rgba(0, 255, 204, 0.75);
}
@media (max-width: 900px) {
#sci-shell {
width: calc(100vw - 16px);
padding-top: 8px;
}
.brand-row {
flex-direction: column;
}
.status-pill {
width: 100%;
}
.module-strip {
grid-template-columns: 1fr;
}
.panel-right {
min-height: auto;
}
}
"""
def startup_status_html() -> str:
return '
MODEL CORE: READY
ZEROGPU LOADS ON DEMAND
'
def initial_terminal_log() -> str:
return (
"[boot] SciVisual-Agent interface online\n"
"[model] Adapter loads on demand inside the ZeroGPU render callback\n"
"[render] Waiting for prompt"
)
def get_agent():
global agent
if agent is None:
agent = build_default_agent()
return agent
@spaces.GPU(duration=300)
def run_visual_lab(prompt: str, retries: int) -> Tuple[str | None, str, str]:
try:
visual_agent = get_agent()
success, video_path, code, log = visual_agent.generate_and_fix(
user_prompt=prompt,
domain="Mathematics",
max_retries=int(retries),
)
status = "[complete] Video render available" if success else "[failed] Render unavailable"
return video_path if success else None, code, f"{log}\n{status}"
except Exception:
error_log = traceback.format_exc()
return None, "", f"[fatal] Application exception\n{error_log}"
def load_example(example: str) -> str:
examples = {
"Spring Mass": """Create a professional 2D physics simulation of a horizontal spring-mass system with a real-time synchronized position graph using ManimCE. Fix the velocity easing issue.
Requirements:
1. Layout & Axes:
- Shift the entire Spring-Mass system to the upper half (around y = 1.5).
- In the lower half (centered at y = -2), create a coordinate system `Axes` using `Axes(x_range=[0, 7, 1], y_range=[-2.5, 2.5, 1], x_length=7, y_length=3)`. Color the axes WHITE and add labels "Time (t)" and "Position (x)".
2. Physical Objects:
- Vertical wall at x = -4 (from y = 0.5 to y = 2.5), color = WHITE.
- Horizontal track from x = -4 to x = 4 at y = 1.2, color = WHITE.
- Mass block using `Square(side_length=0.6, color=PURPLE, fill_opacity=0.8)`.
- Use a `ValueTracker(0)` for time `t`.
- Update the block's center position via `add_updater` to be exactly at `np.array([2 * np.cos(3 * t.get_value()), 1.5, 0])`.
- Create the spring using `always_redraw`. Calculate `L = mass.get_left()[0] - (-4)`. Use `ParametricFunction` with `t_range=[0, 1]` where:
* x_func(u) = -4 + u * L
* y_func(u) = 1.5 + 0.3 * np.sin(2 * np.pi * 8 * u)
Color the spring WHITE.
3. Graph Synchronization:
- Create an orange dynamic dot on the graph tracker using `add_updater` at `axes.c2p(t.get_value(), 2 * np.cos(3 * t.get_value()), 0)`.
- Create the graph line using `always_redraw` plotting `axes.plot(lambda time: 2 * np.cos(3 * time), x_range=[0, max(0.001, t.get_value())], color=ORANGE)`.
4. Strict Animation Speed (CRITICAL SPEED FIX):
- Animate the tracker `t` from 0 to 2*PI using `self.play(t.animate.set_value(2 * PI), run_time=6, rate_func=linear)`.
- YOU MUST EXPLICITLY INCLUDE `rate_func=linear` to eliminate the default smooth easing effect. This ensures that time 't' updates at a perfectly constant velocity, making the physical oscillation and graph rendering completely uniform from start to finish.""",
"Orbit": """Create a beautiful, scientifically accurate 2D physics animation of a satellite in an elliptical orbit around Earth using ManimCE.
Requirements:
1. Scaling & Geometry (CRITICAL to avoid overlapping):
- Represent Earth as a clear sphere or circle in the center, but keep its radius small (e.g., radius=0.6) so it doesn't swallow the orbit.
- Create an explicit, highly elongated elliptical orbit path (`Ellipse(width=6.0, height=3.5)`) shifted slightly so that Earth sits exactly at one of the focal points (Foci) of the ellipse, NOT at the geometric center (Kepler's First Law).
2. Dynamic Vectors & Updaters:
- Represent the satellite as a distinct, colored Dot (e.g., Cyber Cyan) moving along the elliptical path using a custom tracker or `ValueTracker` for the orbital angle.
- Create two dynamic arrows (`Vector` or `Arrow`) attached to the satellite dot:
* Gravitational Force Vector (Color: Neon Red): Must always point directly from the satellite's current position to Earth's center. Its length must dynamically increase when close to Earth and decrease when far away (Inverse-square law).
* Velocity Vector (Color: Bright Green): Must always be perfectly tangent to the elliptical orbit path in the direction of motion. Its length must dynamically represent orbital speed (faster at perigee, slower at apogee).
- Use `add_updater` on both vectors so their positions, directions, and lengths update smoothly at every single frame based on the satellite's position.
3. Labels & Overlay:
- Add text labels for "Velocity (v)" and "Gravity (Fg)" matching the vector colors.
- In the top-right corner, add a LaTeX mathematical text displaying Newton's Law of Universal Gravitation: "F_g = G * (M_1 * M_2) / r²".
4. Animation flow: Run the animation for 2 complete orbital periods (about 10-12 seconds) so the viewer can clearly observe the dramatic speeding up at the close approach and slowing down at the far end. Wrap everything inside a single clean Scene class.""",
"Simple Pendulum": """Create a high-quality Physics simulation of a Damped Simple Pendulum using ManimCE.
Requirements:
1. Physics Setup: Define explicit variables for gravitational acceleration (g=9.81), rod length (L=3.0), initial angle (theta = pi/4), and a damping coefficient (b=0.15) to simulate real-world air resistance. Use a clear numerical integration method (like Euler-Cromer) inside an object updater (`add_updater`) to dynamically recalculate the angular velocity and displacement at every frame (`dt`).
2. Visual Elements (Strict Coordinate Updates):
- A fixed ceiling line or pivot dot at the top center.
- A colored dot (e.g., Cyber Cyan or Neon Pink) representing the heavy bob.
- A clean line representing the pendulum rod that attaches the pivot to the bob. CRITICAL: You must attach an updater to this rod using `add_updater` so that its end position dynamically calls `rod.put_start_and_end_on(pivot.get_center(), bob.get_center())` at every single frame. The rod must always move dynamically with the bob.
- Add an elegant fading trace/trail (`TracedPath`) attached to the bob to visually map its decaying sinusoidal path across the screen over time.
3. Mathematical Overlay: In the upper left corner, display the differential equation governing the motion: "d²θ/dt² + (b/m)dθ/dt + (g/L)sin(θ) = 0" rendered beautifully via LaTeX.
4. Animation flow: Let the simulation run smoothly for 12 seconds to clearly demonstrate the kinetic energy converting to thermal energy as the oscillation slowly dampens to a complete halt. Ensure the code is self-contained and wrapped inside a single executable Scene class.""",
"Visual proof (a+b)² = a² + 2ab + b²": """Create a perfect 2D geometric animation proving the identity (a+b)² = a² + 2ab + b² using ManimCE based on strict quadrant alignment. Fix the overlapping bug.
Requirements:
1. Geometry & Scale Setup:
- Define lengths: a = 2.0 and b = 1.0.
- Define a central intersection origin point: `origin = np.array([0, 0, 0])`. All blocks must be strictly positioned relative to this point by alignment.
2. Precise Corner Alignment (CRITICAL OVERLAP FIX):
- Block 1 (Square a²): Create a Square with side_length=a, color=BLUE. Use `.next_to(origin, UL, buff=0)` or explicitly align its bottom-right corner to `origin` so it sits entirely in the Upper-Left quadrant.
- Block 2 (Square b²): Create a Square with side_length=b, color=PINK. Use `.next_to(origin, DR, buff=0)` or explicitly align its top-left corner to `origin` so it sits entirely in the Lower-Right quadrant.
- Block 3 (Rectangle ab - Top Right): Create a Rectangle with width=b, height=a, color=GREEN. Use `.next_to(origin, UR, buff=0)` or align its bottom-left corner to `origin` so it sits entirely in the Upper-Right quadrant.
- Block 4 (Rectangle ab - Bottom Left): Create a Rectangle with width=a, height=b, color=GREEN. Use `.next_to(origin, DL, buff=0)` or align its top-right corner to `origin` so it sits entirely in the Lower-Left quadrant.
- Set fill_opacity=0.5 and stroke_color=WHITE for all 4 blocks.
3. Main Outer Square Framework:
- Create a large outer Square with side_length=(a+b) to represent the final boundary. Center it precisely at the combined visual center of the 4 blocks, which is `np.array([(-a+b)/2, (a-b)/2, 0])`. Color it WHITE with a thin stroke width, without fill.
4. Text Labels & Math Mapping:
- Place MathTex text labels ("a²", "b²", "ab", "ab") centered inside each corresponding block using `.move_to(block.get_center())`. Note: Labels must be created and positioned AFTER the blocks have been moved to their correct quadrants.
- Place the formula "(a + b)² = a² + 2ab + b²" at the top edge of the screen.
5. Animation Sequence:
- Step 1: Write the formula at the top and create the thin outer main square framework first (1.5s).
- Step 2: Draw Block 1 (a²) and Block 2 (b²) simultaneously using `FadeIn` (1s).
- Step 3: Animate the two Rectangle blocks (ab) creating themselves using `Create` to fill the remaining empty corners (1.5s).
- Step 4: Display the labels inside each block using `Write`.
6. Constraint: Keep the code clean, modular, and under 5 seconds total run_time. Do not use random pixel offsets; strictly use `next_to` with `buff=0` for perfect grid locking.""",
"Fourier Series" : """Animate the geometric construction of a square wave using its first 7 Fourier components (odd harmonics: n=1,3,5,7,9,11,13) in ManimCE. Fix the always_redraw compilation conflict.
Requirements:
1. Math Equations:
- Base square wave graph: `axes.plot(lambda x: np.sign(np.sin(x)), color=RED)`. (Do NOT wrap this static red wave in always_redraw).
- Fourier mathematical sum formula inside the loop: `sum((4 / (n * PI)) * np.sin(n * x) for n in range(1, 2 * int(N_tracker.get_value()), 2))`.
2. Correct Animation Sequence (CRITICAL GRAPHICS FIX):
- Step 1: Add the Axes and the static red square wave directly to the scene using `self.add(axes, square_wave)`.
- Step 2: Create a STATIC fundamental sine wave curve (where N=1 hardcoded) using `fourier_base = axes.plot(lambda x: (4 / PI) * np.sin(x), color=YELLOW)`. Animate this static line with `self.play(Create(fourier_base), run_time=1.0)`. This guarantees a beautiful, bug-free drawing effect from left to right in the first second.
- Step 3: Initialize `N_tracker = ValueTracker(1)`. Now, introduce the dynamic morphing curve using `always_redraw`. Inside its lambda, it must calculate the Fourier sum based on `N_tracker.get_value()`.
- Step 4: Use `self.add(fourier_dynamic)` to overlay it, remove the static `fourier_base`, and immediately animate `N_tracker` from 1 to 7 using `self.play(N_tracker.animate.set_value(7), run_time=4.5, rate_func=linear)`.
3. Ensure no while-loops are used. The transition between the static line and the morphing line must be seamless.""",
}
return examples.get(example, "")
with gr.Blocks(css=custom_css, title="SciVisual-Agent") as demo:
gr.HTML(
f"""
Physics & Mathematics Virtual Animation Lab
SciVisual-Agent
Turn maths and physics ideas into accurate animated Manim videos.
"""
)
with gr.Row(elem_classes=["dashboard-grid"]):
with gr.Column(scale=5, elem_classes=["panel-left"]):
prompt = gr.Textbox(
label="Describe the animation",
placeholder="Please enter your animation description or select one of the examples below.",
lines=8,
max_lines=14,
interactive=True,
)
retries = gr.Slider(
minimum=0,
maximum=5,
value=3,
step=1,
label="Self-Correction Retries",
interactive=True,
)
with gr.Row():
generate_btn = gr.Button("Render", variant="primary")
clear_btn = gr.Button("Clear", elem_id="clear-btn")
example_choice = gr.Radio(
choices=["Simple Pendulum", "Spring Mass", "Visual proof (a+b)² = a² + 2ab + b²", "Fourier Series"],
label="Quick Examples",
value=None,
interactive=True,
elem_id="quick-examples",
)
terminal = gr.Textbox(
label="System Terminal Log",
value=initial_terminal_log(),
lines=12,
max_lines=18,
interactive=False,
elem_id="terminal",
)
gr.HTML('Status bus: prompt -> model adapter code -> temp_scene.py -> Manim render -> MP4 viewport
')
with gr.Column(scale=7, elem_classes=["panel-right"]):
gr.HTML('Output Observatory
')
with gr.Tabs():
with gr.Tab("Rendered Video"):
video = gr.Video(label=None, elem_id="render-video", height=560, autoplay=True, loop=True)
with gr.Tab("Generated Code"):
source = gr.Textbox(
label="Clean Python Source",
lines=24,
max_lines=34,
interactive=False,
elem_id="source-code",
)
gr.HTML("")
generate_btn.click(
fn=run_visual_lab,
inputs=[prompt, retries],
outputs=[video, source, terminal],
show_progress=True,
)
clear_btn.click(
fn=lambda: ("", None, "", initial_terminal_log()),
inputs=None,
outputs=[prompt, example_choice, source, terminal],
)
example_choice.change(fn=load_example, inputs=example_choice, outputs=prompt)
if __name__ == "__main__":
demo.launch()