diff --git a/AGENTS.md b/AGENTS.md index c6316e4..b6b19e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ The Playwright/VHS/demo-function/per-function/discover-tests/catalog surface are Commands registered on the **`docgen`** CLI include: - **`init`** — scaffold bundle layout and `docgen.yaml`. -- **`wizard`** — local web UI for narration/bootstrap workflows. +- **`wizard`** — local web UI for narration/bootstrap workflows (focus files, **in-place narration revise**, per-segment **asset freshness** + **rebuild-from-here**). - **`tts`** — text-to-speech for segment files. - **`timestamps`** — word/segment timing (`timing.json`). Default engine **`local`** aligns the known narration text against the mp3 offline (ffmpeg silencedetect, no API); **`--engine whisper`** keeps OpenAI whisper-1 transcription. Both emit the same Whisper-shaped blocks. - **`image-generate`** — render scene-spec **image elements** (`image:` + `prompt:` boxes) via the OpenAI Images API into the bundle (also runs for missing assets inside `generate-all`). diff --git a/README.md b/README.md index 6eaab4d..83a5bbb 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]` | 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 wizard [--port 8501]` | Local web GUI: focus files, **revise narration in place**, asset freshness chips, **rebuild-from-here** (default cascade: TTS → timestamps → scene-retime → Manim → compose → validate; LLM scene-spec is explicit) | | `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/src/docgen/asset_graph.py b/src/docgen/asset_graph.py new file mode 100644 index 0000000..3511672 --- /dev/null +++ b/src/docgen/asset_graph.py @@ -0,0 +1,340 @@ +"""Per-segment pipeline asset graph (freshness + rebuild-from-here). + +Used by the wizard to show which redo steps are fresh/stale/missing and to +cascade ``run`` from a chosen step through validate. Aligns with CLI +``generate-all`` defaults: after timestamps prefer offline ``scene-retime`` +(LLM ``scene-spec`` remains an explicit expensive step). +""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + from docgen.config import Config + +# Default cascade after timestamps (offline retime, not OpenAI scene-spec). +DEFAULT_CASCADE: tuple[str, ...] = ( + "tts", + "timestamps", + "scene-retime", + "manim", + "compose", + "validate", +) + +# LLM scene-spec replaces scene-retime when the maintainer opts into regen. +LLM_SCENE_CASCADE: tuple[str, ...] = ( + "tts", + "timestamps", + "scene-spec", + "manim", + "compose", + "validate", +) + +KNOWN_STEPS = frozenset( + { + "tts", + "timestamps", + "scene-retime", + "scene-spec", + "manim", + "compose", + "validate", + } +) + + +@dataclass(frozen=True) +class StepStatus: + step: str + status: str # fresh | stale | missing | n/a + detail: str + path: str | None = None + mtime: float | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def cascade_steps(start: str, *, llm_scene_spec: bool = False) -> list[str]: + """Return ordered steps from ``start`` inclusive through validate. + + ``start="scene-spec"`` always continues with the LLM cascade tail + (manim → compose → validate), even when the default chain uses retime. + """ + start = str(start).strip() + if start not in KNOWN_STEPS: + raise ValueError(f"unknown pipeline step: {start!r}") + + if start == "scene-spec": + order = list(LLM_SCENE_CASCADE) + elif llm_scene_spec: + order = list(LLM_SCENE_CASCADE) + else: + order = list(DEFAULT_CASCADE) + + if start not in order: + # e.g. start=scene-retime while llm_scene_spec=True — use default chain. + order = list(DEFAULT_CASCADE) + idx = order.index(start) + return order[idx:] + + +def _mtime(path: Path | None) -> float | None: + if path is None or not path.is_file(): + return None + try: + return path.stat().st_mtime + except OSError: + return None + + +def _rel(cfg: "Config", path: Path | None) -> str | None: + if path is None: + return None + try: + return str(path.relative_to(cfg.base_dir)) + except ValueError: + return str(path) + + +def _find_asset(directory: Path, seg_name: str, seg_id: str, ext: str) -> Path | None: + if not directory.exists(): + return None + exact = directory / f"{seg_name}{ext}" + if exact.exists(): + return exact + exact_id = directory / f"{seg_id}{ext}" + if exact_id.exists(): + return exact_id + for f in directory.glob(f"{seg_id}-*{ext}"): + return f + for f in directory.glob(f"{seg_id}*{ext}"): + return f + return None + + +def _timing_entry_exists(cfg: "Config", seg_name: str, audio: Path | None) -> bool: + timing_path = cfg.animations_dir / "timing.json" + if not timing_path.is_file(): + return False + try: + data = json.loads(timing_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + if not isinstance(data, dict): + return False + if seg_name in data: + return True + if audio is not None and audio.stem in data: + return True + return False + + +def _scene_spec_path(cfg: "Config", seg_id: str, seg_name: str) -> Path | None: + from docgen.scene_retime import list_scene_spec_paths + + paths = list_scene_spec_paths(cfg, segment_id=seg_id) + if paths: + return paths[0] + # Fall back to conventional stem even if missing (for path reporting). + candidate = cfg.animations_dir / "specs" / f"{seg_name}.scene.yaml" + return candidate if candidate.is_file() else None + + +def _manim_visual_path(cfg: "Config", seg_id: str) -> Path | None: + """Best-effort Manim/composed visual input path for freshness.""" + vmap = cfg.visual_map.get(seg_id, {}) + if not isinstance(vmap, dict): + return None + src = str(vmap.get("source", "")).strip() + if src: + for candidate in (cfg.animations_dir / src, cfg.base_dir / src): + if candidate.is_file(): + return candidate + # Search common Manim media layout for the scene class name. + scene = str(vmap.get("scene") or vmap.get("class") or "").strip() + media = cfg.animations_dir / "media" / "videos" / "scenes" + if scene and media.is_dir(): + matches = sorted(media.rglob(f"{scene}.mp4")) + if matches: + return matches[0] + return None + + +def _status( + *, + step: str, + output: Path | None, + upstream_mtimes: list[float | None], + cfg: "Config", + missing_detail: str, + fresh_detail: str, +) -> StepStatus: + out_m = _mtime(output) + if out_m is None: + return StepStatus(step, "missing", missing_detail, path=_rel(cfg, output)) + ups = [m for m in upstream_mtimes if m is not None] + if ups and out_m < max(ups) - 1.0: + return StepStatus( + step, + "stale", + "output older than an upstream input", + path=_rel(cfg, output), + mtime=out_m, + ) + return StepStatus( + step, "fresh", fresh_detail, path=_rel(cfg, output), mtime=out_m + ) + + +def segment_step_statuses(cfg: "Config", seg_id: str) -> list[StepStatus]: + """Compute freshness for each wizard pipeline step for ``seg_id``.""" + sid = str(seg_id).strip() + if sid.isdigit(): + sid = sid.zfill(2) + seg_name = cfg.resolve_segment_name(sid) + + narration = _find_asset(cfg.narration_dir, seg_name, sid, ".md") + audio = _find_asset(cfg.audio_dir, seg_name, sid, ".mp3") + timing_path = cfg.animations_dir / "timing.json" + timing_ok = _timing_entry_exists(cfg, seg_name, audio) + spec = _scene_spec_path(cfg, sid, seg_name) + visual = _manim_visual_path(cfg, sid) + recording = _find_asset(cfg.recordings_dir, seg_name, sid, ".mp4") + + narr_m = _mtime(narration) + audio_m = _mtime(audio) + timing_m = _mtime(timing_path) if timing_ok else None + spec_m = _mtime(spec) + visual_m = _mtime(visual) + rec_m = _mtime(recording) + + out: list[StepStatus] = [] + + # TTS + if narration is None: + out.append(StepStatus("tts", "missing", "no narration.md yet")) + else: + out.append( + _status( + step="tts", + output=audio, + upstream_mtimes=[narr_m], + cfg=cfg, + missing_detail="no audio mp3 — run TTS", + fresh_detail="audio newer than (or equal to) narration", + ) + ) + + # Timestamps + if audio is None: + out.append(StepStatus("timestamps", "missing", "no audio — run TTS first")) + elif not timing_ok: + out.append( + StepStatus( + "timestamps", + "missing", + "no timing.json entry — run timestamps", + path=_rel(cfg, timing_path), + ) + ) + else: + out.append( + _status( + step="timestamps", + output=timing_path, + upstream_mtimes=[audio_m], + cfg=cfg, + missing_detail="no timing.json", + fresh_detail="timing.json newer than (or equal to) audio", + ) + ) + + # Scene retime / scene-spec share the same output artifact (*.scene.yaml + scenes.py). + # Freshness is relative to narration + timing. + for step_name in ("scene-retime", "scene-spec"): + if not timing_ok and audio is None: + out.append( + StepStatus(step_name, "missing", "need audio + timestamps first") + ) + elif spec is None: + out.append( + StepStatus( + step_name, + "missing", + "no animations/specs/*.scene.yaml", + ) + ) + else: + out.append( + _status( + step=step_name, + output=spec, + upstream_mtimes=[narr_m, timing_m], + cfg=cfg, + missing_detail="no scene spec", + fresh_detail="scene spec newer than narration/timing", + ) + ) + + # Manim + vt = str(cfg.visual_map.get(sid, {}).get("type", "")).strip().lower() + if vt and vt != "manim": + out.append(StepStatus("manim", "n/a", f"visual type {vt!r} (not manim)")) + else: + out.append( + _status( + step="manim", + output=visual, + upstream_mtimes=[spec_m, timing_m], + cfg=cfg, + missing_detail="no Manim visual mp4 — run manim", + fresh_detail="visual newer than scene spec/timing", + ) + ) + + # Compose + out.append( + _status( + step="compose", + output=recording, + upstream_mtimes=[audio_m, visual_m], + cfg=cfg, + missing_detail="no recording — run compose", + fresh_detail="recording newer than audio/visual", + ) + ) + + # Validate is advisory — always runnable + out.append( + StepStatus( + "validate", + "n/a", + "run to check drift / timing_sync / story_end / av_sync", + path=_rel(cfg, recording), + mtime=rec_m, + ) + ) + return out + + +def segment_asset_report(cfg: "Config", seg_id: str) -> dict[str, Any]: + """JSON-serializable asset graph summary for the wizard.""" + statuses = segment_step_statuses(cfg, seg_id) + stale_or_missing = [ + s.step for s in statuses if s.status in ("stale", "missing") + ] + return { + "segment_id": str(seg_id).strip().zfill(2) + if str(seg_id).strip().isdigit() + else str(seg_id).strip(), + "steps": [s.to_dict() for s in statuses], + "default_cascade": list(DEFAULT_CASCADE), + "stale_or_missing": stale_or_missing, + } diff --git a/src/docgen/manim_scene_support.py b/src/docgen/manim_scene_support.py index 7922d54..ecad7c7 100644 --- a/src/docgen/manim_scene_support.py +++ b/src/docgen/manim_scene_support.py @@ -183,10 +183,14 @@ def _box(label, color, w=2.2, h=0.75, fs=18): return VGroup(r, t) -def _arrow(start, end, color="#cdd6f4"): - """Connector between box centers (used by scene-spec ``edges``).""" +def _arrow(start, end, color="#cdd6f4", style="solid"): + """Connector between box centers (used by scene-spec ``edges``). + + ``style`` is ``solid`` (default) or ``dashed``. Dashed edges should be + revealed with ``FadeIn`` (not ``GrowArrow``). + """ # Allow palette token names that compile_scene_class emits as bare identifiers. - return Arrow( + arr = Arrow( start, end, color=color, @@ -194,6 +198,9 @@ def _arrow(start, end, color="#cdd6f4"): buff=0.2, max_tip_length_to_length_ratio=0.15, ) + if str(style).strip().lower() == "dashed": + return DashedVMobject(arr, num_dashes=18, dashed_ratio=0.55) + return arr def _image(relpath, w=3.0, h=2.0): diff --git a/src/docgen/scene_spec.py b/src/docgen/scene_spec.py index 837d1dd..e0ba262 100644 --- a/src/docgen/scene_spec.py +++ b/src/docgen/scene_spec.py @@ -55,6 +55,7 @@ ) ALLOWED_PAGE_TRANSITIONS = frozenset({"fade", "none"}) +ALLOWED_EDGE_STYLES = frozenset({"solid", "dashed"}) SPEC_REQUIRED_TOP = ("segment_id", "class_name", "title") @@ -1164,6 +1165,17 @@ def _validate_edges( raise SceneSpecError( f"{ep}: color must be one of {sorted(ALLOWED_COLORS)} if set" ) + style = edge.get("style") + if style is not None and str(style).strip().lower() not in ALLOWED_EDGE_STYLES: + raise SceneSpecError( + f"{ep}: style must be one of {sorted(ALLOWED_EDGE_STYLES)} if set" + ) + label = edge.get("label") + if label is not None: + if not isinstance(label, str): + raise SceneSpecError(f"{ep}: label must be a string if set") + if len(label.strip()) > 40: + raise SceneSpecError(f"{ep}: label must be at most 40 characters") def validate_scene_spec(data: dict[str, Any], *, path_label: str = "spec") -> None: @@ -1348,8 +1360,8 @@ 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]] = {} + # Map (page, later-box-var) → list of (edge_var, anim) where anim is grow|fade. + edges_with_target: dict[tuple[int, str], list[tuple[str, str]]] = {} page_edge_vars: dict[int, list[str]] = {} for p, page in enumerate(pages): @@ -1416,14 +1428,29 @@ def compile_scene_class(spec: dict[str, Any]) -> str: continue evar = f"_ar_{p}_{ei}" ecol = str(edge.get("color") or "C_ACCENT") + estyle = str(edge.get("style") or "solid").strip().lower() or "solid" + elabel = str(edge.get("label") or "").strip() lines.append( - f" {evar} = _arrow({src_var}.get_center(), {dst_var}.get_center(), {ecol})" + f" {evar} = _arrow({src_var}.get_center(), {dst_var}.get_center(), " + f"{ecol}, style={estyle!r})" ) 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) + # GrowArrow only works on solid Arrow; dashed / labeled edges FadeIn. + anim = "grow" if estyle == "solid" and not elabel else "fade" + edges_with_target.setdefault((p, later), []).append((evar, anim)) + if elabel: + lvar = f"{evar}_lbl" + lines.append( + f" {lvar} = Text({elabel!r}, font_size=16, color={ecol})" + ) + lines.append( + f" {lvar}.move_to({evar}.get_center()).shift(UP * 0.22)" + ) + page_edge_vars.setdefault(p, []).append(lvar) + edges_with_target.setdefault((p, later), []).append((lvar, "fade")) lines.append("") @@ -1459,11 +1486,15 @@ def compile_scene_class(spec: dict[str, Any]) -> str: lines.append(f" self.remove({t})") lines.append(" self.timed_wait(0.05)") 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] - ) + edge_anims = edges_with_target.get((p, bx), []) + if edge_anims: + parts = [f"FadeIn({bx})"] + for ev, kind in edge_anims: + if kind == "grow": + parts.append(f"GrowArrow({ev})") + else: + parts.append(f"FadeIn({ev})") + anims = ", ".join(parts) lines.append( f" self.timed_play({anims}, run_time={run_time})" ) diff --git a/src/docgen/scene_spec_generate.py b/src/docgen/scene_spec_generate.py index 3e51018..7970a45 100644 --- a/src/docgen/scene_spec_generate.py +++ b/src/docgen/scene_spec_generate.py @@ -105,8 +105,9 @@ - edges: optional list of connectors for **single-page** ``rows`` specs (see below). 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. +- edges: list of {{ from: , to: , color?: , + style?: solid|dashed, label?: short edge caption ≤40 chars }} + drawn as arrows between those boxes after layout. Box labels must be unique on that page. Prefer edges for pipeline / flow diagrams (A → B → C); omit when boxes are unrelated topics. Use either **rows** (single page) OR **pages** (list of {{ rows: [...], transition?: fade|none, edges?: [...] }} — transition on pages after the first overrides layout.page_transition for exiting the previous page; first page has no transition in). @@ -119,6 +120,8 @@ - **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. + Use ``style: dashed`` for optional/secondary paths and a short ``label`` on the arrow when + the narration names the relationship (keep edge captions terse). - **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 7bdbe27..98c8da4 100644 --- a/src/docgen/static/wizard.css +++ b/src/docgen/static/wizard.css @@ -47,6 +47,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,san .btn-primary:disabled{background:#a0b0e0;cursor:not-allowed} .btn-secondary{background:#e8ecf4;color:#333}.btn-secondary:hover{background:#d0d8ea} .btn-warning{background:#f59e0b;color:#fff}.btn-warning:hover{background:#d97706} +.video-actions .step-stale{box-shadow:inset 0 0 0 1px #f59e0b} +.video-actions .step-missing{box-shadow:inset 0 0 0 1px #ef4444} +.video-actions .step-fresh{opacity:.92} .action-bar{display:flex;align-items:center;gap:1rem;margin-top:1rem} .status-text{font-size:.82rem;color:#666} @@ -104,6 +107,16 @@ textarea:focus,input[type=text]:focus{outline:none;border-color:#4361ee;box-shad .validation-card h3{font-size:.88rem;margin-bottom:.4rem} .validation-card pre{font-size:.78rem;white-space:pre-wrap;max-height:200px;overflow-y:auto;background:#f8f9fc;padding:.5rem;border-radius:4px} .video-actions{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.75rem} +.asset-graph{margin-top:.75rem;padding:.75rem;border:1px solid #e8ecf4;border-radius:8px;background:#fafbff} +.asset-step-list{list-style:none;display:flex;flex-wrap:wrap;gap:.4rem;margin:.4rem 0 0} +.asset-chip{font-size:.72rem;padding:.2rem .55rem;border-radius:999px;font-weight:600;letter-spacing:.02em;border:1px solid transparent} +.asset-chip.fresh{background:#d1fae5;color:#065f46;border-color:#a7f3d0} +.asset-chip.stale{background:#fef3c7;color:#92400e;border-color:#fde68a} +.asset-chip.missing{background:#fee2e2;color:#991b1b;border-color:#fecaca} +.asset-chip.na{background:#e8ecf4;color:#666;border-color:#d0d8ea} +.rebuild-row{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;margin-top:.75rem} +.rebuild-row label{font-size:.82rem;color:#666} +.rebuild-row select{border:1px solid #ddd;border-radius:6px;padding:.35rem .5rem;font-size:.85rem;background:#fff} /* Review action bar */ .review-action-bar{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem;margin-top:1.5rem;padding-top:1rem;border-top:1px solid #e8ecf4} diff --git a/src/docgen/static/wizard.js b/src/docgen/static/wizard.js index 41cc70b..8d48ae7 100644 --- a/src/docgen/static/wizard.js +++ b/src/docgen/static/wizard.js @@ -485,6 +485,45 @@ } document.getElementById("validation-results").innerHTML = '

Run validate to see results.

'; + renderAssetGraph(seg?.assets); + } + + function renderAssetGraph(assets) { + const list = document.getElementById("asset-step-list"); + if (!list) return; + list.innerHTML = ""; + const steps = assets?.steps || []; + if (!steps.length) { + const li = document.createElement("li"); + li.className = "hint"; + li.textContent = "No asset status yet."; + list.appendChild(li); + return; + } + // Prefer one chip per logical stage (skip duplicate scene-spec when showing retime). + const prefer = new Set(["tts", "timestamps", "scene-retime", "manim", "compose", "validate"]); + for (const s of steps) { + if (!prefer.has(s.step) && s.step !== "scene-spec") continue; + if (s.step === "scene-spec") continue; // shown via retime chip; LLM is explicit button + const li = document.createElement("li"); + const st = s.status === "n/a" ? "na" : s.status; + li.className = "asset-chip " + st; + li.title = s.detail || ""; + li.textContent = s.step + ": " + s.status; + list.appendChild(li); + } + // Highlight redo buttons by data-step + const byStep = Object.fromEntries(steps.map((s) => [s.step, s])); + document.querySelectorAll(".video-actions [data-step]").forEach((btn) => { + const step = btn.getAttribute("data-step"); + const info = byStep[step]; + btn.classList.remove("step-stale", "step-missing", "step-fresh"); + if (!info) return; + if (info.status === "stale") btn.classList.add("step-stale"); + else if (info.status === "missing") btn.classList.add("step-missing"); + else if (info.status === "fresh") btn.classList.add("step-fresh"); + btn.title = (btn.title ? btn.title + " — " : "") + (info.detail || info.status); + }); } document.getElementById("btn-add-focus-path")?.addEventListener("click", () => { @@ -533,33 +572,54 @@ }); }); - document.getElementById("btn-regen-narration").addEventListener("click", async () => { + async function generateOrReviseNarration(mode) { if (!activeSegmentId) return; const notes = document.getElementById("revision-notes").value; const guidance = document.getElementById("guidance")?.value || ""; + const current = document.getElementById("narration-editor").value || ""; const seg = prodSegments.find((s) => s.id === activeSegmentId); - const btn = document.getElementById("btn-regen-narration"); - btn.textContent = "Regenerating..."; - btn.disabled = true; + const regenBtn = document.getElementById("btn-regen-narration"); + const reviseBtn = document.getElementById("btn-revise-narration"); + const activeBtn = mode === "revise" ? reviseBtn : regenBtn; + const idleLabel = mode === "revise" ? "Revise narration" : "Regenerate"; + if (mode === "revise" && !notes.trim()) { + alert("Revision notes are required to revise in place."); + return; + } + if (mode === "revise" && !current.trim()) { + alert("Editor is empty — use Regenerate for a full draft, or paste a script first."); + return; + } + activeBtn.textContent = mode === "revise" ? "Revising..." : "Regenerating..."; + regenBtn.disabled = true; + reviseBtn.disabled = true; try { + const body = { + source_paths: prodFocusPaths.length ? prodFocusPaths : (seg?.focus_paths || []), + guidance, + segment_name: seg?.name || activeSegmentId, + segment_id: activeSegmentId, + revision_notes: notes, + mode, + }; + if (mode === "revise") body.current_narration = current; const res = await fetch("/api/generate-narration", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - source_paths: prodFocusPaths.length ? prodFocusPaths : (seg?.focus_paths || []), - guidance, - segment_name: seg?.name || activeSegmentId, - segment_id: activeSegmentId, - revision_notes: notes, - }), + body: JSON.stringify(body), }); 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"; - btn.disabled = false; - }); + regenBtn.textContent = "Regenerate"; + reviseBtn.textContent = "Revise narration"; + regenBtn.disabled = false; + reviseBtn.disabled = false; + } + + document.getElementById("btn-regen-narration").addEventListener("click", () => generateOrReviseNarration("generate")); + document.getElementById("btn-revise-narration")?.addEventListener("click", () => generateOrReviseNarration("revise")); // ---- Pipeline step buttons ---- async function runStep(step) { @@ -571,8 +631,39 @@ return data; } - document.getElementById("btn-redo-tts").addEventListener("click", () => runStep("tts")); + async function runFrom(step) { + if (!activeSegmentId) return null; + const status = document.getElementById("rebuild-status"); + if (status) status.textContent = "Running from " + step + "…"; + const llm = step === "scene-spec"; + const res = await fetch( + "/api/run-from/" + encodeURIComponent(step) + "/" + encodeURIComponent(activeSegmentId), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ llm_scene_spec: llm }), + } + ); + const data = await res.json(); + if (!res.ok || data.error) { + if (status) status.textContent = "Failed at " + (data.failed_step || step); + alert((data.failed_step || step) + " error: " + (data.error || res.status)); + } else { + if (status) status.textContent = "Done (" + (data.ran || []).length + " steps)"; + const validateRun = (data.ran || []).find((r) => r.step === "validate" && r.report); + if (validateRun?.report) { + document.getElementById("validation-results").innerHTML = + "
" + escHtml(JSON.stringify(validateRun.report, null, 2)) + "
"; + } + await loadProductionView(); + } + return data; + } + + document.getElementById("btn-redo-tts")?.addEventListener("click", () => runStep("tts")); + document.getElementById("btn-redo-tts-video")?.addEventListener("click", () => runStep("tts")); document.getElementById("btn-redo-timestamps")?.addEventListener("click", () => runStep("timestamps")); + document.getElementById("btn-redo-scene-retime")?.addEventListener("click", () => runStep("scene-retime")); 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")); @@ -584,11 +675,15 @@ } }); + document.getElementById("btn-rebuild-from")?.addEventListener("click", async () => { + const sel = document.getElementById("rebuild-from-select"); + const step = sel?.value || "scene-retime"; + await runFrom(step); + }); + document.getElementById("btn-redo-all").addEventListener("click", async () => { if (!activeSegmentId) return; - for (const step of ["tts", "timestamps", "scene-spec", "manim", "compose", "validate"]) { - await runStep(step); - } + await runFrom("tts"); }); // ---- Status buttons ---- diff --git a/src/docgen/templates/wizard.html b/src/docgen/templates/wizard.html index c0f9bfc..887dec0 100644 --- a/src/docgen/templates/wizard.html +++ b/src/docgen/templates/wizard.html @@ -94,8 +94,9 @@

- - + + +
@@ -140,17 +141,37 @@

Validation results

+
+

Pipeline freshness (mtime vs upstream). Stale/missing steps need a rebuild.

+
    +
    - - - - - + + + + + + + +
    +
    + + + +
    - +