From d13e48bbbd48a5163f1cbe14216fe4b3b3016ddb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 02:13:08 +0000 Subject: [PATCH] feat(wizard): durable focus-file UI, scene edges, timing fidelity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the Flask wizard so maintainers can select repo focus files per segment, persist them into hint front matter (context.paths), and refresh docgen.yaml via yaml-generate. Broader file-type scanning, production Focus tab, and pipeline steps for timestamps / scene-spec. Improve diagram graphics with first-class scene-spec edges (arrows), richer box/arrow styling, and hyphen-aware label↔word matching. Tighten local timing with pause-aware word weights and silence-interval reconciliation. Pin ruff lint select to the classic E/F gate so Ruff 0.16+ defaults do not fail CI on the existing tree. Co-authored-by: John Menke --- README.md | 2 +- docs/demos/hints/manim-scene-specs.md | 4 +- pyproject.toml | 5 + src/docgen/align.py | 78 +++++++- src/docgen/config.py | 18 ++ src/docgen/manim_scene_support.py | 27 ++- src/docgen/scene_spec.py | 180 +++++++++++++++-- src/docgen/scene_spec_generate.py | 10 +- src/docgen/static/wizard.css | 13 ++ src/docgen/static/wizard.js | 239 ++++++++++++++++++++-- src/docgen/templates/wizard.html | 28 ++- src/docgen/wizard.py | 273 ++++++++++++++++++++++++-- src/docgen/yaml_generate.py | 158 +++++++++++++++ tests/test_align.py | 28 +++ tests/test_scene_spec.py | 80 ++++++++ tests/test_wizard_focus.py | 148 ++++++++++++++ 16 files changed, 1226 insertions(+), 65 deletions(-) create mode 100644 tests/test_wizard_focus.py diff --git a/README.md b/README.md index f589297..5b97d6e 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/demos/hints/manim-scene-specs.md b/docs/demos/hints/manim-scene-specs.md index cbe40d7..8c78189 100644 --- a/docs/demos/hints/manim-scene-specs.md +++ b/docs/demos/hints/manim-scene-specs.md @@ -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/.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/pyproject.toml b/pyproject.toml index a6a6266..304b59c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/docgen/align.py b/src/docgen/align.py index 9038da3..0ae6e04 100644 --- a/src/docgen/align.py +++ b/src/docgen/align.py @@ -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} ) @@ -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, @@ -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]] = [] @@ -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): diff --git a/src/docgen/config.py b/src/docgen/config.py index 821671a..cb4c74c 100644 --- a/src/docgen/config.py +++ b/src/docgen/config.py @@ -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 diff --git a/src/docgen/manim_scene_support.py b/src/docgen/manim_scene_support.py index 227c248..7922d54 100644 --- a/src/docgen/manim_scene_support.py +++ b/src/docgen/manim_scene_support.py @@ -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: @@ -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): diff --git a/src/docgen/scene_spec.py b/src/docgen/scene_spec.py index a66aa47..6b1ccae 100644 --- a/src/docgen/scene_spec.py +++ b/src/docgen/scene_spec.py @@ -322,6 +322,16 @@ def _normalize_word(s: str) -> str: return "".join(ch for ch in str(s).lower() if ch.isalnum()) +def _label_tokens(label: str) -> list[str]: + """Tokenize a diagram label for Whisper matching. + + Splits on whitespace **and** hyphens/underscores so ``yaml-generate`` can + align to spoken ``yaml`` + ``generate`` (not only the glued ``yamlgenerate``). + """ + parts = re.split(r"[\s\-_]+", str(label).strip()) + return [t for t in (_normalize_word(p) for p in parts) if t] + + # Suffix list for cheap English stemming when matching scene labels to spoken words. # Order matters: longer / more specific suffixes are checked before generic ones (e.g. "ing" # before "s", "es" before "s", "tion" before "s") so "tracing" -> "trac" not "tracin". @@ -363,6 +373,16 @@ def _tokens_match(label_token: str, word_token: str) -> bool: return _stem(label_token) == _stem(word_token) +def _soft_token_match(a: str, b: str) -> bool: + """Cheap fuzzy match for long tokens (prefix / containment after length check).""" + if abs(len(a) - len(b)) > 3: + return False + if len(a) < 5 or len(b) < 5: + return False + shorter, longer = (a, b) if len(a) <= len(b) else (b, a) + return longer.startswith(shorter) or shorter in longer + + def segment_index_for_whisper_time( segments: list[dict[str, Any]], wall_time: float ) -> int: @@ -455,7 +475,7 @@ def sync_row_labels_to_whisper_words( def _find_label(label: str, from_idx: int) -> tuple[int, int] | None: """Return (last matched stream index, original ``words`` index of phrase start).""" - tokens = [_normalize_word(t) for t in str(label).split() if _normalize_word(t)] + tokens = _label_tokens(label) if not tokens: return None n = len(word_stream) @@ -470,6 +490,14 @@ def _find_label(label: str, from_idx: int) -> tuple[int, int] | None: if ok: return (i + m - 1, word_stream[i][2]) i += 1 + # Soft fallback: accept a single long label token that equals a spoken + # word after stripping a short edit distance (hyphen/TTS orthography). + if m == 1 and len(tokens[0]) >= 5: + target = tokens[0] + for j in range(from_idx, n): + w = word_stream[j][0] + if _tokens_match(target, w) or _soft_token_match(target, w): + return (j, word_stream[j][2]) return None def _process_rows(rows: list[Any]) -> None: @@ -1048,6 +1076,61 @@ def _validate_row_list(rows: list[Any], *, path_label: str, prefix: str) -> None ) +def _page_box_labels(rows: list[Any]) -> dict[str, int]: + """Map box label → occurrence count on a page (labeled boxes only).""" + counts: dict[str, int] = {} + for row in rows: + if not isinstance(row, dict): + continue + for box in row.get("boxes") or []: + if not isinstance(box, dict) or _is_image_element(box): + continue + lab = str(box.get("label", "")).strip() + if lab: + counts[lab] = counts.get(lab, 0) + 1 + return counts + + +def _validate_edges( + edges: Any, + *, + labels: dict[str, int], + path_label: str, +) -> None: + if edges is None: + return + if not isinstance(edges, list): + raise SceneSpecError(f"{path_label}: edges must be a list if set") + for i, edge in enumerate(edges): + ep = f"{path_label}: edges[{i}]" + if not isinstance(edge, dict): + raise SceneSpecError(f"{ep}: edge must be a mapping") + for fld in ("from", "to"): + if fld not in edge or not str(edge[fld]).strip(): + raise SceneSpecError(f"{ep}: {fld} is required (box label on this page)") + src = str(edge["from"]).strip() + dst = str(edge["to"]).strip() + if src not in labels: + raise SceneSpecError(f"{ep}: from={src!r} is not a box label on this page") + if dst not in labels: + raise SceneSpecError(f"{ep}: to={dst!r} is not a box label on this page") + if labels[src] > 1: + raise SceneSpecError( + f"{ep}: from={src!r} is ambiguous (duplicate labels on this page)" + ) + if labels[dst] > 1: + raise SceneSpecError( + f"{ep}: to={dst!r} is ambiguous (duplicate labels on this page)" + ) + if src == dst: + raise SceneSpecError(f"{ep}: from and to must differ") + col = edge.get("color") + if col is not None and str(col) not in ALLOWED_COLORS: + raise SceneSpecError( + f"{ep}: color must be one of {sorted(ALLOWED_COLORS)} if set" + ) + + def validate_scene_spec(data: dict[str, Any], *, path_label: str = "spec") -> None: for k in SPEC_REQUIRED_TOP: if k not in data: @@ -1101,8 +1184,19 @@ def validate_scene_spec(data: dict[str, Any], *, path_label: str = "spec") -> No if not isinstance(rows, list) or not rows: raise SceneSpecError(f"{path_label}: rows must be a non-empty list") _validate_row_list(rows, path_label=path_label, prefix="rows") + _validate_edges( + data.get("edges"), + labels=_page_box_labels(rows), + path_label=path_label, + ) return + if data.get("edges") is not None: + raise SceneSpecError( + f"{path_label}: top-level edges are only valid with rows; " + f"put edges on each page for multi-page specs" + ) + pages = data["pages"] if not isinstance(pages, list) or not pages: raise SceneSpecError(f"{path_label}: pages must be a non-empty list") @@ -1116,6 +1210,11 @@ def validate_scene_spec(data: dict[str, Any], *, path_label: str = "spec") -> No if not isinstance(pr, list) or not pr: raise SceneSpecError(f"{pp}: rows must be a non-empty list") _validate_row_list(pr, path_label=path_label, prefix=f"pages[{pi}].rows") + _validate_edges( + page.get("edges"), + labels=_page_box_labels(pr), + path_label=pp, + ) if pi > 0: ptx = page.get("transition", pt) if str(ptx) not in ALLOWED_PAGE_TRANSITIONS: @@ -1125,7 +1224,7 @@ def validate_scene_spec(data: dict[str, Any], *, path_label: str = "spec") -> No def _normalized_pages(spec: dict[str, Any]) -> list[dict[str, Any]]: - """Return page dicts with keys rows, transition (None for first page only).""" + """Return page dicts with keys rows, transition, edges (None for first page transition).""" layout = spec.get("layout") or {} default_tr = str(layout.get("page_transition", "fade")) if spec.get("pages") is not None: @@ -1134,9 +1233,29 @@ def _normalized_pages(spec: dict[str, Any]) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] for pi, page in enumerate(pages_raw): tr = None if pi == 0 else str(page.get("transition", default_tr)) - out.append({"rows": page["rows"], "transition": tr}) + edges = page.get("edges") if isinstance(page.get("edges"), list) else [] + out.append({"rows": page["rows"], "transition": tr, "edges": edges}) return out - return [{"rows": spec["rows"], "transition": None}] + edges = spec.get("edges") if isinstance(spec.get("edges"), list) else [] + return [{"rows": spec["rows"], "transition": None, "edges": edges}] + + +def _box_var_by_label(page_index: int, rows: list[Any]) -> dict[str, str]: + """Map unique box label → generated Python variable name for that page.""" + out: dict[str, str] = {} + for r, row in enumerate(rows): + if not isinstance(row, dict): + continue + boxes = row.get("boxes") or [] + if not isinstance(boxes, list): + continue + for b, box in enumerate(boxes): + if not isinstance(box, dict) or _is_image_element(box): + continue + lab = str(box.get("label", "")).strip() + if lab: + out[lab] = f"_bx_{page_index}_{r}_{b}" + return out def _any_wait_segment_in_pages(pages: list[dict[str, Any]]) -> bool: @@ -1194,6 +1313,10 @@ def compile_scene_class(spec: dict[str, Any]) -> str: "", ] + # Map (page, later-box-var) → list of edge var names to GrowArrow with that box. + edges_with_target: dict[tuple[int, str], list[str]] = {} + page_edge_vars: dict[int, list[str]] = {} + for p, page in enumerate(pages): rows = page["rows"] page_has_image = any( @@ -1245,6 +1368,28 @@ def compile_scene_class(spec: dict[str, Any]) -> str: f" {stack_var}.next_to(title, DOWN, buff={first_row_title_buff})" ) + # Build edge arrows after layout so endpoints use final positions. + label_vars = _box_var_by_label(p, rows) + for ei, edge in enumerate(page.get("edges") or []): + if not isinstance(edge, dict): + continue + src = str(edge["from"]).strip() + dst = str(edge["to"]).strip() + src_var = label_vars.get(src) + dst_var = label_vars.get(dst) + if not src_var or not dst_var: + continue + evar = f"_ar_{p}_{ei}" + ecol = str(edge.get("color") or "C_ACCENT") + lines.append( + f" {evar} = _arrow({src_var}.get_center(), {dst_var}.get_center(), {ecol})" + ) + page_edge_vars.setdefault(p, []).append(evar) + # Reveal with the later endpoint (second in box creation order). + order = {v: i for i, v in enumerate(label_vars.values())} + later = dst_var if order.get(dst_var, 0) >= order.get(src_var, 0) else src_var + edges_with_target.setdefault((p, later), []).append(evar) + lines.append("") for p, page in enumerate(pages): @@ -1266,18 +1411,31 @@ def compile_scene_class(spec: dict[str, Any]) -> str: ) if p > 0 and r == 0 and b_idx == 0: trans = page.get("transition") + prev_stack = f"_p{p - 1}_stack" + prev_edges = page_edge_vars.get(p - 1) or [] + fade_targets = [prev_stack] + prev_edges + fade_args = ", ".join(f"FadeOut({t})" for t in fade_targets) if trans == "fade": - prev_stack = f"_p{p - 1}_stack" lines.append( - f" self.timed_play(FadeOut({prev_stack}), run_time={page_tr_run})" + f" self.timed_play({fade_args}, run_time={page_tr_run})" ) elif trans == "none": - prev_stack = f"_p{p - 1}_stack" - lines.append(f" self.remove({prev_stack})") + for t in fade_targets: + lines.append(f" self.remove({t})") lines.append(" self.timed_wait(0.05)") - lines.append( - f" self.timed_play(FadeIn(_bx_{p}_{r}_{b_idx}), run_time={run_time})" - ) + bx = f"_bx_{p}_{r}_{b_idx}" + edge_vars = edges_with_target.get((p, bx), []) + if edge_vars: + anims = ", ".join( + [f"FadeIn({bx})"] + [f"GrowArrow({ev})" for ev in edge_vars] + ) + lines.append( + f" self.timed_play({anims}, run_time={run_time})" + ) + else: + lines.append( + f" self.timed_play(FadeIn({bx}), run_time={run_time})" + ) lines.extend( [ diff --git a/src/docgen/scene_spec_generate.py b/src/docgen/scene_spec_generate.py index bd9acbe..bd21138 100644 --- a/src/docgen/scene_spec_generate.py +++ b/src/docgen/scene_spec_generate.py @@ -98,8 +98,14 @@ Optional top-level: - layout: optional first_row_title_buff, row_gap, column_gap (positive numbers); for multi-page specs also page_transition: fade | none (default fade), page_transition_run_time (default 0.45, max 5). +- edges: optional list of connectors for **single-page** ``rows`` specs (see below). -Use either **rows** (single page) OR **pages** (list of {{ rows: [...], transition?: fade|none }} — transition on pages after the first overrides layout.page_transition for exiting the previous page; first page has no transition in). +Optional per-page (when using ``pages``): +- edges: list of {{ from: , to: , color?: }} + drawn as arrows between those boxes after layout. 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?: fade|none, edges?: [...] }} — transition on pages after the first overrides layout.page_transition for exiting the previous page; first page has no transition in). Palette tokens (exact spelling): {", ".join(sorted(ALLOWED_COLORS))} @@ -107,6 +113,8 @@ - **Frame:** dogfood Manim canvas is ~14.22 × 8 units; title + buffer eat the top — see user-message budget. Never stack so many tall rows that boxes would clip off the bottom. - **Do not** rely on shrinking: split into **pages** with fade between them. - **Rows** within a page stack vertically; multiple boxes in one row arrange horizontally with safe spacing. +- **Edges / arrows:** when narration describes a flow or pipeline, add ``edges`` so the board shows + directed connections (not only isolated boxes). Keep edge endpoints as spoken labels. - **Subject-beat coverage (mandatory):** consecutive sentences on the same topic are one beat — **hold the board**. When the topic shifts, reveal a new spoken-phrase label for that beat. Do **not** invent a box per sentence, and do **not** leave a new topic without a matching label. diff --git a/src/docgen/static/wizard.css b/src/docgen/static/wizard.css index a167699..7bdbe27 100644 --- a/src/docgen/static/wizard.css +++ b/src/docgen/static/wizard.css @@ -111,3 +111,16 @@ textarea:focus,input[type=text]:focus{outline:none;border-color:#4361ee;box-shad /* Placeholder */ .placeholder{text-align:center;padding:4rem 2rem;color:#aaa;font-size:1rem} + +/* Focus file picker */ +.tree-toolbar{display:flex;gap:.5rem;align-items:center;margin-bottom:.75rem;flex-wrap:wrap} +.tree-toolbar input[type=text]{flex:1;min-width:140px} +.ext-toggle{font-size:.78rem;color:#666;display:flex;align-items:center;gap:.3rem;white-space:nowrap} +.segment-slot.active{border-color:#4361ee;background:#eef1ff;box-shadow:0 0 0 1px rgba(67,97,238,.25)} +.focus-path-list{list-style:none;margin:.5rem 0 1rem;max-height:240px;overflow-y:auto} +.focus-path-item{display:flex;align-items:center;gap:.5rem;padding:.35rem .5rem;border-radius:6px;margin-bottom:2px;background:#f8f9fc;font-size:.82rem} +.focus-path-item code{flex:1;word-break:break-all} +.focus-picker{display:flex;gap:.5rem;margin-bottom:.75rem} +.focus-picker input{flex:1} +.focus-count{margin-left:auto;font-size:.7rem;color:#888;background:#e8ecf4;padding:.1rem .4rem;border-radius:8px} +#segment-list li{flex-wrap:wrap} diff --git a/src/docgen/static/wizard.js b/src/docgen/static/wizard.js index d2f376d..41cc70b 100644 --- a/src/docgen/static/wizard.js +++ b/src/docgen/static/wizard.js @@ -10,7 +10,11 @@ let segments = []; // setup segment slots let prodSegments = []; // production segment data let activeSegmentId = null; + let activeSetupSegId = null; + let prodFocusPaths = []; let appState = { segments: {} }; + let scanExtensions = null; + let filterText = ""; // ---- View switching ---- document.querySelectorAll(".nav-btn").forEach((btn) => { @@ -40,11 +44,39 @@ // ================================================================ async function loadFileTree() { - const res = await fetch("/api/scan"); + const mdOnly = document.getElementById("md-only")?.checked; + const qs = mdOnly ? "?extensions=.md" : ""; + const res = await fetch("/api/scan" + qs); const data = await res.json(); fileTree = data.tree; flatFiles = data.files; - renderTree(fileTree, document.getElementById("file-tree")); + scanExtensions = data.extensions || []; + renderTreeFiltered(); + } + + function pathMatchesFilter(path) { + if (!filterText) return true; + return path.toLowerCase().includes(filterText.toLowerCase()); + } + + function filterTree(nodes) { + const out = []; + for (const node of nodes) { + if (node.type === "file") { + if (pathMatchesFilter(node.path)) out.push(node); + } else { + const children = filterTree(node.children || []); + if (children.length || pathMatchesFilter(node.path || node.name)) { + out.push({ ...node, children }); + } + } + } + return out; + } + + function renderTreeFiltered() { + const container = document.getElementById("file-tree"); + renderTree(filterTree(fileTree), container); } function renderTree(nodes, container) { @@ -54,7 +86,7 @@ const dirEl = document.createElement("div"); dirEl.className = "tree-item"; const label = document.createElement("div"); - label.className = "tree-dir"; + label.className = "tree-dir open"; label.textContent = node.name; label.addEventListener("click", () => { label.classList.toggle("open"); @@ -68,10 +100,15 @@ } else { const fileEl = document.createElement("div"); fileEl.className = "tree-item tree-file"; + fileEl.draggable = true; + fileEl.addEventListener("dragstart", (e) => { + e.dataTransfer.setData("text/plain", node.path); + }); const lbl = document.createElement("label"); const cb = document.createElement("input"); cb.type = "checkbox"; cb.dataset.path = node.path; + cb.checked = selectedFiles.has(node.path); cb.addEventListener("change", () => { if (cb.checked) selectedFiles.add(node.path); else selectedFiles.delete(node.path); @@ -93,8 +130,10 @@ } function updateGenerateBtn() { - document.getElementById("btn-generate").disabled = - selectedFiles.size === 0 || segments.length === 0; + const hasSeg = segments.length > 0; + const hasFiles = selectedFiles.size > 0 || segments.some((s) => s.files.length > 0); + document.getElementById("btn-generate").disabled = !hasSeg || !hasFiles; + document.getElementById("btn-save-focus").disabled = !hasSeg; } // ---- Segment slots ---- @@ -102,8 +141,13 @@ document.getElementById("btn-add-segment").addEventListener("click", () => { segCounter++; - const seg = { id: "seg-" + segCounter, name: String(segCounter).padStart(2, "0"), files: [] }; + const seg = { + id: "seg-" + segCounter, + name: String(segCounter).padStart(2, "0"), + files: Array.from(selectedFiles), + }; segments.push(seg); + activeSetupSegId = seg.id; renderSegmentSlots(); updateGenerateBtn(); }); @@ -120,7 +164,23 @@ for (const [dir, files] of Object.entries(groups).sort()) { segCounter++; const name = dir.replace(/\//g, "-").replace(/[^a-zA-Z0-9-]/g, "") || "root"; - segments.push({ id: "seg-" + segCounter, name: String(segCounter).padStart(2, "0") + "-" + name, files }); + segments.push({ + id: "seg-" + segCounter, + name: String(segCounter).padStart(2, "0") + "-" + name, + files, + }); + } + activeSetupSegId = segments[0]?.id || null; + renderSegmentSlots(); + updateGenerateBtn(); + }); + + document.getElementById("btn-assign-selected").addEventListener("click", () => { + if (!activeSetupSegId || selectedFiles.size === 0) return; + const seg = segments.find((s) => s.id === activeSetupSegId); + if (!seg) return; + for (const p of selectedFiles) { + if (!seg.files.includes(p)) seg.files.push(p); } renderSegmentSlots(); updateGenerateBtn(); @@ -131,8 +191,12 @@ container.innerHTML = ""; for (const seg of segments) { const slot = document.createElement("div"); - slot.className = "segment-slot"; + slot.className = "segment-slot" + (seg.id === activeSetupSegId ? " active" : ""); slot.dataset.segId = seg.id; + slot.addEventListener("click", () => { + activeSetupSegId = seg.id; + renderSegmentSlots(); + }); slot.addEventListener("dragover", (e) => e.preventDefault()); slot.addEventListener("drop", (e) => { e.preventDefault(); @@ -140,6 +204,7 @@ if (path && !seg.files.includes(path)) { seg.files.push(path); renderSegmentSlots(); + updateGenerateBtn(); } }); const header = document.createElement("div"); @@ -148,12 +213,15 @@ inp.type = "text"; inp.value = seg.name; inp.addEventListener("input", () => { seg.name = inp.value; }); + inp.addEventListener("click", (e) => e.stopPropagation()); header.appendChild(inp); const rmBtn = document.createElement("button"); rmBtn.className = "btn-remove-seg"; rmBtn.textContent = "×"; - rmBtn.addEventListener("click", () => { + rmBtn.addEventListener("click", (e) => { + e.stopPropagation(); segments = segments.filter((s) => s.id !== seg.id); + if (activeSetupSegId === seg.id) activeSetupSegId = segments[0]?.id || null; renderSegmentSlots(); updateGenerateBtn(); }); @@ -166,35 +234,82 @@ tag.className = "seg-file-tag"; tag.textContent = f.split("/").pop(); tag.title = f; - tag.addEventListener("click", () => { + tag.addEventListener("click", (e) => { + e.stopPropagation(); seg.files = seg.files.filter((x) => x !== f); renderSegmentSlots(); + updateGenerateBtn(); }); filesDiv.appendChild(tag); } + if (!seg.files.length) { + const empty = document.createElement("span"); + empty.className = "hint"; + empty.textContent = "Drop focus files here"; + filesDiv.appendChild(empty); + } slot.appendChild(filesDiv); container.appendChild(slot); } } + function segmentIdFromName(name) { + const m = String(name).match(/^(\d{2})/); + return m ? m[1] : name; + } + + async function saveFocusForSegments() { + const status = document.getElementById("generate-status"); + status.textContent = "Saving focus files…"; + let ok = 0; + for (const seg of segments) { + const files = seg.files.length > 0 ? seg.files : Array.from(selectedFiles); + const sid = segmentIdFromName(seg.name); + const res = await fetch("/api/segments/" + encodeURIComponent(sid) + "/focus", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ paths: files, also_manim: true, yaml_generate: true }), + }); + const data = await res.json(); + if (!res.ok || data.error) { + status.textContent = "Error saving " + sid + ": " + (data.error || res.status); + return; + } + ok++; + } + status.textContent = "Saved focus files for " + ok + " segment(s) → hints + yaml-generate."; + } + + document.getElementById("btn-save-focus").addEventListener("click", () => { + saveFocusForSegments(); + }); + // ---- Generate narration ---- document.getElementById("btn-generate").addEventListener("click", async () => { const btn = document.getElementById("btn-generate"); const status = document.getElementById("generate-status"); btn.disabled = true; - status.textContent = "Generating..."; + status.textContent = "Saving focus + generating…"; + + await saveFocusForSegments(); const guidance = document.getElementById("guidance").value; const drafts = []; for (const seg of segments) { const files = seg.files.length > 0 ? seg.files : Array.from(selectedFiles); + const sid = segmentIdFromName(seg.name); status.textContent = "Generating " + seg.name + "..."; try { const res = await fetch("/api/generate-narration", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ source_paths: files, guidance, segment_name: seg.name }), + body: JSON.stringify({ + source_paths: files, + guidance, + segment_name: seg.name, + segment_id: sid, + }), }); const data = await res.json(); if (data.error) throw new Error(data.error); @@ -239,6 +354,12 @@ }); } + document.getElementById("file-filter")?.addEventListener("input", (e) => { + filterText = e.target.value || ""; + renderTreeFiltered(); + }); + document.getElementById("md-only")?.addEventListener("change", () => loadFileTree()); + // ================================================================ // PRODUCTION VIEW // ================================================================ @@ -268,6 +389,12 @@ badge.textContent = st; li.appendChild(badge); li.appendChild(document.createTextNode(" " + seg.id)); + if (seg.focus_paths?.length) { + const fc = document.createElement("span"); + fc.className = "focus-count"; + fc.textContent = seg.focus_paths.length + " files"; + li.appendChild(fc); + } li.addEventListener("click", () => loadSegment(seg.id)); list.appendChild(li); } @@ -277,6 +404,36 @@ document.getElementById("progress-text").textContent = approved + " / " + total + " approved"; } + function renderFocusList() { + const list = document.getElementById("focus-path-list"); + if (!list) return; + list.innerHTML = ""; + if (!prodFocusPaths.length) { + const li = document.createElement("li"); + li.className = "hint"; + li.textContent = "No focus files yet — add paths below."; + list.appendChild(li); + return; + } + for (const p of prodFocusPaths) { + const li = document.createElement("li"); + li.className = "focus-path-item"; + const span = document.createElement("code"); + span.textContent = p; + li.appendChild(span); + const rm = document.createElement("button"); + rm.className = "btn-remove-seg"; + rm.textContent = "×"; + rm.title = "Remove"; + rm.addEventListener("click", () => { + prodFocusPaths = prodFocusPaths.filter((x) => x !== p); + renderFocusList(); + }); + li.appendChild(rm); + list.appendChild(li); + } + } + async function loadSegment(segId) { activeSegmentId = segId; document.getElementById("no-segment-selected").classList.add("hidden"); @@ -290,14 +447,22 @@ badge.className = "badge badge-" + st.replace(/\s+/g, "-"); badge.textContent = st; - // Load narration try { const res = await fetch("/api/narration/" + encodeURIComponent(segId)); const data = await res.json(); document.getElementById("narration-editor").value = data.text || ""; } catch { document.getElementById("narration-editor").value = ""; } - // Audio + try { + const fres = await fetch("/api/segments/" + encodeURIComponent(segId) + "/focus"); + const fdata = await fres.json(); + prodFocusPaths = Array.isArray(fdata.paths) ? fdata.paths.slice() : (seg?.focus_paths || []).slice(); + } catch { + prodFocusPaths = (seg?.focus_paths || []).slice(); + } + renderFocusList(); + document.getElementById("focus-save-status").textContent = ""; + const audioEl = document.getElementById("audio-player"); const audioStatus = document.getElementById("audio-status"); if (seg?.audio_path) { @@ -309,7 +474,6 @@ audioStatus.textContent = "No audio generated yet."; } - // Video const videoEl = document.getElementById("video-player"); const videoStatus = document.getElementById("video-status"); if (seg?.recording_path) { @@ -323,6 +487,41 @@ document.getElementById("validation-results").innerHTML = '

Run validate to see results.

'; } + document.getElementById("btn-add-focus-path")?.addEventListener("click", () => { + const inp = document.getElementById("focus-path-input"); + const p = (inp.value || "").trim(); + if (!p) return; + if (!prodFocusPaths.includes(p)) prodFocusPaths.push(p); + inp.value = ""; + renderFocusList(); + }); + + document.getElementById("btn-save-prod-focus")?.addEventListener("click", async () => { + if (!activeSegmentId) return; + const status = document.getElementById("focus-save-status"); + status.textContent = "Saving…"; + const res = await fetch( + "/api/segments/" + encodeURIComponent(activeSegmentId) + "/focus", + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + paths: prodFocusPaths, + also_manim: true, + yaml_generate: true, + }), + } + ); + const data = await res.json(); + if (!res.ok || data.error) { + status.textContent = "Error: " + (data.error || res.status); + return; + } + status.textContent = "Saved " + (data.paths?.length || 0) + " path(s)" + + (data.hint_path ? " → " + data.hint_path : ""); + await loadProductionView(); + }); + // ---- Narration save / regenerate ---- document.getElementById("btn-save-narration").addEventListener("click", async () => { if (!activeSegmentId) return; @@ -347,13 +546,15 @@ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - source_paths: [], + source_paths: prodFocusPaths.length ? prodFocusPaths : (seg?.focus_paths || []), guidance, - segment_name: activeSegmentId, + segment_name: seg?.name || activeSegmentId, + segment_id: activeSegmentId, revision_notes: notes, }), }); const data = await res.json(); + if (data.error) throw new Error(data.error); if (data.narration) document.getElementById("narration-editor").value = data.narration; } catch (err) { alert("Error: " + err.message); } btn.textContent = "Regenerate narration"; @@ -371,6 +572,8 @@ } document.getElementById("btn-redo-tts").addEventListener("click", () => runStep("tts")); + document.getElementById("btn-redo-timestamps")?.addEventListener("click", () => runStep("timestamps")); + document.getElementById("btn-redo-scene-spec")?.addEventListener("click", () => runStep("scene-spec")); document.getElementById("btn-redo-manim").addEventListener("click", () => runStep("manim")); document.getElementById("btn-redo-compose").addEventListener("click", () => runStep("compose")); document.getElementById("btn-run-validate").addEventListener("click", async () => { @@ -383,7 +586,7 @@ document.getElementById("btn-redo-all").addEventListener("click", async () => { if (!activeSegmentId) return; - for (const step of ["tts", "manim", "compose", "validate"]) { + for (const step of ["tts", "timestamps", "scene-spec", "manim", "compose", "validate"]) { await runStep(step); } }); diff --git a/src/docgen/templates/wizard.html b/src/docgen/templates/wizard.html index d00ae89..c0f9bfc 100644 --- a/src/docgen/templates/wizard.html +++ b/src/docgen/templates/wizard.html @@ -20,8 +20,12 @@
-

Project files

-

Select .md files to use as narration source material.

+

Focus files

+

Select repo files to steer narration and scene generation. Markdown, Python, YAML, and other source types are included.

+
+ + +
@@ -31,14 +35,16 @@

Guidance

Segment mapping

-

Drag selected files into segments, or auto-group.

+

Assign focus files to segments (or auto-group). Saving writes durable hint wiring + runs yaml-generate.

+
+
@@ -78,6 +84,7 @@

+
@@ -93,6 +100,19 @@

+
+

Repo files wired into this segment's hint front matter (context.paths). Changes persist via yaml-generate.

+
    +
    + + +
    +
    + + +
    +
    +
    @@ -121,6 +141,8 @@

    Validation results

    + + diff --git a/src/docgen/wizard.py b/src/docgen/wizard.py index 9bf5623..a765911 100644 --- a/src/docgen/wizard.py +++ b/src/docgen/wizard.py @@ -46,15 +46,54 @@ def _is_ignored(rel_path: str, gitignore: list[str], extra_excludes: list[str]) return False +# Default extensions the wizard can offer as narration / scene focus context. +DEFAULT_SCAN_EXTENSIONS = ( + ".md", + ".py", + ".yaml", + ".yml", + ".toml", + ".json", + ".txt", + ".rst", + ".ts", + ".tsx", + ".js", + ".jsx", + ".go", + ".rs", + ".java", +) + + def scan_md_files(repo_root: Path, exclude_patterns: list[str] | None = None) -> list[dict]: """Walk repo_root and return a flat list of .md file info dicts.""" + return scan_repo_files(repo_root, exclude_patterns=exclude_patterns, extensions=(".md",)) + + +def scan_repo_files( + repo_root: Path, + exclude_patterns: list[str] | None = None, + extensions: tuple[str, ...] | list[str] | None = None, +) -> list[dict]: + """Walk ``repo_root`` and return file info dicts for allowed extensions. + + Paths are repo-root-relative. Used by the wizard so maintainers can pick + focus files (not only Markdown) for ``context.paths``. + """ from docgen.path_filters import is_under_archive_dir gitignore = _load_gitignore_patterns(repo_root) excludes = exclude_patterns or [] + exts = tuple(extensions) if extensions is not None else DEFAULT_SCAN_EXTENSIONS + exts_norm = {e.lower() if e.startswith(".") else f".{e.lower()}" for e in exts} results: list[dict] = [] - for md in sorted(repo_root.rglob("*.md")): - rel = str(md.relative_to(repo_root)) + for path in sorted(repo_root.rglob("*")): + if not path.is_file(): + continue + if path.suffix.lower() not in exts_norm: + continue + rel = str(path.relative_to(repo_root)) if rel.startswith(".git/"): continue if is_under_archive_dir(rel): @@ -63,11 +102,11 @@ def scan_md_files(repo_root: Path, exclude_patterns: list[str] | None = None) -> continue snippet = "" try: - lines = md.read_text(encoding="utf-8", errors="replace").splitlines()[:4] + lines = path.read_text(encoding="utf-8", errors="replace").splitlines()[:4] snippet = "\n".join(lines) except OSError: pass - results.append({"path": rel, "snippet": snippet}) + results.append({"path": rel, "snippet": snippet, "ext": path.suffix.lower()}) return results @@ -232,10 +271,25 @@ def index(): def api_scan(): cfg = _cfg() root = cfg.repo_root if cfg else Path.cwd() - excludes = cfg.wizard_config.get("exclude_patterns", []) if cfg else [] - files = scan_md_files(root, excludes) + wiz = cfg.wizard_config if cfg else {} + excludes = wiz.get("exclude_patterns", []) + raw_ext = request.args.get("extensions") + if raw_ext: + exts = tuple(e.strip() for e in raw_ext.split(",") if e.strip()) + else: + cfg_ext = wiz.get("scan_extensions") + if isinstance(cfg_ext, list) and cfg_ext: + exts = tuple(str(e) for e in cfg_ext) + else: + exts = DEFAULT_SCAN_EXTENSIONS + files = scan_repo_files(root, exclude_patterns=excludes, extensions=exts) tree = build_file_tree(files) - return jsonify({"tree": tree, "files": files, "repo_root": str(root)}) + return jsonify({ + "tree": tree, + "files": files, + "repo_root": str(root), + "extensions": list(exts), + }) # -- API: read file content ------------------------------------------------ @@ -255,26 +309,43 @@ def api_file(): def api_generate_narration(): cfg = _cfg() data = request.json or {} - source_paths: list[str] = data.get("source_paths", []) + source_paths: list[str] = list(data.get("source_paths") or []) guidance: str = data.get("guidance", "") segment_name: str = data.get("segment_name", "untitled") revision_notes: str = data.get("revision_notes", "") topic_label: str | None = data.get("topic_label") or None - if topic_label is None and cfg is not None: - seg_id_hint: str | None = data.get("segment_id") - if seg_id_hint: - try: - topic_label = cfg.narration_topic_label(seg_id_hint) - except Exception: - topic_label = None + seg_id_hint: str | None = data.get("segment_id") + if topic_label is None and cfg is not None and seg_id_hint: + try: + topic_label = cfg.narration_topic_label(seg_id_hint) + except Exception: + topic_label = None root = cfg.repo_root if cfg else Path.cwd() wiz = cfg.wizard_config if cfg else {} + # When the UI sends no explicit paths, reuse durable focus files from + # hint wiring / narration_from_source.context.paths. + if not source_paths and cfg is not None: + sid = seg_id_hint or ( + segment_name[:2] if len(segment_name) >= 2 and segment_name[:2].isdigit() else None + ) + if sid: + from docgen.narrate_from_source import merged_narration_from_source_settings + from docgen.yaml_generate import read_hint_focus_paths + + hint_paths = read_hint_focus_paths(cfg.hints_dir, sid) + settings = merged_narration_from_source_settings(cfg, sid) + seen: list[str] = [] + for p in hint_paths + list(settings.context_paths): + if p not in seen: + seen.append(p) + source_paths = seen + source_texts = [] for rel in source_paths: fpath = root / rel - if fpath.exists(): + if fpath.exists() and fpath.is_file(): source_texts.append( f"## File: {rel}\n{fpath.read_text(encoding='utf-8', errors='replace')}" ) @@ -297,7 +368,11 @@ def api_generate_narration(): out = narration_dir / f"{segment_name}.md" out.write_text(narration + "\n", encoding="utf-8") - return jsonify({"narration": narration, "path": str(out)}) + return jsonify({ + "narration": narration, + "path": str(out), + "source_paths": source_paths, + }) # -- API: segment state ---------------------------------------------------- @@ -322,6 +397,8 @@ def api_segments(): cfg = _cfg() if not cfg: return jsonify({"segments": []}) + from docgen.yaml_generate import read_hint_focus_paths + base = cfg.base_dir state = load_state(base) result = [] @@ -333,6 +410,12 @@ def api_segments(): rec_found = _find_asset(cfg.recordings_dir, seg_name, seg_id, ".mp4") seg_state = state.get("segments", {}).get(seg_id, {}) + focus_paths = read_hint_focus_paths(cfg.hints_dir, seg_id) + if not focus_paths: + from docgen.narrate_from_source import merged_narration_from_source_settings + focus_paths = list( + merged_narration_from_source_settings(cfg, seg_id).context_paths + ) result.append({ "id": seg_id, "name": seg_name, @@ -345,9 +428,105 @@ def api_segments(): "audio_path": str(audio_found.relative_to(base)) if audio_found else None, "recording_path": str(rec_found.relative_to(base)) if rec_found else None, "visual_map": cfg.visual_map.get(seg_id, {}), + "focus_paths": focus_paths, }) return jsonify({"segments": result}) + @app.route("/api/segments//focus") + def api_get_focus(segment_id: str): + """Return durable focus file paths for a segment (hint wiring / config).""" + cfg = _cfg() + if not cfg: + return jsonify({"error": "no config"}), 400 + from docgen.yaml_generate import find_hint_path_for_segment, read_hint_focus_paths + + paths = read_hint_focus_paths(cfg.hints_dir, segment_id) + hint = find_hint_path_for_segment(cfg.hints_dir, segment_id) + return jsonify({ + "segment_id": segment_id, + "paths": paths, + "hint_path": str(hint.relative_to(cfg.base_dir)) if hint else None, + }) + + @app.route("/api/segments//focus", methods=["PUT"]) + def api_put_focus(segment_id: str): + """Persist focus files into hint front matter and re-run yaml-generate merge. + + Body: ``{"paths": ["rel/path.py", ...], "also_manim": true, "yaml_generate": true}`` + """ + cfg = _cfg() + if not cfg: + return jsonify({"error": "no config"}), 400 + data = request.json or {} + paths = data.get("paths") + if not isinstance(paths, list): + return jsonify({"error": "paths must be a list of repo-root-relative strings"}), 400 + also_manim = data.get("also_manim", True) + do_yaml = data.get("yaml_generate", True) + + root = cfg.repo_root.resolve() + clean: list[str] = [] + for raw in paths: + rel = str(raw).strip().replace("\\", "/") + if not rel or rel.startswith("/") or ".." in rel.split("/"): + return jsonify({"error": f"invalid path: {raw!r}"}), 400 + fpath = (root / rel).resolve() + try: + fpath.relative_to(root) + except ValueError: + return jsonify({"error": f"path escapes repo_root: {rel}"}), 400 + if not fpath.is_file(): + return jsonify({"error": f"file not found: {rel}"}), 400 + clean.append(rel) + + from docgen.yaml_generate import ensure_segment_hint_with_focus + + stem = cfg.resolve_segment_name(segment_id) + try: + hint_path, written = ensure_segment_hint_with_focus( + cfg.hints_dir, + segment_id, + stem=stem, + paths=clean, + also_manim=bool(also_manim), + ) + except (ValueError, TypeError) as exc: + return jsonify({"error": str(exc)}), 400 + + yaml_changes: list[str] = [] + if do_yaml: + try: + import copy + + import yaml as _yaml + + from docgen.yaml_generate import default_header, merge_defaults, write_docgen_yaml + + raw = _yaml.safe_load(cfg.yaml_path.read_text(encoding="utf-8")) or {} + if not isinstance(raw, dict): + raw = {} + # Work on a deep copy so a failed merge never leaves half-mutated state + # in memory before we rewrite the file. + working = copy.deepcopy(raw) + yaml_changes.extend(merge_defaults(working, cfg)) + write_docgen_yaml(cfg.yaml_path, working, header=default_header(cfg.yaml_path)) + from docgen.config import Config + app.config["DOCGEN"] = Config.from_yaml(cfg.yaml_path) + except Exception as exc: + return jsonify({ + "error": f"hints updated but yaml-generate failed: {exc}", + "hint_path": str(hint_path.relative_to(cfg.base_dir)), + "paths": written, + }), 500 + + return jsonify({ + "ok": True, + "segment_id": segment_id, + "paths": written, + "hint_path": str(hint_path.relative_to(cfg.base_dir)), + "yaml_changes": yaml_changes, + }) + # -- API: read/write narration text ---------------------------------------- @app.route("/api/narration/") @@ -394,6 +573,66 @@ def api_run_step(step: str, segment_id: str): gen.generate(segment=segment_id) return jsonify({"ok": True, "step": "tts", "segment": segment_id}) + elif step == "timestamps": + import json as _json + + from docgen.timestamps import TimestampExtractor + + seg_name = cfg.resolve_segment_name(segment_id) + mp3 = _find_asset(cfg.audio_dir, seg_name, segment_id, ".mp3") + if mp3 is None: + raise RuntimeError( + f"no audio for segment {segment_id}; run TTS first" + ) + ts = TimestampExtractor(cfg) + engine = ts.resolve_engine(None) + block = ( + ts.extract(mp3) if engine == "whisper" else ts.extract_local(mp3) + ) + out = cfg.animations_dir / "timing.json" + timing: dict = {} + if out.is_file(): + try: + timing = _json.loads(out.read_text(encoding="utf-8")) + except _json.JSONDecodeError: + timing = {} + if not isinstance(timing, dict): + timing = {} + timing[mp3.stem] = block + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text( + _json.dumps(timing, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + from docgen.manim_scene_support import sync_audio_tail_waits_in_scenes + sync_audio_tail_waits_in_scenes(cfg) + return jsonify({"ok": True, "step": "timestamps", "segment": segment_id}) + + elif step == "scene-spec": + from docgen.scene_spec_generate import ( + generate_scene_spec, + inject_class_block_into_scenes_py, + linted_class_block_from_spec, + ) + + res = generate_scene_spec( + cfg, segment_id, extra_paths=[], extra_hints=[] + ) + specs_dir = cfg.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( + cfg, res.spec, timing_key=res.seg_name + ) + inject_class_block_into_scenes_py( + cfg, + seg_id=merged["segment_id"], + class_name=merged["class_name"], + class_block=class_block, + ) + return jsonify({"ok": True, "step": "scene-spec", "segment": segment_id}) + elif step == "manim": from docgen.manim_runner import ManimRunner runner = ManimRunner(cfg) diff --git a/src/docgen/yaml_generate.py b/src/docgen/yaml_generate.py index af7b811..8f66512 100644 --- a/src/docgen/yaml_generate.py +++ b/src/docgen/yaml_generate.py @@ -374,6 +374,164 @@ def collect_hint_wirings_by_segment(hints_dir: Path) -> dict[str, dict[str, Any] return out +def find_hint_path_for_segment(hints_dir: Path, seg_id: str) -> Path | None: + """Return the maintainer hint file that declares ``docgen.segment.id == seg_id``.""" + sid = str(seg_id).strip() + if sid.isdigit(): + sid = sid.zfill(2) + if not hints_dir.is_dir(): + return None + for path in sorted(hints_dir.glob("*.md")): + if path.name.lower() == "readme.md": + continue + decl = parse_hint_segment_declaration(path) + if decl and decl[0] == sid: + return path + return None + + +def _context_paths_from_wiring(wiring: dict[str, Any] | None) -> list[str]: + if not isinstance(wiring, dict): + return [] + seen: list[str] = [] + for key in ("narration", "manim_scene"): + block = wiring.get(key) + if not isinstance(block, dict): + continue + ctx = block.get("context") + if not isinstance(ctx, dict): + continue + for p in ctx.get("paths") or []: + s = str(p).strip() + if s and s not in seen: + seen.append(s) + return seen + + +def read_hint_focus_paths(hints_dir: Path, seg_id: str) -> list[str]: + """Union of narration + manim ``context.paths`` from the segment's hint wiring.""" + path = find_hint_path_for_segment(hints_dir, seg_id) + if path is None: + return [] + doc = parse_hint_docgen_front_matter(path) + if not doc: + return [] + wiring = doc.get("wiring") + return _context_paths_from_wiring(wiring if isinstance(wiring, dict) else None) + + +def _set_wiring_context_paths(wiring: dict[str, Any], paths: list[str], *, also_manim: bool) -> None: + clean = [str(p).strip() for p in paths if str(p).strip()] + nar = wiring.setdefault("narration", {}) + if not isinstance(nar, dict): + nar = {} + wiring["narration"] = nar + ctx = nar.setdefault("context", {}) + if not isinstance(ctx, dict): + ctx = {} + nar["context"] = ctx + ctx["paths"] = list(clean) + if also_manim: + ms = wiring.setdefault("manim_scene", {}) + if not isinstance(ms, dict): + ms = {} + wiring["manim_scene"] = ms + mctx = ms.setdefault("context", {}) + if not isinstance(mctx, dict): + mctx = {} + ms["context"] = mctx + mctx["paths"] = list(clean) + + +def update_hint_focus_paths( + hint_path: Path, + paths: list[str], + *, + also_manim: bool = True, +) -> list[str]: + """Write ``context.paths`` into an existing hint file's ``docgen.wiring`` front matter. + + Returns the cleaned path list that was written. Preserves the Markdown body and + other front-matter keys (segment declaration, visual wiring, hints, etc.). + """ + text = hint_path.read_text(encoding="utf-8") + m = _HINT_FRONT_MATTER_RE.match(text) + if not m: + raise ValueError(f"{hint_path}: missing YAML front matter") + try: + data = yaml.safe_load(m.group("body")) + except yaml.YAMLError as exc: + raise ValueError(f"{hint_path}: invalid front matter YAML: {exc}") from exc + if not isinstance(data, dict): + raise TypeError(f"{hint_path}: front matter root must be a mapping") + doc = data.setdefault("docgen", {}) + if not isinstance(doc, dict): + raise TypeError(f"{hint_path}: docgen must be a mapping") + wiring = doc.setdefault("wiring", {}) + if not isinstance(wiring, dict): + wiring = {} + doc["wiring"] = wiring + _set_wiring_context_paths(wiring, paths, also_manim=also_manim) + clean = _context_paths_from_wiring(wiring) + new_fm = yaml.safe_dump( + data, + default_flow_style=False, + allow_unicode=True, + sort_keys=False, + ).rstrip() + "\n" + body = text[m.end() :] + if body and not body.startswith("\n"): + body = "\n" + body + hint_path.write_text(f"---\n{new_fm}---{body}", encoding="utf-8") + return clean + + +def ensure_segment_hint_with_focus( + hints_dir: Path, + seg_id: str, + *, + stem: str, + paths: list[str], + also_manim: bool = True, +) -> tuple[Path, list[str]]: + """Update or create ``hints/segment--topic.md`` with focus ``context.paths``. + + Prefer an existing segment hint file when present. New files get a minimal + ``docgen.segment`` + ``docgen.wiring`` front matter (category B input). + """ + sid = str(seg_id).strip() + if sid.isdigit(): + sid = sid.zfill(2) + stem_s = str(stem).strip() or f"{sid}-topic" + hints_dir.mkdir(parents=True, exist_ok=True) + existing = find_hint_path_for_segment(hints_dir, sid) + if existing is not None: + clean = update_hint_focus_paths(existing, paths, also_manim=also_manim) + return existing, clean + + target = hints_dir / f"segment-{sid}-topic.md" + clean = [str(p).strip() for p in paths if str(p).strip()] + data: dict[str, Any] = { + "docgen": { + "segment": {"create": True, "id": sid, "stem": stem_s}, + "wiring": { + "narration": {"context": {"paths": list(clean)}}, + }, + } + } + if also_manim: + data["docgen"]["wiring"]["manim_scene"] = {"context": {"paths": list(clean)}} + fm = yaml.safe_dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False) + body = ( + f"\n# Narration focus (segment {sid})\n\n" + "Focus files for this segment are listed in the front matter " + "(`docgen.wiring.*.context.paths`). Edit them via the wizard or by hand, " + "then run `docgen yaml-generate`.\n" + ) + target.write_text(f"---\n{fm}---{body}", encoding="utf-8") + return target, clean + + def merge_hint_wiring(raw: dict[str, Any], cfg: "Config") -> list[str]: """Apply ``visual`` overrides from hints, re-sync Manim lists, then merge narration / manim_scene blocks.""" disc = raw.get("discovery") diff --git a/tests/test_align.py b/tests/test_align.py index f34264a..6330c1a 100644 --- a/tests/test_align.py +++ b/tests/test_align.py @@ -7,6 +7,7 @@ from docgen.align import ( build_local_timing, parse_silencedetect_output, + reconcile_intervals_to_sentences, split_sentences, ) @@ -102,3 +103,30 @@ def test_timing_json_shape_matches_whisper_contract(self) -> None: assert set(s.keys()) == {"start", "end", "text"} for w in timing["words"]: assert set(w.keys()) == {"start", "end", "word"} + + def test_pause_aware_weights_give_punctuated_tokens_more_span(self) -> None: + # "Hello," should claim a larger share than "Hi" of equal letter length. + timing = build_local_timing("Hi Hello,", 4.0, [(0.0, 4.0)]) + words = {w["word"]: w for w in timing["words"]} + hi_span = words["Hi"]["end"] - words["Hi"]["start"] + hello_span = words["Hello,"]["end"] - words["Hello,"]["start"] + assert hello_span > hi_span + + def test_reconcile_merges_extra_intervals_toward_sentence_count(self) -> None: + # Three intervals for two sentences → merge shortest gap. + ivs = [(0.0, 1.0), (1.1, 2.0), (3.0, 5.0)] + out = reconcile_intervals_to_sentences(ivs, 2) + assert len(out) == 2 + assert out[0] == (0.0, 2.0) + assert out[1] == (3.0, 5.0) + + def test_build_local_timing_uses_reconcile_for_near_miss_counts(self) -> None: + text = "First sentence here. Second sentence follows." + # Three speech intervals for two sentences — should still 1:1 after merge. + timing = build_local_timing( + text, 6.0, [(0.0, 2.0), (2.1, 3.5), (4.0, 6.0)] + ) + assert len(timing["segments"]) == 2 + assert timing["segments"][0]["start"] == pytest.approx(0.0) + assert timing["segments"][0]["end"] == pytest.approx(3.5) + assert timing["segments"][1]["start"] == pytest.approx(4.0) diff --git a/tests/test_scene_spec.py b/tests/test_scene_spec.py index 0551881..349f765 100644 --- a/tests/test_scene_spec.py +++ b/tests/test_scene_spec.py @@ -815,3 +815,83 @@ def test_sync_row_labels_overwrite_true_clears_unmatched_wait_word() -> None: words = [{"word": "hello", "start": 0.0, "end": 0.3}] out = sync_row_labels_to_whisper_words(spec, words, overwrite=True) assert out["rows"][0]["boxes"][0].get("wait_word") is None + + +def test_sync_row_labels_hyphenated_label_matches_spoken_parts() -> None: + """``yaml-generate`` aligns to spoken ``yaml`` + ``generate``.""" + spec = { + "segment_id": "1", + "class_name": "X", + "title": {"text": "T", "font_size": 36, "color": "C_WHITE"}, + "rows": [_row("yaml-generate")], + } + words = [ + {"word": "run", "start": 0.0, "end": 0.2}, + {"word": "yaml", "start": 1.0, "end": 1.3}, + {"word": "generate", "start": 1.4, "end": 1.9}, + ] + out = sync_row_labels_to_whisper_words(spec, words) + assert out["rows"][0]["boxes"][0]["wait_word"] == 1 + + +def test_compile_edges_emits_arrows_and_grow() -> None: + spec = { + "segment_id": "01", + "class_name": "FlowScene", + "timing_key": "01-flow", + "title": {"text": "Flow", "font_size": 36, "color": "C_WHITE"}, + "rows": [ + { + "run_time": 0.8, + "boxes": [ + { + "label": "Hints", + "color": "C_GREEN", + "width": 3.0, + "height": 0.8, + "font_size": 18, + "wait_word": 0, + }, + { + "label": "YAML", + "color": "C_BLUE", + "width": 3.0, + "height": 0.8, + "font_size": 18, + "wait_word": 2, + }, + ], + }, + ], + "edges": [{"from": "Hints", "to": "YAML", "color": "C_ACCENT"}], + } + out = compile_scene_class(spec) + assert "_ar_0_0 = _arrow(_bx_0_0_0.get_center(), _bx_0_0_1.get_center(), C_ACCENT)" in out + assert "GrowArrow(_ar_0_0)" in out + assert "FadeIn(_bx_0_0_1)" in out + + +def test_validate_edges_requires_known_labels() -> None: + with pytest.raises(SceneSpecError, match="not a box label"): + validate_scene_spec( + { + "segment_id": "1", + "class_name": "X", + "title": {"text": "T", "font_size": 36, "color": "C_WHITE"}, + "rows": [ + { + "run_time": 1.0, + "boxes": [ + { + "label": "A", + "color": "C_GREEN", + "width": 2.0, + "height": 1.0, + "font_size": 18, + } + ], + } + ], + "edges": [{"from": "A", "to": "Missing"}], + } + ) diff --git a/tests/test_wizard_focus.py b/tests/test_wizard_focus.py new file mode 100644 index 0000000..c4ac959 --- /dev/null +++ b/tests/test_wizard_focus.py @@ -0,0 +1,148 @@ +"""Tests for wizard focus-file scan + durable hint persistence.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + +from docgen.config import Config +from docgen.wizard import create_app, scan_repo_files +from docgen.yaml_generate import ( + ensure_segment_hint_with_focus, + find_hint_path_for_segment, + read_hint_focus_paths, + update_hint_focus_paths, +) + + +def test_scan_repo_files_includes_source_types(tmp_path: Path) -> None: + (tmp_path / "README.md").write_text("# Hello\n", encoding="utf-8") + (tmp_path / "src").mkdir() + (tmp_path / "src" / "main.py").write_text("print(1)\n", encoding="utf-8") + (tmp_path / "cfg.yaml").write_text("a: 1\n", encoding="utf-8") + (tmp_path / "skip.bin").write_bytes(b"\x00\x01") + + files = scan_repo_files(tmp_path) + paths = {f["path"] for f in files} + assert "README.md" in paths + assert "src/main.py" in paths + assert "cfg.yaml" in paths + assert "skip.bin" not in paths + + +def test_scan_repo_files_extensions_filter(tmp_path: Path) -> None: + (tmp_path / "a.md").write_text("m", encoding="utf-8") + (tmp_path / "b.py").write_text("p", encoding="utf-8") + files = scan_repo_files(tmp_path, extensions=(".md",)) + assert [f["path"] for f in files] == ["a.md"] + + +def _bundle_cfg(tmp_path: Path) -> Config: + hints = tmp_path / "hints" + hints.mkdir() + (hints / "segment-01-topic.md").write_text( + "---\n" + "docgen:\n" + " segment:\n" + " create: true\n" + " id: \"01\"\n" + " stem: 01-demo\n" + " wiring:\n" + " narration:\n" + " context:\n" + " paths:\n" + " - README.md\n" + "---\n\n# Topic\n", + encoding="utf-8", + ) + (tmp_path / "README.md").write_text("# Root\n", encoding="utf-8") + (tmp_path / "src").mkdir() + (tmp_path / "src" / "app.py").write_text("x = 1\n", encoding="utf-8") + raw = { + "repo_root": ".", + "dirs": { + "narration": "narration", + "audio": "audio", + "animations": "animations", + "recordings": "recordings", + "hints": "hints", + }, + "segments": {"all": ["01"], "default": ["01"]}, + "segment_names": {"01": "01-demo"}, + "visual_map": {"01": {"type": "manim", "scene": "DemoScene", "source": "DemoScene.mp4"}}, + "discovery": {"auto_visual_map": False, "merge_hint_segments": True}, + "narration_from_source": { + "segments": {"01": {"context": {"paths": ["README.md"]}}}, + }, + } + yml = tmp_path / "docgen.yaml" + yml.write_text(yaml.dump(raw), encoding="utf-8") + return Config.from_yaml(yml) + + +def test_update_hint_focus_paths_rewrites_front_matter(tmp_path: Path) -> None: + cfg = _bundle_cfg(tmp_path) + hint = find_hint_path_for_segment(cfg.hints_dir, "01") + assert hint is not None + written = update_hint_focus_paths(hint, ["README.md", "src/app.py"], also_manim=True) + assert written == ["README.md", "src/app.py"] + assert read_hint_focus_paths(cfg.hints_dir, "01") == ["README.md", "src/app.py"] + doc = yaml.safe_load(hint.read_text(encoding="utf-8").split("---", 2)[1]) + assert doc["docgen"]["wiring"]["manim_scene"]["context"]["paths"] == [ + "README.md", + "src/app.py", + ] + assert "# Topic" in hint.read_text(encoding="utf-8") + + +def test_ensure_creates_hint_when_missing(tmp_path: Path) -> None: + hints = tmp_path / "hints" + hints.mkdir() + (tmp_path / "notes.md").write_text("n", encoding="utf-8") + path, written = ensure_segment_hint_with_focus( + hints, "02", stem="02-new", paths=["notes.md"] + ) + assert path.name == "segment-02-topic.md" + assert written == ["notes.md"] + assert read_hint_focus_paths(hints, "02") == ["notes.md"] + + +def test_wizard_focus_api_persists(tmp_path: Path) -> None: + cfg = _bundle_cfg(tmp_path) + app = create_app(cfg) + client = app.test_client() + + get_res = client.get("/api/segments/01/focus") + assert get_res.status_code == 200 + assert get_res.get_json()["paths"] == ["README.md"] + + put_res = client.put( + "/api/segments/01/focus", + json={"paths": ["README.md", "src/app.py"], "yaml_generate": True}, + ) + assert put_res.status_code == 200, put_res.get_json() + body = put_res.get_json() + assert body["ok"] is True + assert body["paths"] == ["README.md", "src/app.py"] + + seg_res = client.get("/api/segments") + segs = seg_res.get_json()["segments"] + assert segs[0]["focus_paths"] == ["README.md", "src/app.py"] + + # Merged into docgen.yaml narration context. + raw = yaml.safe_load((tmp_path / "docgen.yaml").read_text(encoding="utf-8")) + paths = raw["narration_from_source"]["segments"]["01"]["context"]["paths"] + assert "src/app.py" in paths + + +def test_wizard_scan_api_returns_extensions(tmp_path: Path) -> None: + cfg = _bundle_cfg(tmp_path) + app = create_app(cfg) + client = app.test_client() + res = client.get("/api/scan") + data = res.get_json() + assert res.status_code == 200 + paths = {f["path"] for f in data["files"]} + assert "src/app.py" in paths + assert ".py" in data["extensions"]