pilbox / tests /test_ffmpret.py
John Ho
Add text_overlay param to ffmpret.extract_frames
00ea497
Raw
History Blame Contribute Delete
5.23 kB
"""Unit tests for the ffmpret video-extraction module.
The pure-Python logic (frame-name parsing, metadata parsing / duration fallback)
is tested with a mocked ``ffmpeg.probe``. The end-to-end extraction test needs the
``ffmpeg`` binary and is skipped when it is unavailable.
"""
import shutil
import subprocess
import pytest
import ffmpret
FFMPEG = shutil.which("ffmpeg")
def test_parse_frame_name_simple():
assert ffmpret.parse_frame_name("clip_12.jpg") == ("clip", 12)
def test_parse_frame_name_with_underscores():
# frame types containing underscores must round-trip (rsplit on last "_")
assert ffmpret.parse_frame_name("my_clip_12.jpg") == ("my_clip", 12)
def test_get_video_metadata_duration_falls_back_to_format(monkeypatch):
# the video stream carries no "duration" (as with many MKV/WebM files);
# it must fall back to the container/format duration rather than 0.
fake_probe = {
"streams": [
{
"codec_type": "video",
"width": 320,
"height": 240,
"r_frame_rate": "30/1",
"codec_name": "h264",
}
],
"format": {"duration": "12.5", "format_name": "matroska", "size": "1000"},
}
monkeypatch.setattr(ffmpret.ffmpeg, "probe", lambda path: fake_probe)
meta = ffmpret.get_video_metadata("dummy.mkv", bverbose=False)
assert meta["duration"] == 12.5
assert meta["fps"] == 30.0
assert meta["width"] == 320 and meta["height"] == 240
assert meta["audio_codec"] == "none"
def test_get_video_metadata_raises_without_video_stream(monkeypatch):
# a probe failure / no video stream should surface a legible error,
# not silently return None for callers to subscript.
monkeypatch.setattr(
ffmpret.ffmpeg, "probe", lambda path: {"streams": [], "format": {}}
)
with pytest.raises(ValueError):
ffmpret.get_video_metadata("dummy.mp4", bverbose=False)
def test_build_drawtext_text_legacy_template():
# with no overlay, the textfile content must render exactly what the old
# inline text='...' strings rendered (timestamp / frame-number template).
assert (
ffmpret._build_drawtext_text(True, True)
== "Timestamp:%{pts:hms} |Frame Number: %{frame_num}"
)
assert ffmpret._build_drawtext_text(True, False) == "Timestamp:%{pts:hms}"
def test_build_drawtext_text_overlay_is_prefix():
assert ffmpret._build_drawtext_text(False, False, "cam-1") == "cam-1"
assert (
ffmpret._build_drawtext_text(True, True, "cam-1")
== "cam-1 |Timestamp:%{pts:hms} |Frame Number: %{frame_num}"
)
# frame number stays gated on write_timestamp
assert ffmpret._build_drawtext_text(False, True, "cam-1") == "cam-1"
def test_drawtext_escape_special_chars():
# textfile content only goes through drawtext's text expansion: \ escapes
# the next char and a bare % starts a %{...} sequence; all else is literal.
assert ffmpret._drawtext_escape("50% off") == "50\\% off"
assert ffmpret._drawtext_escape("a\\b") == "a\\\\b"
assert ffmpret._drawtext_escape("it's: fine, ok") == "it's: fine, ok"
assert ffmpret._drawtext_escape("plain text") == "plain text"
def test_extract_frames_text_overlay(tiny_video):
# overlay with every awkward char; must not blow up the filter chain and
# must render (frames come back at source size, drawtext needs a font so
# this also exercises the textfile= path end-to-end).
frames = ffmpret.extract_frames(
tiny_video,
fps=5,
write_timestamp=False,
text_overlay="it's: 100% a\\b, ok",
)
assert 4 <= len(frames) <= 6
assert all(f.size == (64, 48) for f in frames)
@pytest.fixture
def tiny_video(tmp_path):
"""Generate a 1s, 10fps, 64x48 test clip via ffmpeg (skips if no binary)."""
if FFMPEG is None:
pytest.skip("ffmpeg binary not available")
path = tmp_path / "testsrc.mp4"
subprocess.run(
[
FFMPEG, "-y", "-f", "lavfi",
"-i", "testsrc=duration=1:size=64x48:rate=10",
"-pix_fmt", "yuv420p", str(path),
],
check=True,
capture_output=True,
)
return str(path)
def test_extract_frames_returns_expected_frames(tiny_video, tmp_path):
out_dir = tmp_path / "frames"
out_dir.mkdir()
# write_timestamp=False avoids the drawtext/font dependency; source short
# edge (48) < default max_short_edge so no scaling -> frames stay 64x48.
frames = ffmpret.extract_frames(
tiny_video,
fps=5,
write_timestamp=False,
output_dir=str(out_dir),
)
# 1 second sampled at 5 fps -> ~5 frames (allow rounding at the boundary)
assert 4 <= len(frames) <= 6
assert all(f.size == (64, 48) for f in frames)
# every returned frame was also written to disk
assert len(list(out_dir.glob("*.jpg"))) == len(frames)
def test_get_video_metadata_on_real_clip(tiny_video):
meta = ffmpret.get_video_metadata(tiny_video, bverbose=False)
assert meta["width"] == 64 and meta["height"] == 48
assert meta["duration"] == pytest.approx(1.0, abs=0.3)
assert meta["fps"] == pytest.approx(10.0, abs=0.1)