Spaces:
Running
Running
| """ | |
| Animation script generator — 3Blue1Brown / Math Motion style. | |
| Claude turns a paper passage (or podcast turn) into a JSON SceneSpec that | |
| two renderers consume: | |
| - design/animation-player.jsx — browser KaTeX + SVG + tween player | |
| - agents/manim_renderer.py — emits a Manim CE .py file + runs CLI | |
| SceneSpec shape: | |
| { | |
| "title": str, | |
| "duration_s": float, | |
| "voiceover": str, # optional narration script | |
| "beats": [ | |
| { | |
| "t": float, # start in seconds | |
| "duration_s": float, | |
| "kind": "title" | "equation" | "transform_eq" | | |
| "vector_field" | "graph_plot" | | |
| "geometric_step" | "callout" | "highlight", | |
| "payload": { ...kind-specific } | |
| } | |
| ] | |
| } | |
| Payload by kind: | |
| title {text} | |
| equation {latex} | |
| transform_eq {from_latex, to_latex, label} # morph one eq into another | |
| vector_field {vectors: [{x,y,dx,dy,color}], grid: bool} | |
| graph_plot {x_label, y_label, curves: [{name, color, points:[[x,y],...]}]} | |
| geometric_step {svg_primitives: [{kind:'circle'|'line'|'polygon', ...}]} | |
| callout {text, position: 'top'|'bottom'|'center'} | |
| highlight {target_id, color} # ring a previous element | |
| Caching: data/sessions/animation_cache/<hash>.json | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import json | |
| import re | |
| from pathlib import Path | |
| PROJECT_ROOT = Path(__file__).parent.parent | |
| CACHE_DIR = PROJECT_ROOT / "data" / "sessions" / "animation_cache" | |
| CACHE_DIR.mkdir(parents=True, exist_ok=True) | |
| SYSTEM = """You are an animation director in the style of 3Blue1Brown and | |
| Math Motion. Given a paper passage or concept description, produce a JSON | |
| SceneSpec for a 20-45 second animation that builds geometric / visual | |
| intuition. | |
| PRINCIPLES (lifted from 3b1b): | |
| - One core idea per scene. Stack visual transformations to make it click. | |
| - Equations should MORPH into the next form. Use transform_eq beats so the | |
| viewer sees Q K^T turn into Q_g K^T (etc.) rather than cutting. | |
| - Show, then label. A vector field appears; THEN a callout names what it | |
| represents. | |
| - Each beat 2-5 seconds. Total duration 20-45 seconds. | |
| - Voiceover narration tracks the visuals; one sentence per beat. | |
| OUTPUT: valid JSON only, no preface, no fence. | |
| Available beat kinds + payloads: | |
| title {"text": "..."} | |
| equation {"latex": "..."} | |
| transform_eq {"from_latex": "...", "to_latex": "...", "label": "..."} | |
| vector_field {"vectors": [{"x":0,"y":0,"dx":1,"dy":2,"color":"#7DD3FC"}], "grid": true} | |
| graph_plot {"x_label":"...", "y_label":"...", | |
| "curves": [{"name":"...","color":"#FBB036","points":[[0,0],[1,1]]}]} | |
| geometric_step {"svg_primitives": [ | |
| {"kind":"circle","cx":50,"cy":50,"r":20,"stroke":"#F96167"}, | |
| {"kind":"line","x1":0,"y1":0,"x2":100,"y2":100,"stroke":"#7DD3FC"}, | |
| {"kind":"polygon","points":"10,10 90,10 50,90","fill":"#FBB03622"} | |
| ]} | |
| callout {"text":"...","position":"top"|"bottom"|"center"} | |
| highlight {"target_id":"<id of earlier beat>", "color":"#F96167"} | |
| Each beat may include a "id" string (so later beats can target it via | |
| highlight). Times in seconds, accumulate naturally.""" | |
| def generate(passage: str, voiceover_hint: str = "", force: bool = False) -> dict | None: | |
| """Produce a SceneSpec for `passage`. Cached on hash.""" | |
| if not passage or not passage.strip(): | |
| return None | |
| key = hashlib.sha1((passage + "|" + voiceover_hint).encode()).hexdigest()[:16] | |
| cache_file = CACHE_DIR / f"{key}.json" | |
| if cache_file.exists() and not force: | |
| try: | |
| return json.loads(cache_file.read_text()) | |
| except json.JSONDecodeError: | |
| pass | |
| user = f"PASSAGE:\n\n{passage[:6000]}" | |
| if voiceover_hint: | |
| user += f"\n\nVOICEOVER HINT (optional): {voiceover_hint}" | |
| try: | |
| from .specialists import SYNTHESIZER_MODEL | |
| from .orchestrator import _call_claude | |
| raw = _call_claude( | |
| model=SYNTHESIZER_MODEL, | |
| system=SYSTEM, | |
| user=user, | |
| max_tokens=3500, | |
| retries=2, | |
| ) | |
| except Exception: | |
| return None | |
| parsed = _parse_json(raw) | |
| if not parsed or not parsed.get("beats"): | |
| return None | |
| # Best-effort: assign sequential ids if missing | |
| for i, beat in enumerate(parsed["beats"]): | |
| beat.setdefault("id", f"b{i}") | |
| cache_file.write_text(json.dumps(parsed, indent=2)) | |
| return parsed | |
| def _parse_json(raw: str) -> dict | None: | |
| raw = (raw or "").strip() | |
| if raw.startswith("```"): | |
| raw = re.sub(r"^```(?:json)?\s*", "", raw) | |
| raw = re.sub(r"\s*```$", "", raw) | |
| m = re.search(r"\{.*\}", raw, re.DOTALL) | |
| if not m: | |
| return None | |
| try: | |
| return json.loads(m.group(0)) | |
| except json.JSONDecodeError: | |
| return None | |