Skip to main content

all: add the initial implementation of glancecast

ID
acc46f8
date
2026-09-13 08:46:23+00:00
author
Alex Chan <alex@alexwlchan.net>
parent
bdb4a5d
message
all: add the initial implementation of glancecast
changed files
11 files, 576 additions, 20 deletions

Changed files

.gitignore (10 → 16)

diff --git a/.gitignore b/.gitignore
index 6350e98..32bc78e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
 .coverage
+*.mp3

CHANGELOG.md (0 → 50)

diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..ed4f9b7
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# CHANGELOG
+
+## v1 - 2026-09-13
+
+Initial version.

README.md (275 → 1412)

diff --git a/README.md b/README.md
index 8910b29..71f997d 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,45 @@
 # glancecast
 
-glancecast is a script that converts a video file into an MP3 file which has frames from the video stored as chapters.
-This allows you to listen to a video in an audio-only podcast player like Overcast, and glance at the screen if you need some visual context.
+glancecast is a script that converts a video file into an MP3 with chapters that have sampled video frames as cover art.
+
+This allows you to listen to a video in an audio-only podcast player, but glance at the screen if you need some visual context.
+
+## Installation
+
+Clone the Git repository, create a Python virtualenv, and install dependencies:
+
+```console
+$ git clone git://alexwlchan.net/projects/glancecast.git
+$ cd glancecast
+$ python3 -m venv .venv
+$ source .venv/bin/activate
+$ pip install -e .
+```
+
+You also need [FFmpeg](https://ffmpeg.org) installed, which is not included with the pip installer.
+
+## Usage
+
+Run the `glancecast.py` script, passing your video file as a single argument.
+It prints the path to the MP3 file:
+
+```console
+$ python3 glancecast.py tests/fixtures/lego_age_picker.mp4
+tests/fixtures/lego_age_picker.mp3
+```
+
+There are three optional flags:
+
+*   `--interval=INTERVAL_SEC` sets the time interval between captured frames in seconds.
+*   `--max-dimension=MAX_DIMENSION` sets the maximum width/height in pixels for captured frames.
+*   `--out-dir=OUT_DIR` sets the output directory where the MP3 file will be saved.
+
+You can see these options by running `python3 glancecast.py --help`.
+
+## Versioning and stability
+
+This library is monotonically versioned (releases simply go up sequentially).
+
+## License
+
+This project is shared under the [MIT license](./LICENSE).

dev_requirements.in (45 → 58)

diff --git a/dev_requirements.in b/dev_requirements.in
index 26794a2..02a2b6c 100644
--- a/dev_requirements.in
+++ b/dev_requirements.in
@@ -1,6 +1,8 @@
 -r requirements.txt
 
 coverage
+eyed3
+Pillow
 pytest
 ruff
 ty

dev_requirements.txt (571 → 760)

diff --git a/dev_requirements.txt b/dev_requirements.txt
index 39da9e4..09107b6 100644
--- a/dev_requirements.txt
+++ b/dev_requirements.txt
@@ -2,12 +2,22 @@
 #    uv pip compile dev_requirements.in --output-file=dev_requirements.txt --exclude-newer=P7D --exclude-newer-package alexwlchan-chives=false
 coverage==7.16.0
     # via -r dev_requirements.in
+deprecation==2.1.0
+    # via eyed3
+eyed3==0.9.9
+    # via -r dev_requirements.in
+filetype==1.2.0
+    # via eyed3
 iniconfig==2.3.0
     # via pytest
 mutagen==1.48.1
     # via -r requirements.txt
 packaging==26.3
-    # via pytest
+    # via
+    #   deprecation
+    #   pytest
+pillow==12.3.0
+    # via -r dev_requirements.in
 pluggy==1.6.0
     # via pytest
 pygments==2.21.0

glancecast.py (1363 → 7985)

diff --git a/glancecast.py b/glancecast.py
index 7306709..0c28297 100644
--- a/glancecast.py
+++ b/glancecast.py
@@ -6,21 +6,29 @@ This allows you to listen to a video in an audio-only podcast player like
 Overcast, and glance at the screen if you need some visual context.
 """
 
+import argparse
+from collections.abc import Iterator
+from dataclasses import dataclass
+import itertools
 from pathlib import Path
 import subprocess
 import shutil
 import sys
 import tempfile
 
+from mutagen.id3 import APIC, CHAP, ID3, PictureType
+
+
+__version__ = "1"
+
 
 def ensure_tool_installed(name: str):
     """
-    Ensure that a tool with the given name is installed and available
-    in the PATH.
+    Ensure a required tool is available in the PATH.
     """
     p = shutil.which(name)
     if p is None:
-        sys.exit(f"missing required tool: {name}")
+        sys.exit(f"Error: required tool {name!r} is not installed or not in PATH.")
 
 
 def ffmpeg(*args: str | Path) -> None:
@@ -30,22 +38,235 @@ def ffmpeg(*args: str | Path) -> None:
     ensure_tool_installed("ffmpeg")
     cmd = ["ffmpeg"] + [str(a) for a in args]
 
-    # TODO: This dumps the ffmpeg output to stderr. Reduce it or suppress
-    # it unless the command fails.
-    subprocess.check_call(cmd)
+    try:
+        subprocess.run(cmd, check=True, capture_output=True, text=True)
+    except subprocess.CalledProcessError as e:
+        if e.stderr:
+            print(e.stderr, file=sys.stderr)
+        else:  # pragma: no cover
+            pass
+        raise
 
 
-def convert_video_to_mp3(video_path: Path) -> Path:
+def convert_video_to_mp3(video_path: Path, tmp_dir: Path) -> Path:
     """
-    Convert a video file to an MP3 file. Return the path to the new MP3 file.
+    Extract audio track from a video file into a temporary MP3 file.
+
+    Return the path to the temporary MP3 file.
     """
     if not video_path.exists():
         raise FileNotFoundError(f"no video file found: {video_path}")
 
-    tmp_dir = Path(tempfile.mkdtemp())
     mp3_path = tmp_dir / (video_path.stem + ".mp3")
 
     # Example: `ffmpeg -i video.mp4 audio.mp3`
     ffmpeg("-i", video_path, mp3_path)
 
     return Path(mp3_path)
+
+
+@dataclass
+class Frame:
+    """
+    Represents a single image from a video and its timestamp range.
+    """
+
+    path: Path
+    mime_type: str
+    start_time_ms: int
+    end_time_ms: int
+
+
+def extract_images_from_video(
+    video_path: Path, tmp_dir: Path, *, interval_sec: int, max_dimension: int
+) -> list[Frame]:
+    """
+    Extract still frames from a video at fixed intervals.
+
+    The frames will be either JPEG or PNG, whichever is smaller for
+    a given frame.
+
+    The images will be clamped to fit inside a square of maximum dimension,
+    preservign the original aspect ratio.
+    """
+    jpg_dir = tmp_dir / "jpg"
+    png_dir = tmp_dir / "png"
+    jpg_dir.mkdir()
+    png_dir.mkdir()
+
+    vf_filter = ",".join(
+        [
+            # Example: fps=1/5 samples 1 frame every 5 seconds
+            f"fps=1/{interval_sec}",
+            # Change the width to account for the sample aspect ratio
+            # See https://alexwlchan.net/2025/square-pixels/
+            "scale=iw*sar:ih",
+            # Example: scale=min(\,750):min(\,750):force_original_aspect_ratio=decrease
+            # scales the image, maintaining its original aspect ratio, and
+            # clamps the size to 750px square or the original, whichever is
+            # the smaller.
+            f"scale=min(iw*dar\\,{max_dimension}):min(ih\\,{max_dimension}):force_original_aspect_ratio=decrease",
+            # "setsar=1"
+        ]
+    )
+
+    # Create images as both PNG and JPEG, and then pick the
+    # smaller thumbnail to optimise the size of the MP3.
+    ffmpeg("-i", video_path, "-vf", vf_filter, png_dir / "thumbnail_%04d.png")
+    ffmpeg("-i", video_path, "-vf", vf_filter, jpg_dir / "thumbnail_%04d.jpg")
+
+    jpg_files = sorted(jpg_dir.iterdir())
+    png_files = sorted(png_dir.iterdir())
+
+    jpg_count = len(jpg_files)
+    png_count = len(png_files)
+    assert jpg_count == png_count, (
+        f"Mismatched thumbnail frame count: jpg={jpg_count}, png={png_count}"
+    )
+
+    result: list[Frame] = []
+
+    for i, (jpg_path, png_path) in enumerate(zip(jpg_files, png_files)):
+        if jpg_path.stat().st_size < png_path.stat().st_size:
+            path, mime_type = jpg_path, "image/jpeg"
+        else:
+            path, mime_type = png_path, "image/png"
+
+        result.append(
+            Frame(
+                path=path,
+                mime_type=mime_type,
+                start_time_ms=i * interval_sec * 1000,
+                end_time_ms=(i + 1) * interval_sec * 1000,
+            )
+        )
+
+    return result
+
+
+def add_chapters_to_mp3(mp3_path: Path, frames: list[Frame]) -> None:
+    """
+    Embed chapter markers and associated per-chapter artwork into an MP3 file.
+    """
+    audio = ID3(mp3_path)
+
+    for i, fr in enumerate(frames, start=1):
+        image_frame = APIC(
+            mime=fr.mime_type, type=PictureType.OTHER, data=fr.path.read_bytes()
+        )
+        chapter_frame = CHAP(
+            element_id=f"chp{i}",
+            start_time=fr.start_time_ms,
+            end_time=fr.end_time_ms,
+            sub_frames=[image_frame],
+        )
+        audio.add(chapter_frame)
+
+    audio.save()
+
+
+def convert_video_to_glanceable_mp3(
+    video_path: Path | str,
+    *,
+    interval_sec: int,
+    max_dimension: int,
+    out_dir: Path | None = None,
+) -> Path:
+    """
+    Convert a video file into an MP3 with chapters that have sampled
+    video frames as cover art.
+
+    TODO: Allow adding cover art for the overall MP3.
+    """
+    video_path = Path(video_path)
+
+    with tempfile.TemporaryDirectory() as tmp_dir:
+        print("Converting video to MP3...", file=sys.stderr)
+        tmp_mp3_path = convert_video_to_mp3(video_path, tmp_dir=Path(tmp_dir))
+
+        print("Extracting images from video...", file=sys.stderr)
+        frames = extract_images_from_video(
+            video_path,
+            tmp_dir=Path(tmp_dir),
+            interval_sec=interval_sec,
+            max_dimension=max_dimension,
+        )
+
+        print("Adding chapters to MP3...", file=sys.stderr)
+        add_chapters_to_mp3(tmp_mp3_path, frames)
+
+        target_dir = out_dir or video_path.parent
+        out_name = video_path.stem
+
+        def candidate_paths() -> Iterator[Path]:
+            """
+            Choose possible output files for the MP3, appending a numeric suffix
+            if our first choice isn't available.
+            """
+            yield target_dir / f"{out_name}.mp3"
+            for i in itertools.count(start=1):
+                yield target_dir / f"{out_name}-{i}.mp3"
+            else:  # pragma: no cover
+                assert 0, "unreachable"
+
+        for mp3_path in candidate_paths():
+            try:
+                with open(tmp_mp3_path, "rb") as src, open(mp3_path, "xb") as dst:
+                    shutil.copyfileobj(src, dst)
+                return mp3_path
+            except FileExistsError:
+                continue
+
+    assert 0, "unreachable"  # pragma: no cover
+
+
+def cli(argv: list[str]) -> None:  # pragma: no cover
+    """
+    CLI entrypoint for glancecast.
+    """
+    parser = argparse.ArgumentParser(
+        description=(
+            "Convert a video file into an MP3 with chapters that have "
+            "sampled video frames as cover art."
+        )
+    )
+    parser.add_argument(
+        "video", type=Path, metavar="VIDEO", help="path to the source video file"
+    )
+    parser.add_argument(
+        "--interval",
+        dest="interval_sec",
+        type=int,
+        default=5,
+        help="time interval between captured frames in seconds (default: 5)",
+    )
+    parser.add_argument(
+        "--max-dimension",
+        type=int,
+        default=945,
+        help="maximum width/height in pixels for captured frames (default: 945)",
+    )
+    parser.add_argument(
+        "-o",
+        "--out-dir",
+        type=Path,
+        help="directory to save the generated MP3 (defaults to input file directory)",
+    )
+
+    parser.add_argument(
+        "--version", action="version", version=f"%(prog)s v{__version__}"
+    )
+
+    args = parser.parse_args(argv)
+
+    mp3_path = convert_video_to_glanceable_mp3(
+        video_path=args.video,
+        interval_sec=args.interval_sec,
+        max_dimension=args.max_dimension,
+        out_dir=args.out_dir,
+    )
+    print(mp3_path)
+
+
+if __name__ == "__main__":  # pragma: no cover
+    cli(argv=sys.argv[1:])

test_glancecast.py (1757 → 10010)

diff --git a/test_glancecast.py b/test_glancecast.py
index e7328d7..fd4dff9 100644
--- a/test_glancecast.py
+++ b/test_glancecast.py
@@ -5,10 +5,83 @@ Tests for glancecast.
 from pathlib import Path
 from subprocess import CalledProcessError
 
+import eyed3
 from mutagen.mp3 import MP3
+from PIL import Image
 import pytest
+from pytest import CaptureFixture
 
-from glancecast import convert_video_to_mp3, ensure_tool_installed
+from glancecast import (
+    Frame,
+    add_chapters_to_mp3,
+    convert_video_to_glanceable_mp3,
+    convert_video_to_mp3,
+    ensure_tool_installed,
+    extract_images_from_video,
+    ffmpeg,
+)
+
+
+@pytest.fixture
+def tmp_dir(tmp_path: Path) -> Path:
+    """
+    Temporary directory; alias for the pytest `tmp_path` fixture.
+    """
+    return tmp_path
+
+
+class TestConvertVideoToGlanceableMP3:
+    """
+    Tests for `convert_video_to_glanceable_mp3`.
+    """
+
+    def test_e2e(self, tmp_dir: Path) -> None:
+        """
+        End-to-end test for `convert_video_to_glanceable_mp3`.
+        """
+        mp3_path = convert_video_to_glanceable_mp3(
+            video_path="tests/fixtures/lego_age_picker.mp4",
+            interval_sec=5,
+            max_dimension=100,
+            out_dir=tmp_dir,
+        )
+
+        assert mp3_path == tmp_dir / "lego_age_picker.mp3"
+        assert mp3_path.exists()
+
+        # Use a different MP3 library to load the metadata, and check
+        # there are four chapters which all have an APIC subframe
+        mp3 = eyed3.load(mp3_path)
+        assert mp3 is not None
+
+        assert len(mp3.tag.chapters) == 4
+
+        for c in mp3.tag.chapters:
+            assert c.sub_frames.keys() == {b"APIC"}
+
+    def test_does_not_overwrite(self, tmp_dir: Path) -> None:
+        """
+        If an MP3 path already exists at the output location, it picks
+        an alternative name.
+        """
+        mp3_paths = [
+            convert_video_to_glanceable_mp3(
+                video_path="tests/fixtures/lego_age_picker.mp4",
+                interval_sec=5,
+                max_dimension=100,
+                out_dir=tmp_dir,
+            )
+            for _ in range(5)
+        ]
+
+        assert mp3_paths == [
+            tmp_dir / "lego_age_picker.mp3",
+            tmp_dir / "lego_age_picker-1.mp3",
+            tmp_dir / "lego_age_picker-2.mp3",
+            tmp_dir / "lego_age_picker-3.mp3",
+            tmp_dir / "lego_age_picker-4.mp3",
+        ]
+        assert all(p.exists() for p in mp3_paths)
 
 
 class TestEnsureToolInstalled:
@@ -26,22 +99,58 @@ class TestEnsureToolInstalled:
         """
         Ensuring that a non-existent tool is installed raises an error.
         """
-        with pytest.raises(SystemExit, match="missing required tool"):
+        with pytest.raises(
+            SystemExit,
+            match="required tool 'does_not_exist' is not installed or not in PATH",
+        ):
             ensure_tool_installed("does_not_exist")
 
 
+class TestFFMPEG:
+    """
+    Tests for `ffmpeg`.
+    """
+
+    def test_success_has_no_output(
+        self, capsys: CaptureFixture[str], tmp_dir: Path
+    ) -> None:
+        """
+        If the FFmpeg command succeeds, nothing is printed to stdout or stderr.
+        """
+        ffmpeg(
+            "-i",
+            "tests/fixtures/lego_age_picker.mp4",
+            str(tmp_dir / "lego_age_picker.mp3"),
+        )
+
+        captured = capsys.readouterr()
+        assert captured.out == ""
+        assert captured.err == ""
+
+    def test_failing_command_prints_output(self, capsys: CaptureFixture[str]) -> None:
+        """
+        If the FFmpeg command fails, the FFmpeg output is printed to stderr.
+        """
+        with pytest.raises(CalledProcessError):
+            ffmpeg("-i", "does_not_exist.mp4", "does_not_exist.mp3")
+
+        captured = capsys.readouterr()
+        assert captured.out == ""
+        assert captured.err != ""
+
+
 class TestConvertVideoToMP3:
     """
     Tests for `convert_video_to_mp3`.
     """
 
-    def test_creates_mp3_file(self) -> None:
+    def test_creates_mp3_file(self, tmp_dir: Path) -> None:
         """
         Convert a video file to an MP3, and check it's the expected length.
         """
         p = Path("tests/fixtures/lego_age_picker.mp4")
 
-        mp3_path = convert_video_to_mp3(p)
+        mp3_path = convert_video_to_mp3(p, tmp_dir)
         assert mp3_path.exists()
         assert mp3_path.suffix == ".mp3"
 
@@ -49,20 +158,187 @@ class TestConvertVideoToMP3:
         assert mp3.info is not None
         assert mp3.info.length == 19.536
 
-    def test_missing_video_file(self) -> None:
+    def test_missing_video_file(self, tmp_dir: Path) -> None:
         """
         Trying to convert a non-existent video file is an error.
         """
         p = Path("does_not_exist.mkv")
 
         with pytest.raises(FileNotFoundError):
-            convert_video_to_mp3(p)
+            convert_video_to_mp3(p, tmp_dir)
 
-    def test_convert_non_video_file(self) -> None:
+    def test_convert_non_video_file(self, tmp_dir: Path) -> None:
         """
         Trying to convert a non-video file is an error.
         """
         p = Path("README.md")
 
         with pytest.raises(CalledProcessError):
-            convert_video_to_mp3(p)
+            convert_video_to_mp3(p, tmp_dir)
+
+
+class TestExtractImagesFromVideo:
+    """
+    Tests for `extract_images_from_video`.
+    """
+
+    def test_extract_images_from_video(self, tmp_dir: Path) -> None:
+        """
+        Extract images from a video file, and check we get the correct frames.
+        """
+        p = Path("tests/fixtures/lego_age_picker.mp4")
+        frames = extract_images_from_video(
+            p, tmp_dir, interval_sec=5, max_dimension=100
+        )
+
+        for f in frames:
+            f.path = Path(f.path.name)
+
+        assert frames == [
+            Frame(
+                path=Path("thumbnail_0001.jpg"),
+                mime_type="image/jpeg",
+                start_time_ms=0,
+                end_time_ms=5000,
+            ),
+            Frame(
+                path=Path("thumbnail_0002.jpg"),
+                mime_type="image/jpeg",
+                start_time_ms=5000,
+                end_time_ms=10000,
+            ),
+            Frame(
+                path=Path("thumbnail_0003.jpg"),
+                mime_type="image/jpeg",
+                start_time_ms=10000,
+                end_time_ms=15000,
+            ),
+            Frame(
+                path=Path("thumbnail_0004.jpg"),
+                mime_type="image/jpeg",
+                start_time_ms=15000,
+                end_time_ms=20000,
+            ),
+        ]
+
+    @pytest.mark.parametrize(
+        "name, suffix, mime_type",
+        [
+            ("lego_age_picker.mp4", ".jpg", "image/jpeg"),
+            ("animated_squares.mp4", ".png", "image/png"),
+        ],
+    )
+    def test_chooses_png_or_jpeg(
+        self, tmp_dir: Path, name: str, suffix: str, mime_type: str
+    ) -> None:
+        """
+        The sampler picks PNG or JPEG depending on which is smaller.
+        """
+        frames = extract_images_from_video(
+            Path("tests/fixtures") / name,
+            tmp_dir,
+            interval_sec=5,
+            max_dimension=100,
+        )
+
+        assert frames[0].path.suffix == suffix
+        assert frames[0].mime_type == mime_type
+
+    @pytest.mark.parametrize(
+        "interval_sec, frame_count",
+        # The video is ~19 seconds long
+        [(1, 19), (5, 4), (6, 3), (10, 2)],
+    )
+    def test_correct_frame_count(
+        self, tmp_dir: Path, interval_sec: int, frame_count: int
+    ) -> None:
+        """
+        We get the correct number of frames based on interval_sec.
+        """
+        p = Path("tests/fixtures/lego_age_picker.mp4")
+
+        frames = extract_images_from_video(
+            p,
+            tmp_dir,
+            interval_sec=interval_sec,
+            max_dimension=100,
+        )
+
+        assert len(frames) == frame_count
+
+    @pytest.mark.parametrize(
+        "name, max_dimension, actual_size",
+        [
+            # lego_age_picker.mp4 is 1173×1080 pixels, and it has
+            # a storage aspect ratio which isn't 1
+            # See https://alexwlchan.net/2025/square-pixels/
+            ("lego_age_picker.mp4", 100, (100, 92)),
+            ("lego_age_picker.mp4", 1080, (1080, 995)),
+            ("lego_age_picker.mp4", 1173, (1172, 1080)),
+            ("lego_age_picker.mp4", 2000, (1172, 1080)),
+            # lego_age_picker_rotated.mov is 588×640 pixels
+            # For some reason this isn't expanding to the full size.
+            # TODO: Investigate further.
+            ("lego_age_picker_rotated.mov", 100, (92, 100)),
+            ("lego_age_picker_rotated.mov", 588, (540, 588)),
+            ("lego_age_picker_rotated.mov", 640, (540, 588)),
+            ("lego_age_picker_rotated.mov", 1000, (540, 588)),
+        ],
+    )
+    def test_landscape_images_are_correct_size(
+        self,
+        tmp_dir: Path,
+        name: str,
+        max_dimension: int,
+        actual_size: tuple[int, int],
+    ) -> None:
+        """
+        The image preserves its aspect ratio, and is clamped to `max_dimension`
+        or the original size, whichever is smaller.
+        """
+        frames = extract_images_from_video(
+            Path("tests/fixtures") / name,
+            tmp_dir,
+            interval_sec=20,
+            max_dimension=max_dimension,
+        )
+
+        with Image.open(frames[0].path) as im:
+            assert im.size == actual_size
+
+
+class TestAddChaptersToMP3:
+    """
+    Tests for `add_chapters_to_mp3`.
+    """
+
+    @pytest.mark.parametrize(
+        "name, mime_type",
+        [
+            ("noise.jpg", "image/jpeg"),
+            ("stripes.png", "image/png"),
+        ],
+    )
+    def test_mime_type(self, tmp_dir: Path, name: str, mime_type: str) -> None:
+        """
+        The per-chapter cover art has the correct MIME type.
+        """
+        p = Path("tests/fixtures/lego_age_picker.mp4")
+        mp3_path = convert_video_to_mp3(p, tmp_dir)
+
+        frame = Frame(
+            path=Path("tests/fixtures") / name,
+            mime_type=mime_type,
+            start_time_ms=0,
+            end_time_ms=5000,
+        )
+
+        add_chapters_to_mp3(mp3_path, frames=[frame])
+
+        mp3 = eyed3.load(mp3_path)
+        assert mp3 is not None
+
+        assert len(mp3.tag.chapters) == 1
+
+        for c in mp3.tag.chapters:
+            assert c.sub_frames[b"APIC"][0].mime_type == mime_type

tests/fixtures/animated_squares.mp4 (0 → 1806)

diff --git a/tests/fixtures/animated_squares.mp4 b/tests/fixtures/animated_squares.mp4
new file mode 100644
index 0000000..06085d2
Binary files /dev/null and b/tests/fixtures/animated_squares.mp4 differ

tests/fixtures/lego_age_picker_rotated.mov (0 → 1747471)

diff --git a/tests/fixtures/lego_age_picker_rotated.mov b/tests/fixtures/lego_age_picker_rotated.mov
new file mode 100644
index 0000000..706e8fd
Binary files /dev/null and b/tests/fixtures/lego_age_picker_rotated.mov differ

tests/fixtures/noise.jpg (0 → 25175)

diff --git a/tests/fixtures/noise.jpg b/tests/fixtures/noise.jpg
new file mode 100644
index 0000000..0998e82
Binary files /dev/null and b/tests/fixtures/noise.jpg differ

tests/fixtures/stripes.png (0 → 1778)

diff --git a/tests/fixtures/stripes.png b/tests/fixtures/stripes.png
new file mode 100644
index 0000000..51b3759
Binary files /dev/null and b/tests/fixtures/stripes.png differ