Skip to content
Draft
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ docgen validate --pre-push
| Command | Description |
|---------|-------------|
| `docgen init [TARGET_DIR] [--defaults] [--segments-file FILE]` | Scaffold a new project: `docgen.yaml`, wrapper scripts, directories |
| `docgen wizard [--port 8501]` | Launch narration setup wizard (local web GUI) |
| `docgen wizard [--port 8501]` | Local web GUI: pick **focus files** per segment (persists to hint `context.paths` + `yaml-generate`), draft narration, review/approve, rerun TTS → timestamps → scene-spec → Manim → compose |
| `docgen tts [--segment 01] [--dry-run]` | Generate TTS audio |
| `docgen timestamps [--engine local\|whisper]` | Extract word/segment timestamps from TTS audio → `timing.json` (default `local`: offline narration-text alignment; `whisper`: OpenAI transcription) |
| `docgen image-generate [--segment 01 \| --all \| --spec PATH] [--force] [--dry-run] [--model …] [--size …]` | Generate scene-spec image assets (`image:` + `prompt:` boxes) via the OpenAI Images API into the bundle |
Expand Down
4 changes: 2 additions & 2 deletions docs/demos/hints/manim-scene-specs.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

Use these constraints when generating **`animations/specs/*.scene.yaml`** (via `docgen scene-spec-generate` or by hand):

- **Rows of `_box` only** in the spec compiler — short ASCII labels, no unicode arrows or smart punctuation (use `->` or hyphen).
- **Pages, not shrinking:** use top-level **`pages`** (list of `{ rows: [...], transition?: fade|none }`) when the story needs more boxes than fit on one screen. The compiler does **not** scale everything down; it **fade**s out the previous page’s stack (or **none** for an instant remove) before animating the next page. Single-page specs keep top-level **`rows`** only.
- **Rows of `_box`** in the spec compiler — short ASCII labels, no unicode arrows or smart punctuation in labels (use `->` or hyphen). Optional **`edges`** (`from` / `to` box labels, optional palette `color`) draw directed connectors via `_arrow` / `GrowArrow` after layout — prefer them for pipeline / flow boards.
- **Pages, not shrinking:** use top-level **`pages`** (list of `{ rows: [...], transition?: fade|none, edges?: [...] }`) when the story needs more boxes than fit on one screen. The compiler does **not** scale everything down; it **fade**s out the previous page’s stack (or **none** for an instant remove) before animating the next page. Single-page specs keep top-level **`rows`** (and optional top-level **`edges`**).
- **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
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,8 @@ markers = [
[tool.ruff]
line-length = 100
target-version = "py310"

# Pin the classic default rule set. Ruff 0.16+ enables a much broader default
# selection that fails the existing codebase; keep CI on the historical E/F gate.
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F"]
78 changes: 72 additions & 6 deletions src/docgen/align.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,16 +142,36 @@ def to_wall(t_speech: float) -> float:
return total, to_wall


def _token_weight(tok: str) -> float:
"""Character weight with a pause bump for trailing punctuation (TTS breath).

Commas / semicolons / colons and sentence-final punctuation get a small
extra share of the span so word ``start`` times land slightly earlier —
closer to real TTS pacing than pure character-proportional splits.
"""
base = float(max(len(tok), 1))
if tok.endswith(("...", "…")):
return base + 2.2
if tok.endswith((".", "!", "?")):
return base + 1.4
if tok.endswith((",", ";", ":")):
return base + 0.8
if tok.endswith(("-", "—", "–")):
return base + 0.5
return base


def _words_for_span(sentence: str, start: float, end: float) -> list[dict[str, Any]]:
toks = sentence.split()
if not toks:
return []
span = max(0.0, end - start)
char_total = sum(len(t) for t in toks) or 1
weights = [_token_weight(t) for t in toks]
weight_total = sum(weights) or 1.0
out: list[dict[str, Any]] = []
cursor = start
for tok in toks:
w_span = span * len(tok) / char_total
for tok, w in zip(toks, weights):
w_span = span * w / weight_total
out.append(
{"start": round(cursor, 3), "end": round(cursor + w_span, 3), "word": tok}
)
Expand All @@ -161,6 +181,51 @@ def _words_for_span(sentence: str, start: float, end: float) -> list[dict[str, A
return out


def _merge_nearest_intervals(
intervals: list[tuple[float, float]], target: int
) -> list[tuple[float, float]]:
"""Merge shortest gaps until ``len(intervals) == target`` (when over-segmented)."""
ivs = list(intervals)
if target < 1 or len(ivs) <= target:
return ivs
while len(ivs) > target:
# Merge the pair with the smallest inter-interval gap (silence between).
best_i = 0
best_gap = float("inf")
for i in range(len(ivs) - 1):
gap = ivs[i + 1][0] - ivs[i][1]
if gap < best_gap:
best_gap = gap
best_i = i
a0, _a1 = ivs[best_i]
_b0, b1 = ivs[best_i + 1]
ivs = ivs[:best_i] + [(a0, b1)] + ivs[best_i + 2 :]
return ivs


def reconcile_intervals_to_sentences(
intervals: list[tuple[float, float]], n_sentences: int
) -> list[tuple[float, float]]:
"""Nudge speech-interval count toward sentence count for 1:1 mapping.

When silencedetect finds a few **extra** pauses vs punctuation, merge the
nearest gaps so we keep the high-accuracy 1:1 path. Under-segmented audio
(fewer intervals than sentences — including a single continuous interval)
stays on the proportional path, which uses pause-aware sentence weights.
"""
if n_sentences < 1 or not intervals:
return intervals
ivs = list(intervals)
if len(ivs) == n_sentences:
return ivs
if len(ivs) > n_sentences:
# Only reconcile when close (small silence over-detection).
if len(ivs) - n_sentences <= max(2, n_sentences // 3):
return _merge_nearest_intervals(ivs, n_sentences)
return ivs
return ivs


def build_local_timing(
text: str,
duration: float,
Expand All @@ -172,6 +237,7 @@ def build_local_timing(
return {"text": text, "segments": [], "words": []}

intervals = [iv for iv in speech_intervals if iv[1] > iv[0]] or [(0.0, duration)]
intervals = reconcile_intervals_to_sentences(intervals, len(sentences))

segments: list[dict[str, Any]] = []
words: list[dict[str, Any]] = []
Expand All @@ -182,10 +248,10 @@ def build_local_timing(
segments.append({"start": round(s0, 3), "end": round(s1, 3), "text": sent})
words.extend(_words_for_span(sent, s0, s1))
else:
# Proportional path: allocate each sentence a char-weighted slice of the
# concatenated speech timeline (silences are skipped by the mapper).
# Proportional path: allocate each sentence a pause-aware weighted slice
# of the concatenated speech timeline (silences are skipped by the mapper).
total_speech, to_wall = _speech_timeline_mapper(intervals)
weights = [max(len(s), 1) for s in sentences]
weights = [sum(_token_weight(t) for t in s.split()) or 1.0 for s in sentences]
total_w = sum(weights)
cursor = 0.0
for sent, w in zip(sentences, weights):
Expand Down
18 changes: 18 additions & 0 deletions src/docgen/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,24 @@ def wizard_config(self) -> dict[str, Any]:
"**/archive/**",
"**/__pycache__/**",
],
# Extensions offered in the wizard file tree for focus selection.
"scan_extensions": [
".md",
".py",
".yaml",
".yml",
".toml",
".json",
".txt",
".rst",
".ts",
".tsx",
".js",
".jsx",
".go",
".rs",
".java",
],
}
defaults.update(self.raw.get("wizard", {}))
return defaults
Expand Down
27 changes: 21 additions & 6 deletions src/docgen/manim_scene_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,19 @@ def _load_timing_words(segment_key: str) -> list[dict]:


def _box(label, color, w=2.2, h=0.75, fs=18):
"""Labeled rounded box — slightly stronger fill/stroke for readable diagram boards."""
r = RoundedRectangle(
corner_radius=0.15, width=w, height=h,
stroke_color=color, fill_color=color, fill_opacity=0.2,
corner_radius=0.18, width=w, height=h,
stroke_color=color, stroke_width=2.5,
fill_color=color, fill_opacity=0.28,
)
t = Text(str(label), font_size=fs, color=color)
inner_w = max(w - 0.25, 0.35)
inner_h = max(h - 0.2, 0.35)
t = Text(str(label), font_size=fs, color=C_WHITE)
# Prefer white label text for contrast; fall back to the accent color when
# the palette token is already near-white.
if str(color) in (C_WHITE, "C_WHITE", "#cdd6f4"):
t.set_color(color)
inner_w = max(w - 0.3, 0.35)
inner_h = max(h - 0.22, 0.35)
if t.width > inner_w:
t.scale(inner_w / t.width)
if t.height > inner_h:
Expand All @@ -178,7 +184,16 @@ def _box(label, color, w=2.2, h=0.75, fs=18):


def _arrow(start, end, color="#cdd6f4"):
return Arrow(start, end, color=color, stroke_width=2, buff=0.15, max_tip_length_to_length_ratio=0.12)
"""Connector between box centers (used by scene-spec ``edges``)."""
# Allow palette token names that compile_scene_class emits as bare identifiers.
return Arrow(
start,
end,
color=color,
stroke_width=3,
buff=0.2,
max_tip_length_to_length_ratio=0.15,
)


def _image(relpath, w=3.0, h=2.0):
Expand Down
Loading