2glancecast converts a video file into an MP3 file which has frames from
3the video stored as chapters.
5This allows you to listen to a video in an audio-only podcast player like
6Overcast, and glance at the screen if you need some visual context.
10from collections.abc import Iterator
11from dataclasses import dataclass
13from pathlib import Path
19from mutagen.id3 import APIC, CHAP, ID3, PictureType
25def ensure_tool_installed(name: str):
27 Ensure a required tool is available in the PATH.
29 p = shutil.which(name)
31 sys.exit(f"Error: required tool {name!r} is not installed or not in PATH.")
34def ffmpeg(*args: str | Path) -> None:
36 Run an ffmpeg command and ensure it succeeds.
38 ensure_tool_installed("ffmpeg")
39 cmd = ["ffmpeg"] + [str(a) for a in args]
42 subprocess.run(cmd, check=True, capture_output=True, text=True)
43 except subprocess.CalledProcessError as e:
45 print(e.stderr, file=sys.stderr)
46 else: # pragma: no cover
51def convert_video_to_mp3(video_path: Path, tmp_dir: Path) -> Path:
53 Extract audio track from a video file into a temporary MP3 file.
55 Return the path to the temporary MP3 file.
57 if not video_path.exists():
58 raise FileNotFoundError(f"no video file found: {video_path}")
60 mp3_path = tmp_dir / (video_path.stem + ".mp3")
62 # Example: `ffmpeg -i video.mp4 audio.mp3`
63 ffmpeg("-i", video_path, mp3_path)
71 Represents a single image from a video and its timestamp range.
80def extract_images_from_video(
81 video_path: Path, tmp_dir: Path, *, interval_sec: int, max_dimension: int
84 Extract still frames from a video at fixed intervals.
86 The frames will be either JPEG or PNG, whichever is smaller for
89 The images will be clamped to fit inside a square of maximum dimension,
90 preservign the original aspect ratio.
92 jpg_dir = tmp_dir / "jpg"
93 png_dir = tmp_dir / "png"
99 # Example: fps=1/5 samples 1 frame every 5 seconds
100 f"fps=1/{interval_sec}",
101 # Change the width to account for the sample aspect ratio
102 # See https://alexwlchan.net/2025/square-pixels/
104 # Example: scale=min(\,750):min(\,750):force_original_aspect_ratio=decrease
105 # scales the image, maintaining its original aspect ratio, and
106 # clamps the size to 750px square or the original, whichever is
108 f"scale=min(iw*dar\\,{max_dimension}):min(ih\\,{max_dimension}):force_original_aspect_ratio=decrease",
113 # Create images as both PNG and JPEG, and then pick the
114 # smaller thumbnail to optimise the size of the MP3.
115 ffmpeg("-i", video_path, "-vf", vf_filter, png_dir / "thumbnail_%04d.png")
116 ffmpeg("-i", video_path, "-vf", vf_filter, jpg_dir / "thumbnail_%04d.jpg")
118 jpg_files = sorted(jpg_dir.iterdir())
119 png_files = sorted(png_dir.iterdir())
121 jpg_count = len(jpg_files)
122 png_count = len(png_files)
123 assert jpg_count == png_count, (
124 f"Mismatched thumbnail frame count: jpg={jpg_count}, png={png_count}"
127 result: list[Frame] = []
129 for i, (jpg_path, png_path) in enumerate(zip(jpg_files, png_files)):
130 if jpg_path.stat().st_size < png_path.stat().st_size:
131 path, mime_type = jpg_path, "image/jpeg"
133 path, mime_type = png_path, "image/png"
139 start_time_ms=i * interval_sec * 1000,
140 end_time_ms=(i + 1) * interval_sec * 1000,
147def add_chapters_to_mp3(mp3_path: Path, frames: list[Frame]) -> None:
149 Embed chapter markers and associated per-chapter artwork into an MP3 file.
151 audio = ID3(mp3_path)
153 for i, fr in enumerate(frames, start=1):
155 mime=fr.mime_type, type=PictureType.OTHER, data=fr.path.read_bytes()
157 chapter_frame = CHAP(
158 element_id=f"chp{i}",
159 start_time=fr.start_time_ms,
160 end_time=fr.end_time_ms,
161 sub_frames=[image_frame],
163 audio.add(chapter_frame)
168def convert_video_to_glanceable_mp3(
169 video_path: Path | str,
173 out_dir: Path | None = None,
176 Convert a video file into an MP3 with chapters that have sampled
177 video frames as cover art.
179 TODO: Allow adding cover art for the overall MP3.
181 video_path = Path(video_path)
183 with tempfile.TemporaryDirectory() as tmp_dir:
184 print("Converting video to MP3...", file=sys.stderr)
185 tmp_mp3_path = convert_video_to_mp3(video_path, tmp_dir=Path(tmp_dir))
187 print("Extracting images from video...", file=sys.stderr)
188 frames = extract_images_from_video(
190 tmp_dir=Path(tmp_dir),
191 interval_sec=interval_sec,
192 max_dimension=max_dimension,
195 print("Adding chapters to MP3...", file=sys.stderr)
196 add_chapters_to_mp3(tmp_mp3_path, frames)
198 target_dir = out_dir or video_path.parent
199 out_name = video_path.stem
201 def candidate_paths() -> Iterator[Path]:
203 Choose possible output files for the MP3, appending a numeric suffix
204 if our first choice isn't available.
206 yield target_dir / f"{out_name}.mp3"
207 for i in itertools.count(start=1):
208 yield target_dir / f"{out_name}-{i}.mp3"
209 else: # pragma: no cover
210 assert 0, "unreachable"
212 for mp3_path in candidate_paths():
214 with open(tmp_mp3_path, "rb") as src, open(mp3_path, "xb") as dst:
215 shutil.copyfileobj(src, dst)
217 except FileExistsError:
220 assert 0, "unreachable" # pragma: no cover
223def cli(argv: list[str]) -> None: # pragma: no cover
225 CLI entrypoint for glancecast.
227 parser = argparse.ArgumentParser(
229 "Convert a video file into an MP3 with chapters that have "
230 "sampled video frames as cover art."
234 "video", type=Path, metavar="VIDEO", help="path to the source video file"
241 help="time interval between captured frames in seconds (default: 5)",
247 help="maximum width/height in pixels for captured frames (default: 945)",
253 help="directory to save the generated MP3 (defaults to input file directory)",
257 "--version", action="version", version=f"%(prog)s v{__version__}"
260 args = parser.parse_args(argv)
262 mp3_path = convert_video_to_glanceable_mp3(
263 video_path=args.video,
264 interval_sec=args.interval_sec,
265 max_dimension=args.max_dimension,
266 out_dir=args.out_dir,
271if __name__ == "__main__": # pragma: no cover
272 cli(argv=sys.argv[1:])