Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/docgen/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ def _compose_simple(self, seg_id: str, video_path: Path, *, strict: bool = True)
print(f" SKIP: missing {video_path}")
return False

if video_path.exists() and audio.exists():
if video_path.stat().st_mtime < audio.stat().st_mtime - 1:
print(
f" WARNING: visual ({video_path.name}) was last modified before audio "
f"({audio.name}). The visual may be stale. "
"Re-render the visual source after regenerating TTS."
)

out = self._output_path(seg_id)
out.parent.mkdir(parents=True, exist_ok=True)

Expand Down
21 changes: 20 additions & 1 deletion src/docgen/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,33 @@ def manim_scenes(self) -> list[str]:

@property
def manim_quality(self) -> str:
return self.raw.get("manim", {}).get("quality", "720p30")
return self.raw.get("manim", {}).get("quality", "1080p30")

@property
def manim_font(self) -> str:
"""Font family used for all Manim Text() calls (default: Liberation Sans)."""
return str(self.raw.get("manim", {}).get("font", "Liberation Sans"))

@property
def manim_min_font_size(self) -> int:
"""Minimum font size enforced in Manim scene lint (default: 14)."""
return int(self.raw.get("manim", {}).get("min_font_size", 14))

@property
def manim_path(self) -> str | None:
"""Optional absolute/relative path to the Manim executable."""
value = self.raw.get("manim", {}).get("manim_path")
return str(value) if value else None

@property
def manim_unsafe_unicode(self) -> list[str]:
"""Unicode characters that trigger Pango font fallback."""
default = ["\u2192", "\u2190", "\u2194", "\u203a", "\u2039",
"\u2260", "\u2264", "\u2265", "\u2014", "\u2013",
"\u2018", "\u2019", "\u201c", "\u201d", "\u2022",
"\u2026"]
return self.raw.get("manim", {}).get("unsafe_unicode", default)

@property
def vhs_config(self) -> dict[str, Any]:
defaults: dict[str, Any] = {
Expand Down
4 changes: 3 additions & 1 deletion src/docgen/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,9 @@ def _write_config(plan: InitPlan) -> str:
"segment_names": segment_names,
"visual_map": visual_map,
"manim": {
"quality": "720p30",
"quality": "1080p30",
"font": "Liberation Sans",
"min_font_size": 14,
"scenes": [f"Scene{s['id']}" for s in plan.segments],
"manim_path": "",
},
Expand Down
25 changes: 24 additions & 1 deletion src/docgen/manim_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,38 @@ def render(self, scene: str | None = None) -> None:
print(f"[manim] scenes.py not found at {scenes_file}")
return

self._check_font()

quality_args, quality_label = self._quality_args()
manim_bin = self._resolve_manim_binary()
if not manim_bin:
return

print(f"[manim] Rendering at {quality_label}")
font = self.config.manim_font
print(f"[manim] Rendering at {quality_label}, font={font}")
for s in scenes:
self._render_one(manim_bin, scenes_file, s, quality_args)

def _check_font(self) -> None:
"""Verify the configured font is installed on the system."""
font = self.config.manim_font
try:
result = subprocess.run(
["fc-list", font],
capture_output=True, text=True, timeout=10,
)
if not result.stdout.strip():
print(
f"[manim] WARNING: font '{font}' not found by fc-list. "
"Pango may substitute a different font. "
f"Install it (e.g. `apt install fonts-liberation`) or set "
f"`manim.font` in docgen.yaml to an available font."
)
else:
print(f"[manim] Font '{font}' verified via fc-list")
except (FileNotFoundError, subprocess.TimeoutExpired):
pass

def _render_one(
self,
manim_bin: str,
Expand Down
34 changes: 31 additions & 3 deletions src/docgen/tts.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,27 @@
from __future__ import annotations

import re
import subprocess
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from docgen.config import Config


def _probe_duration(path: Path) -> float | None:
"""Return the duration of an audio file in seconds, or None on failure."""
try:
out = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "csv=p=0", str(path)],
capture_output=True, text=True, timeout=30,
)
return float(out.stdout.strip())
except (ValueError, subprocess.TimeoutExpired, FileNotFoundError):
return None


def markdown_to_tts_plain(text: str) -> str:
"""Strip markdown formatting, metadata, and stage directions from narration text."""
lines: list[str] = []
Expand Down Expand Up @@ -52,7 +67,6 @@ def _generate_one(self, seg_id: str, dry_run: bool) -> None:
narration_dir = self.config.narration_dir
audio_dir = self.config.audio_dir

# Find narration file
candidates = list(narration_dir.glob(f"*{seg_id}*.md")) if narration_dir.exists() else []
if not candidates:
print(f"[tts] No narration file found for segment {seg_id}, skipping")
Expand All @@ -71,12 +85,12 @@ def _generate_one(self, seg_id: str, dry_run: bool) -> None:
import openai

audio_dir.mkdir(parents=True, exist_ok=True)
out_path = audio_dir / f"{seg_id}.mp3"

# Find the output name matching the narration filename stem
stem = src.stem
out_path = audio_dir / f"{stem}.mp3"

old_duration = _probe_duration(out_path) if out_path.exists() else None

print(f"[tts] Generating audio for {seg_id} ({len(plain)} chars) -> {out_path}")

client = openai.OpenAI()
Expand All @@ -88,3 +102,17 @@ def _generate_one(self, seg_id: str, dry_run: bool) -> None:
)
response.stream_to_file(str(out_path))
print(f"[tts] Wrote {out_path}")

new_duration = _probe_duration(out_path)
if old_duration is not None and new_duration is not None and old_duration > 0:
change_pct = ((new_duration - old_duration) / old_duration) * 100
print(
f"[tts] {seg_id}: {old_duration:.1f}s -> {new_duration:.1f}s "
f"({change_pct:+.1f}%)"
)
if abs(change_pct) > 5:
print(
f"[tts] WARNING: {seg_id} duration changed by {change_pct:+.1f}% — "
"scenes and timestamps need regeneration. "
"Run `docgen timestamps` and `docgen manim` to update."
)
69 changes: 66 additions & 3 deletions src/docgen/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,22 @@ def _is_bold_weight(node: ast.AST) -> bool:
return False


def _lint_manim_text_usage(path: Path) -> list[str]:
def _extract_font_size(node: ast.Call) -> int | None:
"""Return the font_size value from a Text() call, or None if absent/dynamic."""
for kw in node.keywords:
if kw.arg == "font_size" and isinstance(kw.value, ast.Constant):
val = kw.value.value
if isinstance(val, (int, float)):
return int(val)
return None


def _lint_manim_text_usage(
path: Path,
*,
min_font_size: int = 14,
unsafe_unicode: list[str] | None = None,
) -> list[str]:
try:
source = path.read_text(encoding="utf-8")
except OSError as exc:
Expand All @@ -147,6 +162,17 @@ def _lint_manim_text_usage(path: Path) -> list[str]:
return [f"{path}:{line} could not parse scenes.py ({exc.msg})"]

issues: list[str] = []

if unsafe_unicode:
for lineno, line_text in enumerate(source.splitlines(), start=1):
for ch in unsafe_unicode:
if ch in line_text:
issues.append(
f"{path}:{lineno} Unsafe unicode character U+{ord(ch):04X} "
f"({repr(ch)}) may trigger Pango font fallback; "
"use an ASCII equivalent."
)

for node in ast.walk(tree):
if not isinstance(node, ast.Call) or not _is_text_call(node):
continue
Expand All @@ -164,6 +190,13 @@ def _lint_manim_text_usage(path: Path) -> list[str]:
"prefer emphasis with color/size."
)

font_size = _extract_font_size(node)
if font_size is not None and font_size < min_font_size:
issues.append(
f"{path}:{node.lineno} Text() font_size={font_size} is below minimum {min_font_size}; "
"small text is unreadable in video."
)

return issues


Expand Down Expand Up @@ -197,6 +230,10 @@ def validate_segment(
report.checks.append(self._check_freeze_ratio(rec, samples))
report.checks.append(self._check_blank_frames(rec, samples))
report.checks.append(self._check_ocr(rec, samples))

vmap = self.config.visual_map.get(seg_id, {})
if vmap.get("type") == "manim":
report.checks.append(self._check_layout(rec))
else:
report.checks.append(CheckResult("recording_exists", False, [f"No recording for {seg_id}"]))

Expand All @@ -219,7 +256,7 @@ def run_pre_push(self) -> None:
if isinstance(r, dict):
for c in r.get("checks", []):
if not c.get("passed", True):
soft_checks = {"recording_exists", "ocr_scan", "freeze_ratio"}
soft_checks = {"recording_exists", "ocr_scan", "freeze_ratio", "layout"}
if c.get("name") in soft_checks:
print(f"WARN [{r.get('segment')}] {c.get('name')}: {c.get('details')}")
else:
Expand Down Expand Up @@ -391,7 +428,11 @@ def _check_manim_scene_lint(self) -> CheckResult:
self._manim_lint_cache = result
return result

issues = _lint_manim_text_usage(scenes)
issues = _lint_manim_text_usage(
scenes,
min_font_size=self.config.manim_min_font_size,
unsafe_unicode=self.config.manim_unsafe_unicode,
)
result = CheckResult(
"manim_scene_lint",
not issues,
Expand All @@ -400,6 +441,28 @@ def _check_manim_scene_lint(self) -> CheckResult:
self._manim_lint_cache = result
return result

def _check_layout(self, path: Path) -> CheckResult:
"""Run overlap/spacing/edge layout checks on a Manim video recording."""
try:
import pytesseract
pytesseract.get_tesseract_version()
except Exception:
return CheckResult("layout", True, ["tesseract not installed — layout check skipped"])

try:
from docgen.manim_layout import LayoutValidator
lv = LayoutValidator(self.config)
report = lv.validate_video(path)
if report.passed:
return CheckResult("layout", True, ["No layout issues detected"])
details = [
f"[{i.kind}] {i.description} at {i.timestamp_sec:.1f}s"
for i in report.issues[:10]
]
return CheckResult("layout", False, details)
except Exception as exc:
return CheckResult("layout", True, [f"Layout check error (skipped): {exc}"])

# ── ffprobe-based checks ──────────────────────────────────────────

def _check_streams(self, path: Path) -> CheckResult:
Expand Down
28 changes: 28 additions & 0 deletions tests/test_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,34 @@ def test_manim_source_uses_configured_quality_dir(tmp_path: Path) -> None:
assert resolved == target / "Scene01.mp4"


def test_stale_visual_warning_when_video_older_than_audio(tmp_path: Path, capsys, monkeypatch) -> None:
"""Compose should warn when visual file is older than audio file."""
cfg = {
"dirs": {"terminal": "terminal", "audio": "audio", "recordings": "recordings", "animations": "animations"},
"segments": {"default": ["01"], "all": ["01"]},
"segment_names": {"01": "01-demo"},
"visual_map": {"01": {"type": "vhs", "source": "01-demo.mp4"}},
}
c = _write_cfg(tmp_path, cfg)
audio = tmp_path / "audio" / "01-demo.mp3"
video = tmp_path / "terminal" / "rendered" / "01-demo.mp4"
video.parent.mkdir(parents=True, exist_ok=True)
audio.parent.mkdir(parents=True, exist_ok=True)
video.write_text("video", encoding="utf-8")
audio.write_text("audio", encoding="utf-8")
now = time.time()
os.utime(video, (now - 100, now - 100))
os.utime(audio, (now, now))

composer = Composer(c)
monkeypatch.setattr(composer, "_probe_duration", lambda _p: 10.0)
monkeypatch.setattr(composer, "_run_ffmpeg", lambda _cmd: None)
(tmp_path / "recordings").mkdir(parents=True, exist_ok=True)
composer._compose_simple("01", video, strict=False)
out = capsys.readouterr().out
assert "visual may be stale" in out


def test_stale_vhs_warning_printed(tmp_path: Path, capsys) -> None:
cfg = {
"dirs": {"terminal": "terminal", "audio": "audio", "recordings": "recordings", "animations": "animations"},
Expand Down
24 changes: 23 additions & 1 deletion tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ def test_defaults():
try:
c = Config.from_yaml(cfg_path)
assert c.tts_voice == "coral"
assert c.manim_quality == "720p30"
assert c.manim_quality == "1080p30"
assert c.manim_font == "Liberation Sans"
assert c.manim_min_font_size == 14
assert isinstance(c.manim_unsafe_unicode, list)
assert "\u2192" in c.manim_unsafe_unicode
assert c.max_drift_sec == 2.75
assert c.ocr_config["sample_interval_sec"] == 2
assert c.ffmpeg_timeout_sec == 300
Expand All @@ -80,6 +84,24 @@ def test_resolved_dirs(tmp_config):
assert c.audio_dir == tmp_config.parent / "audio"


def test_manim_font_and_quality_overrides(tmp_path):
cfg = {
"manim": {
"quality": "720p30",
"font": "DejaVu Sans",
"min_font_size": 16,
"unsafe_unicode": ["\u2192"],
},
}
p = tmp_path / "docgen.yaml"
p.write_text(yaml.dump(cfg), encoding="utf-8")
c = Config.from_yaml(p)
assert c.manim_quality == "720p30"
assert c.manim_font == "DejaVu Sans"
assert c.manim_min_font_size == 16
assert c.manim_unsafe_unicode == ["\u2192"]


def test_binary_paths_and_compose_config(tmp_path):
cfg = {
"manim": {"manim_path": "/opt/bin/manim"},
Expand Down
3 changes: 3 additions & 0 deletions tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ def test_generate_files_minimal(tmp_path: Path) -> None:
assert cfg["segments"]["all"] == ["01", "02"]
assert cfg["segment_names"]["01"] == "01-intro"
assert cfg["vhs"]["render_timeout_sec"] == 120
assert cfg["manim"]["quality"] == "1080p30"
assert cfg["manim"]["font"] == "Liberation Sans"
assert cfg["manim"]["min_font_size"] == 14
assert "test-project" in cfg["tts"]["instructions"]

assert len(created) >= 7
Expand Down
18 changes: 16 additions & 2 deletions tests/test_tts.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Tests for docgen.tts markdown stripping."""
"""Tests for docgen.tts markdown stripping and duration change detection."""

from docgen.tts import markdown_to_tts_plain
from unittest.mock import patch

from docgen.tts import _probe_duration, markdown_to_tts_plain


def test_strip_headings():
Expand Down Expand Up @@ -45,3 +47,15 @@ def test_strip_horizontal_rules():
def test_passthrough_plain():
text = "This is a normal sentence about Tekton pipelines."
assert markdown_to_tts_plain(text) == text


def test_probe_duration_returns_none_for_missing_file(tmp_path):
result = _probe_duration(tmp_path / "nonexistent.mp3")
assert result is None


@patch("docgen.tts.subprocess.run")
def test_probe_duration_returns_float(mock_run):
mock_run.return_value = type("R", (), {"stdout": "12.345\n"})()
result = _probe_duration(__import__("pathlib").Path("/tmp/test.mp3"))
assert result == 12.345
Loading