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
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,13 @@ Commands registered on the **`docgen`** CLI include:
- **`clean-bundle`** — remove regenerable outputs per policy.
- **`concat`** — stitch segment videos.
- **`pages`** — emit static HTML for demo assets.
- **`generate-all`** — orchestrated pipeline for a bundle.
- **`rebuild-after-audio`** — rerun steps that depend on fresh audio/timing.
- **`generate-all`** — orchestrated pipeline: TTS → timestamps → **scene retime** (compile existing `*.scene.yaml` against fresh timing) → images → Manim → compose → validate → concat → pages. Optional `--regen-scene-specs` for LLM scene YAML first.
- **`rebuild-after-audio`** — same as generate-all with TTS skipped (still retimes scenes after timestamps).

## 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`** and **`manim`** instead.
- **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.
- **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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ CI installs `ffmpeg` and `tesseract` via apt — see `.github/workflows/ci.yml`.
```bash
cd your-project/docs/demos
docgen wizard # optional: bootstrap narration from project docs
docgen generate-all # TTS → timestamps → Manim → composevalidateconcat
docgen generate-all # TTS → timestamps → scene retime → Manimcomposevalidate
docgen validate --pre-push
```

Expand All @@ -110,12 +110,12 @@ docgen validate --pre-push
| `docgen lint [--segment 01]` | Narration lint only |
| `docgen concat [--config full-demo]` | Concatenate full demo files |
| `docgen pages [--force]` | Generate `index.html`, `pages.yml`, `.gitattributes`, `.gitignore` |
| `docgen generate-all [--skip-tts] [--skip-manim] [--retry-manim]` | Full pipeline |
| `docgen rebuild-after-audio` | Recompose + validate + concat (skips TTS) |
| `docgen generate-all [--skip-tts] [--skip-manim] [--retry-manim] [--regen-scene-specs]` | Full pipeline: TTS → timestamps → **scene retime** (existing specs) → Manim → compose → validate. `--regen-scene-specs` also runs OpenAI scene-spec-generate |
| `docgen rebuild-after-audio [--regen-scene-specs]` | Timestamps → scene retime → Manim → compose → validate (skips TTS) |
| `docgen clean-bundle [-y] [--delete-config] [--keep-narration]` | Remove regenerable outputs under the bundle |
| `docgen narration-generate --segment 01 [--extra-path REL] [--hint TEXT] [--dry-run] [--force]` | Generate narration `.md` from repo sources + owner hints (OpenAI); see `narration_from_source` in YAML |
| `docgen yaml-generate [--merge-defaults] [--llm] [--dry-run] [--list-gaps]` | Merge defaults into `docgen.yaml`; optional OpenAI refresh of `tts.instructions` / `wizard.system_prompt` (rewrites the file — review in Git) |
| `docgen scene-compile SPEC.scene.yaml [--dry-run]` | Compile a declarative scene spec (YAML) into a `_TimedScene` class and inject it into `animations/scenes.py` — deterministic layout (rows of `_box`); applies auto-pagination + Whisper `wait_word` |
| `docgen scene-compile [SPEC.scene.yaml \| --all] [--retime] [--dry-run]` | Compile declarative scene YAML into `animations/scenes.py`. **`--all --retime`** re-derives `wait_word` from current `timing.json` with no OpenAI; unmatched labels fail closed (or set `pace: none`) |
| `docgen scene-spec-generate [--segment 01 \| --all] [--compile] [--print-only] [--output PATH] [--hint …] [--model …]` | Call OpenAI to emit YAML only (same schema as `scene-compile`); rejects frame-budget overflow and **subject-beat coverage** failures (hold board on same topic; cover topic shifts; no invented labels — not a blind count); auto-paginate + word-alignment; optionally writes `animations/specs/<stem>.scene.yaml` and `--compile`s into `scenes.py` |

## Configuration
Expand Down
121 changes: 97 additions & 24 deletions src/docgen/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,47 +428,93 @@ def narration_generate(
@main.command("scene-compile")
@click.argument(
"spec_path",
required=False,
default=None,
type=click.Path(path_type=Path, exists=True, dir_okay=False),
)
@click.option(
"--all",
"all_specs",
is_flag=True,
help="Compile every animations/specs/*.scene.yaml (mutually exclusive with SPEC_PATH).",
)
@click.option(
"--retime",
is_flag=True,
help=(
"Re-derive wait_word indices from current timing.json (no OpenAI) and fail if "
"labels do not match spoken words. Implied by --all when timing exists; safe to "
"pass explicitly after `docgen timestamps`."
),
)
@click.option(
"--dry-run",
is_flag=True,
help="Print generated Python only; do not write animations/scenes.py.",
)
@click.pass_context
def scene_compile(ctx: click.Context, spec_path: Path, dry_run: bool) -> None:
def scene_compile(
ctx: click.Context,
spec_path: Path | None,
all_specs: bool,
retime: bool,
dry_run: bool,
) -> None:
"""Compile a declarative ``*.scene.yaml`` into ``animations/scenes.py``.

Deterministic layout (rows of ``_box`` mobjects) — use for reliable diagrams
or after an LLM emits **only** YAML. Schema: :mod:`docgen.scene_spec`.
``timing_key`` defaults from ``segment_names`` in docgen.yaml when omitted.

After TTS/timestamps, prefer ``docgen scene-compile --all --retime`` (or
``generate-all``, which retimes existing specs automatically) so beat sync
uses fresh ``timing.json`` without calling OpenAI.
"""
if ctx.obj.get("config") is None:
raise click.ClickException("No docgen.yaml found (use --config PATH).")
if all_specs and spec_path is not None:
raise click.ClickException("Pass SPEC_PATH or --all, not both.")
if not all_specs and spec_path is None:
raise click.ClickException("Pass SPEC_PATH or --all.")

from docgen.manim_scene_support import SceneGenerationError
from docgen.scene_spec import load_scene_spec
from docgen.scene_spec_generate import inject_class_block_into_scenes_py, linted_class_block_from_spec
from docgen.scene_retime import list_scene_spec_paths, retime_compile_spec
from docgen.scene_spec import SceneSpecError

cfg = ctx.obj["config"]
raw = load_scene_spec(spec_path)
try:
class_block, merged = linted_class_block_from_spec(cfg, dict(raw))
except SceneGenerationError as exc:
raise click.ClickException(str(exc)) from exc
# --retime is the same compile path (label sync + pacing gate); the flag
# documents intent and is the recommended post-timestamps invocation.
_ = retime

if dry_run:
click.echo(class_block, nl=False)
return
paths = list_scene_spec_paths(cfg) if all_specs else [spec_path]
if not paths:
raise click.ClickException("No animations/specs/*.scene.yaml files found.")

sid = str(merged["segment_id"]).strip()
class_name = str(merged["class_name"]).strip()
scenes_path = inject_class_block_into_scenes_py(
cfg, seg_id=sid, class_name=class_name, class_block=class_block
)
click.echo(
f"[scene-compile] wrote {class_name} to {scenes_path} "
f"(segment {sid} → timing_key {merged['timing_key']!r})"
)
failures: list[str] = []
for path in paths:
assert path is not None
try:
result = retime_compile_spec(cfg, path, dry_run=dry_run)
except (SceneGenerationError, SceneSpecError) as exc:
if all_specs:
click.echo(f"[scene-compile] FAIL {path.name}: {exc}", err=True)
failures.append(path.name)
continue
raise click.ClickException(str(exc)) from exc
if dry_run:
click.echo(result["class_block"], nl=False)
if all_specs:
click.echo(f"\n--- end {path.name} ---\n")
continue
click.echo(
f"[scene-compile] wrote {result['class_name']} to {result['scenes_path']} "
f"(segment {result['segment_id']} → timing_key {result['timing_key']!r}"
f"{', retime' if retime or all_specs else ''})"
)
if failures:
raise click.ClickException(
f"scene-compile --all: {len(failures)} failed: " + ", ".join(failures)
)


@main.command("scene-spec-generate")
Expand Down Expand Up @@ -1009,14 +1055,34 @@ def pages(ctx: click.Context, force: bool) -> None:
is_flag=True,
help="If compose hits FREEZE GUARD, clear Manim cache and retry Manim + compose once.",
)
@click.option(
"--regen-scene-specs",
is_flag=True,
help=(
"After timestamps, run OpenAI scene-spec-generate for every manim segment "
"(expensive). Default only retime-compiles existing animations/specs/*.scene.yaml."
),
)
@click.option(
"--skip-scene-retime",
is_flag=True,
help="Skip the post-timestamps scene retime / scene-spec stage.",
)
@click.pass_context
def generate_all(
ctx: click.Context,
skip_tts: bool,
skip_manim: bool,
retry_manim: bool,
regen_scene_specs: bool,
skip_scene_retime: bool,
) -> None:
"""Run full pipeline: TTS -> Manim -> compose -> validate -> concat -> pages."""
"""Run full pipeline: TTS → timestamps → scene retime → Manim → compose → validate.

Order matters for beat sync: timestamps must land before scene compile so
``wait_word`` indices match the current mp3. Existing declarative specs are
retime-compiled offline; pass ``--regen-scene-specs`` to call OpenAI first.
"""
from docgen.pipeline import Pipeline

cfg = ctx.obj["config"]
Expand All @@ -1025,16 +1091,23 @@ def generate_all(
skip_tts=skip_tts,
skip_manim=skip_manim,
retry_manim_on_freeze=retry_manim,
regen_scene_specs=regen_scene_specs,
skip_scene_retime=skip_scene_retime,
)


@main.command("rebuild-after-audio")
@click.option(
"--regen-scene-specs",
is_flag=True,
help="Also regenerate scene specs via OpenAI before retime-compile.",
)
@click.pass_context
def rebuild_after_audio(ctx: click.Context) -> None:
"""Rebuild everything after new audio: Manim -> compose -> validate -> concat."""
def rebuild_after_audio(ctx: click.Context, regen_scene_specs: bool) -> None:
"""Rebuild after new audio: timestamps → scene retime → Manim compose validate."""
from docgen.pipeline import Pipeline

cfg = ctx.obj["config"]
pipeline = Pipeline(cfg)
pipeline.run(skip_tts=True)
pipeline.run(skip_tts=True, regen_scene_specs=regen_scene_specs)

90 changes: 89 additions & 1 deletion src/docgen/pipeline.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
"""Pipeline orchestrator: tts -> manim -> compose -> validate -> concat -> pages.
"""Pipeline orchestrator: tts -> timestamps -> scene retime -> manim -> compose -> validate.

The Manim stage renders only scenes referenced by ``visual_map`` for active ``segments.all``
entries (see :meth:`docgen.config.Config.pipeline_manim_scene_names`). Segments whose visuals
are pre-recorded (``recordings/*.mp4``) do not run through Manim capture here.

After timestamps, existing ``animations/specs/*.scene.yaml`` files are **retime-compiled**
against fresh ``timing.json`` (no OpenAI) so ``wait_word`` indices stay aligned. Optional
``regen_scene_specs`` runs LLM ``scene-spec-generate`` for manim segments before that compile.
"""

from __future__ import annotations
Expand All @@ -23,6 +27,8 @@ def run(
skip_tts: bool = False,
skip_manim: bool = False,
retry_manim_on_freeze: bool = False,
regen_scene_specs: bool = False,
skip_scene_retime: bool = False,
) -> None:
if not skip_tts:
print("\n=== Stage: TTS ===")
Expand All @@ -33,6 +39,9 @@ def run(
from docgen.timestamps import TimestampExtractor
TimestampExtractor(self.config).extract_all()

if not skip_manim and not skip_scene_retime:
self._run_scene_stages(regen_scene_specs=regen_scene_specs)

if not skip_manim:
from docgen.image_generate import generate_missing_images_for_bundle
image_msgs = generate_missing_images_for_bundle(self.config)
Expand Down Expand Up @@ -84,6 +93,85 @@ def run(

print("\n=== Pipeline complete ===")

def _manim_segment_ids(self) -> list[str]:
ids: list[str] = []
for seg_id in self.config.segments_all:
vm = self.config.visual_map.get(seg_id)
if isinstance(vm, dict) and str(vm.get("type", "")).strip().lower() == "manim":
ids.append(str(seg_id))
elif isinstance(vm, dict) and not str(vm.get("type", "")).strip():
# Untyped but has a scene class — treat as manim for regen.
if vm.get("scene") or vm.get("class"):
ids.append(str(seg_id))
return ids

def _run_scene_stages(self, *, regen_scene_specs: bool) -> None:
if regen_scene_specs:
manim_ids = self._manim_segment_ids()
if not manim_ids:
print("\n=== Stage: Scene-spec generate (skipped — no manim segments) ===")
return
print("\n=== Stage: Scene-spec generate (LLM) ===")
from docgen.manim_scene_support import SceneGenerationError
from docgen.scene_spec_generate import (
generate_scene_spec,
inject_class_block_into_scenes_py,
linted_class_block_from_spec,
)

failures: list[str] = []
for sid in manim_ids:
print(f"[scene-spec-generate] segment {sid}")
try:
res = generate_scene_spec(
self.config, sid, extra_paths=[], extra_hints=[]
)
specs_dir = self.config.animations_dir / "specs"
specs_dir.mkdir(parents=True, exist_ok=True)
wpath = specs_dir / f"{res.seg_name}.scene.yaml"
wpath.write_text(res.yaml_text, encoding="utf-8")
class_block, merged = linted_class_block_from_spec(
self.config, res.spec, timing_key=res.seg_name
)
inject_class_block_into_scenes_py(
self.config,
seg_id=merged["segment_id"],
class_name=merged["class_name"],
class_block=class_block,
)
print(f"[scene-spec-generate] wrote {wpath.name} → {merged['class_name']}")
except (SceneGenerationError, OSError, ValueError) as exc:
print(f"[scene-spec-generate] FAIL {sid}: {exc}")
failures.append(sid)
if failures:
raise RuntimeError(
"scene-spec-generate failed for: " + ", ".join(failures)
)
return

# Default: offline retime of existing declarative specs against fresh timing.
from docgen.scene_retime import list_scene_spec_paths, retime_compile_all

paths = list_scene_spec_paths(self.config)
if not paths:
print("\n=== Stage: Scene retime (skipped — no animations/specs/*.scene.yaml) ===")
return

print("\n=== Stage: Scene retime (compile specs against timing.json) ===")
results, errors = retime_compile_all(self.config)
for res in results:
print(
f"[scene-retime] {res['path'].name} → {res['class_name']} "
f"(timing_key {res.get('timing_key')!r})"
)
for err in errors:
print(f"[scene-retime] FAIL {err}")
if errors:
raise RuntimeError(
f"scene retime failed for {len(errors)} spec(s); "
"fix unmatched labels (spoken phrases) or set pace: none, then re-run"
)

@staticmethod
def _should_retry_manim(
exc: Exception, skip_manim: bool, retry_manim_on_freeze: bool
Expand Down
Loading