diff --git a/src/docgen/ocr.py b/src/docgen/ocr.py index db64a15..726f068 100644 --- a/src/docgen/ocr.py +++ b/src/docgen/ocr.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math import re from dataclasses import dataclass, field from pathlib import Path @@ -26,6 +27,19 @@ class OCRReport: 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 OCRScanner: def __init__(self, config: Config) -> None: self.config = config @@ -44,7 +58,7 @@ def scan_video(self, video_path: str | Path) -> OCRReport: report.frames.append(OCRFrame(0, "", issues=["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_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) duration = total_frames / fps if fps > 0 else 0 interval = self.ocr_cfg.get("sample_interval_sec", 2) diff --git a/tests/test_ocr.py b/tests/test_ocr.py new file mode 100644 index 0000000..85076f4 --- /dev/null +++ b/tests/test_ocr.py @@ -0,0 +1,62 @@ +"""OCR scanning 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.ocr import OCRScanner + + +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 + 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"): + OCRScanner(_cfg(tmp_path)).scan_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 = 30.0 + monkeypatch.setattr(cv2, "VideoCapture", lambda *_a, **_k: cap) + report = OCRScanner(_cfg(tmp_path)).scan_video(tmp_path / "clip.mp4") + assert cap.sampled is True + assert report.passed is True