Skip to main content

glancecast.py

1"""
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.
7"""
9import argparse
10from collections.abc import Iterator
11from dataclasses import dataclass
12import itertools
13from pathlib import Path
14import subprocess
15import shutil
16import sys
17import tempfile
19from mutagen.id3 import APIC, CHAP, ID3, PictureType
22__version__ = "1"
25def ensure_tool_installed(name: str):
26 """
27 Ensure a required tool is available in the PATH.
28 """
29 p = shutil.which(name)
30 if p is None:
31 sys.exit(f"Error: required tool {name!r} is not installed or not in PATH.")
34def ffmpeg(*args: str | Path) -> None:
35 """
36 Run an ffmpeg command and ensure it succeeds.
37 """
38 ensure_tool_installed("ffmpeg")
39 cmd = ["ffmpeg"] + [str(a) for a in args]
41 try:
42 subprocess.run(cmd, check=True, capture_output=True, text=True)
43 except subprocess.CalledProcessError as e:
44 if e.stderr:
45 print(e.stderr, file=sys.stderr)
46 else: # pragma: no cover
47 pass
48 raise
51def convert_video_to_mp3(video_path: Path, tmp_dir: Path) -> Path:
52 """
53 Extract audio track from a video file into a temporary MP3 file.
55 Return the path to the temporary MP3 file.
56 """
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)
65 return Path(mp3_path)
68@dataclass
69class Frame:
70 """
71 Represents a single image from a video and its timestamp range.
72 """
74 path: Path
75 mime_type: str
76 start_time_ms: int
77 end_time_ms: int
80def extract_images_from_video(
81 video_path: Path, tmp_dir: Path, *, interval_sec: int, max_dimension: int
82) -> list[Frame]:
83 """
84 Extract still frames from a video at fixed intervals.
86 The frames will be either JPEG or PNG, whichever is smaller for
87 a given frame.
89 The images will be clamped to fit inside a square of maximum dimension,
90 preservign the original aspect ratio.
91 """
92 jpg_dir = tmp_dir / "jpg"
93 png_dir = tmp_dir / "png"
94 jpg_dir.mkdir()
95 png_dir.mkdir()
97 vf_filter = ",".join(
98 [
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/
103 "scale=iw*sar:ih",
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
107 # the smaller.
108 f"scale=min(iw*dar\\,{max_dimension}):min(ih\\,{max_dimension}):force_original_aspect_ratio=decrease",
109 # "setsar=1"
110 ]
111 )
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}"
125 )
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"
132 else:
133 path, mime_type = png_path, "image/png"
135 result.append(
136 Frame(
137 path=path,
138 mime_type=mime_type,
139 start_time_ms=i * interval_sec * 1000,
140 end_time_ms=(i + 1) * interval_sec * 1000,
141 )
142 )
144 return result
147def add_chapters_to_mp3(mp3_path: Path, frames: list[Frame]) -> None:
148 """
149 Embed chapter markers and associated per-chapter artwork into an MP3 file.
150 """
151 audio = ID3(mp3_path)
153 for i, fr in enumerate(frames, start=1):
154 image_frame = APIC(
155 mime=fr.mime_type, type=PictureType.OTHER, data=fr.path.read_bytes()
156 )
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],
162 )
163 audio.add(chapter_frame)
165 audio.save()
168def convert_video_to_glanceable_mp3(
169 video_path: Path | str,
170 *,
171 interval_sec: int,
172 max_dimension: int,
173 out_dir: Path | None = None,
174) -> Path:
175 """
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.
180 """
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(
189 video_path,
190 tmp_dir=Path(tmp_dir),
191 interval_sec=interval_sec,
192 max_dimension=max_dimension,
193 )
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]:
202 """
203 Choose possible output files for the MP3, appending a numeric suffix
204 if our first choice isn't available.
205 """
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():
213 try:
214 with open(tmp_mp3_path, "rb") as src, open(mp3_path, "xb") as dst:
215 shutil.copyfileobj(src, dst)
216 return mp3_path
217 except FileExistsError:
218 continue
220 assert 0, "unreachable" # pragma: no cover
223def cli(argv: list[str]) -> None: # pragma: no cover
224 """
225 CLI entrypoint for glancecast.
226 """
227 parser = argparse.ArgumentParser(
228 description=(
229 "Convert a video file into an MP3 with chapters that have "
230 "sampled video frames as cover art."
231 )
232 )
233 parser.add_argument(
234 "video", type=Path, metavar="VIDEO", help="path to the source video file"
235 )
236 parser.add_argument(
237 "--interval",
238 dest="interval_sec",
239 type=int,
240 default=5,
241 help="time interval between captured frames in seconds (default: 5)",
242 )
243 parser.add_argument(
244 "--max-dimension",
245 type=int,
246 default=945,
247 help="maximum width/height in pixels for captured frames (default: 945)",
248 )
249 parser.add_argument(
250 "-o",
251 "--out-dir",
252 type=Path,
253 help="directory to save the generated MP3 (defaults to input file directory)",
254 )
256 parser.add_argument(
257 "--version", action="version", version=f"%(prog)s v{__version__}"
258 )
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,
267 )
268 print(mp3_path)
271if __name__ == "__main__": # pragma: no cover
272 cli(argv=sys.argv[1:])