diff --git a/src/docgen/manim_layout.py b/src/docgen/manim_layout.py index 56f097e..d57e969 100644 --- a/src/docgen/manim_layout.py +++ b/src/docgen/manim_layout.py @@ -2,9 +2,10 @@ from __future__ import annotations +import math from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING +from typing import Any, TYPE_CHECKING if TYPE_CHECKING: from docgen.config import Config @@ -24,6 +25,19 @@ class LayoutReport: passed: bool = True +def _require_positive_fps(cap: Any, raw: float) -> float: + """A zero or missing frame rate must not be treated as 30 fps.""" + try: + fps = float(raw) + except (TypeError, ValueError) as exc: + cap.release() + raise ValueError("video frame rate is not a number") from exc + if not math.isfinite(fps) or fps <= 0: + cap.release() + raise ValueError("video frame rate must be a positive finite number") + return fps + + class LayoutValidator: def __init__(self, config: Config) -> None: self.config = config @@ -45,7 +59,7 @@ def validate_video(self, video_path: str | Path) -> LayoutReport: report.issues.append(LayoutIssue(0, "error", "Could not open video")) return report - fps = cap.get(cv2.CAP_PROP_FPS) or 30 + fps = _require_positive_fps(cap, cap.get(cv2.CAP_PROP_FPS)) total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) duration = total / fps if fps > 0 else 0 h_frame = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) diff --git a/tests/test_manim_layout.py b/tests/test_manim_layout.py new file mode 100644 index 0000000..e8df3a1 --- /dev/null +++ b/tests/test_manim_layout.py @@ -0,0 +1,64 @@ +"""Layout checks must not invent a frame rate when the video does not report one.""" + +from __future__ import annotations + +from pathlib import Path + +import cv2 +import pytest +import yaml + +from docgen.config import Config +from docgen.manim_layout import LayoutValidator + + +def _cfg(tmp_path: Path) -> Config: + raw = {"segments": {"all": ["01"], "default": ["01"]}} + path = tmp_path / "docgen.yaml" + path.write_text(yaml.dump(raw), encoding="utf-8") + return Config.from_yaml(path) + + +class _Cap: + def __init__(self, *_args: object, **_kwargs: object) -> None: + self.fps = 0.0 + self.frames = 0.0 + self.sampled = False + + def isOpened(self) -> bool: + return True + + def get(self, prop: int) -> float: + if prop == cv2.CAP_PROP_FRAME_COUNT: + return self.frames + if prop in (cv2.CAP_PROP_FRAME_HEIGHT, cv2.CAP_PROP_FRAME_WIDTH): + return 100.0 + return self.fps + + def set(self, *_args: object) -> None: + self.sampled = True + + def read(self) -> tuple[bool, None]: + self.sampled = True + return False, None + + def release(self) -> None: + return None + + +def test_zero_fps_is_refused(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + cap = _Cap() + monkeypatch.setattr(cv2, "VideoCapture", lambda *_a, **_k: cap) + with pytest.raises(ValueError, match="positive finite"): + LayoutValidator(_cfg(tmp_path)).validate_video(tmp_path / "clip.mp4") + assert cap.sampled is False + + +def test_positive_fps_still_samples(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + cap = _Cap() + cap.fps = 30.0 + cap.frames = 90.0 + monkeypatch.setattr(cv2, "VideoCapture", lambda *_a, **_k: cap) + report = LayoutValidator(_cfg(tmp_path)).validate_video(tmp_path / "clip.mp4") + assert cap.sampled is True + assert report.passed is True