Verdiola commited on
Commit
73ec2a2
·
verified ·
1 Parent(s): 5fe40ea

add smaller model

Browse files
Files changed (1) hide show
  1. app/main.py +762 -24
app/main.py CHANGED
@@ -1,8 +1,9 @@
1
- import json, os, re, uuid, subprocess, sys, time, traceback, threading
2
  from io import BytesIO
3
  from collections import deque
4
  from pathlib import Path
5
- from typing import Optional, Tuple
 
6
 
7
  from fastapi import FastAPI, HTTPException, Response
8
  from fastapi.middleware.cors import CORSMiddleware
@@ -22,6 +23,7 @@ from google.genai import types
22
  API_KEY = os.getenv("GEMINI_API_KEY", "")
23
  # Switch to 2.5 Flash as requested
24
  MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
 
25
  PORT = int(os.getenv("PORT", "7860"))
26
 
27
  client = genai.Client(api_key=API_KEY) if API_KEY else None
@@ -79,6 +81,7 @@ class RateLimiter:
79
  self.acquire()
80
 
81
  limiter = RateLimiter(10)
 
82
 
83
  def gemini_call(*, system: str, contents):
84
  """Wrapper to: enforce RPM and standardize text extraction."""
@@ -92,6 +95,20 @@ def gemini_call(*, system: str, contents):
92
  )
93
  return getattr(resp, "text", str(resp))
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  # ---------------- prompts ----------------
96
  SYSTEM_PROMPT = """You are a Manim CE (0.19.x) code generator/refiner.
97
  Return ONLY valid Python code (no backticks, no prose).
@@ -124,6 +141,161 @@ class AutoScene(Scene):
124
  self.wait(0.75)
125
  """
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  # ---------- NEW: carry full CLI error back to the refiner ----------
128
  class RenderError(Exception):
129
  def __init__(self, log: str):
@@ -169,7 +341,295 @@ def _preflight_sanitize(code: str) -> str:
169
  c = re.sub(r",\s*\)", ")", c)
170
  return c
171
 
172
- def _run_manim(scene_code: str, run_id: Optional[str] = None) -> Tuple[bytes, Optional[Path]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  """Render MP4 (fast) and also save a steady-state PNG (last frame)."""
174
  run_id = run_id or str(uuid.uuid4())[:8]
175
  work = RUNS / run_id; work.mkdir(parents=True, exist_ok=True)
@@ -183,9 +643,11 @@ def _run_manim(scene_code: str, run_id: Optional[str] = None) -> Tuple[bytes, Op
183
  env = os.environ.copy()
184
  env["PYTHONPATH"] = str(work)
185
 
 
 
186
  # 1) Render video
187
  cmd_video = [
188
- "manim", "-ql", "--disable_caching",
189
  "--media_dir", str(media),
190
  "-o", f"{run_id}.mp4",
191
  str(scene_path), "AutoScene",
@@ -215,7 +677,7 @@ def _run_manim(scene_code: str, run_id: Optional[str] = None) -> Tuple[bytes, Op
215
  # 2) Save last frame PNG (leverages our CAPTURE_POINT rule)
216
  png_path = None
217
  cmd_png = [
218
- "manim", "-ql", "--disable_caching", "-s", # -s saves the last frame as an image
219
  "--media_dir", str(media),
220
  str(scene_path), "AutoScene",
221
  ]
@@ -247,12 +709,30 @@ def _upload_image_to_gemini(png_path: Path):
247
  return file_ref
248
 
249
 
250
- def llm_generate_manim_code(prompt: str, previous_code: Optional[str] = None) -> str:
 
 
 
 
251
  """First-pass generation (capture-aware)."""
252
  if not client:
253
  return DEFAULT_SCENE
254
  try:
255
  contents = f"Create AutoScene for: {prompt}\nRemember the CAPTURE POLICY and Common API constraints."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  response_text = gemini_call(system=SYSTEM_PROMPT, contents=contents)
257
  code = _clean_code(response_text)
258
  if "class AutoScene" not in code:
@@ -263,7 +743,12 @@ def llm_generate_manim_code(prompt: str, previous_code: Optional[str] = None) ->
263
  traceback.print_exc()
264
  return previous_code or DEFAULT_SCENE
265
 
266
- def llm_refine_from_error(previous_code: str, error_message: str, original_user_prompt: str) -> str:
 
 
 
 
 
267
  """When Manim fails; send the *real* CLI log/trace to Gemini."""
268
  if not client:
269
  return previous_code or DEFAULT_SCENE
@@ -286,8 +771,26 @@ Requirements:
286
  - Keep the CAPTURE POLICY and ensure # CAPTURE_POINT is at the final steady layout.
287
  - Scan for nonexistent methods (e.g., `.to_center`) or invalid kwargs (e.g., `vertex=` on RightAngle) and replace with valid Manim CE 0.19 API.
288
  - Prefer `.center()`/`.move_to(ORIGIN)`, and `.move_to()`, `.align_to()`, `.to_edge()`, `.next_to()` for layout.
 
 
 
289
  - Return ONLY the corrected Python code (no backticks).
290
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  response_text = gemini_call(system=SYSTEM_PROMPT, contents=user_prompt)
292
  code = _clean_code(response_text)
293
  if "class AutoScene" not in code:
@@ -298,7 +801,12 @@ Requirements:
298
  traceback.print_exc()
299
  return previous_code or DEFAULT_SCENE
300
 
301
- def llm_visual_refine_from_image(original_user_prompt: str, previous_code: str, png_path: Optional[Path]) -> str:
 
 
 
 
 
302
  """
303
  Use the screenshot to request layout/legibility/placement fixes.
304
  Includes the original prompt and current code, and asks for minimal edits.
@@ -324,8 +832,25 @@ Tasks (optimize for readability and visual quality without changing the math mea
324
  - Keep animation semantics as-is unless they're obviously broken.
325
  - Keep exactly one class AutoScene(Scene).
326
  - Preserve the CAPTURE POLICY and place `# CAPTURE_POINT` at the final steady layout with self.wait(0.75) and NO outro after that.
 
 
 
327
  Return ONLY the revised Python code (no backticks).
328
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
 
330
  response_text = gemini_call(system=SYSTEM_PROMPT, contents=[file_ref, visual_prompt])
331
  code = _clean_code(response_text)
@@ -337,18 +862,24 @@ Return ONLY the revised Python code (no backticks).
337
  traceback.print_exc()
338
  return previous_code
339
 
340
- def refine_loop(user_prompt: str, max_error_refines: int = 3, do_visual_refine: bool = True) -> bytes:
 
 
 
 
 
341
  """
342
  Generate → render; on error, refine up to N times from Manim traceback → re-render.
343
  If first render succeeds and do_visual_refine==True, run an image-based refinement
344
  using the saved steady-state PNG, then re-render. Fallback to the best successful MP4.
345
  """
346
  # 1) initial generation (capture-aware)
347
- code = llm_generate_manim_code(user_prompt)
 
348
 
349
  # 2) render attempt
350
  try:
351
- mp4_bytes, png_path = _run_manim(code, run_id="iter0")
352
  except RenderError as e:
353
  print("Render failed (iter0), attempting error-based refinement...", file=sys.stderr)
354
  if max_error_refines <= 0:
@@ -357,9 +888,14 @@ def refine_loop(user_prompt: str, max_error_refines: int = 3, do_visual_refine:
357
  last_err = e.log or ""
358
  while attempts < max_error_refines:
359
  attempts += 1
360
- refined = llm_refine_from_error(previous_code=code, error_message=last_err, original_user_prompt=user_prompt)
 
 
 
 
 
361
  try:
362
- mp4_bytes, png_path = _run_manim(refined, run_id=f"iter_err_{attempts}")
363
  code = refined
364
  break
365
  except RenderError as e2:
@@ -376,9 +912,14 @@ def refine_loop(user_prompt: str, max_error_refines: int = 3, do_visual_refine:
376
  last_err = traceback.format_exc()
377
  while attempts < max_error_refines:
378
  attempts += 1
379
- refined = llm_refine_from_error(previous_code=code, error_message=last_err, original_user_prompt=user_prompt)
 
 
 
 
 
380
  try:
381
- mp4_bytes, png_path = _run_manim(refined, run_id=f"iter_err_{attempts}")
382
  code = refined
383
  break
384
  except Exception:
@@ -388,10 +929,15 @@ def refine_loop(user_prompt: str, max_error_refines: int = 3, do_visual_refine:
388
 
389
  # 3) optional visual refinement loop
390
  if do_visual_refine and png_path and png_path.exists():
391
- refined2 = llm_visual_refine_from_image(original_user_prompt=user_prompt, previous_code=code, png_path=png_path)
 
 
 
 
 
392
  if refined2.strip() != code.strip():
393
  try:
394
- mp4_bytes2, _ = _run_manim(refined2, run_id="iter2")
395
  return mp4_bytes2
396
  except Exception:
397
  print("Visual refine render failed; returning best known render.", file=sys.stderr)
@@ -399,9 +945,153 @@ def refine_loop(user_prompt: str, max_error_refines: int = 3, do_visual_refine:
399
 
400
  return mp4_bytes
401
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
  # ---------------- API ----------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  class PromptIn(BaseModel):
404
  prompt: str
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
 
406
  class EmailIn(BaseModel):
407
  email: str
@@ -424,19 +1114,15 @@ def health():
424
  return {"ok": True, "model": MODEL, "has_gemini": bool(API_KEY)}
425
 
426
  @app.post("/generate-code")
427
- def generate_code(inp: PromptIn):
428
  """Return ONLY the generated Manim Python code (no rendering)."""
429
- if not inp.prompt or not inp.prompt.strip():
430
- raise HTTPException(400, "Missing prompt")
431
- code = llm_generate_manim_code(inp.prompt.strip())
432
  return {"code": code}
433
 
434
  @app.post("/generate-and-render")
435
  def generate_and_render(inp: PromptIn):
436
- if not inp.prompt or not inp.prompt.strip():
437
- raise HTTPException(400, "Missing prompt")
438
  try:
439
- mp4 = refine_loop(inp.prompt.strip(), max_error_refines=3, do_visual_refine=True)
440
  except Exception:
441
  raise HTTPException(500, "Failed to produce video after refinement")
442
  return Response(
@@ -445,6 +1131,58 @@ def generate_and_render(inp: PromptIn):
445
  headers={"Content-Disposition": 'inline; filename="result.mp4"'}
446
  )
447
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
  @app.post("/store-email")
449
  def store_email(email: EmailIn):
450
  """Store the provided email address in the configured Hugging Face dataset."""
 
1
+ import json, os, re, uuid, subprocess, sys, time, traceback, threading, base64
2
  from io import BytesIO
3
  from collections import deque
4
  from pathlib import Path
5
+ from typing import Optional, Tuple, List, Dict, Any
6
+ from dataclasses import dataclass, field
7
 
8
  from fastapi import FastAPI, HTTPException, Response
9
  from fastapi.middleware.cors import CORSMiddleware
 
23
  API_KEY = os.getenv("GEMINI_API_KEY", "")
24
  # Switch to 2.5 Flash as requested
25
  MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
26
+ SMALL_MODEL = os.getenv("GEMINI_SMALL_MODEL") or MODEL
27
  PORT = int(os.getenv("PORT", "7860"))
28
 
29
  client = genai.Client(api_key=API_KEY) if API_KEY else None
 
81
  self.acquire()
82
 
83
  limiter = RateLimiter(10)
84
+ storyboard_limiter = RateLimiter(30)
85
 
86
  def gemini_call(*, system: str, contents):
87
  """Wrapper to: enforce RPM and standardize text extraction."""
 
95
  )
96
  return getattr(resp, "text", str(resp))
97
 
98
+
99
+ def gemini_small_call(*, system: str, contents: str) -> str:
100
+ """Lightweight wrapper for the storyboard assistant (smaller model)."""
101
+ if not client:
102
+ raise RuntimeError("Gemini client is not configured")
103
+ target_model = SMALL_MODEL or MODEL
104
+ storyboard_limiter.acquire()
105
+ resp = client.models.generate_content(
106
+ model=target_model,
107
+ config=types.GenerateContentConfig(system_instruction=system),
108
+ contents=contents,
109
+ )
110
+ return getattr(resp, "text", str(resp))
111
+
112
  # ---------------- prompts ----------------
113
  SYSTEM_PROMPT = """You are a Manim CE (0.19.x) code generator/refiner.
114
  Return ONLY valid Python code (no backticks, no prose).
 
141
  self.wait(0.75)
142
  """
143
 
144
+ STORYBOARD_SYSTEM_PROMPT = """You are MathFrames' storyboard director.
145
+ You interview educators, refine their ideas, and maintain a structured shot list for a short Manim video.
146
+
147
+ Always respond with a single JSON object matching this schema exactly:
148
+ {
149
+ "reply": "<short conversational answer for the user>",
150
+ "plan": {
151
+ "concept": "<core idea you are visualizing>",
152
+ "notes": "<optional reminders or staging notes>",
153
+ "scenes": [
154
+ {
155
+ "title": "Scene 1: Setup",
156
+ "objective": "<what this scene accomplishes>",
157
+ "steps": ["<bullet-level action>", "..."]
158
+ }
159
+ ]
160
+ },
161
+ "questions": ["<optional clarification question>", "..."]
162
+ }
163
+
164
+ Rules:
165
+ - Keep scene titles in the format: "Scene N: Subtitle".
166
+ - Each scene must list 1-5 clear, imperative steps or beats (use educational language, no code).
167
+ - Reflect any user-provided edits exactly.
168
+ - If the user supplies a plan JSON, treat it as the source of truth and improve it gently.
169
+ - Ask for clarification only when needed; otherwise leave the questions array empty.
170
+ - Never include Markdown fences, prose outside JSON, or code snippets.
171
+
172
+ # Professional editor guidance (use to drive the conversation naturally):
173
+ - Confirm the concept/topic and any subtopics that should appear.
174
+ - Capture the learning goal: what must the viewer understand by the end?
175
+ - Clarify how deep the explanation should go (introductory vs. detailed walk-through).
176
+ - Ask about any specific visuals, references, or prior scenes the user wants included.
177
+ - Check whether there's an existing script or outline to honor.
178
+ - Note any stylistic tone or audience expectations (e.g., middle school vs. college).
179
+ """
180
+
181
+ STORYBOARD_CONFIRM_SYSTEM_PROMPT = """You are MathFrames' storyboard director.
182
+ The user has finalized their plan. Craft the final handoff for the rendering model.
183
+
184
+ Return a JSON object:
185
+ {
186
+ "reply": "<brief confirmation for the user>",
187
+ "render_prompt": "<single paragraph prompt for the Manim code generator>",
188
+ "plan": { ... same structure as provided ... }
189
+ }
190
+
191
+ Guidelines:
192
+ - Keep render_prompt concise but fully descriptive. Mention each scene's purpose and key visuals.
193
+ - Respect the provided storyboard plan exactly—do not invent new scenes or steps.
194
+ - Include relevant settings (style, length, audience, resolution) when supplied.
195
+ - Do not add Markdown or code; respond with JSON only.
196
+ """
197
+
198
+ MAX_STORYBOARD_SCENES = 6
199
+
200
+
201
+ class ScenePayload(BaseModel):
202
+ id: Optional[str] = None
203
+ title: str
204
+ objective: Optional[str] = ""
205
+ steps: List[str]
206
+
207
+ @validator("title", pre=True)
208
+ def _clean_title(cls, value: Any) -> str:
209
+ if isinstance(value, str):
210
+ value = value.strip()
211
+ if not value:
212
+ return "Scene"
213
+ return value
214
+
215
+ @validator("steps", pre=True)
216
+ def _coerce_steps(cls, value: Any) -> List[str]:
217
+ collected: List[str] = []
218
+ if isinstance(value, str):
219
+ candidates = value.replace("\r", "").split("\n")
220
+ collected.extend(candidates)
221
+ elif isinstance(value, (list, tuple)):
222
+ for item in value:
223
+ if isinstance(item, str):
224
+ collected.extend(item.replace("\r", "").split("\n"))
225
+ elif isinstance(item, (list, tuple)):
226
+ for sub in item:
227
+ if isinstance(sub, str):
228
+ collected.append(sub)
229
+ cleaned = []
230
+ for step in collected:
231
+ step = str(step).strip(" •\t-")
232
+ if step:
233
+ cleaned.append(step)
234
+ return cleaned or ["Outline the key idea for this scene."]
235
+
236
+
237
+ class PlanPayload(BaseModel):
238
+ concept: str
239
+ scenes: List[ScenePayload]
240
+ notes: Optional[str] = ""
241
+
242
+ @validator("concept", pre=True)
243
+ def _clean_concept(cls, value: Any) -> str:
244
+ if isinstance(value, str):
245
+ value = value.strip()
246
+ return value or "Untitled Concept"
247
+
248
+ @validator("scenes", pre=True)
249
+ def _ensure_scenes(cls, value: Any) -> List[Any]:
250
+ if isinstance(value, (list, tuple)):
251
+ return list(value)
252
+ return []
253
+
254
+
255
+ class StoryboardChatIn(BaseModel):
256
+ session_id: Optional[str] = None
257
+ message: Optional[str] = ""
258
+ plan: Optional[PlanPayload] = None
259
+ settings: Optional[Dict[str, Any]] = None
260
+
261
+ @validator("message", pre=True, always=True)
262
+ def _default_message(cls, value: Any) -> str:
263
+ if value is None:
264
+ return ""
265
+ return str(value)
266
+
267
+ @validator("settings", pre=True, always=True)
268
+ def _sanitize_settings(cls, value: Any) -> Dict[str, Any]:
269
+ if isinstance(value, dict):
270
+ return value
271
+ return {}
272
+
273
+
274
+ class StoryboardConfirmIn(BaseModel):
275
+ session_id: Optional[str] = None
276
+ plan: PlanPayload
277
+ settings: Optional[Dict[str, Any]] = None
278
+
279
+ @validator("settings", pre=True, always=True)
280
+ def _sanitize_settings(cls, value: Any) -> Dict[str, Any]:
281
+ if isinstance(value, dict):
282
+ return value
283
+ return {}
284
+
285
+
286
+ @dataclass
287
+ class PlanSession:
288
+ session_id: str
289
+ messages: List[Dict[str, Any]] = field(default_factory=list)
290
+ plan: Optional[PlanPayload] = None
291
+ settings: Dict[str, Any] = field(default_factory=dict)
292
+ created_at: float = field(default_factory=time.time)
293
+ updated_at: float = field(default_factory=time.time)
294
+
295
+
296
+ PLAN_SESSIONS: Dict[str, PlanSession] = {}
297
+ PLAN_LOCK = threading.Lock()
298
+
299
  # ---------- NEW: carry full CLI error back to the refiner ----------
300
  class RenderError(Exception):
301
  def __init__(self, log: str):
 
341
  c = re.sub(r",\s*\)", ")", c)
342
  return c
343
 
344
+
345
+ def _extract_json_dict(raw: str) -> Dict[str, Any]:
346
+ """Best-effort JSON extraction from the LLM response."""
347
+ if not raw:
348
+ raise ValueError("Empty response from model")
349
+ stripped = raw.strip()
350
+ if stripped.startswith("```"):
351
+ stripped = re.sub(r"^```(?:json)?", "", stripped, flags=re.IGNORECASE).strip()
352
+ stripped = re.sub(r"```$", "", stripped).strip()
353
+ try:
354
+ return json.loads(stripped)
355
+ except json.JSONDecodeError:
356
+ match = re.search(r"\{.*\}", stripped, flags=re.DOTALL)
357
+ if match:
358
+ candidate = match.group(0)
359
+ try:
360
+ return json.loads(candidate)
361
+ except json.JSONDecodeError:
362
+ pass
363
+ raise ValueError("Model did not return valid JSON")
364
+
365
+
366
+ def _generate_scene_id(index: int) -> str:
367
+ return f"scene-{index}-{uuid.uuid4().hex[:6]}"
368
+
369
+
370
+ def _normalize_scene_title(index: int, title: str) -> str:
371
+ title = title.strip()
372
+ if not title:
373
+ return f"Scene {index}: Beat"
374
+ prefix = f"Scene {index}"
375
+ if not title.lower().startswith("scene"):
376
+ return f"{prefix}: {title}"
377
+ parts = title.split(":", 1)
378
+ if len(parts) == 2:
379
+ return f"{prefix}: {parts[1].strip()}"
380
+ return f"{prefix}: {title.split(maxsplit=1)[-1]}"
381
+
382
+
383
+ def _sanitize_plan(plan: Optional[PlanPayload], *, concept_hint: str = "Untitled Concept") -> PlanPayload:
384
+ if not plan:
385
+ default_scene = ScenePayload(
386
+ id=_generate_scene_id(1),
387
+ title="Scene 1: Setup",
388
+ objective=f"Introduce {concept_hint}",
389
+ steps=[
390
+ f"Display the title \"{concept_hint}\"",
391
+ "Provide quick context for the viewer",
392
+ "Highlight the main question to explore",
393
+ ],
394
+ )
395
+ return PlanPayload(concept=concept_hint, notes="", scenes=[default_scene])
396
+
397
+ concept = plan.concept.strip() or concept_hint or "Untitled Concept"
398
+ sanitized_scenes: List[ScenePayload] = []
399
+ for idx, scene in enumerate(plan.scenes[:MAX_STORYBOARD_SCENES], start=1):
400
+ steps = [str(step).strip() for step in scene.steps if step and str(step).strip()]
401
+ if not steps:
402
+ steps = [f"Explain the next idea for {concept}."]
403
+ title = _normalize_scene_title(idx, scene.title or f"Scene {idx}")
404
+ objective = (scene.objective or "").strip()
405
+ sanitized_scenes.append(
406
+ ScenePayload(
407
+ id=scene.id or _generate_scene_id(idx),
408
+ title=title,
409
+ objective=objective or f"Advance the story about {concept}.",
410
+ steps=steps,
411
+ )
412
+ )
413
+
414
+ if not sanitized_scenes:
415
+ sanitized_scenes.append(
416
+ ScenePayload(
417
+ id=_generate_scene_id(1),
418
+ title="Scene 1: Setup",
419
+ objective=f"Introduce {concept}",
420
+ steps=[
421
+ f"Present the main idea \"{concept}\"",
422
+ "Explain why it matters to the viewer",
423
+ ],
424
+ )
425
+ )
426
+
427
+ notes = (plan.notes or "").strip()
428
+ return PlanPayload(concept=concept, notes=notes, scenes=sanitized_scenes)
429
+
430
+
431
+ def _plan_to_public_dict(plan: PlanPayload) -> Dict[str, Any]:
432
+ return plan.dict()
433
+
434
+
435
+ def _format_conversation(messages: List[Dict[str, Any]], limit: int = 8) -> str:
436
+ if not messages:
437
+ return "None yet."
438
+ recent = messages[-limit:]
439
+ lines = []
440
+ for msg in recent:
441
+ role = msg.get("role", "assistant").title()
442
+ content = str(msg.get("content", "")).strip()
443
+ lines.append(f"{role}: {content}")
444
+ return "\n".join(lines)
445
+
446
+
447
+ def _audience_label(value: Optional[str]) -> Optional[str]:
448
+ mapping = {
449
+ "ms": "middle school students",
450
+ "hs": "high school students",
451
+ "ug": "undergraduate students",
452
+ }
453
+ return mapping.get(str(value).lower()) if value else None
454
+
455
+
456
+ def _style_label(value: Optional[str]) -> Optional[str]:
457
+ mapping = {
458
+ "minimal": "minimal visuals (focus on narration and a few key elements)",
459
+ "steps": "step-by-step exposition with clear transitions",
460
+ "geometry": "geometry-focused visuals that highlight shapes and spatial relationships",
461
+ }
462
+ return mapping.get(str(value).lower()) if value else None
463
+
464
+
465
+ def _length_label(value: Optional[str]) -> Optional[str]:
466
+ mapping = {
467
+ "short": "short (~30–45s)",
468
+ "medium": "medium (~60–90s)",
469
+ }
470
+ return mapping.get(str(value).lower()) if value else None
471
+
472
+
473
+ def _quality_from_settings(settings: Optional[Dict[str, Any]]) -> str:
474
+ if not settings:
475
+ return "medium"
476
+ resolution = str(settings.get("resolution", "")).lower()
477
+ if resolution == "480p":
478
+ return "low"
479
+ if resolution == "1080p":
480
+ return "high"
481
+ return "medium"
482
+
483
+
484
+ def _quality_flag(quality: str) -> str:
485
+ return {
486
+ "low": "-ql",
487
+ "medium": "-qm",
488
+ "high": "-qh",
489
+ }.get(quality, "-qm")
490
+
491
+
492
+ def _compose_default_render_prompt(plan: PlanPayload, settings: Dict[str, Any], conversation: List[Dict[str, Any]]) -> str:
493
+ lines = [
494
+ f"Create a concise Manim CE 0.19 scene illustrating the concept \"{plan.concept}\".",
495
+ "Structure the animation around these storyboard scenes:",
496
+ ]
497
+ for scene in plan.scenes:
498
+ lines.append(f"- {scene.title} ({scene.objective})")
499
+ for step in scene.steps:
500
+ lines.append(f" • {step}")
501
+ if plan.notes:
502
+ lines.append(f"Production notes: {plan.notes}")
503
+ if settings:
504
+ audience_text = _audience_label(settings.get("audience"))
505
+ style_text = _style_label(settings.get("style"))
506
+ length_text = _length_label(settings.get("length"))
507
+ lines.append("Production settings to honor:")
508
+ if audience_text:
509
+ lines.append(f"- Tailor explanations for {audience_text} (language, pacing, assumptions).")
510
+ if style_text:
511
+ lines.append(f"- Presentation style: {style_text}.")
512
+ if length_text:
513
+ lines.append(f"- Keep total runtime {length_text}.")
514
+ resolution = settings.get("resolution")
515
+ if resolution:
516
+ lines.append(f"- Render for {resolution} output (frame layout should read well at that resolution).")
517
+ if conversation:
518
+ lines.append("Incorporate the important constraints already discussed with the user.")
519
+ lines.append("Follow the CAPTURE policy: include # CAPTURE_POINT just before the final self.wait(0.75).")
520
+ return "\n".join(lines)
521
+
522
+
523
+ def _prune_plan_sessions(max_sessions: int = 200, max_age_seconds: int = 3600) -> None:
524
+ now = time.time()
525
+ with PLAN_LOCK:
526
+ if len(PLAN_SESSIONS) > max_sessions:
527
+ sorted_items = sorted(PLAN_SESSIONS.items(), key=lambda item: item[1].updated_at)
528
+ for session_id, _ in sorted_items[: len(PLAN_SESSIONS) - max_sessions]:
529
+ PLAN_SESSIONS.pop(session_id, None)
530
+ for session_id, session in list(PLAN_SESSIONS.items()):
531
+ if now - session.updated_at > max_age_seconds:
532
+ PLAN_SESSIONS.pop(session_id, None)
533
+
534
+
535
+ def _get_or_create_session(session_id: Optional[str], settings: Optional[Dict[str, Any]] = None) -> PlanSession:
536
+ with PLAN_LOCK:
537
+ if session_id and session_id in PLAN_SESSIONS:
538
+ session = PLAN_SESSIONS[session_id]
539
+ if settings:
540
+ session.settings.update(settings)
541
+ return session
542
+ new_id = session_id or uuid.uuid4().hex
543
+ session = PlanSession(session_id=new_id)
544
+ if settings:
545
+ session.settings.update(settings)
546
+ PLAN_SESSIONS[new_id] = session
547
+ _prune_plan_sessions()
548
+ return session
549
+
550
+
551
+ def _storyboard_model_reply(session: PlanSession, user_message: str) -> Tuple[str, PlanPayload, List[str]]:
552
+ concept_hint = session.plan.concept if session.plan else (user_message.strip() or "Untitled Concept")
553
+ session.plan = _sanitize_plan(session.plan, concept_hint=concept_hint)
554
+ session.updated_at = time.time()
555
+ plan_json = json.dumps(_plan_to_public_dict(session.plan), indent=2)
556
+ settings_json = json.dumps(session.settings or {}, indent=2)
557
+ history_text = _format_conversation(session.messages)
558
+ latest_message = user_message.strip() or "User adjusted the storyboard without additional text."
559
+ contents = f"""You are refining a math animation storyboard with the user.
560
+ Current storyboard plan JSON:
561
+ {plan_json}
562
+
563
+ Session settings:
564
+ {settings_json}
565
+
566
+ Conversation so far:
567
+ {history_text}
568
+
569
+ Update the plan if needed and craft your reply (JSON only). Latest user message:
570
+ {latest_message}
571
+ """
572
+ raw_response = gemini_small_call(system=STORYBOARD_SYSTEM_PROMPT, contents=contents)
573
+ try:
574
+ parsed = _extract_json_dict(raw_response)
575
+ except Exception as exc:
576
+ print("Storyboard model JSON parse failed:", exc, file=sys.stderr)
577
+ parsed = {}
578
+
579
+ reply_text = str(parsed.get("reply") or "").strip() or "Understood—updating the storyboard."
580
+ plan_data = parsed.get("plan")
581
+ new_plan = session.plan
582
+ if isinstance(plan_data, dict):
583
+ try:
584
+ new_plan = PlanPayload(**plan_data)
585
+ except Exception as exc:
586
+ print("Unable to parse plan from storyboard model:", exc, file=sys.stderr)
587
+ session.plan = _sanitize_plan(new_plan, concept_hint=session.plan.concept if session.plan else concept_hint)
588
+ questions_field = parsed.get("questions") or []
589
+ questions = [str(q).strip() for q in questions_field if isinstance(q, (str, int)) and str(q).strip()]
590
+ session.updated_at = time.time()
591
+ return reply_text, session.plan, questions
592
+
593
+
594
+ def _storyboard_model_confirm(session: PlanSession) -> Tuple[str, PlanPayload, str]:
595
+ session.plan = _sanitize_plan(session.plan, concept_hint=session.plan.concept if session.plan else "Untitled Concept")
596
+ plan_json = json.dumps(_plan_to_public_dict(session.plan), indent=2)
597
+ settings_json = json.dumps(session.settings or {}, indent=2)
598
+ history_text = _format_conversation(session.messages)
599
+ contents = f"""The user has approved this storyboard plan:
600
+ {plan_json}
601
+
602
+ Session settings:
603
+ {settings_json}
604
+
605
+ Conversation summary:
606
+ {history_text}
607
+
608
+ Produce the confirmation JSON only (no Markdown)."""
609
+ raw_response = gemini_small_call(system=STORYBOARD_CONFIRM_SYSTEM_PROMPT, contents=contents)
610
+ try:
611
+ parsed = _extract_json_dict(raw_response)
612
+ except Exception as exc:
613
+ print("Storyboard confirm JSON parse failed:", exc, file=sys.stderr)
614
+ parsed = {}
615
+
616
+ reply_text = str(parsed.get("reply") or "").strip() or "Great! Locking the storyboard and preparing the renderer."
617
+ plan_data = parsed.get("plan")
618
+ final_plan = session.plan
619
+ if isinstance(plan_data, dict):
620
+ try:
621
+ final_plan = PlanPayload(**plan_data)
622
+ except Exception as exc:
623
+ print("Unable to parse confirmed plan:", exc, file=sys.stderr)
624
+ final_plan = _sanitize_plan(final_plan, concept_hint=final_plan.concept if final_plan else session.plan.concept)
625
+ render_prompt = str(parsed.get("render_prompt") or "").strip()
626
+ if not render_prompt:
627
+ render_prompt = _compose_default_render_prompt(final_plan, session.settings, session.messages)
628
+ session.plan = final_plan
629
+ session.updated_at = time.time()
630
+ return reply_text, final_plan, render_prompt
631
+
632
+ def _run_manim(scene_code: str, run_id: Optional[str] = None, quality: str = "medium") -> Tuple[bytes, Optional[Path]]:
633
  """Render MP4 (fast) and also save a steady-state PNG (last frame)."""
634
  run_id = run_id or str(uuid.uuid4())[:8]
635
  work = RUNS / run_id; work.mkdir(parents=True, exist_ok=True)
 
643
  env = os.environ.copy()
644
  env["PYTHONPATH"] = str(work)
645
 
646
+ quality_flag = _quality_flag(quality)
647
+
648
  # 1) Render video
649
  cmd_video = [
650
+ "manim", quality_flag, "--disable_caching",
651
  "--media_dir", str(media),
652
  "-o", f"{run_id}.mp4",
653
  str(scene_path), "AutoScene",
 
677
  # 2) Save last frame PNG (leverages our CAPTURE_POINT rule)
678
  png_path = None
679
  cmd_png = [
680
+ "manim", quality_flag, "--disable_caching", "-s", # -s saves the last frame as an image
681
  "--media_dir", str(media),
682
  str(scene_path), "AutoScene",
683
  ]
 
709
  return file_ref
710
 
711
 
712
+ def llm_generate_manim_code(
713
+ prompt: str,
714
+ settings: Optional[Dict[str, Any]] = None,
715
+ previous_code: Optional[str] = None,
716
+ ) -> str:
717
  """First-pass generation (capture-aware)."""
718
  if not client:
719
  return DEFAULT_SCENE
720
  try:
721
  contents = f"Create AutoScene for: {prompt}\nRemember the CAPTURE POLICY and Common API constraints."
722
+ if settings:
723
+ audience_text = _audience_label(settings.get("audience"))
724
+ style_text = _style_label(settings.get("style"))
725
+ length_text = _length_label(settings.get("length"))
726
+ contents += "\nProduction settings to respect:"
727
+ if audience_text:
728
+ contents += f"\n- Tailor explanations for {audience_text}."
729
+ if style_text:
730
+ contents += f"\n- Style: {style_text}."
731
+ if length_text:
732
+ contents += f"\n- Target runtime: {length_text}."
733
+ resolution = settings.get("resolution")
734
+ if resolution:
735
+ contents += f"\n- Design visuals that read clearly at {resolution}."
736
  response_text = gemini_call(system=SYSTEM_PROMPT, contents=contents)
737
  code = _clean_code(response_text)
738
  if "class AutoScene" not in code:
 
743
  traceback.print_exc()
744
  return previous_code or DEFAULT_SCENE
745
 
746
+ def llm_refine_from_error(
747
+ previous_code: str,
748
+ error_message: str,
749
+ original_user_prompt: str,
750
+ settings: Optional[Dict[str, Any]] = None,
751
+ ) -> str:
752
  """When Manim fails; send the *real* CLI log/trace to Gemini."""
753
  if not client:
754
  return previous_code or DEFAULT_SCENE
 
771
  - Keep the CAPTURE POLICY and ensure # CAPTURE_POINT is at the final steady layout.
772
  - Scan for nonexistent methods (e.g., `.to_center`) or invalid kwargs (e.g., `vertex=` on RightAngle) and replace with valid Manim CE 0.19 API.
773
  - Prefer `.center()`/`.move_to(ORIGIN)`, and `.move_to()`, `.align_to()`, `.to_edge()`, `.next_to()` for layout.
774
+ - Apply the smallest change necessary to resolve the failure; do not overhaul structure, pacing, or stylistic choices the user made.
775
+ - Preserve all existing text content (titles, labels, strings) unless it directly causes the error.
776
+ - Do not alter functional math/logic that already works; only touch the problematic lines needed for a successful render.
777
  - Return ONLY the corrected Python code (no backticks).
778
  """
779
+ if settings:
780
+ audience_text = _audience_label(settings.get("audience"))
781
+ style_text = _style_label(settings.get("style"))
782
+ length_text = _length_label(settings.get("length"))
783
+ extra = "\nProduction targets to preserve:"
784
+ if audience_text:
785
+ extra += f"\n- Audience: {audience_text}."
786
+ if style_text:
787
+ extra += f"\n- Style: {style_text}."
788
+ if length_text:
789
+ extra += f"\n- Runtime goal: {length_text}."
790
+ resolution = settings.get("resolution")
791
+ if resolution:
792
+ extra += f"\n- Ensure layout reads clearly at {resolution}."
793
+ user_prompt += extra
794
  response_text = gemini_call(system=SYSTEM_PROMPT, contents=user_prompt)
795
  code = _clean_code(response_text)
796
  if "class AutoScene" not in code:
 
801
  traceback.print_exc()
802
  return previous_code or DEFAULT_SCENE
803
 
804
+ def llm_visual_refine_from_image(
805
+ original_user_prompt: str,
806
+ previous_code: str,
807
+ png_path: Optional[Path],
808
+ settings: Optional[Dict[str, Any]] = None,
809
+ ) -> str:
810
  """
811
  Use the screenshot to request layout/legibility/placement fixes.
812
  Includes the original prompt and current code, and asks for minimal edits.
 
832
  - Keep animation semantics as-is unless they're obviously broken.
833
  - Keep exactly one class AutoScene(Scene).
834
  - Preserve the CAPTURE POLICY and place `# CAPTURE_POINT` at the final steady layout with self.wait(0.75) and NO outro after that.
835
+ - Make the minimal adjustments needed to fix readability; do not rework the overall composition or pacing beyond what the user already authored.
836
+ - Preserve all text labels, titles, and strings as written unless they directly cause overlap/legibility issues.
837
+ - Avoid rewriting functioning math/logic—only adjust positioning, styling, or other elements required to fix the visual defect.
838
  Return ONLY the revised Python code (no backticks).
839
  """
840
+ if settings:
841
+ audience_text = _audience_label(settings.get("audience"))
842
+ style_text = _style_label(settings.get("style"))
843
+ length_text = _length_label(settings.get("length"))
844
+ visual_prompt += "\nKeep these production settings in mind:"
845
+ if audience_text:
846
+ visual_prompt += f"\n- Audience: {audience_text}."
847
+ if style_text:
848
+ visual_prompt += f"\n- Style: {style_text}."
849
+ if length_text:
850
+ visual_prompt += f"\n- Runtime target: {length_text}."
851
+ resolution = settings.get("resolution")
852
+ if resolution:
853
+ visual_prompt += f"\n- Layout should stay readable at {resolution}."
854
 
855
  response_text = gemini_call(system=SYSTEM_PROMPT, contents=[file_ref, visual_prompt])
856
  code = _clean_code(response_text)
 
862
  traceback.print_exc()
863
  return previous_code
864
 
865
+ def refine_loop(
866
+ user_prompt: str,
867
+ settings: Optional[Dict[str, Any]] = None,
868
+ max_error_refines: int = 3,
869
+ do_visual_refine: bool = True,
870
+ ) -> bytes:
871
  """
872
  Generate → render; on error, refine up to N times from Manim traceback → re-render.
873
  If first render succeeds and do_visual_refine==True, run an image-based refinement
874
  using the saved steady-state PNG, then re-render. Fallback to the best successful MP4.
875
  """
876
  # 1) initial generation (capture-aware)
877
+ code = llm_generate_manim_code(user_prompt, settings=settings)
878
+ quality = _quality_from_settings(settings)
879
 
880
  # 2) render attempt
881
  try:
882
+ mp4_bytes, png_path = _run_manim(code, run_id="iter0", quality=quality)
883
  except RenderError as e:
884
  print("Render failed (iter0), attempting error-based refinement...", file=sys.stderr)
885
  if max_error_refines <= 0:
 
888
  last_err = e.log or ""
889
  while attempts < max_error_refines:
890
  attempts += 1
891
+ refined = llm_refine_from_error(
892
+ previous_code=code,
893
+ error_message=last_err,
894
+ original_user_prompt=user_prompt,
895
+ settings=settings,
896
+ )
897
  try:
898
+ mp4_bytes, png_path = _run_manim(refined, run_id=f"iter_err_{attempts}", quality=quality)
899
  code = refined
900
  break
901
  except RenderError as e2:
 
912
  last_err = traceback.format_exc()
913
  while attempts < max_error_refines:
914
  attempts += 1
915
+ refined = llm_refine_from_error(
916
+ previous_code=code,
917
+ error_message=last_err,
918
+ original_user_prompt=user_prompt,
919
+ settings=settings,
920
+ )
921
  try:
922
+ mp4_bytes, png_path = _run_manim(refined, run_id=f"iter_err_{attempts}", quality=quality)
923
  code = refined
924
  break
925
  except Exception:
 
929
 
930
  # 3) optional visual refinement loop
931
  if do_visual_refine and png_path and png_path.exists():
932
+ refined2 = llm_visual_refine_from_image(
933
+ original_user_prompt=user_prompt,
934
+ previous_code=code,
935
+ png_path=png_path,
936
+ settings=settings,
937
+ )
938
  if refined2.strip() != code.strip():
939
  try:
940
+ mp4_bytes2, _ = _run_manim(refined2, run_id="iter2", quality=quality)
941
  return mp4_bytes2
942
  except Exception:
943
  print("Visual refine render failed; returning best known render.", file=sys.stderr)
 
945
 
946
  return mp4_bytes
947
 
948
+
949
+ def _auto_fix_render(
950
+ user_prompt: str,
951
+ code: str,
952
+ settings: Optional[Dict[str, Any]],
953
+ initial_log: str,
954
+ max_attempts: int = 3,
955
+ ) -> Tuple[Optional[str], Optional[bytes], str]:
956
+ """Attempt to auto-fix user code via LLM refinement if available."""
957
+ if not client:
958
+ return None, None, initial_log
959
+ quality = _quality_from_settings(settings)
960
+ attempt_code = code
961
+ last_log = initial_log
962
+ for attempt in range(max_attempts):
963
+ refined = llm_refine_from_error(
964
+ previous_code=attempt_code,
965
+ error_message=last_log,
966
+ original_user_prompt=user_prompt,
967
+ settings=settings,
968
+ )
969
+ if refined.strip() == attempt_code.strip():
970
+ break
971
+ attempt_code = refined
972
+ try:
973
+ mp4_bytes, _ = _run_manim(
974
+ attempt_code,
975
+ run_id=f"manual_fix_{attempt}",
976
+ quality=quality,
977
+ )
978
+ return attempt_code, mp4_bytes, ""
979
+ except RenderError as err:
980
+ last_log = err.log or last_log
981
+ return None, None, last_log
982
+
983
  # ---------------- API ----------------
984
+ @app.post("/storyboard/chat")
985
+ def storyboard_chat(inp: StoryboardChatIn):
986
+ if not client:
987
+ raise HTTPException(500, "Gemini client is not configured")
988
+ if not inp.message.strip() and not inp.plan:
989
+ raise HTTPException(400, "Message or plan updates are required.")
990
+
991
+ session = _get_or_create_session(inp.session_id, inp.settings or {})
992
+ if inp.settings:
993
+ session.settings.update(inp.settings)
994
+
995
+ if inp.plan:
996
+ try:
997
+ session.plan = _sanitize_plan(inp.plan, concept_hint=inp.plan.concept)
998
+ except Exception as exc:
999
+ print("Failed to apply user-supplied plan:", exc, file=sys.stderr)
1000
+
1001
+ user_message = inp.message.strip()
1002
+ if user_message:
1003
+ session.messages.append({"role": "user", "content": user_message})
1004
+ else:
1005
+ session.messages.append({"role": "user", "content": "[Plan updated without additional message]"})
1006
+
1007
+ try:
1008
+ reply_text, plan_model, questions = _storyboard_model_reply(session, user_message)
1009
+ except Exception as exc:
1010
+ print("Storyboard chat error:", exc, file=sys.stderr)
1011
+ raise HTTPException(500, "Storyboard assistant failed to respond")
1012
+
1013
+ session.messages.append({"role": "assistant", "content": reply_text})
1014
+ return {
1015
+ "session_id": session.session_id,
1016
+ "reply": reply_text,
1017
+ "plan": plan_model.dict(),
1018
+ "questions": questions,
1019
+ "settings": session.settings,
1020
+ }
1021
+
1022
+
1023
+ @app.post("/storyboard/confirm")
1024
+ def storyboard_confirm(inp: StoryboardConfirmIn):
1025
+ if not client:
1026
+ raise HTTPException(500, "Gemini client is not configured")
1027
+
1028
+ session = _get_or_create_session(inp.session_id, inp.settings or {})
1029
+ if inp.settings:
1030
+ session.settings.update(inp.settings)
1031
+
1032
+ session.plan = _sanitize_plan(inp.plan, concept_hint=inp.plan.concept)
1033
+ session.messages.append({"role": "user", "content": "[User confirmed the storyboard plan]"})
1034
+
1035
+ try:
1036
+ reply_text, final_plan, render_prompt = _storyboard_model_confirm(session)
1037
+ except Exception as exc:
1038
+ print("Storyboard confirm error:", exc, file=sys.stderr)
1039
+ final_plan = session.plan
1040
+ render_prompt = _compose_default_render_prompt(final_plan, session.settings, session.messages)
1041
+ reply_text = "Plan confirmed. Falling back to a templated prompt."
1042
+
1043
+ session.messages.append({"role": "assistant", "content": reply_text})
1044
+ return {
1045
+ "session_id": session.session_id,
1046
+ "reply": reply_text,
1047
+ "render_prompt": render_prompt,
1048
+ "plan": final_plan.dict(),
1049
+ "settings": session.settings,
1050
+ }
1051
+
1052
+
1053
  class PromptIn(BaseModel):
1054
  prompt: str
1055
+ settings: Optional[Dict[str, Any]] = None
1056
+
1057
+ @validator("prompt")
1058
+ def _validate_prompt(cls, value: str) -> str:
1059
+ if not value or not value.strip():
1060
+ raise ValueError("Prompt cannot be empty")
1061
+ return value.strip()
1062
+
1063
+ @validator("settings", pre=True, always=True)
1064
+ def _sanitize_settings(cls, value: Any) -> Optional[Dict[str, Any]]:
1065
+ if isinstance(value, dict):
1066
+ return value
1067
+ return None
1068
+
1069
+
1070
+ class GenerateCodeIn(PromptIn):
1071
+ pass
1072
+
1073
+
1074
+ class RenderCodeIn(BaseModel):
1075
+ code: str
1076
+ prompt: Optional[str] = ""
1077
+ settings: Optional[Dict[str, Any]] = None
1078
+ auto_fix: bool = False
1079
+
1080
+ @validator("code")
1081
+ def _validate_code(cls, value: str) -> str:
1082
+ if not value or not value.strip():
1083
+ raise ValueError("Code cannot be empty")
1084
+ return value
1085
+
1086
+ @validator("prompt", pre=True, always=True)
1087
+ def _sanitize_prompt(cls, value: Any) -> str:
1088
+ return str(value or "").strip()
1089
+
1090
+ @validator("settings", pre=True, always=True)
1091
+ def _sanitize_settings(cls, value: Any) -> Optional[Dict[str, Any]]:
1092
+ if isinstance(value, dict):
1093
+ return value
1094
+ return None
1095
 
1096
  class EmailIn(BaseModel):
1097
  email: str
 
1114
  return {"ok": True, "model": MODEL, "has_gemini": bool(API_KEY)}
1115
 
1116
  @app.post("/generate-code")
1117
+ def generate_code(inp: GenerateCodeIn):
1118
  """Return ONLY the generated Manim Python code (no rendering)."""
1119
+ code = llm_generate_manim_code(inp.prompt, settings=inp.settings)
 
 
1120
  return {"code": code}
1121
 
1122
  @app.post("/generate-and-render")
1123
  def generate_and_render(inp: PromptIn):
 
 
1124
  try:
1125
+ mp4 = refine_loop(inp.prompt, settings=inp.settings, max_error_refines=3, do_visual_refine=True)
1126
  except Exception:
1127
  raise HTTPException(500, "Failed to produce video after refinement")
1128
  return Response(
 
1131
  headers={"Content-Disposition": 'inline; filename="result.mp4"'}
1132
  )
1133
 
1134
+
1135
+ @app.post("/render-code")
1136
+ def render_code(inp: RenderCodeIn):
1137
+ quality = _quality_from_settings(inp.settings)
1138
+ try:
1139
+ mp4_bytes, _ = _run_manim(inp.code, run_id="manual", quality=quality)
1140
+ return Response(
1141
+ content=mp4_bytes,
1142
+ media_type="video/mp4",
1143
+ headers={"Content-Disposition": 'inline; filename="result.mp4"'}
1144
+ )
1145
+ except RenderError as exc:
1146
+ log = exc.log or ""
1147
+ if not inp.auto_fix:
1148
+ raise HTTPException(
1149
+ status_code=400,
1150
+ detail={
1151
+ "error": "Render failed",
1152
+ "message": "Render failed. Attempting automatic fix...",
1153
+ },
1154
+ )
1155
+ fixed_code, fixed_video, final_log = _auto_fix_render(
1156
+ user_prompt=inp.prompt or "User-edited Manim code",
1157
+ code=inp.code,
1158
+ settings=inp.settings,
1159
+ initial_log=log,
1160
+ )
1161
+ if fixed_code and fixed_video:
1162
+ payload = {
1163
+ "auto_fixed": True,
1164
+ "message": "Your code triggered a Manim error, so I applied the smallest possible fix (keeping your edits) and reran the render.",
1165
+ "code": fixed_code,
1166
+ "video_base64": base64.b64encode(fixed_video).decode("utf-8"),
1167
+ "video_mime_type": "video/mp4",
1168
+ "files": [
1169
+ {"filename": "scene.py", "contents": fixed_code}
1170
+ ],
1171
+ "meta": {"resolution": inp.settings.get("resolution") if inp.settings else None},
1172
+ "log_tail": (log or "")[-600:]
1173
+ }
1174
+ return Response(
1175
+ content=json.dumps(payload),
1176
+ media_type="application/json",
1177
+ )
1178
+ detail_log = (final_log or log)[-6000:]
1179
+ raise HTTPException(
1180
+ status_code=400,
1181
+ detail={"error": "Render failed", "log": detail_log, "code": inp.code},
1182
+ )
1183
+ except Exception as exc:
1184
+ raise HTTPException(status_code=500, detail={"error": "Unexpected render failure", "log": str(exc)})
1185
+
1186
  @app.post("/store-email")
1187
  def store_email(email: EmailIn):
1188
  """Store the provided email address in the configured Hugging Face dataset."""