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: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,9 @@ jobs:
backoff=$((backoff * 2))
attempt=$((attempt + 1))
done
- run: pip install --no-cache-dir ".[dev]" "mutmut==3.8.0"
# mutmut 3.8.0 reads pytest's private Function._nodeid. Pytest 9 removed
# that attribute (use nodeid). 3.8.1 matches the public attribute.
- run: pip install --no-cache-dir ".[dev]" "mutmut==3.8.1"
- name: Mutation score for path_filters.py
env:
PYTHONPATH: ${{ github.workspace }}/src
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,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. The unit CI job runs ``pytest tests/``, which includes this module on a temp fixture: a hand-edited generated-region label or ``run_time`` fails ``scene_assets``.
- **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``. Legacy row-level ``wait_segment`` is upgraded to ``wait_word`` and written back on ``scene-compile`` (direct ``compile_scene_class`` still rejects leftover ``wait_segment``). 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). Long holds emit additional mid-hold pulses (`timed_wait` + emphasis) so the board does not freeze after the first Indicate. 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`.
- **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``. Legacy row-level ``wait_segment`` is upgraded to ``wait_word`` and written back on ``scene-compile`` (direct ``compile_scene_class`` still rejects leftover ``wait_segment``). 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). Long holds emit additional mid-hold pulses (`timed_wait` + emphasis) so the board does not freeze after the first Indicate. Optional box fields: `shape` (rounded/pill/diamond), `reveal` (fade/grow/slide), `emphasis` (none/pulse/ring). Page transitions (`fade`, `slide`, `rise`, `zoom`, `none`) animate the previous page's revealed boxes off and, for slide / rise / zoom, bring the next page's first node in with the matching motion. They FadeOut those boxes individually, not the parent `VGroup`. Durations stay clamped like other reveals. `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.
- **Image-prompt alignment:** implemented in `image_align.image_prompt_alignment_violations`; **`scene-spec-generate`** and **`image-generate`** reject prompts that share no documented terms; **`validate`** reports the same failures on **`scene_assets`** (`validation.image_prompt_alignment.enabled`, default true). `image-generate` also wraps the Images API prompt with narration/source (`image_generation.align_with_docs`, default true).
- **Image-asset alignment:** OCR + vision review of the generated PNG (`docgen.image_align`). **`image-generate`** fails closed and retries once (`image_generation.align_review`, default true). **`validate`** folds the same pixel gate into **`scene_assets`** (`validation.image_asset_alignment`, OCR on by default, vision opt-in).
Expand Down
4 changes: 2 additions & 2 deletions docs/demos/hints/manim-scene-specs.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
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 **`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`) and additional mid-hold pulses before the board would sit still, 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`**).
- **Pages, not shrinking:** use top-level **`pages`** (list of `{ rows: [...], transition?: fade|slide|rise|zoom|none, edges?: [...] }`) when the story needs more boxes than fit on one screen. The compiler does **not** scale everything down. Between pages it plays a **page transition**: **`fade`** (default dissolve), **`slide`** (board travels left, next node enters from the right), **`rise`** (board drops, next node settles from above), **`zoom`** (board shrinks, next node scales up), or **`none`** (instant remove). Only the first node of the next page uses that entrance; later boxes stay paced to the narration. An explicit box `reveal` on that first node wins over the page entrance. 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.
- **Subject-beat coverage (library contract):** hold the board while consecutive sentences elaborate the **same** topic; when the topic shifts, add a spoken-phrase label for that beat. `scene-spec-generate` and `validate` run `layout_density_violations` — **coverage of subject beats + no invented unspoken labels**, not a blind label count. Disable with `validation.subject_beat_coverage.enabled: false` if needed.
- **Hyphenated / script labels:** Whisper often emits `version-controlled` or `setup-agent-prompts.sh` as **one** word. Prefer those exact compounds in box labels (or set `pace: none`). Retiming glues split label tokens to match those spoken compounds.
- **Palette tokens only:** `C_BG`, `C_ACCENT`, `C_GREEN`, `C_ORANGE`, `C_BLUE`, `C_RED`, `C_TEAL`, `C_PURPLE`, `C_WHITE`.
- **Readable sizes:** `font_size` ≥ 14; widths ~3–6, heights ~0.7–1.3; tune `row_gap` / `column_gap` in `layout` if labels feel cramped.
- **Layout gate:** `docgen scene-spec-generate` rejects specs that exceed a computed vertical stack budget or safe row width (`layout_budget_violations` in `scene_spec.py`). `docgen scene-compile` does **not** enforce that (hand fixes allowed).
- **Layout:** `first_row_title_buff` ~0.5 under the title; `page_transition` / `page_transition_run_time` for defaults between pages (`fade` | `none`).
- **Layout:** `first_row_title_buff` ~0.5 under the title; `page_transition` / `page_transition_run_time` for defaults between pages (`fade` | `slide` | `rise` | `zoom` | `none`).
48 changes: 45 additions & 3 deletions src/docgen/scene_asset_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,49 @@ def _const_value(node: ast.AST) -> object | None:
return None


def _call_keyword(call: ast.Call, name: str) -> ast.AST | None:
for kw in call.keywords:
if kw.arg == name:
return kw.value
return None


def _shift_direction(call: ast.Call) -> str | None:
"""``LEFT`` / ``DOWN`` / … from ``shift=LEFT * n``, or None when unset."""
node = _call_keyword(call, "shift")
if node is None:
return None
if isinstance(node, ast.BinOp):
node = node.left
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
return node.attr
return "shift"


def _fade_in_kind(call: ast.Call) -> str:
direction = _shift_direction(call)
if _call_keyword(call, "scale") is not None and direction is None:
return "zoom"
if direction == "DOWN":
return "rise"
if direction is not None:
return "slide"
return "fade"


def _fade_out_token(call: ast.Call) -> str:
direction = _shift_direction(call)
if _call_keyword(call, "scale") is not None and direction is None:
return "page_zoom"
if direction in {"LEFT", "RIGHT"}:
return "page_slide"
if direction in {"UP", "DOWN"}:
return "page_rise"
return "page_fade"


def _construct_calls(source: str) -> list[ast.Call]:
try:
tree = ast.parse(source)
Expand Down Expand Up @@ -221,9 +264,8 @@ def motion_plan_from_source(source: str) -> list[str]:
name = _call_name(arg)
target = _first_arg_id(arg)
if name == "FadeIn":
has_shift = any(k.arg == "shift" for k in arg.keywords)
if target and str(target).startswith("_bx_"):
plan.append(f"reveal:{'slide' if has_shift else 'fade'}:{target}")
plan.append(f"reveal:{_fade_in_kind(arg)}:{target}")
elif target and str(target).startswith("_ar_"):
plan.append(f"edge:fade:{target}")
elif name == "GrowFromCenter" and target:
Expand All @@ -235,7 +277,7 @@ def motion_plan_from_source(source: str) -> list[str]:
elif name == "GrowArrow" and target:
plan.append(f"edge:grow:{target}")
elif name == "FadeOut":
plan.append("page_fade")
plan.append(_fade_out_token(arg))
return plan


Expand Down
Loading
Loading