diff --git a/src/docgen/cli.py b/src/docgen/cli.py index 8d2ce48..5344531 100644 --- a/src/docgen/cli.py +++ b/src/docgen/cli.py @@ -191,6 +191,29 @@ def lint(ctx: click.Context, segment: str | None) -> None: raise SystemExit(1) +@main.command("scene-lint") +@click.pass_context +def scene_lint(ctx: click.Context) -> None: + """Lint Manim scene files for known pitfalls (weight=BOLD, positional color args).""" + from docgen.scene_lint import lint_scene_dir + + cfg = ctx.obj["config"] + results = lint_scene_dir(cfg) + + if not results: + click.echo("[scene-lint] No issues found") + return + + for r in results: + click.echo(f" {r.path}") + for issue in r.issues: + click.echo(f" {issue}") + + total = sum(len(r.issues) for r in results) + click.echo(f"\n[scene-lint] {total} issue(s) in {len(results)} file(s)") + raise SystemExit(1) + + @main.command() @click.option("--config-name", "concat_name", default=None, help="Concat config name.") @click.pass_context diff --git a/src/docgen/compose.py b/src/docgen/compose.py index d0c49d5..9f2d5c6 100644 --- a/src/docgen/compose.py +++ b/src/docgen/compose.py @@ -223,14 +223,63 @@ def _find_audio(self, seg_id: str) -> Path | None: def _manim_path(self, vmap: dict[str, Any]) -> Path: src = vmap.get("source", "") - return self.config.animations_dir / "media" / "videos" / "scenes" / "720p30" / src + base = self.config.animations_dir / "media" / "videos" / "scenes" + return self._resolve_quality_path(base, src) def _vhs_path(self, vmap: dict[str, Any]) -> Path: src = vmap.get("source", "") - return self.config.terminal_dir / "rendered" / src + rendered = self.config.terminal_dir / "rendered" / src + if self.config.compose_warn_stale: + self._check_stale_vhs(src, rendered) + return rendered + + def _check_stale_vhs(self, source_name: str, rendered: Path) -> None: + """Warn if a .tape file is newer than its rendered mp4.""" + if not rendered.exists(): + return + stem = Path(source_name).stem + terminal_dir = self.config.terminal_dir + for tape in terminal_dir.glob(f"*{stem}*.tape"): + if tape.stat().st_mtime > rendered.stat().st_mtime: + print( + f" WARNING: {tape.name} is newer than {rendered.name} — " + f"run 'docgen vhs' to re-render before composing." + ) + break + + def _resolve_quality_path(self, base: Path, source: str) -> Path: + """Find the rendered Manim file, trying the configured quality first, + then falling back to any available quality directory.""" + from docgen.manim_runner import ManimRunner + preferred = ManimRunner(self.config).quality_subdir() + + candidate = base / preferred / source + if candidate.exists(): + return candidate + + for subdir in ("1080p60", "1080p30", "1440p60", "720p30", "480p15", "2160p60"): + candidate = base / subdir / source + if candidate.exists(): + if subdir != preferred: + print( + f" NOTE: using {subdir}/{source} " + f"(configured quality {preferred} not found)" + ) + return candidate + + no_scenes = base.parent / preferred / source + if no_scenes.exists(): + return no_scenes + for subdir in ("1080p60", "1080p30", "1440p60", "720p30", "480p15", "2160p60"): + no_scenes = base.parent / subdir / source + if no_scenes.exists(): + return no_scenes + + return base / preferred / source def _resolve_source(self, source: str) -> Path: - manim_path = self.config.animations_dir / "media" / "videos" / "scenes" / "720p30" / source + base = self.config.animations_dir / "media" / "videos" / "scenes" + manim_path = self._resolve_quality_path(base, source) if manim_path.exists(): return manim_path vhs_path = self.config.terminal_dir / "rendered" / source @@ -254,13 +303,16 @@ def _probe_duration(path: Path) -> float | None: except (ValueError, subprocess.TimeoutExpired, FileNotFoundError): return None - @staticmethod - def _run_ffmpeg(cmd: list[str]) -> None: + def _run_ffmpeg(self, cmd: list[str]) -> None: + timeout = self.config.ffmpeg_timeout try: - subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=300) + subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=timeout) except FileNotFoundError: print(" ERROR: ffmpeg not found in PATH") except subprocess.CalledProcessError as exc: print(f" ERROR: ffmpeg failed: {exc.stderr[:300]}") except subprocess.TimeoutExpired: - print(" ERROR: ffmpeg timed out") + print( + f" ERROR: ffmpeg timed out after {timeout}s. " + f"Increase compose.ffmpeg_timeout in docgen.yaml for long scenes." + ) diff --git a/src/docgen/config.py b/src/docgen/config.py index 5ad77c3..85000d2 100644 --- a/src/docgen/config.py +++ b/src/docgen/config.py @@ -88,6 +88,22 @@ def manim_scenes(self) -> list[str]: def manim_quality(self) -> str: return self.raw.get("manim", {}).get("quality", "720p30") + @property + def manim_font(self) -> str: + return self.raw.get("manim", {}).get("font", "Liberation Sans") + + # -- Compose / ffmpeg ------------------------------------------------------ + + @property + def ffmpeg_timeout(self) -> int: + """Timeout in seconds for ffmpeg subprocess calls.""" + return int(self.raw.get("compose", {}).get("ffmpeg_timeout", 600)) + + @property + def compose_warn_stale(self) -> bool: + """Warn during compose if a VHS .tape file is newer than its rendered mp4.""" + return bool(self.raw.get("compose", {}).get("warn_stale_vhs", True)) + # -- Validation ------------------------------------------------------------ @property diff --git a/src/docgen/manim_runner.py b/src/docgen/manim_runner.py index 682e50d..6702385 100644 --- a/src/docgen/manim_runner.py +++ b/src/docgen/manim_runner.py @@ -2,12 +2,25 @@ from __future__ import annotations +import re +import shutil import subprocess +import sys +from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: from docgen.config import Config +_PRESET_FLAGS: dict[str, list[str]] = { + "480p15": ["-ql"], + "720p30": ["-qm"], + "1080p60": ["-qh"], + "2160p60": ["-qp"], +} + +_CUSTOM_RE = re.compile(r"^(\d+)p(\d+)$") + class ManimRunner: def __init__(self, config: Config) -> None: @@ -24,28 +37,99 @@ def render(self, scene: str | None = None) -> None: print(f"[manim] scenes.py not found at {scenes_file}") return - quality_flag = self._quality_flag() + manim_bin = self._find_manim() + if not manim_bin: + return + + quality_flags = self._quality_flags() for s in scenes: - self._render_one(scenes_file, s, quality_flag) + self._render_one(manim_bin, scenes_file, s, quality_flags) - def _render_one(self, scenes_file, scene_name: str, quality_flag: str) -> None: - print(f"[manim] Rendering {scene_name}") - cmd = ["manim", quality_flag, str(scenes_file), scene_name] + def _find_manim(self) -> str | None: + """Locate the manim binary, checking the active venv first.""" + venv = Path(sys.prefix) / "bin" / "manim" + if venv.is_file(): + return str(venv) + + found = shutil.which("manim") + if found: + return found + + print( + "[manim] manim not found in PATH. " + "Install with: pip install manim (in this venv) " + "or set PATH to include the directory containing manim." + ) + return None + + def _render_one( + self, manim_bin: str, scenes_file: Path, scene_name: str, quality_flags: list[str] + ) -> None: + quality_label = self.config.manim_quality + print(f"[manim] Rendering {scene_name} at {quality_label}") + cmd = [manim_bin, *quality_flags, str(scenes_file), scene_name] try: subprocess.run( cmd, check=True, cwd=str(self.config.animations_dir), - timeout=300, + timeout=self.config.ffmpeg_timeout, ) except FileNotFoundError: - print("[manim] manim not found in PATH — install with: pip install manim") + print( + "[manim] manim not found in PATH. " + "Install with: pip install manim" + ) except subprocess.CalledProcessError as exc: print(f"[manim] FAILED {scene_name}: exit code {exc.returncode}") except subprocess.TimeoutExpired: - print(f"[manim] TIMEOUT {scene_name}") + print(f"[manim] TIMEOUT {scene_name} (limit {self.config.ffmpeg_timeout}s)") - def _quality_flag(self) -> str: + def _quality_flags(self) -> list[str]: + """Return CLI flags for Manim based on the configured quality string. + + Recognised presets: 480p15, 720p30, 1080p60, 2160p60. + Arbitrary ``p`` strings (e.g. ``1080p30``) are parsed + into explicit ``--resolution`` and ``--frame_rate`` flags. + """ q = self.config.manim_quality - mapping = {"480p15": "-pql", "720p30": "-pqm", "1080p60": "-pqh"} - return mapping.get(q, "-pqm") + if q in _PRESET_FLAGS: + return list(_PRESET_FLAGS[q]) + + m = _CUSTOM_RE.match(q) + if m: + height = int(m.group(1)) + fps = int(m.group(2)) + width = _width_for_height(height) + print(f"[manim] Using custom quality {width}x{height} @ {fps}fps") + return ["--resolution", f"{width},{height}", "--frame_rate", str(fps)] + + valid = ", ".join(sorted(_PRESET_FLAGS.keys())) + print( + f"[manim] WARNING: quality '{q}' not recognised, " + f"falling back to 720p30. Valid presets: {valid} " + f"(or use p e.g. 1080p30)" + ) + return ["-qm"] + + def quality_subdir(self) -> str: + """Return the subdirectory name Manim uses for the configured quality.""" + q = self.config.manim_quality + preset_dirs = { + "480p15": "480p15", + "720p30": "720p30", + "1080p60": "1080p60", + "2160p60": "2160p60", + } + if q in preset_dirs: + return preset_dirs[q] + m = _CUSTOM_RE.match(q) + if m: + return q + return "720p30" + + +def _width_for_height(height: int) -> int: + """Derive 16:9 width from height, rounding to even.""" + w = int(height * 16 / 9) + return w + (w % 2) diff --git a/src/docgen/pipeline.py b/src/docgen/pipeline.py index e1bf6ce..6eba6e1 100644 --- a/src/docgen/pipeline.py +++ b/src/docgen/pipeline.py @@ -2,6 +2,7 @@ from __future__ import annotations +import shutil from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -41,8 +42,27 @@ def run( print(f" WARNING: {r.tape} had errors: {r.errors}") print("\n=== Stage: Compose ===") - from docgen.compose import Composer - Composer(self.config).compose_segments(self.config.segments_all) + from docgen.compose import Composer, ComposeError + composer = Composer(self.config) + try: + composer.compose_segments(self.config.segments_all) + except ComposeError: + if skip_manim: + raise + print( + "\n=== Stage: Manim (retry) ===\n" + "[pipeline] Compose hit FREEZE GUARD — this often happens on the " + "first run because Manim scenes need timing data from timestamps.\n" + "[pipeline] Clearing Manim cache and re-rendering…" + ) + media_dir = self.config.animations_dir / "media" + if media_dir.exists(): + shutil.rmtree(media_dir) + from docgen.manim_runner import ManimRunner as _MR + _MR(self.config).render() + + print("\n=== Stage: Compose (retry) ===") + composer.compose_segments(self.config.segments_all) print("\n=== Stage: Validate ===") from docgen.validate import Validator diff --git a/src/docgen/scene_lint.py b/src/docgen/scene_lint.py new file mode 100644 index 0000000..8a4862e --- /dev/null +++ b/src/docgen/scene_lint.py @@ -0,0 +1,75 @@ +"""Lint Manim scene files for known pitfalls: bold weight, positional color args.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from docgen.config import Config + + +@dataclass +class SceneLintResult: + path: str + passed: bool + issues: list[str] = field(default_factory=list) + + +_BOLD_PATTERN = re.compile(r"\bweight\s*=\s*BOLD\b") + +_TEXT_POSITIONAL_COLOR = re.compile( + r"""Text\(\s*(?:f?["'][^"']*["']|[A-Za-z_]\w*)\s*,\s*["']#[0-9a-fA-F]""", +) + +_TEXT_POSITIONAL_COLOR_VAR = re.compile( + r"""Text\(\s*(?:f?["'][^"']*["']|[A-Za-z_]\w*)\s*,\s*C_[A-Z_]+""", +) + + +def lint_scene_file(path: Path) -> SceneLintResult: + """Scan a single Manim scene file for known issues.""" + result = SceneLintResult(path=str(path), passed=True) + + if not path.exists(): + return result + + text = path.read_text(encoding="utf-8") + for lineno, line in enumerate(text.splitlines(), 1): + stripped = line.strip() + if stripped.startswith("#"): + continue + + if _BOLD_PATTERN.search(line): + result.issues.append( + f"Line {lineno}: weight=BOLD causes Pango font substitution — " + f"use font_size and color for emphasis instead" + ) + result.passed = False + + if _TEXT_POSITIONAL_COLOR.search(line) or _TEXT_POSITIONAL_COLOR_VAR.search(line): + result.issues.append( + f"Line {lineno}: color passed as positional arg to Text() — " + f"use keyword: Text(..., color=C_COLOR)" + ) + result.passed = False + + return result + + +def lint_scene_dir(config: Config) -> list[SceneLintResult]: + """Lint all .py files under the animations directory.""" + anim_dir = config.animations_dir + if not anim_dir.exists(): + return [] + + results: list[SceneLintResult] = [] + for py_file in sorted(anim_dir.glob("**/*.py")): + if py_file.name.startswith("_"): + continue + result = lint_scene_file(py_file) + if result.issues: + results.append(result) + return results diff --git a/src/docgen/validate.py b/src/docgen/validate.py index 21a8c9a..8e8e343 100644 --- a/src/docgen/validate.py +++ b/src/docgen/validate.py @@ -117,10 +117,15 @@ 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)) + + vtype = self.config.visual_map.get(seg_id, {}).get("type", "vhs") + if vtype == "manim": + report.checks.append(self._check_layout(rec)) else: report.checks.append(CheckResult("recording_exists", False, [f"No recording for {seg_id}"])) report.checks.append(self._check_narration_lint(seg_id)) + report.checks.append(self._check_scene_lint()) return report.to_dict() @@ -137,7 +142,10 @@ 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", "scene_lint", + } if c.get("name") in soft_checks: print(f"WARN [{r.get('segment')}] {c.get('name')}: {c.get('details')}") else: @@ -346,6 +354,49 @@ def _check_drift(self, path: Path, max_drift: float) -> CheckResult: except Exception as exc: return CheckResult("av_drift", False, [str(exc)]) + # ── Layout validation (Manim frames — pytesseract) ────────────── + + def _check_layout(self, path: Path) -> CheckResult: + """Run layout overlap/edge checks on a Manim-rendered video. + + Degrades gracefully if pytesseract or tesseract is not installed. + """ + try: + from docgen.manim_layout import LayoutValidator + import pytesseract # noqa: F401 + pytesseract.get_tesseract_version() + except Exception: + return CheckResult("layout", True, ["tesseract not available (skipped)"]) + + validator = LayoutValidator(self.config) + report = validator.validate_video(path) + + if not report.issues: + return CheckResult("layout", True, ["No layout issues detected"]) + + details = [ + f"{i.kind} at {i.timestamp_sec:.1f}s: {i.description}" + for i in report.issues[:10] + ] + return CheckResult("layout", report.passed, details) + + # ── Scene lint (weight=BOLD, positional color) ──────────────── + + def _check_scene_lint(self) -> CheckResult: + """Lint Manim scene files for known pitfalls.""" + from docgen.scene_lint import lint_scene_dir + + results = lint_scene_dir(self.config) + if not results: + return CheckResult("scene_lint", True, []) + + details: list[str] = [] + for r in results: + for issue in r.issues: + details.append(f"{r.path}: {issue}") + + return CheckResult("scene_lint", len(details) == 0, details[:10]) + # ── Helpers ──────────────────────────────────────────────────────── def _find_narration(self, seg_id: str) -> Path | None: diff --git a/src/docgen/vhs.py b/src/docgen/vhs.py index e1bed35..28d0937 100644 --- a/src/docgen/vhs.py +++ b/src/docgen/vhs.py @@ -4,6 +4,7 @@ import os import re +import shutil import subprocess import tempfile from dataclasses import dataclass, field @@ -13,6 +14,12 @@ if TYPE_CHECKING: from docgen.config import Config +_VHS_COMMON_PATHS = [ + Path.home() / "go" / "bin" / "vhs", + Path("/usr/local/bin/vhs"), + Path("/snap/bin/vhs"), +] + ERROR_PATTERNS = [ r"command not found", r"No such file or directory", @@ -53,11 +60,37 @@ def render(self, tape: str | None = None, strict: bool = False) -> list[VHSResul else: tapes = sorted(terminal_dir.glob("*.tape")) + vhs_bin = self._find_vhs() + if not vhs_bin: + return [ + VHSResult(tape=t.name, success=False, errors=["vhs not found"]) + for t in tapes + ] + results: list[VHSResult] = [] for t in tapes: - results.append(self._render_one(t, strict)) + results.append(self._render_one(t, strict, vhs_bin=vhs_bin)) return results + @staticmethod + def _find_vhs() -> str | None: + """Locate the vhs binary, checking PATH then common install locations.""" + found = shutil.which("vhs") + if found: + return found + + for candidate in _VHS_COMMON_PATHS: + if candidate.is_file() and os.access(candidate, os.X_OK): + return str(candidate) + + print( + "[vhs] vhs not found in PATH or common locations. " + "Install with: go install github.com/charmbracelet/vhs@latest " + "or see https://github.com/charmbracelet/vhs#installation\n" + "Checked: PATH, ~/go/bin, /usr/local/bin, /snap/bin" + ) + return None + @staticmethod def _clean_env() -> dict[str, str]: """Build a minimal environment that produces a clean VHS recording. @@ -102,22 +135,34 @@ def _clean_env() -> dict[str, str]: } return env - def _render_one(self, tape_path: Path, strict: bool) -> VHSResult: + def _render_one( + self, tape_path: Path, strict: bool, *, vhs_bin: str = "vhs" + ) -> VHSResult: print(f"[vhs] Rendering {tape_path.name}") env = self._clean_env() + timeout = self.config.ffmpeg_timeout try: proc = subprocess.run( - ["vhs", str(tape_path)], + [vhs_bin, str(tape_path)], capture_output=True, text=True, - timeout=300, + timeout=timeout, cwd=str(tape_path.parent), env=env, ) except FileNotFoundError: - return VHSResult(tape=tape_path.name, success=False, errors=["vhs not found in PATH"]) + return VHSResult( + tape=tape_path.name, success=False, + errors=[ + f"vhs not found at '{vhs_bin}'. " + "Install with: go install github.com/charmbracelet/vhs@latest" + ], + ) except subprocess.TimeoutExpired: - return VHSResult(tape=tape_path.name, success=False, errors=["VHS render timed out"]) + return VHSResult( + tape=tape_path.name, success=False, + errors=[f"VHS render timed out after {timeout}s"], + ) finally: fake_home = env.get("HOME", "") if fake_home and "vhs_home_" in fake_home: diff --git a/tests/test_compose_extras.py b/tests/test_compose_extras.py new file mode 100644 index 0000000..9b28b1a --- /dev/null +++ b/tests/test_compose_extras.py @@ -0,0 +1,104 @@ +"""Tests for new compose.py features: quality-aware paths, stale VHS warnings.""" + +import time + +import yaml + +from docgen.compose import Composer +from docgen.config import Config + + +def _make_config(tmp_path, quality="720p30", warn_stale=True): + cfg = { + "segments": {"default": ["01"], "all": ["01"]}, + "segment_names": {"01": "01-test"}, + "visual_map": {"01": {"type": "manim", "source": "Scene01.mp4"}}, + "manim": {"quality": quality}, + "compose": {"warn_stale_vhs": warn_stale}, + } + (tmp_path / "docgen.yaml").write_text(yaml.dump(cfg), encoding="utf-8") + for d in ("narration", "audio", "recordings", "terminal/rendered", "animations"): + (tmp_path / d).mkdir(parents=True, exist_ok=True) + return Config.from_yaml(tmp_path / "docgen.yaml") + + +class TestQualityAwarePath: + def test_finds_configured_quality(self, tmp_path): + config = _make_config(tmp_path, quality="1080p30") + base = tmp_path / "animations" / "media" / "videos" / "scenes" + (base / "1080p30").mkdir(parents=True) + (base / "1080p30" / "Scene01.mp4").write_text("fake") + + c = Composer(config) + path = c._manim_path({"source": "Scene01.mp4"}) + assert "1080p30" in str(path) + assert path.exists() + + def test_falls_back_to_other_quality(self, tmp_path): + config = _make_config(tmp_path, quality="1080p30") + base = tmp_path / "animations" / "media" / "videos" / "scenes" + (base / "720p30").mkdir(parents=True) + (base / "720p30" / "Scene01.mp4").write_text("fake") + + c = Composer(config) + path = c._manim_path({"source": "Scene01.mp4"}) + assert "720p30" in str(path) + assert path.exists() + + def test_checks_no_scenes_subdir(self, tmp_path): + config = _make_config(tmp_path, quality="1080p30") + base = tmp_path / "animations" / "media" / "videos" + (base / "1080p30").mkdir(parents=True) + (base / "1080p30" / "Scene01.mp4").write_text("fake") + + c = Composer(config) + path = c._manim_path({"source": "Scene01.mp4"}) + assert path.exists() + + +class TestStaleVHSCheck: + def test_warns_stale_tape(self, tmp_path, capsys): + config = _make_config(tmp_path, warn_stale=True) + rendered = tmp_path / "terminal" / "rendered" / "01-test.mp4" + rendered.write_text("fake video") + + time.sleep(0.05) + tape = tmp_path / "terminal" / "01-test.tape" + tape.write_text("Set Shell bash\nType echo hello\nEnter\n") + + c = Composer(config) + c._vhs_path({"source": "01-test.mp4"}) + + captured = capsys.readouterr() + assert "WARNING" in captured.out + assert "newer" in captured.out + + def test_no_warning_when_disabled(self, tmp_path, capsys): + config = _make_config(tmp_path, warn_stale=False) + rendered = tmp_path / "terminal" / "rendered" / "01-test.mp4" + rendered.write_text("fake video") + + time.sleep(0.05) + tape = tmp_path / "terminal" / "01-test.tape" + tape.write_text("Set Shell bash\nType echo hello\n") + + c = Composer(config) + c._vhs_path({"source": "01-test.mp4"}) + + captured = capsys.readouterr() + assert "WARNING" not in captured.out + + def test_no_warning_when_rendered_is_newer(self, tmp_path, capsys): + config = _make_config(tmp_path, warn_stale=True) + tape = tmp_path / "terminal" / "01-test.tape" + tape.write_text("Set Shell bash\nType echo hello\n") + + time.sleep(0.05) + rendered = tmp_path / "terminal" / "rendered" / "01-test.mp4" + rendered.write_text("fake video") + + c = Composer(config) + c._vhs_path({"source": "01-test.mp4"}) + + captured = capsys.readouterr() + assert "WARNING" not in captured.out diff --git a/tests/test_manim_runner.py b/tests/test_manim_runner.py new file mode 100644 index 0000000..fcb4f73 --- /dev/null +++ b/tests/test_manim_runner.py @@ -0,0 +1,87 @@ +"""Tests for docgen.manim_runner — quality flag parsing and binary discovery.""" + +import tempfile +from pathlib import Path + +import yaml + +from docgen.config import Config +from docgen.manim_runner import ManimRunner, _width_for_height + + +def _make_config(quality: str = "720p30") -> Config: + cfg_path = Path(tempfile.mktemp(suffix=".yaml")) + cfg_path.write_text(yaml.dump({"manim": {"quality": quality}}), encoding="utf-8") + try: + return Config.from_yaml(cfg_path) + finally: + cfg_path.unlink(missing_ok=True) + + +class TestQualityFlags: + def test_720p30_preset(self): + runner = ManimRunner(_make_config("720p30")) + assert runner._quality_flags() == ["-qm"] + + def test_1080p60_preset(self): + runner = ManimRunner(_make_config("1080p60")) + assert runner._quality_flags() == ["-qh"] + + def test_480p15_preset(self): + runner = ManimRunner(_make_config("480p15")) + assert runner._quality_flags() == ["-ql"] + + def test_2160p60_preset(self): + runner = ManimRunner(_make_config("2160p60")) + assert runner._quality_flags() == ["-qp"] + + def test_1080p30_custom(self): + runner = ManimRunner(_make_config("1080p30")) + flags = runner._quality_flags() + assert "--resolution" in flags + assert "1920,1080" in flags + assert "--frame_rate" in flags + assert "30" in flags + + def test_1440p60_custom(self): + runner = ManimRunner(_make_config("1440p60")) + flags = runner._quality_flags() + assert "--resolution" in flags + assert "2560,1440" in flags + assert "60" in flags + + def test_unknown_falls_back_with_warning(self, capsys): + runner = ManimRunner(_make_config("banana")) + flags = runner._quality_flags() + assert flags == ["-qm"] + captured = capsys.readouterr() + assert "WARNING" in captured.out + assert "banana" in captured.out + + +class TestQualitySubdir: + def test_preset_subdir(self): + runner = ManimRunner(_make_config("720p30")) + assert runner.quality_subdir() == "720p30" + + def test_custom_subdir(self): + runner = ManimRunner(_make_config("1080p30")) + assert runner.quality_subdir() == "1080p30" + + def test_unknown_subdir_defaults(self): + runner = ManimRunner(_make_config("banana")) + assert runner.quality_subdir() == "720p30" + + +class TestWidthForHeight: + def test_1080(self): + assert _width_for_height(1080) == 1920 + + def test_720(self): + assert _width_for_height(720) == 1280 + + def test_1440(self): + assert _width_for_height(1440) == 2560 + + def test_480(self): + assert _width_for_height(480) == 854 diff --git a/tests/test_scene_lint.py b/tests/test_scene_lint.py new file mode 100644 index 0000000..ce6f426 --- /dev/null +++ b/tests/test_scene_lint.py @@ -0,0 +1,84 @@ +"""Tests for docgen.scene_lint — detect weight=BOLD and positional color args.""" + +from docgen.scene_lint import lint_scene_file + + +def test_clean_scene(tmp_path): + scene = tmp_path / "scenes.py" + scene.write_text( + 'from manim import *\n' + 'class MyScene(Scene):\n' + ' def construct(self):\n' + ' t = Text("Hello", font_size=36, color=WHITE)\n' + ) + result = lint_scene_file(scene) + assert result.passed + assert result.issues == [] + + +def test_detects_weight_bold(tmp_path): + scene = tmp_path / "scenes.py" + scene.write_text( + 'from manim import *\n' + 'class MyScene(Scene):\n' + ' def construct(self):\n' + ' t = Text("Title", weight=BOLD, font_size=36)\n' + ) + result = lint_scene_file(scene) + assert not result.passed + assert any("weight=BOLD" in i for i in result.issues) + + +def test_detects_positional_hex_color(tmp_path): + scene = tmp_path / "scenes.py" + scene.write_text( + 'from manim import *\n' + 'class MyScene(Scene):\n' + ' def construct(self):\n' + ' t = Text("Hello", "#2979ff", font_size=14)\n' + ) + result = lint_scene_file(scene) + assert not result.passed + assert any("positional" in i for i in result.issues) + + +def test_detects_positional_color_constant(tmp_path): + scene = tmp_path / "scenes.py" + scene.write_text( + 'from manim import *\n' + 'class MyScene(Scene):\n' + ' def construct(self):\n' + ' t = Text("Hello", C_BLUE, font_size=14)\n' + ) + result = lint_scene_file(scene) + assert not result.passed + assert any("positional" in i for i in result.issues) + + +def test_ignores_comments(tmp_path): + scene = tmp_path / "scenes.py" + scene.write_text( + '# weight=BOLD is banned\n' + '# Text("Hello", C_BLUE)\n' + ) + result = lint_scene_file(scene) + assert result.passed + + +def test_missing_file(tmp_path): + result = lint_scene_file(tmp_path / "missing.py") + assert result.passed + + +def test_multiple_issues(tmp_path): + scene = tmp_path / "scenes.py" + scene.write_text( + 'from manim import *\n' + 'class MyScene(Scene):\n' + ' def construct(self):\n' + ' t = Text("Title", weight=BOLD)\n' + ' u = Text("Sub", "#ff0000")\n' + ) + result = lint_scene_file(scene) + assert not result.passed + assert len(result.issues) == 2