5from pathlib import Path
6from subprocess import CalledProcessError
9from mutagen.mp3 import MP3
12from pytest import CaptureFixture
14from glancecast import (
17 convert_video_to_glanceable_mp3,
19 ensure_tool_installed,
20 extract_images_from_video,
26def tmp_dir(tmp_path: Path) -> Path:
28 Temporary directory; alias for the pytest `tmp_path` fixture.
33class TestConvertVideoToGlanceableMP3:
35 Tests for `convert_video_to_glanceable_mp3`.
38 def test_e2e(self, tmp_dir: Path) -> None:
40 End-to-end test for `convert_video_to_glanceable_mp3`.
42 mp3_path = convert_video_to_glanceable_mp3(
43 video_path="tests/fixtures/lego_age_picker.mp4",
49 assert mp3_path == tmp_dir / "lego_age_picker.mp3"
50 assert mp3_path.exists()
52 # Use a different MP3 library to load the metadata, and check
53 # there are four chapters which all have an APIC subframe
54 mp3 = eyed3.load(mp3_path)
55 assert mp3 is not None
57 assert len(mp3.tag.chapters) == 4
59 for c in mp3.tag.chapters:
60 assert c.sub_frames.keys() == {b"APIC"}
62 def test_does_not_overwrite(self, tmp_dir: Path) -> None:
64 If an MP3 path already exists at the output location, it picks
68 convert_video_to_glanceable_mp3(
69 video_path="tests/fixtures/lego_age_picker.mp4",
78 tmp_dir / "lego_age_picker.mp3",
79 tmp_dir / "lego_age_picker-1.mp3",
80 tmp_dir / "lego_age_picker-2.mp3",
81 tmp_dir / "lego_age_picker-3.mp3",
82 tmp_dir / "lego_age_picker-4.mp3",
84 assert all(p.exists() for p in mp3_paths)
87class TestEnsureToolInstalled:
89 Tests for `ensure_tool_installed`.
92 def test_installed_tool(self) -> None:
94 Ensuring that an extant tool is installed doesn't raise an error.
96 ensure_tool_installed("python")
98 def test_missing_tool(self) -> None:
100 Ensuring that a non-existent tool is installed raises an error.
104 match="required tool 'does_not_exist' is not installed or not in PATH",
106 ensure_tool_installed("does_not_exist")
114 def test_success_has_no_output(
115 self, capsys: CaptureFixture[str], tmp_dir: Path
118 If the FFmpeg command succeeds, nothing is printed to stdout or stderr.
122 "tests/fixtures/lego_age_picker.mp4",
123 str(tmp_dir / "lego_age_picker.mp3"),
126 captured = capsys.readouterr()
127 assert captured.out == ""
128 assert captured.err == ""
130 def test_failing_command_prints_output(self, capsys: CaptureFixture[str]) -> None:
132 If the FFmpeg command fails, the FFmpeg output is printed to stderr.
134 with pytest.raises(CalledProcessError):
135 ffmpeg("-i", "does_not_exist.mp4", "does_not_exist.mp3")
137 captured = capsys.readouterr()
138 assert captured.out == ""
139 assert captured.err != ""
142class TestConvertVideoToMP3:
144 Tests for `convert_video_to_mp3`.
147 def test_creates_mp3_file(self, tmp_dir: Path) -> None:
149 Convert a video file to an MP3, and check it's the expected length.
151 p = Path("tests/fixtures/lego_age_picker.mp4")
153 mp3_path = convert_video_to_mp3(p, tmp_dir)
154 assert mp3_path.exists()
155 assert mp3_path.suffix == ".mp3"
158 assert mp3.info is not None
159 assert mp3.info.length == 19.536
161 def test_missing_video_file(self, tmp_dir: Path) -> None:
163 Trying to convert a non-existent video file is an error.
165 p = Path("does_not_exist.mkv")
167 with pytest.raises(FileNotFoundError):
168 convert_video_to_mp3(p, tmp_dir)
170 def test_convert_non_video_file(self, tmp_dir: Path) -> None:
172 Trying to convert a non-video file is an error.
174 p = Path("README.md")
176 with pytest.raises(CalledProcessError):
177 convert_video_to_mp3(p, tmp_dir)
180class TestExtractImagesFromVideo:
182 Tests for `extract_images_from_video`.
185 def test_extract_images_from_video(self, tmp_dir: Path) -> None:
187 Extract images from a video file, and check we get the correct frames.
189 p = Path("tests/fixtures/lego_age_picker.mp4")
190 frames = extract_images_from_video(
191 p, tmp_dir, interval_sec=5, max_dimension=100
195 f.path = Path(f.path.name)
199 path=Path("thumbnail_0001.jpg"),
200 mime_type="image/jpeg",
205 path=Path("thumbnail_0002.jpg"),
206 mime_type="image/jpeg",
211 path=Path("thumbnail_0003.jpg"),
212 mime_type="image/jpeg",
217 path=Path("thumbnail_0004.jpg"),
218 mime_type="image/jpeg",
224 @pytest.mark.parametrize(
225 "name, suffix, mime_type",
227 ("lego_age_picker.mp4", ".jpg", "image/jpeg"),
228 ("animated_squares.mp4", ".png", "image/png"),
231 def test_chooses_png_or_jpeg(
232 self, tmp_dir: Path, name: str, suffix: str, mime_type: str
235 The sampler picks PNG or JPEG depending on which is smaller.
237 frames = extract_images_from_video(
238 Path("tests/fixtures") / name,
244 assert frames[0].path.suffix == suffix
245 assert frames[0].mime_type == mime_type
247 @pytest.mark.parametrize(
248 "interval_sec, frame_count",
249 # The video is ~19 seconds long
250 [(1, 19), (5, 4), (6, 3), (10, 2)],
252 def test_correct_frame_count(
253 self, tmp_dir: Path, interval_sec: int, frame_count: int
256 We get the correct number of frames based on interval_sec.
258 p = Path("tests/fixtures/lego_age_picker.mp4")
260 frames = extract_images_from_video(
263 interval_sec=interval_sec,
267 assert len(frames) == frame_count
269 @pytest.mark.parametrize(
270 "name, max_dimension, actual_size",
272 # lego_age_picker.mp4 is 1173×1080 pixels, and it has
273 # a storage aspect ratio which isn't 1
274 # See https://alexwlchan.net/2025/square-pixels/
275 ("lego_age_picker.mp4", 100, (100, 92)),
276 ("lego_age_picker.mp4", 1080, (1080, 995)),
277 ("lego_age_picker.mp4", 1173, (1172, 1080)),
278 ("lego_age_picker.mp4", 2000, (1172, 1080)),
279 # lego_age_picker_rotated.mov is 588×640 pixels
280 # For some reason this isn't expanding to the full size.
281 # TODO: Investigate further.
282 ("lego_age_picker_rotated.mov", 100, (92, 100)),
283 ("lego_age_picker_rotated.mov", 588, (540, 588)),
284 ("lego_age_picker_rotated.mov", 640, (540, 588)),
285 ("lego_age_picker_rotated.mov", 1000, (540, 588)),
288 def test_landscape_images_are_correct_size(
293 actual_size: tuple[int, int],
296 The image preserves its aspect ratio, and is clamped to `max_dimension`
297 or the original size, whichever is smaller.
299 frames = extract_images_from_video(
300 Path("tests/fixtures") / name,
303 max_dimension=max_dimension,
306 with Image.open(frames[0].path) as im:
307 assert im.size == actual_size
310class TestAddChaptersToMP3:
312 Tests for `add_chapters_to_mp3`.
315 @pytest.mark.parametrize(
318 ("noise.jpg", "image/jpeg"),
319 ("stripes.png", "image/png"),
322 def test_mime_type(self, tmp_dir: Path, name: str, mime_type: str) -> None:
324 The per-chapter cover art has the correct MIME type.
326 p = Path("tests/fixtures/lego_age_picker.mp4")
327 mp3_path = convert_video_to_mp3(p, tmp_dir)
330 path=Path("tests/fixtures") / name,
336 add_chapters_to_mp3(mp3_path, frames=[frame])
338 mp3 = eyed3.load(mp3_path)
339 assert mp3 is not None
341 assert len(mp3.tag.chapters) == 1
343 for c in mp3.tag.chapters:
344 assert c.sub_frames[b"APIC"][0].mime_type == mime_type