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
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 (`fade`, `slide`, `rise`, `zoom`, `none`) play the previous page's exit and the next page's first node in one `timed_play` (crossfade, slide, rise, or zoom) so the canvas does not empty between them. `none` still removes instantly. 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`.
- **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`) play the previous page's exit and the next page's first node in one `timed_play` (crossfade, slide, rise, or zoom) so the canvas does not empty between them. `none` still removes instantly. 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`. A page may set `chart` (`kind: bar` or `line`) instead of `rows`. Each series item is its own paced reveal: a bar grows from the left, or a line point fades in and the segment from the previous point is drawn. The value's digits must appear in the narration.
- **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
3 changes: 2 additions & 1 deletion docs/demos/hints/manim-scene-specs.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
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|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** as one motion: the previous board leaves while the next page's first node arrives, so the frame does not go empty. **`fade`** (default crossfade), **`slide`** (board travels left while the next node enters from the right), **`rise`** (board drops while the next node settles from just under the title), **`zoom`** (board shrinks while the next node scales up), or **`none`** (instant remove, then the next reveal). Only the first node of the next page rides 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`**).
- **Chart page:** a page may set **`chart`** instead of `rows` when the narration compares quantities. `kind: bar` grows one horizontal bar per series item from the left. `kind: line` fades in each point and draws the segment from the previous point. Each series item is `label` (a spoken phrase) plus `value` (a positive number whose digits appear in the narration: `12`, not "twelve"). At most 6 items. Do not also set `rows` or `edges` on that page.
- **Pages, not shrinking:** use top-level **`pages`** (list of `{ rows: [...], transition?: fade|slide|rise|zoom|none, edges?: [...] }` or `{ chart: { kind: bar|line, series: [...] }, transition? }`) 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** as one motion: the previous board leaves while the next page's first node arrives, so the frame does not go empty. **`fade`** (default crossfade), **`slide`** (board travels left while the next node enters from the right), **`rise`** (board drops while the next node settles from just under the title), **`zoom`** (board shrinks while the next node scales up), or **`none`** (instant remove, then the next reveal). Only the first node of the next page rides 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.
Expand Down
14 changes: 14 additions & 0 deletions src/docgen/benchmark_data/baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,20 @@
"defect_points": 0,
"quality_points": 18,
"score": 100
},
"chart_bars": {
"role": "quality",
"wait_skips": 0,
"overshoots": 0,
"hold_idle_violations": 0,
"cadence_violations": 0,
"sim_drift": 0,
"mid_hold_pulses": 8,
"box_reveals": 3,
"last_motion_frac": 0.9951,
"defect_points": 0,
"quality_points": 20,
"score": 100
}
}
}
16 changes: 16 additions & 0 deletions src/docgen/scene_asset_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,25 @@ def motion_plan_from_source(source: str) -> list[str]:
plan.append(f"edge:grow:{target}")
elif name == "FadeOut":
plan.append(_fade_out_token(arg))
plan.extend(_chart_play_tokens(call))
return plan


def _chart_play_tokens(call: ast.Call) -> list[str]:
"""Bar growth and line segments. Diagram FadeIn / GrowArrow stay in the caller."""
tokens: list[str] = []
for arg in call.args:
if not isinstance(arg, ast.Call):
continue
name = _call_name(arg)
target = _first_arg_id(arg)
if name == "GrowFromEdge" and target:
tokens.append(f"reveal:bar:{target}")
elif name == "Create" and target:
tokens.append(f"edge:draw:{target}")
return tokens


def extract_class_source(scenes_text: str, class_name: str) -> str | None:
try:
tree = ast.parse(scenes_text)
Expand Down
26 changes: 26 additions & 0 deletions src/docgen/scene_benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,32 @@ def standard_cases() -> list[BenchmarkCase]:
{"word": "tail", "start": 18.0, "end": 18.5},
],
),
BenchmarkCase(
id="chart_bars",
title="Bar chart: each bar grows on its own word, no first-board dump",
spec=_spec(
"chart_bars",
[],
pages=[
{
"chart": {
"kind": "bar",
"series": [
{"label": "Alpha", "value": 12},
{"label": "Beta", "value": 4},
{"label": "Gamma", "value": 9},
],
}
}
],
),
words=[
{"word": "Alpha", "start": 1.2, "end": 1.5},
{"word": "Beta", "start": 8.0, "end": 8.3},
{"word": "Gamma", "start": 16.0, "end": 16.3},
{"word": "tail", "start": 24.0, "end": 24.4},
],
),
]


Expand Down
Loading
Loading