2Functions for interacting with images/videos.
6* https://github.com/alexwlchan/create_thumbnail
7* https://github.com/alexwlchan/dominant_colours
8* https://github.com/alexwlchan/get_live_text
11* https://alexwlchan.net/2021/dominant-colours/
12* https://alexwlchan.net/2025/detecting-av1-videos/
13* https://stackoverflow.com/a/58567453
17from fractions import Fraction
19from pathlib import Path
21from typing import Literal, NotRequired, TypedDict, TYPE_CHECKING
29 "create_image_entity",
30 "create_video_entity",
40def is_av1_video(path: str | Path) -> bool:
42 Return True if a video is encoded with AV1, False otherwise.
48 # Set the logging level
51 # Select the first video stream
52 "-select_streams", "v:0",
54 # Print the codec_name (e.g. av1)
55 "-show_entries", "stream=codec_name",
57 # Print just the value
58 "-output_format", "default=noprint_wrappers=1:nokey=1",
60 # Name of the video to check
65 output = subprocess.check_output(cmd, text=True)
67 return output.strip() == "av1"
70class ImageEntity(TypedDict):
72 ImageEntity contains all the fields I need to render an image
76 type: Literal["image"]
78 # The path to the image on disk
81 # The path to a low-resolution thumbnail
82 thumbnail_path: NotRequired[str]
84 # The display resolution of the image
88 # A hex-encoded colour which is prominent in this image.
91 # Whether the image is animated (GIF and WebP only)
92 is_animated: NotRequired[Literal[True]]
94 # Whether the image has transparent pixels
95 has_transparency: NotRequired[Literal[True]]
97 # The alt text of the image, if available
98 alt_text: NotRequired[str]
100 # The source URL of the image, if available
101 source_url: NotRequired[str]
104class SubtitlesEntity(TypedDict):
106 SubtitlesEntity describes a set of subtitles.
113class VideoEntity(TypedDict):
115 VideoEntity contains all the fields I need to render a video
119 type: Literal["video"]
121 # The path to the video on disk
124 # The poster image for the video
127 # The display resolution of the video
131 # The duration of the video, as an HOURS:MM:SS.MICROSECONDS string
134 # Path to the subtitles for the video, if available
135 subtitles: NotRequired[list[SubtitlesEntity]]
137 # The source URL of the image, if available
138 source_url: NotRequired[str]
140 # Whether the video should play automatically. This is used for
141 # videos that are substituting for animated GIFs.
142 autoplay: NotRequired[Literal[True]]
145MediaEntity = ImageEntity | VideoEntity
148def get_media_paths(e: MediaEntity) -> set[Path]:
150 Return a list of all media paths represented by this media entity.
152 result: set[str | Path] = set()
157 raise TypeError(f"Entity does not have a type: {e}")
159 if e["type"] == "video":
160 result.add(e["path"])
163 for se in e["subtitles"]:
164 result.add(se["path"])
167 for p in get_media_paths(e["poster"]):
169 elif e["type"] == "image":
170 result.add(e["path"])
172 result.add(e["thumbnail_path"])
176 raise TypeError(f"Unrecognised entity type: {e['type']}")
178 return {Path(p) for p in result}
181class ThumbnailConfig(TypedDict):
183 width: NotRequired[int]
184 height: NotRequired[int]
187def create_image_entity(
190 background: str = "#ffffff",
191 alt_text: str | None = None,
192 source_url: str | None = None,
193 thumbnail_config: ThumbnailConfig | None = None,
194 generate_transcript: bool = False,
197 Create an ImageEntity for a saved image.
199 from PIL import Image, ImageOps
201 with Image.open(path) as im:
202 # Account for EXIF orientation in the dimensions.
203 # See https://alexwlchan.net/til/2024/photos-can-have-orientation-in-exif/
204 transposed_im = ImageOps.exif_transpose(im)
206 entity: ImageEntity = {
209 "tint_colour": get_tint_colour(path, background=background),
210 "width": transposed_im.width,
211 "height": transposed_im.height,
215 entity["is_animated"] = True
217 if _has_transparency(im):
218 entity["has_transparency"] = True
220 if thumbnail_config is not None:
221 entity["thumbnail_path"] = _create_thumbnail(path, thumbnail_config)
223 if alt_text is not None and generate_transcript:
224 raise TypeError("You cannot set alt_text and generate_transcript=True!")
226 elif alt_text is not None:
227 entity["alt_text"] = alt_text
228 elif generate_transcript:
229 transcript = _get_transcript(path)
230 if transcript is not None:
231 entity["alt_text"] = transcript
233 if source_url is not None:
234 entity["source_url"] = source_url
239def create_video_entity(
240 video_path: str | Path,
242 poster_path: str | Path,
243 subtitles_path: str | Path | None = None,
244 source_url: str | None = None,
245 autoplay: bool = False,
246 thumbnail_config: ThumbnailConfig | None = None,
247 background: str = "#ffffff",
250 Create a video entity for files on disk.
252 width, height, duration = _get_video_data(video_path)
253 poster = create_image_entity(
254 poster_path, thumbnail_config=thumbnail_config, background=background
257 entity: VideoEntity = {
259 "path": str(video_path),
262 "duration": duration,
267 entity["subtitles"] = [{"path": str(subtitles_path), "label": "English"}]
270 entity["source_url"] = source_url
273 entity["autoplay"] = autoplay
278def _is_animated(im: "PIL.Image.Image") -> bool:
280 Return True if an image is animated, False otherwise.
282 return getattr(im, "is_animated", False)
285def _has_transparency(im: "PIL.Image.Image") -> bool:
287 Return True if an image has transparent pixels, False otherwise.
289 By Vinyl Da.i'gyu-Kazotetsu on Stack Overflow:
290 https://stackoverflow.com/a/58567453
292 if im.info.get("transparency", None) is not None:
295 transparent = im.info.get("transparency", -1)
296 for _, index in im.getcolors(): # type: ignore
297 # TODO: Find an image that hits this branch, so I can
298 # include it in the test suite.
299 if index == transparent: # pragma: no cover
301 elif im.mode == "RGBA":
302 extrema = im.getextrema()
303 if extrema[3][0] < 255: # type: ignore
308def get_tint_colour(path: str | Path, *, background: str) -> str:
310 Get the tint colour for an image.
312 if background == "white":
313 background = "#ffffff"
314 elif background == "black":
315 background = "#000000"
320 f"--best-against-bg={background}",
324 result = subprocess.check_output(cmd, text=True)
325 return result.strip()
328def _get_transcript(path: str | Path) -> str | None:
330 Get the transcript for an image (if any).
332 result = subprocess.check_output(["get_live_text", str(path)], text=True)
334 return result.strip() or None
337def _create_thumbnail(path: str | Path, thumbnail_config: ThumbnailConfig) -> str:
339 Create a thumbnail for an image and return the path.
341 cmd = ["create_thumbnail", str(path), "--out-dir", thumbnail_config["out_dir"]]
343 if "width" in thumbnail_config:
344 cmd.extend(["--width", str(thumbnail_config["width"])])
346 if "height" in thumbnail_config:
347 cmd.extend(["--height", str(thumbnail_config["height"])])
349 return subprocess.check_output(cmd, text=True)
352def _get_video_data(video_path: str | Path) -> tuple[int, int, str]:
354 Return the dimensions and duration of a video, as a width/height fraction.
359 # verbosity level = error
363 # only get information about the first video stream
367 # only gather the entries I'm interested in
369 "stream=width,height,sample_aspect_ratio,duration",
371 # print the duration in HH:MM:SS.microseconds format
374 # print output in JSON, which is easier to parse
382 output = subprocess.check_output(cmd)
383 ffprobe_resp = json.loads(output)
385 # The output will be structured something like:
392 # "sample_aspect_ratio": "45:64"
398 # If the video doesn't specify a pixel aspect ratio, then it won't
399 # have a `sample_aspect_ratio` key.
400 video_stream = ffprobe_resp["streams"][0]
403 pixel_aspect_ratio = Fraction(
404 video_stream["sample_aspect_ratio"].replace(":", "/")
407 pixel_aspect_ratio = Fraction(1)
409 width = round(video_stream["width"] * pixel_aspect_ratio)
410 height = video_stream["height"]
411 duration = video_stream["duration"]
413 return width, height, duration
416def convert_srt_to_vtt(srt_path: Path) -> Path:
418 Convert an SRT subtitles file to a VTT file.
420 SRT subtitles are commonly used for video platform uploads (like Vimeo
421 or YouTube), but I want VTT files for use in the HTML5 <video> tag.
423 This deletes the SRT file and returns the path to new VTT file.
425 if srt_path.suffix != ".srt":
427 f"convert_srt_to_vtt can only take .srt files, got {srt_path!r}"
430 with open(srt_path, encoding="utf-8-sig") as srt_file:
431 srt_lines = srt_file.readlines()
433 if len(srt_lines) == 0:
434 raise ValueError("cannot convert empty SRT file")
436 vtt_path = srt_path.with_suffix(".vtt")
437 assert vtt_path != srt_path, vtt_path
439 with open(vtt_path, "w", encoding="utf-8") as vtt_file:
440 # Write the required WEBVTT header and blank line
441 vtt_file.write("WEBVTT\n\n")
443 for line in srt_lines:
444 # Remove caption numbers; they're not required in VTT files
445 if line.strip().isdigit():
448 # VTT timestamps uses commas rather than periods in timestamps
450 line = line.replace(",", ".")