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
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