Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Commands registered on the **`docgen`** CLI include:
- **`image-generate`** — render scene-spec **image elements** (`image:` + `prompt:` boxes) via the OpenAI Images API into the bundle (also runs for missing assets inside `generate-all`).
- **`manim`** — render Manim scenes declared in config.
- **`compose`** — mux narration audio with visual sources via ffmpeg.
- **`validate`** / **`validate --pre-push`** — drift, narration lint, Manim hints, **`timing_sync`**, **`story_end`** (last paced reveal vs audio end; hard fail), **`av_sync`** (soft; prefers scene-spec labels as OCR anchors), **`subject_beat_coverage`** (declarative specs vs narration topic beats; hard fail when enabled), and related checks.
- **`validate`** / **`validate --pre-push`** — drift, narration lint, Manim hints, **`timing_sync`**, **`story_end`** (last paced reveal vs audio end; hard fail), **`scene_assets`** (pre-render: stuck-board cadence, frame-budget overlaps, `MANIM_FONT` consistency, stale helpers / stale compiled class — hard fail; also a `generate-all` gate before Manim), **`av_sync`** (soft; prefers scene-spec labels as OCR anchors), **`subject_beat_coverage`** (declarative specs vs narration topic beats; hard fail when enabled), and related checks.
- **`lint`** — narration lint helper.
- **`narration-generate`** — LLM-assisted narration from hints and repo context; optional **`--revise --revision-notes`** for in-place edits (same contract as the wizard Revise button).
- **`scene-spec-generate`** — LLM emits declarative **`*.scene.yaml`**; enforces frame budget + **subject-beat coverage** (dwell OK; cover topic shifts; reject invented labels).
Expand All @@ -62,7 +62,7 @@ Commands registered on the **`docgen`** CLI include:
## Implications for changes here

- **Manim / `scenes.py` (marker blocks):** Fix generators under `src/docgen/**` (`manim_scene_support.py`, `scene_spec.py`, `scene_spec_generate.py`, `validate`, `yaml_generate`, tests). **Do not** patch generated classes inside a consumer's **`animations/scenes.py`** between **`BEGIN/END GENERATED SCENE`** markers; re-run **`scene-spec-generate`** / **`scene-compile --retime`** and **`manim`** instead. Preferred consumer order: narration → TTS → timestamps → scene-spec/compile → Manim → compose.
- **Beat sync (fail-closed):** when `timing.json` has words, every story box label must match a spoken phrase (`wait_word`); unmatched labels and leftover LLM indices are rejected. Opt out with ``pace: none``. Fuzzy containment matching is not used. **`scene-compile` clamps FadeIn / page-fade `run_time` against the next word start** so `_TimedScene._clock` cannot race past waits (issue #66 — do not emit cascading first-board dumps). Page transitions FadeOut revealed boxes, not the parent `VGroup`.
- **Beat sync (fail-closed):** when `timing.json` has words, every story box label must match a spoken phrase (`wait_word`); unmatched labels and leftover LLM indices are rejected. Opt out with ``pace: none``. Fuzzy containment matching is not used. **`scene-compile` clamps FadeIn / page-fade `run_time` against the next word start** so `_TimedScene._clock` cannot race past waits (issue #66 — do not emit cascading first-board dumps). After a reveal, a **dwell** slot may play `Indicate` / `Circumscribe` when the gap to the next `wait_word` is long enough (also clamped). Optional box fields: `shape` (rounded/pill/diamond), `reveal` (fade/grow/slide), `emphasis` (none/pulse/ring). Page transitions FadeOut revealed boxes, not the parent `VGroup`. `scene-compile` refreshes stale `_box` / `_arrow` / `_TimedScene` helpers in `scenes.py`.
- **Subject-beat coverage:** implemented in `scene_spec.layout_density_violations` / `cluster_subject_beats`; enforced by **`scene-spec-generate`** and **`validate`** (`validation.subject_beat_coverage.enabled`, default true). Not a blind label count.
- Prefer **stable CLI / library contracts** and **documented exit codes** so CI can depend on them.
- **`narration_from_source`:** hints in config + **`docgen narration-generate`** — owner-supplied context paths, not opaque bulk edits to outputs.
Expand Down
2 changes: 1 addition & 1 deletion docs/demos/hints/manim-scene-specs.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Use these constraints when generating **`animations/specs/*.scene.yaml`** (via `docgen scene-spec-generate` or by hand):

- **Rows of `_box`** in the spec compiler — short ASCII labels, no unicode arrows or smart punctuation in labels (use `->` or hyphen). Optional **`edges`** (`from` / `to` box labels, optional palette `color`) draw directed connectors via `_arrow` / `GrowArrow` after layout — prefer them for pipeline / flow boards.
- **Rows of `_box`** in the spec compiler — short ASCII labels, no unicode arrows or smart punctuation in labels (use `->` or hyphen). Optional **`shape`** (`rounded` | `pill` | `diamond`), **`reveal`** (`fade` | `grow` | `slide`), and **`emphasis`** (`none` | `pulse` | `ring`) add motion without inventing labels. Optional **`edges`** (`from` / `to` box labels, optional palette `color`) draw **edge-to-edge** connectors via `_arrow` / `GrowArrow` after layout — prefer them for pipeline / flow boards. Long subject-beat holds get a clock-safe pulse (`layout.dwell_emphasis: auto`) unless you set `emphasis: none`.
- **Pages, not shrinking:** use top-level **`pages`** (list of `{ rows: [...], transition?: fade|none, edges?: [...] }`) when the story needs more boxes than fit on one screen. The compiler does **not** scale everything down; it **fade**s out the previous page’s stack (or **none** for an instant remove) before animating the next page. Single-page specs keep top-level **`rows`** (and optional top-level **`edges`**).
- **Frame budget:** dogfood scenes use a **14.22×8** Manim frame (`scenes.py` header). Content sits under the title — tall stacks (**many rows × box `height` + `row_gap`**) scroll past the bottom. Prefer **extra pages** or **shorter boxes** (`height` ~0.72–0.9, tighter `row_gap`) over piling 5+ full-height rows on one page.
- **~3 rows per page** is a safe default (~6 when rows use compact height); match beats in **`narration/<segment>.md`** and optional **`wait_segment`** / **`wait_at`** when `timing.json` has Whisper data.
Expand Down
11 changes: 11 additions & 0 deletions src/docgen/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,17 @@ def timing_sync_config(self) -> dict[str, Any]:
defaults.update(self.raw.get("validation", {}).get("timing_sync", {}))
return defaults

@property
def scene_assets_config(self) -> dict[str, Any]:
"""Pre-render checks for stuck boards, overlaps, fonts, and compile sync.

Runs in ``docgen validate`` (hard fail in ``--pre-push``) and as a
``generate-all`` gate before Manim so a stale spec cannot burn a render.
"""
defaults: dict[str, Any] = {"enabled": True}
defaults.update(self.raw.get("validation", {}).get("scene_assets", {}))
return defaults

@property
def story_end_config(self) -> dict[str, Any]:
"""Visual story finished early vs narration (``docgen validate`` ``story_end``).
Expand Down
101 changes: 101 additions & 0 deletions src/docgen/manim_primitives.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Clock-safe motion math shared by scene-spec compile and tests.

Manim-facing copies of ``_box`` / ``_arrow`` live in ``BOOTSTRAP_HEADER``.
This module stays importable without Manim so unit tests can lock geometry
and dwell budgets independently of a render.
"""

from __future__ import annotations

import math

# Floor when allocating Indicate / Circumscribe after a reveal.
MIN_DWELL_RUN_TIME = 0.35
DEFAULT_DWELL_RUN_TIME = 0.5
# Leave this much clock before the next wait_word so dwell cannot skip it.
DWELL_CLOCK_MARGIN = 0.12

ALLOWED_SHAPES = frozenset({"rounded", "pill", "diamond"})
ALLOWED_REVEALS = frozenset({"fade", "grow", "slide"})
ALLOWED_EMPHASIS = frozenset({"none", "pulse", "ring"})
ALLOWED_DWELL_EMPHASIS = frozenset({"auto", "none"})


def connector_endpoints(
c1: tuple[float, float],
half1: tuple[float, float],
c2: tuple[float, float],
half2: tuple[float, float],
buff: float = 0.2,
) -> tuple[tuple[float, float], tuple[float, float]]:
"""Axis-aligned bbox edge points facing each other, plus ``buff`` along the ray.

``half1`` / ``half2`` are ``(half_width, half_height)`` of each box.
Used to lock edge-to-edge arrow geometry without importing Manim.
"""
x1, y1 = c1
x2, y2 = c2
dx = x2 - x1
dy = y2 - y1
if abs(dx) < 1e-9 and abs(dy) < 1e-9:
return c1, c2

def _hit(hw: float, hh: float, sx: float, sy: float) -> tuple[float, float]:
tx = hw / abs(sx) if abs(sx) > 1e-9 else float("inf")
ty = hh / abs(sy) if abs(sy) > 1e-9 else float("inf")
t = min(tx, ty)
return (t * sx, t * sy)

start_off = _hit(half1[0], half1[1], dx, dy)
end_off = _hit(half2[0], half2[1], -dx, -dy)
length = math.hypot(dx, dy)
ux, uy = dx / length, dy / length
start = (x1 + start_off[0] + ux * buff, y1 + start_off[1] + uy * buff)
end = (x2 + end_off[0] - ux * buff, y2 + end_off[1] - uy * buff)
return start, end


def resolve_box_emphasis(box: dict, layout: dict | None) -> str:
"""Return ``none`` | ``pulse`` | ``ring``. Omitted box field inherits layout.

``layout.dwell_emphasis`` is ``auto`` (default → pulse when budget allows)
or ``none``. A box-level ``emphasis`` always wins.
"""
raw = box.get("emphasis") if isinstance(box, dict) else None
if raw is not None:
val = str(raw).strip().lower()
if val == "auto":
return "pulse"
return val
mode = str((layout or {}).get("dwell_emphasis") or "auto").strip().lower()
if mode == "none":
return "none"
return "pulse"


def compute_dwell_run_time(
clock: float,
next_target: float | None,
*,
requested: str,
default_rt: float = DEFAULT_DWELL_RUN_TIME,
) -> float:
"""Seconds of emphasis after a reveal, or 0 if it would race the next wait.

``next_target`` is the next paced ``wait_word`` start. ``None`` means this
is the last reveal — use ``default_rt`` (audio tail still waits after).
"""
if requested == "none":
return 0.0
try:
default = float(default_rt)
except (TypeError, ValueError):
default = DEFAULT_DWELL_RUN_TIME
if default <= 0:
return 0.0
if next_target is None:
return default
usable = float(next_target) - float(clock) - DWELL_CLOCK_MARGIN
if usable < MIN_DWELL_RUN_TIME:
return 0.0
return min(default, usable)
153 changes: 136 additions & 17 deletions src/docgen/manim_scene_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ def lint_manim_title_down_row_collision_risk(code: str) -> list[str]:
C_TEAL = "#26c6da"
C_PURPLE = "#ce93d8"
C_WHITE = "#cdd6f4"
# Single family for every Text() — do not rely on Pango's machine default.
MANIM_FONT = "Liberation Sans"

# Layout: default Manim 16:9 frame ~14.22 x 8. Leave margin so nothing touches edges.
SAFE_CONTENT_WIDTH = 12.85
Expand Down Expand Up @@ -161,26 +163,49 @@ def _load_timing_words(segment_key: str) -> list[dict]:
return list(words) if isinstance(words, list) else []


def _box(label, color, w=2.2, h=0.75, fs=18, subtitle=""):
"""Labeled rounded box - slightly stronger fill/stroke for readable diagram boards.
def _box(label, color, w=2.2, h=0.75, fs=18, subtitle="", shape="rounded"):
"""Labeled diagram node. ``shape`` is rounded (default), pill, or diamond.

Optional ``subtitle`` is a second, smaller line under the primary label
(decorative; not used for wait_word beat matching).
"""
r = RoundedRectangle(
corner_radius=0.18, width=w, height=h,
stroke_color=color, stroke_width=2.5,
fill_color=color, fill_opacity=0.28,
)
t = Text(str(label), font_size=fs, color=C_WHITE)
kind = str(shape or "rounded").strip().lower()
if kind == "diamond":
r = Polygon(
[0, h / 2, 0],
[w / 2, 0, 0],
[0, -h / 2, 0],
[-w / 2, 0, 0],
stroke_color=color,
stroke_width=2.5,
fill_color=color,
fill_opacity=0.28,
)
elif kind == "pill":
r = RoundedRectangle(
corner_radius=max(h / 2, 0.08),
width=w,
height=h,
stroke_color=color,
stroke_width=2.5,
fill_color=color,
fill_opacity=0.28,
)
else:
r = RoundedRectangle(
corner_radius=0.18, width=w, height=h,
stroke_color=color, stroke_width=2.5,
fill_color=color, fill_opacity=0.28,
)
t = Text(str(label), font_size=fs, color=C_WHITE, font=MANIM_FONT)
# Prefer white label text for contrast; fall back to the accent color when
# the palette token is already near-white.
if str(color) in (C_WHITE, "C_WHITE", "#cdd6f4"):
t.set_color(color)
sub = str(subtitle or "").strip()
if sub:
sub_fs = max(10, int(fs * 0.72))
s = Text(sub, font_size=sub_fs, color=C_WHITE)
sub_fs = max(14, int(fs * 0.72))
s = Text(sub, font_size=sub_fs, color=C_WHITE, font=MANIM_FONT)
if str(color) in (C_WHITE, "C_WHITE", "#cdd6f4"):
s.set_color(color)
s.set_opacity(0.85)
Expand All @@ -198,15 +223,24 @@ def _box(label, color, w=2.2, h=0.75, fs=18, subtitle=""):


def _arrow(start, end, color="#cdd6f4", style="solid"):
"""Connector between box centers (used by scene-spec ``edges``).
"""Connector for scene-spec ``edges``.

``style`` is ``solid`` (default) or ``dashed``. Dashed edges should be
revealed with ``FadeIn`` (not ``GrowArrow``).
Accepts two mobjects (edge-to-edge via ``get_critical_point``) or two
points (legacy ``.get_center()`` compile output). ``style`` is ``solid``
(default) or ``dashed``. Dashed edges should be revealed with ``FadeIn``.
"""
# Allow palette token names that compile_scene_class emits as bare identifiers.
def _is_mob(x):
return hasattr(x, "get_center") and hasattr(x, "get_critical_point")

if _is_mob(start) and _is_mob(end):
direction = end.get_center() - start.get_center()
p0 = start.get_critical_point(direction)
p1 = end.get_critical_point(-direction)
else:
p0, p1 = start, end
arr = Arrow(
start,
end,
p0,
p1,
color=color,
stroke_width=3,
buff=0.2,
Expand Down Expand Up @@ -989,6 +1023,12 @@ def lint_generated_block(
if not is_text:
continue

kw_names = {kw.arg for kw in node.keywords if kw.arg}
if "font" not in kw_names:
issues.append(
f"line {node.lineno}: Text() is missing font=MANIM_FONT — "
"machine Pango defaults drift (font consistency)"
)
for kw in node.keywords:
if kw.arg == "weight" and isinstance(kw.value, ast.Name) and kw.value.id == "BOLD":
issues.append(
Expand Down Expand Up @@ -1155,13 +1195,91 @@ def _bootstrap_helper_source(name: str) -> str:
"""Extract one top-level helper definition from :data:`BOOTSTRAP_HEADER` by name."""
tree = ast.parse(BOOTSTRAP_HEADER)
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name == name:
if isinstance(node, (ast.FunctionDef, ast.ClassDef)) and node.name == name:
segment = ast.get_source_segment(BOOTSTRAP_HEADER, node)
if segment:
return segment
raise SceneGenerationError(f"bootstrap helper {name!r} not found in BOOTSTRAP_HEADER")


def _fn_arg_names(node: ast.FunctionDef) -> set[str]:
names = {a.arg for a in node.args.args}
names.update(a.arg for a in node.args.kwonlyargs)
return names


def _class_method(node: ast.ClassDef, name: str) -> ast.FunctionDef | None:
for child in node.body:
if isinstance(child, ast.FunctionDef) and child.name == name:
return child
return None


def helper_needs_refresh(tree: ast.AST, name: str) -> bool:
"""True when a present helper is missing the current motion/clock API."""
for node in tree.body:
if name == "_box" and isinstance(node, ast.FunctionDef) and node.name == "_box":
return "shape" not in _fn_arg_names(node) or "MANIM_FONT" not in ast.unparse(node)
if name == "_arrow" and isinstance(node, ast.FunctionDef) and node.name == "_arrow":
return "get_critical_point" not in ast.unparse(node)
if name == "_TimedScene" and isinstance(node, ast.ClassDef) and node.name == "_TimedScene":
timed = _class_method(node, "timed_play")
if timed is None:
return True
return "not_past" not in _fn_arg_names(timed)
if name == "_image" and isinstance(node, ast.FunctionDef) and node.name == "_image":
return False
return False


def _replace_top_level_def(text: str, node: ast.AST, new_src: str) -> str:
if getattr(node, "lineno", None) is None or getattr(node, "end_lineno", None) is None:
raise SceneGenerationError("cannot refresh helper without line numbers")
start = node.lineno - 1
decos = getattr(node, "decorator_list", None) or []
if decos:
start = min(d.lineno for d in decos) - 1
end = node.end_lineno
lines = text.splitlines(keepends=True)
replacement = new_src if new_src.endswith("\n") else new_src + "\n"
return "".join(lines[:start]) + replacement + "".join(lines[end:])


def refresh_bootstrap_helpers(scenes_path: Path) -> list[str]:
"""Replace stale ``_box`` / ``_arrow`` / ``_TimedScene`` with canonical bodies.

Does not touch generated scene classes. Missing ``_image`` is still handled
by :func:`ensure_image_helper`. Returns the names that were rewritten.
"""
if not scenes_path.is_file():
return []
text = scenes_path.read_text(encoding="utf-8")
try:
tree = ast.parse(text)
except SyntaxError as exc:
raise SceneGenerationError(
f"{scenes_path} did not parse as Python ({exc.msg} at line {exc.lineno}); "
"fix the file before refreshing helpers."
) from exc

refreshed: list[str] = []
# Replace from the bottom of the file so earlier line numbers stay valid.
nodes: list[tuple[int, str, ast.AST]] = []
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in {"_box", "_arrow"}:
if helper_needs_refresh(tree, node.name):
nodes.append((node.lineno, node.name, node))
elif isinstance(node, ast.ClassDef) and node.name == "_TimedScene":
if helper_needs_refresh(tree, "_TimedScene"):
nodes.append((node.lineno, node.name, node))
for _lineno, name, node in sorted(nodes, key=lambda item: item[0], reverse=True):
text = _replace_top_level_def(text, node, _bootstrap_helper_source(name))
refreshed.append(name)
if refreshed:
scenes_path.write_text(text, encoding="utf-8")
return list(reversed(refreshed))


def ensure_image_helper(scenes_path: Path) -> bool:
"""Append the canonical ``_image`` helper to an existing ``scenes.py`` when missing.

Expand Down Expand Up @@ -1228,6 +1346,7 @@ def ensure_scenes_bootstrap(scenes_path: Path) -> None:
"Either restore the helpers (palette + _box + _arrow + _load_timing + _load_timing_words + _TimedScene) "
f"or delete {scenes_path.name} so scene-spec-generate / scene-compile can write a fresh bootstrap."
)
refresh_bootstrap_helpers(scenes_path)


# ── Narration / timing loaders (scene-spec-generate) ───────────────────────
Expand Down
Loading