diff --git a/AGENTS.md b/AGENTS.md index edcc215..0e12ee2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/docs/demos/hints/manim-scene-specs.md b/docs/demos/hints/manim-scene-specs.md index d236529..827ab67 100644 --- a/docs/demos/hints/manim-scene-specs.md +++ b/docs/demos/hints/manim-scene-specs.md @@ -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/.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. diff --git a/src/docgen/benchmark_data/baseline.json b/src/docgen/benchmark_data/baseline.json index baa5ce0..571df38 100644 --- a/src/docgen/benchmark_data/baseline.json +++ b/src/docgen/benchmark_data/baseline.json @@ -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 } } } diff --git a/src/docgen/scene_asset_validate.py b/src/docgen/scene_asset_validate.py index ea6058a..a77d05f 100644 --- a/src/docgen/scene_asset_validate.py +++ b/src/docgen/scene_asset_validate.py @@ -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) diff --git a/src/docgen/scene_benchmark.py b/src/docgen/scene_benchmark.py index cad08ab..60e59c9 100644 --- a/src/docgen/scene_benchmark.py +++ b/src/docgen/scene_benchmark.py @@ -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}, + ], + ), ] diff --git a/src/docgen/scene_chart.py b/src/docgen/scene_chart.py new file mode 100644 index 0000000..5133dca --- /dev/null +++ b/src/docgen/scene_chart.py @@ -0,0 +1,543 @@ +"""Paced bar and line charts as a page alternative to diagram rows. + +A page sets ``chart`` instead of ``rows``. Each series item is one reveal: +a bar grows from the left, or a line point fades in and the segment from the +previous point is drawn. Digits of each ``value`` must appear in the narration +(``12`` matches; the word "twelve" does not). +""" + +from __future__ import annotations + +import math +import re +from typing import Any + +from docgen.manim_primitives import ALLOWED_EMPHASIS + +_CHART_KINDS = frozenset({"bar", "line"}) +_CHART_COLORS = ("C_GREEN", "C_BLUE", "C_ORANGE", "C_TEAL", "C_PURPLE", "C_ACCENT") +_CHART_KEYS = frozenset({"kind", "series"}) +_SERIES_KEYS = frozenset({"label", "value", "color", "wait_word", "pace", "emphasis"}) +_MAX_SERIES = 6 +_BAR_HEIGHT = 0.36 +_BAR_MIN = 0.45 +_BAR_SPAN = 5.8 +_PLOT_LEFT = -4.2 +_PLOT_SPAN = 8.4 +_PLOT_HEIGHT = 2.6 + + +def _fail(message: str) -> None: + from docgen.scene_spec import SceneSpecError + + raise SceneSpecError(message) + + +def page_has_chart(page: Any) -> bool: + """True when a page mapping authors a chart instead of diagram rows.""" + return isinstance(page, dict) and "chart" in page + + +def uses_chart(spec: dict[str, Any]) -> bool: + """True after materialize when a page's boxes are chart series.""" + if not isinstance(spec, dict): + return False + pages = spec.get("pages") + if not isinstance(pages, list): + return False + return any(_page_is_chart_boxes(page) for page in pages) + + +def _page_is_chart_boxes(page: Any) -> bool: + if not isinstance(page, dict): + return False + rows = page.get("rows") + if not isinstance(rows, list) or not rows or not isinstance(rows[0], dict): + return False + boxes = rows[0].get("boxes") + if not isinstance(boxes, list) or not boxes or not isinstance(boxes[0], dict): + return False + return bool(boxes[0].get("chart_kind")) + + +def materialize_charts(spec: dict[str, Any]) -> dict[str, Any]: + """Return a copy whose chart pages are rows of paced series boxes. + + Specs with no ``chart`` page are returned unchanged. The copy is what + compile, beat sync, and label coverage walk; on-disk YAML keeps ``chart``. + """ + if not _spec_has_chart(spec): + return spec + out = dict(spec) + pages = spec.get("pages") + out["pages"] = [_expand_page(page, i) for i, page in enumerate(pages)] + return out + + +def _spec_has_chart(spec: dict[str, Any]) -> bool: + if not isinstance(spec, dict): + return False + pages = spec.get("pages") + if not isinstance(pages, list): + return False + return any(page_has_chart(page) for page in pages) + + +def _expand_page(page: Any, index: int) -> Any: + if not page_has_chart(page): + return page + path = f"pages[{index}]" + if "rows" in page: + _fail(f"{path}: set rows or chart, not both") + if "edges" in page: + _fail(f"{path}: chart pages do not take edges") + rows = _chart_rows(page.get("chart"), path)[1] + out: dict[str, Any] = {"rows": rows} + if "transition" in page: + out["transition"] = page["transition"] + return out + + +def _chart_rows(chart: Any, path: str) -> tuple[str, list[dict[str, Any]]]: + if not isinstance(chart, dict): + _fail(f"{path}.chart: must be a mapping") + _reject_unknown(chart, _CHART_KEYS, f"{path}.chart") + kind = str(chart.get("kind") or "").strip().lower() + if kind not in _CHART_KINDS: + _fail(f"{path}.chart.kind: must be bar or line") + series = chart.get("series") + if not isinstance(series, list) or not series: + _fail(f"{path}.chart.series: must be a non-empty list") + if len(series) > _MAX_SERIES: + _fail(f"{path}.chart.series: at most {_MAX_SERIES} items fit on one page") + return kind, _series_rows(series, kind, f"{path}.chart.series") + + +def _reject_unknown(mapping: dict[str, Any], allowed: frozenset[str], path: str) -> None: + unknown = sorted(set(mapping) - allowed) + if unknown: + _fail(f"{path}: unknown keys {unknown}") + + +def _series_rows( + series: list[Any], kind: str, path: str +) -> list[dict[str, Any]]: + peak = _series_peak(series, path) + seen: set[str] = set() + rows: list[dict[str, Any]] = [] + for index, item in enumerate(series): + box = _series_box(item, index, kind, peak, path) + label = str(box["label"]) + if label in seen: + _fail(f"{path}[{index}]: duplicate label {label!r}") + seen.add(label) + rows.append({"run_time": 0.55, "boxes": [box]}) + return rows + + +def _series_peak(series: list[Any], path: str) -> float: + values: list[float] = [] + for index, item in enumerate(series): + if not isinstance(item, dict): + _fail(f"{path}[{index}]: series item must be a mapping") + values.append(_positive_value(item.get("value"), f"{path}[{index}]")) + return max(values) + + +def _positive_value(value: Any, path: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + _fail(f"{path}.value: must be a positive number") + number = float(value) + if not math.isfinite(number) or number <= 0: + _fail(f"{path}.value: must be a positive finite number") + return number + + +def _series_box( + item: Any, index: int, kind: str, peak: float, path: str +) -> dict[str, Any]: + loc = f"{path}[{index}]" + if not isinstance(item, dict): + _fail(f"{loc}: series item must be a mapping") + _reject_unknown(item, _SERIES_KEYS, loc) + value = _positive_value(item.get("value"), loc) + box: dict[str, Any] = { + "label": _series_label(item.get("label"), loc), + "color": _series_color(item.get("color"), index, loc), + "width": 2.2, + "height": _BAR_HEIGHT, + "font_size": 22, + "chart_kind": kind, + "value": value, + "value_text": _value_token(value), + "bar_width": _bar_width(value, peak), + } + _copy_optional_series_fields(item, box, loc) + return box + + +def _series_label(label: Any, loc: str) -> str: + if not isinstance(label, str) or not str(label).strip(): + _fail(f"{loc}.label: must be a non-empty string") + text = str(label).strip() + if len(text) > 40: + _fail(f"{loc}.label: must be at most 40 characters") + return text + + +def _series_color(color: Any, index: int, loc: str) -> str: + if color is None: + return _CHART_COLORS[index % len(_CHART_COLORS)] + from docgen.scene_spec import ALLOWED_COLORS + + token = str(color).strip() + if token not in ALLOWED_COLORS: + _fail(f"{loc}.color: must be one of {sorted(ALLOWED_COLORS)}") + return token + + +def _value_token(value: float) -> str: + if value == int(value) and abs(value) < 1e12: + return str(int(value)) + text = f"{value:.6f}".rstrip("0").rstrip(".") + return text or "0" + + +def _bar_width(value: float, peak: float) -> float: + return round(_BAR_MIN + _BAR_SPAN * (value / peak), 3) + + +def _copy_optional_series_fields( + item: dict[str, Any], box: dict[str, Any], loc: str +) -> None: + if "wait_word" in item: + _require_wait_word(item.get("wait_word"), loc) + box["wait_word"] = item["wait_word"] + if "pace" in item and item.get("pace") is not None: + box["pace"] = _require_pace(item.get("pace"), loc) + if "emphasis" in item and item.get("emphasis") is not None: + box["emphasis"] = _require_emphasis(item.get("emphasis"), loc) + + +def _require_wait_word(value: Any, loc: str) -> None: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + _fail(f"{loc}.wait_word: must be a non-negative int") + + +def _require_pace(value: Any, loc: str) -> str: + text = str(value).strip().lower() + if text != "none": + _fail(f"{loc}.pace: must be 'none' if set") + return text + + +def _require_emphasis(value: Any, loc: str) -> str: + text = str(value).strip().lower() + if text not in ALLOWED_EMPHASIS: + _fail(f"{loc}.emphasis: must be one of {sorted(ALLOWED_EMPHASIS)} if set") + return text + + +def chart_value_violations(spec: dict[str, Any], corpus: str) -> list[str]: + """Values whose digits are absent from ``corpus`` (the narration).""" + issues: list[str] = [] + for loc, token in _chart_value_sites(spec): + if _digits_documented(token, corpus): + continue + issues.append( + f"{loc}: value {token} is not written as digits in the narration" + ) + return issues + + +def _chart_value_sites(spec: dict[str, Any]) -> list[tuple[str, str]]: + if not isinstance(spec, dict): + return [] + pages = spec.get("pages") + if not isinstance(pages, list): + return [] + sites: list[tuple[str, str]] = [] + for index, page in enumerate(pages): + sites.extend(_page_value_sites(page, index)) + return sites + + +def _page_value_sites(page: Any, index: int) -> list[tuple[str, str]]: + if not isinstance(page, dict): + return [] + chart = page.get("chart") + if isinstance(chart, dict) and isinstance(chart.get("series"), list): + return _series_value_sites(chart["series"], f"pages[{index}].chart.series") + return _boxed_value_sites(page.get("rows"), f"pages[{index}]") + + +def _series_value_sites(series: list[Any], path: str) -> list[tuple[str, str]]: + sites: list[tuple[str, str]] = [] + for index, item in enumerate(series): + if not isinstance(item, dict): + continue + token = _optional_value_token(item.get("value")) + if token: + sites.append((f"{path}[{index}]", token)) + return sites + + +def _boxed_value_sites(rows: Any, path: str) -> list[tuple[str, str]]: + if not isinstance(rows, list): + return [] + sites: list[tuple[str, str]] = [] + for index, row in enumerate(rows): + sites.extend(_row_value_sites(row, f"{path}.rows[{index}]")) + return sites + + +def _row_value_sites(row: Any, path: str) -> list[tuple[str, str]]: + if not isinstance(row, dict): + return [] + boxes = row.get("boxes") + if not isinstance(boxes, list): + return [] + sites: list[tuple[str, str]] = [] + for index, box in enumerate(boxes): + token = _box_value_token(box) + if token: + sites.append((f"{path}.boxes[{index}]", token)) + return sites + + +def _box_value_token(box: Any) -> str: + if not isinstance(box, dict) or not box.get("chart_kind"): + return "" + return str(box.get("value_text") or "").strip() + + +def _optional_value_token(value: Any) -> str: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return "" + if not math.isfinite(float(value)): + return "" + return _value_token(float(value)) + + +def _digits_documented(token: str, corpus: str) -> bool: + """True when ``token`` appears as its own number, not inside a longer one. + + A trailing sentence period matches (``12.``). A fractional continuation + does not (``12`` is not documented by ``12.5``). + """ + if not token: + return False + pattern = rf"(? bool: + """Emit chart mobjects. Return False so a diagram page uses the row layout.""" + rows = page.get("rows") + if not isinstance(rows, list): + return False + kind = _rows_chart_kind(rows) + if kind == "bar": + _append_bars(lines, page_index, rows, first_row_title_buff) + return True + if kind == "line": + _append_line_chart( + lines, + page_index, + rows, + first_row_title_buff, + edges_with_target, + page_edge_vars, + ) + return True + return False + + +def _rows_chart_kind(rows: list[Any]) -> str: + for row in rows: + if not isinstance(row, dict): + continue + boxes = row.get("boxes") + if not isinstance(boxes, list) or not boxes: + continue + box = boxes[0] + if isinstance(box, dict): + return str(box.get("chart_kind") or "") + return "" + + +def _append_bars( + lines: list[str], + page_index: int, + rows: list[Any], + first_row_title_buff: float, +) -> None: + names: list[str] = [] + for index, row in enumerate(rows): + var = f"_bx_{page_index}_{index}_0" + _append_one_bar(lines, var, row["boxes"][0]) + names.append(var) + joined = ", ".join(names) + stack = f"_p{page_index}_chart" + lines.append( + f" {stack} = VGroup({joined}).arrange(DOWN, buff=0.22, aligned_edge=LEFT)" + ) + lines.append(f" {stack}.next_to(title, DOWN, buff={first_row_title_buff})") + + +def _append_one_bar(lines: list[str], var: str, box: dict[str, Any]) -> None: + label = str(box["label"]) + color = str(box["color"]) + width = _py_num(float(box["bar_width"])) + height = _py_num(float(box["height"])) + value = str(box["value_text"]) + lines.append( + f" {var}_slot = Rectangle(width=2.4, height={height}, stroke_width=0, fill_opacity=0)" + ) + lines.append( + f" {var}_name = Text({label!r}, font_size=22, color=C_WHITE, font=MANIM_FONT)" + ) + lines.append(f" if {var}_name.width > 2.2:") + lines.append(f" {var}_name.scale_to_fit_width(2.2)") + lines.append(f" {var}_name.move_to({var}_slot).align_to({var}_slot, LEFT)") + lines.append( + f" {var}_bar = Rectangle(width={width}, height={height}, stroke_width=0, " + f"fill_color={color}, fill_opacity=0.92)" + ) + lines.append( + f" {var}_val = Text({value!r}, font_size=20, color=C_WHITE, font=MANIM_FONT)" + ) + lines.append(f" {var}_bar.next_to({var}_slot, RIGHT, buff=0.16)") + lines.append(f" {var}_val.next_to({var}_bar, RIGHT, buff=0.14)") + lines.append( + f" {var} = VGroup({var}_slot, {var}_name, {var}_bar, {var}_val)" + ) + + +def _py_num(value: float) -> str: + return f"{round(value, 3):.3f}".rstrip("0").rstrip(".") or "0" + + +def _append_line_chart( + lines: list[str], + page_index: int, + rows: list[Any], + first_row_title_buff: float, + edges_with_target: dict[tuple[int, str], list[tuple[str, str]]], + page_edge_vars: dict[int, list[str]], +) -> None: + boxes = [row["boxes"][0] for row in rows] + points = _plot_points(boxes) + names: list[str] = [] + axis = _axis_span(points) + for index, box in enumerate(boxes): + var = f"_bx_{page_index}_{index}_0" + x, y = points[index] + _append_line_point(lines, var, box, x, y, axis if index == 0 else None) + names.append(var) + joined = ", ".join(names) + stack = f"_p{page_index}_chart" + lines.append( + f" {stack} = VGroup({joined}).next_to(title, DOWN, buff={first_row_title_buff})" + ) + _append_line_segments( + lines, page_index, boxes, edges_with_target, page_edge_vars + ) + + +def _axis_span(points: list[tuple[float, float]]) -> tuple[float, float] | None: + if len(points) < 2: + return None + return points[0][0], points[-1][0] + + +def _plot_points(boxes: list[dict[str, Any]]) -> list[tuple[float, float]]: + values = [float(box["value"]) for box in boxes] + if not values: + return [] + peak = max(values) + count = len(values) + points: list[tuple[float, float]] = [] + for index, value in enumerate(values): + x = _PLOT_LEFT if count == 1 else _PLOT_LEFT + _PLOT_SPAN * index / (count - 1) + y = _PLOT_HEIGHT * (value / peak) + points.append((round(x, 3), round(y, 3))) + return points + + +def _append_line_point( + lines: list[str], + var: str, + box: dict[str, Any], + x: float, + y: float, + axis: tuple[float, float] | None, +) -> None: + color = str(box["color"]) + label = str(box["label"]) + value = str(box["value_text"]) + px, py = _py_num(x), _py_num(y) + lines.append( + f" {var}_dot = Dot(point=[{px}, {py}, 0], color={color}, radius=0.08)" + ) + lines.append( + f" {var}_name = Text({label!r}, font_size=20, color=C_WHITE, font=MANIM_FONT)" + ) + lines.append(f" if {var}_name.width > 1.6:") + lines.append(f" {var}_name.scale_to_fit_width(1.6)") + lines.append(f" {var}_name.next_to({var}_dot, DOWN, buff=0.12)") + lines.append( + f" {var}_val = Text({value!r}, font_size=18, color=C_WHITE, font=MANIM_FONT)" + ) + lines.append(f" {var}_val.next_to({var}_dot, UP, buff=0.08)") + if axis is None: + lines.append(f" {var} = VGroup({var}_dot, {var}_name, {var}_val)") + return + x0, x1 = _py_num(axis[0]), _py_num(axis[1]) + lines.append( + f" {var}_axis = Line([{x0}, 0, 0], [{x1}, 0, 0], color=C_WHITE, stroke_width=1.5)" + ) + lines.append( + f" {var} = VGroup({var}_dot, {var}_name, {var}_val, {var}_axis)" + ) + + +def _append_line_segments( + lines: list[str], + page_index: int, + boxes: list[dict[str, Any]], + edges_with_target: dict[tuple[int, str], list[tuple[str, str]]], + page_edge_vars: dict[int, list[str]], +) -> None: + for index in range(1, len(boxes)): + _append_one_segment( + lines, page_index, index, str(boxes[index]["color"]), + edges_with_target, page_edge_vars, + ) + + +def _append_one_segment( + lines: list[str], + page_index: int, + index: int, + color: str, + edges_with_target: dict[tuple[int, str], list[tuple[str, str]]], + page_edge_vars: dict[int, list[str]], +) -> None: + var = f"_bx_{page_index}_{index}_0" + prev = f"_bx_{page_index}_{index - 1}_0" + seg = f"_ln_{page_index}_{index}" + lines.append( + f" {seg} = Line({prev}_dot.get_center(), {var}_dot.get_center(), " + f"color={color}, stroke_width=3)" + ) + page_edge_vars.setdefault(page_index, []).append(seg) + edges_with_target.setdefault((page_index, var), []).append((seg, "draw")) diff --git a/src/docgen/scene_clock_harness.py b/src/docgen/scene_clock_harness.py index 1228250..b515561 100644 --- a/src/docgen/scene_clock_harness.py +++ b/src/docgen/scene_clock_harness.py @@ -57,7 +57,7 @@ def emphasis_plays(self) -> list[ClockEvent]: ] def reveal_plays(self) -> list[ClockEvent]: - reveal_kinds = {"FadeIn", "GrowFromCenter"} + reveal_kinds = {"FadeIn", "GrowFromCenter", "GrowFromEdge"} return [ e for e in self.play_events() @@ -74,6 +74,10 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self._role = kwargs.pop("_role", None) self._target = args[0] if args else None self._label = kwargs.get("label") + # Compiled charts compare Text.width before scaling. A short stand-in + # skips that branch; real Manim still measures the glyph run. + self.width = 1.0 + self.height = 1.0 def __getattr__(self, name: str) -> Any: def _method(*_a: Any, **_k: Any) -> _Dummy: @@ -172,7 +176,12 @@ def _exec_namespace( "FadeIn": _anim("FadeIn"), "FadeOut": _anim("FadeOut"), "GrowFromCenter": _anim("GrowFromCenter"), + "GrowFromEdge": _anim("GrowFromEdge"), "GrowArrow": _anim("GrowArrow"), + "Create": _anim("Create"), + "Rectangle": _anim("Rectangle"), + "Dot": _anim("Dot"), + "Line": _anim("Line"), "Indicate": _anim("Indicate"), "Circumscribe": _anim("Circumscribe"), "VGroup": _anim("VGroup"), diff --git a/src/docgen/scene_spec.py b/src/docgen/scene_spec.py index d49c99a..92759c4 100644 --- a/src/docgen/scene_spec.py +++ b/src/docgen/scene_spec.py @@ -20,6 +20,10 @@ 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. +* A page may set ``chart`` (``kind: bar`` or ``line``) instead of ``rows``. + Each series item is one paced reveal: a bar grows from the left, or a line + point fades in while the segment from the previous point is drawn. The + value's digits must appear in the narration (``12`` matches; "twelve" does not). * 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 @@ -69,6 +73,13 @@ plan_hold_pulses, resolve_box_emphasis, ) +from docgen.scene_chart import ( + append_chart_page, + chart_value_violations, + materialize_charts, + page_has_chart, + uses_chart, +) _SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?])\s+") @@ -424,10 +435,117 @@ def auto_paginate(spec: dict[str, Any]) -> dict[str, Any]: return spec row_gap = float(layout.get("row_gap", 0.6)) transition_default = str(layout.get("page_transition", "fade")) + new_pages = _paginated_pages( + spec, + budget=budget, + row_gap=row_gap, + transition_default=transition_default, + ) + if new_pages is None: + return spec + return _finish_paginated_spec(spec, new_pages) + + +def _paginated_pages( + spec: dict[str, Any], + *, + budget: float, + row_gap: float, + transition_default: str, +) -> list[dict[str, Any]] | None: + """Split overflowing row pages. Chart pages stay one page, authoring intact.""" + if spec.get("pages") is None: + return _paginated_flat_rows( + spec, + budget=budget, + row_gap=row_gap, + transition_default=transition_default, + ) + return _paginated_explicit_pages( + spec, + budget=budget, + row_gap=row_gap, + transition_default=transition_default, + ) + + +def _paginated_flat_rows( + spec: dict[str, Any], + *, + budget: float, + row_gap: float, + transition_default: str, +) -> list[dict[str, Any]] | None: src_pages = _paginate_source_pages(spec, transition_default) if src_pages is None: - return spec - new_pages = [ + return None + return _chunks_to_pages( + src_pages, + budget=budget, + row_gap=row_gap, + transition_default=transition_default, + ) + + +def _paginated_explicit_pages( + spec: dict[str, Any], + *, + budget: float, + row_gap: float, + transition_default: str, +) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for page_idx, page in enumerate(spec["pages"]): + _append_budget_page( + out, + page, + page_idx, + budget=budget, + row_gap=row_gap, + transition_default=transition_default, + ) + return out + + +def _append_budget_page( + out: list[dict[str, Any]], + page: Any, + page_idx: int, + *, + budget: float, + row_gap: float, + transition_default: str, +) -> None: + if not isinstance(page, dict): + return + if page_has_chart(page): + out.append(dict(page)) + return + rows = page.get("rows") + if not isinstance(rows, list) or not rows: + return + tr = None if page_idx == 0 else str(page.get("transition", transition_default)) + chunks = _split_rows_to_pages(rows, budget=budget, row_gap=row_gap) + for chunk_idx, chunk in enumerate(chunks): + out.append( + _paginate_page_entry( + page_idx=page_idx, + chunk_idx=chunk_idx, + chunk=chunk, + tr=tr, + transition_default=transition_default, + ) + ) + + +def _chunks_to_pages( + src_pages: list[tuple[list[dict[str, Any]], str | None]], + *, + budget: float, + row_gap: float, + transition_default: str, +) -> list[dict[str, Any]]: + return [ _paginate_page_entry( page_idx=page_idx, chunk_idx=chunk_idx, @@ -440,7 +558,6 @@ def auto_paginate(spec: dict[str, Any]) -> dict[str, Any]: _split_rows_to_pages(rows, budget=budget, row_gap=row_gap) ) ] - return _finish_paginated_spec(spec, new_pages) def _normalize_word(s: str) -> str: @@ -776,6 +893,7 @@ def sync_row_labels_to_whisper_words( """ if not isinstance(words, list) or not words: return spec + spec = materialize_charts(spec) word_stream = _whisper_word_stream(words) if not word_stream: return spec @@ -865,6 +983,7 @@ def pacing_violations(spec: dict[str, Any], *, words_present: bool) -> list[str] ``words`` list (run ``docgen timestamps``). Missing words is not an implicit opt-out. """ + spec = materialize_charts(spec) issues: list[str] = [] paced_anchors: list[str] = [] for pi, rows in enumerate(_spec_pages_rows(spec)): @@ -1116,7 +1235,7 @@ def iter_reveal_slots( working = ( sync_row_labels_to_whisper_words(spec, words, overwrite=True) if words - else spec + else materialize_charts(spec) ) layout = working.get("layout") or {} default_tr = str(layout.get("page_transition", "fade")) @@ -1597,6 +1716,7 @@ def count_spec_labels(spec: dict[str, Any]) -> int: def list_spec_labels(spec: dict[str, Any]) -> list[str]: """Return non-empty box/image labels in page order.""" + spec = materialize_charts(spec) out: list[str] = [] pages = _spec_pages_rows(spec) for rows in pages: @@ -1792,6 +1912,7 @@ def layout_density_violations( invented = _invented_label_issue(labels, narration_text) if invented is not None: issues.append(invented) + issues.extend(chart_value_violations(spec, narration_text)) return issues @@ -2486,6 +2607,7 @@ def _validate_pages_block(data: dict[str, Any], path_label: str, transition: Any def validate_scene_spec(data: dict[str, Any], *, path_label: str = "spec") -> None: + data = materialize_charts(data) _validate_required_keys(data, path_label) _validate_title_block(data["title"], path_label) has_pages = data.get("pages") is not None @@ -2827,6 +2949,38 @@ def _append_page_geometry( edges_with_target: dict[tuple[int, str], list[tuple[str, str]]], page_edge_vars: dict[int, list[str]], ) -> None: + _layout_page_or_chart( + lines, + page_index, + page, + column_gap=column_gap, + row_gap=row_gap, + first_row_title_buff=first_row_title_buff, + edges_with_target=edges_with_target, + page_edge_vars=page_edge_vars, + ) + + +def _layout_page_or_chart( + lines: list[str], + page_index: int, + page: dict[str, Any], + *, + column_gap: float, + row_gap: float, + first_row_title_buff: float, + edges_with_target: dict[tuple[int, str], list[tuple[str, str]]], + page_edge_vars: dict[int, list[str]], +) -> None: + if append_chart_page( + lines, + page_index, + page, + first_row_title_buff=first_row_title_buff, + edges_with_target=edges_with_target, + page_edge_vars=page_edge_vars, + ): + return rows = page["rows"] page_has_image = _page_contains_image(rows) _append_page_boxes(lines, page_index, rows) @@ -3069,12 +3223,28 @@ def _reveal_statement( page_has_edges=page_has_edges, is_first_on_page=not first_box_seen, ) + return _chart_reveal_call(bx, box, reveal) + + +def _chart_reveal_call(bx: str, box: dict[str, Any], reveal: str) -> str: + """Bars grow from the left. Line points fade in. Diagram boxes keep ``reveal``.""" + kind = str(box.get("chart_kind") or "") + if kind == "bar": + return f"GrowFromEdge({bx}, LEFT)" + if kind == "line": + return f"FadeIn({bx})" return _reveal_anim(bx, reveal) def _edge_play_part(evar: str, kind: str) -> str: + return _connector_play_call(evar, kind) + + +def _connector_play_call(evar: str, kind: str) -> str: if kind == "grow": return f"GrowArrow({evar})" + if kind == "draw": + return f"Create({evar})" return f"FadeIn({evar})" @@ -3260,6 +3430,16 @@ def _append_audio_tail(lines: list[str], timing_key: str) -> None: ) +def _prepare_compile_spec( + spec: dict[str, Any], words: list[dict[str, Any]] | None +) -> dict[str, Any]: + """Expand chart pages. Bind series labels to spoken words when timing exists.""" + spec = materialize_charts(spec) + if words and uses_chart(spec): + return sync_row_labels_to_whisper_words(spec, words, overwrite=True) + return spec + + def compile_scene_class( spec: dict[str, Any], *, @@ -3275,6 +3455,7 @@ def compile_scene_class( past the next ``wait_word`` start — otherwise the first board dumps and freezes while narration continues (issue #66). """ + spec = _prepare_compile_spec(spec, words) validate_scene_spec(spec, path_label="spec") _reject_nonfinite_compile_words(words) class_name = str(spec["class_name"]).strip() diff --git a/src/docgen/scene_spec_generate.py b/src/docgen/scene_spec_generate.py index 976ffce..d4375db 100644 --- a/src/docgen/scene_spec_generate.py +++ b/src/docgen/scene_spec_generate.py @@ -48,7 +48,7 @@ - class_name: string (echo the value from the user message exactly) - title: mapping with text (string), font_size (int, >= 14), color (one of the palette tokens below); optional subtitle (string ≤80 chars) for a second line under the title -- **Exactly one of:** ``rows`` (non-empty list of row mappings, single page) **or** ``pages`` (non-empty list of page mappings; each page has ``rows`` as above, optionally ``transition``: {_PAGE_TRANSITION_CHOICES} for pages after the first) +- **Exactly one of:** ``rows`` (non-empty list of row mappings, single page) **or** ``pages`` (non-empty list of page mappings). Each page has ``rows`` as above, **or** a ``chart`` instead of rows. Optional ``transition``: {_PAGE_TRANSITION_CHOICES} for pages after the first. Each row must have: - run_time: positive number (seconds for timed_play FadeIn of **each** box in that row) @@ -98,7 +98,7 @@ drawn as arrows between those boxes after layout. Box labels must be unique on that page. 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). +Use either **rows** (single page) OR **pages** (list of {{ rows: [...] }} or {{ chart: {{ kind: bar|line, series: [...] }} }}, optional transition?: {_PAGE_TRANSITION_CHOICES}, edges only on row pages — 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. 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. @@ -106,6 +106,12 @@ - ``fade`` — crossfade (default). Use for a hard topic change. - ``none`` — instant remove, then the next reveal. Rare. +A **chart page** is ``chart`` instead of ``rows`` when the narration compares quantities: +- ``kind: bar`` — one horizontal bar per series item. The bar grows from the left on that item's spoken label. Later bars stay paced; do not dump the chart on the first word. +- ``kind: line`` — one point per item, in order. The point fades in and the segment from the previous point is drawn on the same beat. +- ``series``: 1 to 6 items. Each has ``label`` (spoken phrase) and ``value`` (positive number). The digits must appear in the narration (``12``, not "twelve"). Optional ``color``. +- Do not set ``rows`` or ``edges`` on a chart page. + Palette tokens (exact spelling): {", ".join(sorted(ALLOWED_COLORS))} Design goals: diff --git a/tests/test_scene_benchmark.py b/tests/test_scene_benchmark.py index 65c271c..fdc7a25 100644 --- a/tests/test_scene_benchmark.py +++ b/tests/test_scene_benchmark.py @@ -38,6 +38,7 @@ def test_corpus_ids_are_stable() -> None: "flow_edges", "audio_tail", "legacy_wait_segment", + "chart_bars", ] diff --git a/tests/test_scene_chart.py b/tests/test_scene_chart.py new file mode 100644 index 0000000..cfbbf37 --- /dev/null +++ b/tests/test_scene_chart.py @@ -0,0 +1,270 @@ +"""Paced bar and line chart pages.""" + +from __future__ import annotations + +import pytest + +from docgen.scene_asset_validate import motion_plan_from_source +from docgen.scene_chart import chart_value_violations, materialize_charts +from docgen.scene_clock_harness import ( + run_compiled_scene_clock, + simulator_exec_drift_violations, +) +from docgen.scene_spec import ( + SceneSpecError, + auto_paginate, + compile_scene_class, + layout_density_violations, + list_spec_labels, + pacing_violations, + simulate_reveal_timeline, + sync_row_labels_to_whisper_words, + validate_scene_spec, +) +from docgen.scene_spec_generate import _SCENE_SPEC_SYSTEM_BASE + + +def _title() -> dict: + return {"text": "Counts", "font_size": 36, "color": "C_WHITE"} + + +def _bar_spec(*series: dict) -> dict: + return { + "segment_id": "01", + "class_name": "ChartScene", + "timing_key": "01-chart", + "title": _title(), + "pages": [{"chart": {"kind": "bar", "series": list(series)}}], + } + + +def _line_spec(*series: dict) -> dict: + spec = _bar_spec(*series) + spec["pages"][0]["chart"]["kind"] = "line" + return spec + + +def _pair() -> list[dict]: + return [ + {"label": "Build", "value": 12, "color": "C_GREEN"}, + {"label": "Ship", "value": 4, "color": "C_BLUE"}, + ] + + +def _words() -> list[dict]: + return [ + {"word": "Build", "start": 1.2, "end": 1.5}, + {"word": "Ship", "start": 6.0, "end": 6.3}, + {"word": "tail", "start": 10.0, "end": 10.4}, + ] + + +def _narration() -> str: + return "Build finished in 12 minutes. Ship finished in 4 minutes." + + +def test_prompt_mentions_chart_pages() -> None: + assert "kind: bar" in _SCENE_SPEC_SYSTEM_BASE + assert "kind: line" in _SCENE_SPEC_SYSTEM_BASE + + +def test_validate_accepts_bar_and_line_and_keeps_authoring() -> None: + spec = _bar_spec(*_pair()) + validate_scene_spec(spec) + assert "chart" in spec["pages"][0] + assert "rows" not in spec["pages"][0] + line = _line_spec(*_pair()) + validate_scene_spec(line) + labels = list_spec_labels(spec) + assert labels == ["Build", "Ship"] + + +def test_chart_schema_rejects() -> None: + both = _bar_spec(*_pair()) + both["pages"][0]["rows"] = [{"run_time": 0.5, "boxes": []}] + with pytest.raises(SceneSpecError, match="not both"): + validate_scene_spec(both) + + edged = _bar_spec(*_pair()) + edged["pages"][0]["edges"] = [{"from": "Build", "to": "Ship"}] + with pytest.raises(SceneSpecError, match="do not take edges"): + validate_scene_spec(edged) + + pie = _bar_spec(*_pair()) + pie["pages"][0]["chart"]["kind"] = "pie" + with pytest.raises(SceneSpecError, match="bar or line"): + validate_scene_spec(pie) + + zero = _bar_spec({"label": "Build", "value": 0}) + with pytest.raises(SceneSpecError, match="positive"): + validate_scene_spec(zero) + + word = _bar_spec({"label": "Build", "value": True}) + with pytest.raises(SceneSpecError, match="positive"): + validate_scene_spec(word) + + dup = _bar_spec( + {"label": "Build", "value": 2}, + {"label": "Build", "value": 3}, + ) + with pytest.raises(SceneSpecError, match="duplicate"): + validate_scene_spec(dup) + + too_many = _bar_spec(*[{"label": f"N{i}", "value": i + 1} for i in range(7)]) + with pytest.raises(SceneSpecError, match="at most 6"): + validate_scene_spec(too_many) + + +def test_digits_must_appear_in_the_narration() -> None: + spec = _bar_spec(*_pair()) + assert chart_value_violations(spec, _narration()) == [] + assert chart_value_violations(spec, "Build took twelve minutes. Ship took 4.") + issues = layout_density_violations( + spec, narration_text="Build took twelve minutes. Ship took 4 minutes." + ) + assert any("value 12" in item for item in issues) + assert not any("value 4" in item for item in issues) + # 12.5 does not document 12. A trailing period does. + assert chart_value_violations(spec, "Build took 12.5 minutes. Ship took 4.") + assert chart_value_violations(spec, "Build took 12. Ship took 4.") == [] + assert chart_value_violations(spec, "Build took 120 minutes. Ship took 4.") + + +def test_bars_grow_one_at_a_time() -> None: + spec = _bar_spec(*_pair()) + out = compile_scene_class(spec, words=_words()) + grows = [line.strip() for line in out.splitlines() if "GrowFromEdge" in line] + assert len(grows) == 2 + assert "GrowFromEdge(_bx_0_0_0, LEFT)" in grows[0] + assert "GrowFromEdge(_bx_0_1_0, LEFT)" in grows[1] + assert "Text('12'" in out + assert "Text('4'" in out + assert "aligned_edge=LEFT" in out + plan = motion_plan_from_source(out) + assert "reveal:bar:_bx_0_0_0" in plan + assert "reveal:bar:_bx_0_1_0" in plan + assert plan.index("reveal:bar:_bx_0_0_0") < plan.index("reveal:bar:_bx_0_1_0") + + +def test_line_draws_each_segment_with_its_point() -> None: + spec = _line_spec(*_pair(), {"label": "Run", "value": 9, "color": "C_ORANGE"}) + words = [ + {"word": "Build", "start": 1.2, "end": 1.5}, + {"word": "Ship", "start": 6.0, "end": 6.3}, + {"word": "Run", "start": 9.0, "end": 9.3}, + {"word": "tail", "start": 13.0, "end": 13.4}, + ] + out = compile_scene_class(spec, words=words) + fades = [line for line in out.splitlines() if "FadeIn(_bx_" in line] + assert len(fades) == 3 + assert "Create(" not in fades[0] + assert "Create(_ln_0_1)" in fades[1] + assert "Create(_ln_0_2)" in fades[2] + assert "Dot(point=" in out + plan = motion_plan_from_source(out) + assert "edge:draw:_ln_0_1" in plan + assert "edge:draw:_ln_0_2" in plan + + +def test_slide_handoff_keeps_later_bars_paced() -> None: + spec = { + "segment_id": "01", + "class_name": "ChartScene", + "timing_key": "01-chart", + "title": _title(), + "pages": [ + { + "rows": [ + { + "run_time": 0.6, + "boxes": [ + { + "label": "Intro", + "color": "C_WHITE", + "width": 3.0, + "height": 0.8, + "font_size": 18, + } + ], + } + ] + }, + { + "transition": "slide", + "chart": {"kind": "bar", "series": _pair()}, + }, + ], + } + words = [ + {"word": "Intro", "start": 1.2, "end": 1.5}, + {"word": "Build", "start": 5.0, "end": 5.3}, + {"word": "Ship", "start": 8.0, "end": 8.3}, + {"word": "tail", "start": 12.0, "end": 12.4}, + ] + out = compile_scene_class(spec, words=words) + assert "FadeOut(_bx_0_0_0, shift=LEFT *" in out + entered = [ + line for line in out.splitlines() if "FadeIn(_bx_1_0_0" in line and "shift=LEFT" in line + ] + assert entered + assert "GrowFromEdge" not in entered[0] + assert any("GrowFromEdge(_bx_1_1_0, LEFT)" in line for line in out.splitlines()) + + +def test_chart_clock_does_not_skip_words() -> None: + spec = _bar_spec(*_pair()) + words = _words() + out = compile_scene_class(spec, words=words) + trace = run_compiled_scene_clock(out, words) + assert trace.skipped_waits() == [] + events = simulate_reveal_timeline(spec, words) + assert simulator_exec_drift_violations(events, trace) == [] + grows = [e for e in trace.reveal_plays() if "GrowFromEdge" in e.anim_kinds] + assert len(grows) == 2 + + +def test_sync_and_pacing_see_series_labels() -> None: + spec = _bar_spec(*_pair()) + synced = sync_row_labels_to_whisper_words(spec, _words(), overwrite=True) + labels = list_spec_labels(synced) + assert labels == ["Build", "Ship"] + assert pacing_violations(synced, words_present=True) == [] + bare = materialize_charts(spec) + assert bare["pages"][0]["rows"][0]["boxes"][0]["chart_kind"] == "bar" + assert pacing_violations(spec, words_present=True) + + +def test_auto_paginate_keeps_chart_pages_whole() -> None: + tall = { + "run_time": 0.5, + "boxes": [ + { + "label": "Row", + "color": "C_GREEN", + "width": 3.0, + "height": 2.0, + "font_size": 18, + } + ], + } + spec = { + "segment_id": "01", + "class_name": "ChartScene", + "timing_key": "01-chart", + "title": _title(), + "pages": [ + {"chart": {"kind": "bar", "series": _pair()}}, + {"transition": "slide", "rows": [tall, tall, tall, tall, tall, tall]}, + ], + } + out = auto_paginate(spec) + assert out["pages"][0]["chart"]["kind"] == "bar" + assert "rows" not in out["pages"][0] + assert len(out["pages"]) > 2 + assert out["pages"][1]["transition"] == "slide" + + +def test_density_accepts_spoken_chart_labels() -> None: + spec = _bar_spec(*_pair()) + issues = layout_density_violations(spec, narration_text=_narration()) + assert issues == []