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`) 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`.
- **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`.
- **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
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 @@ -3,7 +3,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 **`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**: **`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`**).
- **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`**).
- **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
153 changes: 120 additions & 33 deletions src/docgen/scene_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@
* Lays out each **page** as a vertical stack of rows (``VGroup`` per row,
then ``arrange(DOWN)``), positioned under the title — **no scaling** to cram
content; use multiple **pages** when the story needs more boxes than fit.
* Between pages, runs a **page transition** so the next page appears on a
clear canvas. ``fade`` (default) dissolves the previous board. ``slide``
travels it off to the left and brings the next page's first node in from
the right. ``rise`` drops the board and settles the next node from above.
``zoom`` shrinks the board away and scales the next node up. ``none``
removes the previous board instantly. Later boxes on the incoming page
stay paced — the handoff does not dump the whole next board.
* Between pages, the previous board and the next page's first node move in
**one** play, so the canvas does not go empty between them. ``fade``
(default) crossfades. ``slide`` travels the board left while the next node
enters from the right. ``rise`` drops the board while the next node settles
from just under the title. ``zoom`` shrinks the board while the next node
scales up. ``none`` removes the previous board instantly, then reveals.
Later boxes on the incoming page stay paced — the handoff does not dump
the whole next board.
* Uses the shared ``_box`` helper (text centered in the node; optional
``shape`` / ``reveal`` / ``emphasis``). After each paced reveal, a **dwell**
slot may play ``Indicate`` / ``Circumscribe`` when the gap to the next
Expand Down Expand Up @@ -2929,26 +2930,121 @@ def _opening_page_transition(page: dict[str, Any]) -> str:
return str(page.get("transition") or "fade")


def _append_incoming_exit(
lines: list[str],
*,
def _page_exit_targets(
page_index: int,
row_index: int,
box_index: int,
page: dict[str, Any],
page_box_vars: dict[int, list[str]],
page_edge_vars: dict[int, list[str]],
page_fade_rt: float,
) -> str | None:
) -> tuple[str, list[str]] | None:
"""Exit kind and revealed names for the first box of a later page."""
if page_index <= 0 or row_index != 0 or box_index != 0:
return None
transition = _opening_page_transition(page)
targets = list(page_box_vars.get(page_index - 1) or [])
targets.extend(page_edge_vars.get(page_index - 1) or [])
return _append_page_exit(
if transition == "none":
return "none", targets
kind = transition if transition in ANIMATED_PAGE_TRANSITIONS else "fade"
return kind, targets


def _cross_handoff(kind: str, targets: list[str], box: dict[str, Any]) -> bool:
"""True when the exit and the first incoming node should share one play."""
if kind == "none" or not targets or _author_reveal(box) is not None:
return False
return True


def _append_cross_handoff(
lines: list[str],
targets: list[str],
kind: str,
enter_call: str,
edge_anims: list[tuple[str, str]],
run_time: float,
) -> None:
"""Previous board leaves while the next node arrives. One clock step."""
parts = [_page_exit_call(target, kind) for target in targets]
parts.append(enter_call)
for evar, edge_kind in edge_anims:
parts.append(_edge_play_part(evar, edge_kind))
anims = ", ".join(parts)
lines.append(f" self.timed_play({anims}, run_time={run_time})")


def _append_plain_reveal(
lines: list[str],
bx: str,
box: dict[str, Any],
ev: RevealEvent | None,
*,
page_has_edges: bool,
first_box_seen: bool,
edge_anims: list[tuple[str, str]],
run_time: float,
) -> None:
reveal_part = _reveal_statement(
bx,
box,
ev,
page_has_edges=page_has_edges,
first_box_seen=first_box_seen,
page_enter_trans=None,
)
_append_reveal_play(lines, bx, reveal_part, edge_anims, run_time)


def _append_box_entrance(
lines: list[str],
*,
page_index: int,
row_index: int,
box_index: int,
box: dict[str, Any],
page: dict[str, Any],
page_has_edges: bool,
first_box_seen: bool,
ev: RevealEvent | None,
run_time: float,
page_fade_rt: float,
page_box_vars: dict[int, list[str]],
page_edge_vars: dict[int, list[str]],
edges_with_target: dict[tuple[int, str], list[tuple[str, str]]],
) -> None:
bx = f"_bx_{page_index}_{row_index}_{box_index}"
edges = edges_with_target.get((page_index, bx), [])
exit_info = _page_exit_targets(
page_index,
row_index,
box_index,
page,
page_box_vars,
page_edge_vars,
)
if exit_info is not None:
kind, targets = exit_info
if _cross_handoff(kind, targets, box):
_append_cross_handoff(
lines,
targets,
kind,
_page_enter_call(bx, kind),
edges,
round(page_fade_rt + run_time, 3),
)
return
_append_page_exit(lines, kind, targets, page_fade_rt)
_append_plain_reveal(
lines,
_opening_page_transition(page),
targets,
page_fade_rt,
bx,
box,
ev,
page_has_edges=page_has_edges,
first_box_seen=first_box_seen,
edge_anims=edges,
run_time=run_time,
)


Expand Down Expand Up @@ -3050,32 +3146,23 @@ def _append_one_box_playback(
ww = _resolved_wait_word(box, box_index, row_wait)
if ww is not None:
lines.append(f" self.wait_until_word(timing_words, {int(ww)})")
page_enter_trans = _append_incoming_exit(
_append_box_entrance(
lines,
page_index=page_index,
row_index=row_index,
box_index=box_index,
box=box,
page=page,
page_has_edges=page_has_edges,
first_box_seen=first_box_seen,
ev=ev,
run_time=run_time,
page_fade_rt=page_fade_rt,
page_box_vars=page_box_vars,
page_edge_vars=page_edge_vars,
page_fade_rt=page_fade_rt,
edges_with_target=edges_with_target,
)
bx = f"_bx_{page_index}_{row_index}_{box_index}"
reveal_part = _reveal_statement(
bx,
box,
ev,
page_has_edges=page_has_edges,
first_box_seen=first_box_seen,
page_enter_trans=page_enter_trans,
)
_append_reveal_play(
lines,
bx,
reveal_part,
edges_with_target.get((page_index, bx), []),
run_time,
)
_append_hold_pulses(lines, bx, ev)


Expand Down
12 changes: 6 additions & 6 deletions src/docgen/scene_spec_generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,12 @@
Prefer edges for pipeline / flow diagrams (A → B → C); omit when boxes are unrelated topics.

Use either **rows** (single page) OR **pages** (list of {{ rows: [...], transition?: {_PAGE_TRANSITION_CHOICES}, edges?: [...] }} — transition on pages after the first overrides layout.page_transition for exiting the previous page; first page has no transition in).
Between pages, pick a handoff (only the **first** node of the next page plays it; later boxes stay paced):
- ``slide`` — previous board travels left, first node enters from the right. Use when the next page continues the same pipeline.
- ``rise`` — previous board drops away, first node settles down from above. Use for a deeper step.
- ``zoom`` — previous board shrinks out, first node scales up. Use when the next page focuses a detail.
- ``fade`` — dissolve (default). Use for a hard topic change.
- ``none`` — instant remove. Rare.
Between pages, pick a handoff. The previous board and the **first** node of the next page move in the same play (the frame does not go empty). Later boxes stay paced:
- ``slide`` — previous board travels left while the first node enters from the right. Use when the next page continues the same pipeline.
- ``rise`` — previous board drops while the first node settles down from just under the title. Use for a deeper step.
- ``zoom`` — previous board shrinks while the first node scales up. Use when the next page focuses a detail.
- ``fade`` — crossfade (default). Use for a hard topic change.
- ``none`` — instant remove, then the next reveal. Rare.

Palette tokens (exact spelling): {", ".join(sorted(ALLOWED_COLORS))}

Expand Down
Loading
Loading