Spaces:
Sleeping
Sleeping
add smaller model
Browse files- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|
| 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",
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 428 |
"""Return ONLY the generated Manim Python code (no rendering)."""
|
| 429 |
-
|
| 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.
|
| 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."""
|