Skip to main content

Add support for media entities

ID
d6dde2c
date
2025-12-05 08:35:12+00:00
author
Alex Chan <alex@alexwlchan.net>
parent
4298500
message
Add support for media entities

* Images and videos including thumbnails, tint colour, and
  transparency
* Replace pymediainfo dependency with ffprobe and my other
  tools
changed files
25 files, 838 additions, 15 deletions

Changed files

.github/workflows/test.yml (720) → .github/workflows/test.yml (2240)

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index bf8288d..51ec186 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -11,7 +11,7 @@ on:
 
 jobs:
   test:
-    runs-on: ubuntu-latest
+    runs-on: macos-latest
     strategy:
       matrix:
         python-version: ["3.13", "3.14"]
@@ -25,9 +25,52 @@ jobs:
         python-version: ${{ matrix.python-version }}
         cache: pip
 
-    - name: Install dependencies
+    - name: Install Python dependencies
       run: pip install -r dev_requirements.txt
 
+    - name: Install create_thumbnail
+      env:
+        GH_TOKEN: ${{ github.token }}
+      run: |
+        gh release download \
+          --repo alexwlchan/create_thumbnail \
+          --pattern create_thumbnail-aarch64-apple-darwin.tar.gz \
+          --output create_thumbnail.tar.gz
+        tar -xzf create_thumbnail.tar.gz --directory /usr/local/bin
+        chmod +x /usr/local/bin/create_thumbnail
+        which create_thumbnail
+
+    - name: Install dominant_colours
+      env:
+        GH_TOKEN: ${{ github.token }}
+      run: |
+        gh release download \
+          --repo alexwlchan/dominant_colours \
+          --pattern dominant_colours-aarch64-apple-darwin.tar.gz \
+          --output dominant_colours.tar.gz
+        tar -xzf dominant_colours.tar.gz --directory /usr/local/bin
+        chmod +x /usr/local/bin/dominant_colours
+        which dominant_colours
+    
+    - name: Install get_live_text
+      env:
+        GH_TOKEN: ${{ github.token }}
+      run: |
+        gh release download \
+          --repo alexwlchan/get_live_text \
+          --pattern get_live_text.aarch64-apple-darwin.zip \
+          --output get_live_text.tar.gz
+        tar -xzf get_live_text.tar.gz --directory /usr/local/bin
+        chmod +x /usr/local/bin/get_live_text
+        which get_live_text
+    
+    - name: Install ffprobe
+      run: |
+        curl -O https://evermeet.cx/ffmpeg/ffprobe-8.0.1.7z
+        tar -xzf ffprobe-8.0.1.7z --directory /usr/local/bin
+        chmod +x /usr/local/bin/ffprobe
+        which ffprobe
+
     - name: Check formatting
       run: |
         ruff check .

CHANGELOG.md (805) → CHANGELOG.md (1112)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2be0e1b..40e8dbe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
 # CHANGELOG
 
+## v9 - 2025-12-05
+
+This adds three models to `chives.media`: `ImageEntity`, `VideoEntity`, and `ImageEntity`.
+These have all the information I need to show an image/video in a web page.
+
+It also includes functions `create_image_entity` and `create_video_entity` which construct instances of these models.
+
 ## v8 - 2025-12-04
 
 Add the `is_mastodon_host()` function.

dev_requirements.txt (2272) → dev_requirements.txt (2268)

diff --git a/dev_requirements.txt b/dev_requirements.txt
index 1182388..38923ca 100644
--- a/dev_requirements.txt
+++ b/dev_requirements.txt
@@ -66,6 +66,8 @@ packaging==25.0
     #   twine
 pathspec==0.12.1
     # via mypy
+pillow==12.0.0
+    # via alexwlchan-chives
 pluggy==1.6.0
     # via
     #   pytest
@@ -75,8 +77,6 @@ pygments==2.19.2
     #   pytest
     #   readme-renderer
     #   rich
-pymediainfo==7.0.1
-    # via alexwlchan-chives
 pyproject-hooks==1.2.0
     # via build
 pytest==9.0.1
@@ -103,7 +103,7 @@ rfc3986==2.0.0
     # via twine
 rich==14.2.0
     # via twine
-ruff==0.14.7
+ruff==0.14.8
     # via -r dev_requirements.in
 silver-nitrate==1.8.1
     # via -r dev_requirements.in

pyproject.toml (1287) → pyproject.toml (1282)

diff --git a/pyproject.toml b/pyproject.toml
index a07a08b..fd19dd2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,7 +24,7 @@ dynamic = ["version"]
 license = "MIT"
 
 [project.optional-dependencies]
-media = ["pymediainfo"]
+media = ["Pillow"]
 urls = ["httpx", "hyperlink"]
 
 [project.urls]

src/chives/__init__.py (390) → src/chives/__init__.py (390)

diff --git a/src/chives/__init__.py b/src/chives/__init__.py
index b05a75c..a6f3dde 100644
--- a/src/chives/__init__.py
+++ b/src/chives/__init__.py
@@ -11,4 +11,4 @@ I share across multiple sites.
 
 """
 
-__version__ = "8"
+__version__ = "9"

src/chives/media.py (441) → src/chives/media.py (10428)

diff --git a/src/chives/media.py b/src/chives/media.py
index 5679dcf..910b6dc 100644
--- a/src/chives/media.py
+++ b/src/chives/media.py
@@ -1,21 +1,398 @@
 """
 Functions for interacting with images/videos.
 
+Dependencies:
+* ffprobe
+* https://github.com/alexwlchan/create_thumbnail
+* https://github.com/alexwlchan/dominant_colours
+* https://github.com/alexwlchan/get_live_text
+
 References:
+* https://alexwlchan.net/2021/dominant-colours/
 * https://alexwlchan.net/2025/detecting-av1-videos/
+* https://stackoverflow.com/a/58567453
 
 """
 
+from fractions import Fraction
+import json
 from pathlib import Path
+import subprocess
+from typing import cast, Literal, NotRequired, TypedDict, TYPE_CHECKING
+
+if TYPE_CHECKING:
+    import PIL
 
-from pymediainfo import MediaInfo
+
+__all__ = [
+    "create_image_entity",
+    "create_video_entity",
+    "get_media_paths",
+    "is_av1_video",
+    "ImageEntity",
+    "MediaEntity",
+    "VideoEntity",
+]
 
 
 def is_av1_video(path: str | Path) -> bool:
     """
     Returns True if a video is encoded with AV1, False otherwise.
     """
-    media_info = MediaInfo.parse(path)
-    video_codec: str = media_info.video_tracks[0].codec_id
+    # fmt: off
+    cmd = [
+        "ffprobe",
+        #
+        # Set the logging level
+        "-loglevel", "error",
+        #
+        # Select the first video stream
+        "-select_streams", "v:0",
+        #
+        # Print the codec_name (e.g. av1)
+        "-show_entries", "stream=codec_name",
+        #
+        # Print just the value
+        "-output_format", "default=noprint_wrappers=1:nokey=1",
+        #
+        # Name of the video to check
+        str(path),
+    ]
+    # fmt: on
+
+    output = subprocess.check_output(cmd, text=True)
+
+    return output.strip() == "av1"
+
+
+class ImageEntity(TypedDict):
+    """
+    ImageEntity contains all the fields I need to render an image
+    in a web page.
+    """
+
+    type: Literal["image"]
+
+    # The path to the image on disk
+    path: str
+
+    # The path to a low-resolution thumbnail
+    thumbnail_path: NotRequired[str]
+
+    # The display resolution of the image
+    width: int
+    height: int
+
+    # A hex-encoded colour which is prominent in this image.
+    tint_colour: str
+
+    # Whether the image is animated (GIF and WebP only)
+    is_animated: NotRequired[Literal[True]]
+
+    # Whether the image has transparent pixels
+    has_transparency: NotRequired[Literal[True]]
+
+    # The alt text of the image, if available
+    alt_text: NotRequired[str]
+
+    # The source URL of the image, if available
+    source_url: NotRequired[str]
+
+
+class VideoEntity(TypedDict):
+    """
+    VideoEntity contains all the fields I need to render a video
+    in a web page.
+    """
+
+    type: Literal["video"]
+
+    # The path to the video on disk
+    path: str
+
+    # The poster image for the video
+    poster: ImageEntity
+
+    # The display resolution of the video
+    width: int
+    height: int
+
+    # The duration of the video, as an HOURS:MM:SS.MICROSECONDS string
+    duration: str
+
+    # Path to the subtitles for the video, if available
+    subtitles_path: NotRequired[str]
+
+    # The source URL of the image, if available
+    source_url: NotRequired[str]
+
+    # Whether the video should play automatically. This is used for
+    # videos that are substituting for animated GIFs.
+    autoplay: NotRequired[Literal[True]]
+
+
+MediaEntity = ImageEntity | VideoEntity
+
+
+def get_media_paths(e: MediaEntity) -> set[Path]:
+    """
+    Returns a list of all media paths represented by this media entity.
+    """
+    result: set[str | Path] = set()
+
+    try:
+        e["type"]
+    except KeyError:
+        raise TypeError(f"Entity does not have a type: {e}")
+
+    if e["type"] == "video":
+        result.add(e["path"])
+        try:
+            result.add(e["subtitles_path"])
+        except KeyError:
+            pass
+        for p in get_media_paths(e["poster"]):
+            result.add(p)
+    elif e["type"] == "image":
+        result.add(e["path"])
+        try:
+            result.add(e["thumbnail_path"])
+        except KeyError:
+            pass
+    else:
+        raise TypeError(f"Unrecognised entity type: {e['type']}")
+
+    return {Path(p) for p in result}
+
+
+_ThumbnailByWidthConfig = TypedDict(
+    "_ThumbnailByWidthConfig", {"out_dir": str | Path, "width": int}
+)
+_ThumbnailByHeightConfig = TypedDict(
+    "_ThumbnailByHeightConfig", {"out_dir": str | Path, "height": int}
+)
+ThumbnailConfig = _ThumbnailByWidthConfig | _ThumbnailByHeightConfig
+
+
+def create_image_entity(
+    path: str | Path,
+    *,
+    background: str = "#ffffff",
+    alt_text: str | None = None,
+    source_url: str | None = None,
+    thumbnail_config: ThumbnailConfig | None = None,
+    generate_transcript: bool = False,
+) -> ImageEntity:
+    """
+    Create an ImageEntity for a saved image.
+    """
+    from PIL import Image, ImageOps
+
+    with Image.open(path) as im:
+        entity: ImageEntity = {
+            "type": "image",
+            "path": str(path),
+            "tint_colour": _get_tint_colour(path, background=background),
+            "width": im.width,
+            "height": im.height,
+        }
+
+        if _is_animated(im):
+            entity["is_animated"] = True
+
+        if _has_transparency(im):
+            entity["has_transparency"] = True
+
+    if thumbnail_config is not None:
+        entity["thumbnail_path"] = _create_thumbnail(path, thumbnail_config)
+
+    if alt_text is not None and generate_transcript:
+        raise TypeError("You cannot set alt_text and generate_transcript=True!")
+
+    elif alt_text is not None:
+        entity["alt_text"] = alt_text
+    elif generate_transcript:
+        transcript = _get_transcript(path)
+        if transcript is not None:
+            entity["alt_text"] = transcript
+
+    if source_url is not None:
+        entity["source_url"] = source_url
+
+    return entity
+
+
+def create_video_entity(
+    video_path: str | Path,
+    *,
+    poster_path: str | Path,
+    subtitles_path: str | Path | None = None,
+    source_url: str | None = None,
+    autoplay: bool = False,
+    thumbnail_config: ThumbnailConfig | None = None,
+    background: str = "#ffffff",
+) -> VideoEntity:
+    """
+    Create a video entity for files on disk.
+    """
+    width, height, duration = _get_video_data(video_path)
+    poster = create_image_entity(
+        poster_path, thumbnail_config=thumbnail_config, background=background
+    )
+
+    entity: VideoEntity = {
+        "type": "video",
+        "path": str(video_path),
+        "width": width,
+        "height": height,
+        "duration": duration,
+        "poster": poster,
+    }
+
+    if subtitles_path:
+        entity["subtitles_path"] = str(subtitles_path)
+
+    if source_url:
+        entity["source_url"] = source_url
+
+    if autoplay:
+        entity["autoplay"] = autoplay
+
+    return entity
+
+
+def _is_animated(im: "PIL.Image.Image") -> bool:
+    """
+    Returns True if an image is animated, False otherwise.
+    """
+    return getattr(im, "is_animated", False)
+
+
+def _has_transparency(im: "PIL.Image.Image") -> bool:
+    """
+    Returns True if an image has transparent pixels, False otherwise.
+
+    By Vinyl Da.i'gyu-Kazotetsu on Stack Overflow:
+    https://stackoverflow.com/a/58567453
+    """
+    if im.info.get("transparency", None) is not None:
+        return True
+    if im.mode == "P":
+        transparent = im.info.get("transparency", -1)
+        for _, index in im.getcolors():  # type: ignore
+            # TODO: Find an image that hits this branch, so I can
+            # include it in the test suite.
+            if index == transparent:  # pragma: no cover
+                return True
+    elif im.mode == "RGBA":
+        extrema = im.getextrema()
+        if extrema[3][0] < 255:  # type: ignore
+            return True
+    return False
+
+
+def _get_tint_colour(path: str | Path, *, background: str) -> str:
+    """
+    Get the tint colour for an image.
+    """
+    if background == "white":
+        background = "#ffffff"
+    elif background == "black":
+        background = "#000000"
+
+    result = subprocess.check_output(
+        ["dominant_colours", str(path), "--best-against-bg", background], text=True
+    )
+    return result.strip()
+
+
+def _get_transcript(path: str | Path) -> str | None:
+    """
+    Get the transcript for an image (if any).
+    """
+    result = subprocess.check_output(["get_live_text", str(path)], text=True)
+
+    return result.strip() or None
+
+
+def _create_thumbnail(path: str | Path, thumbnail_config: ThumbnailConfig) -> str:
+    """
+    Create a thumbnail for an image and return the path.
+    """
+    cmd = ["create_thumbnail", str(path), "--out-dir", thumbnail_config["out_dir"]]
+
+    if "width" in thumbnail_config:
+        config_w = cast(_ThumbnailByWidthConfig, thumbnail_config)
+        cmd.extend(["--width", str(config_w["width"])])
+
+    elif "height" in thumbnail_config:
+        cmd.extend(["--height", str(thumbnail_config["height"])])
+
+    else:  # pragma: no cover
+        raise TypeError(f"Unrecognised thumbnail config: {thumbnail_config!r}")
+
+    return subprocess.check_output(cmd, text=True)
+
+
+def _get_video_data(video_path: str | Path) -> tuple[int, int, str]:
+    """
+    Returns the dimensions and duration of a video, as a width/height fraction.
+    """
+    cmd = [
+        "ffprobe",
+        #
+        # verbosity level = error
+        "-v",
+        "error",
+        #
+        # only get information about the first video stream
+        "-select_streams",
+        "v:0",
+        #
+        # only gather the entries I'm interested in
+        "-show_entries",
+        "stream=width,height,sample_aspect_ratio,duration",
+        #
+        # print the duration in HH:MM:SS.microseconds format
+        "-sexagesimal",
+        #
+        # print output in JSON, which is easier to parse
+        "-print_format",
+        "json",
+        #
+        # input file
+        str(video_path),
+    ]
+
+    output = subprocess.check_output(cmd)
+    ffprobe_resp = json.loads(output)
+
+    # The output will be structured something like:
+    #
+    #   {
+    #       "streams": [
+    #           {
+    #               "width": 1920,
+    #               "height": 1080,
+    #               "sample_aspect_ratio": "45:64"
+    #           }
+    #       ],
+    #       …
+    #   }
+    #
+    # If the video doesn't specify a pixel aspect ratio, then it won't
+    # have a `sample_aspect_ratio` key.
+    video_stream = ffprobe_resp["streams"][0]
+
+    try:
+        pixel_aspect_ratio = Fraction(
+            video_stream["sample_aspect_ratio"].replace(":", "/")
+        )
+    except KeyError:
+        pixel_aspect_ratio = Fraction(1)
+
+    width = round(video_stream["width"] * pixel_aspect_ratio)
+    height = video_stream["height"]
+    duration = video_stream["duration"]
 
-    return video_codec == "av01"
+    return width, height, duration

tests/fixtures/Sintel_360_10s_1MB_AV1.mp4 (1054253) → tests/fixtures/Sintel_360_10s_1MB_AV1.mp4 (0)

diff --git a/tests/fixtures/Sintel_360_10s_1MB_AV1.mp4 b/tests/fixtures/Sintel_360_10s_1MB_AV1.mp4
deleted file mode 100644
index f42219e..0000000
Binary files a/tests/fixtures/Sintel_360_10s_1MB_AV1.mp4 and /dev/null differ

tests/fixtures/Sintel_360_10s_1MB_H264.mp4 (1047614) → tests/fixtures/Sintel_360_10s_1MB_H264.mp4 (0)

diff --git a/tests/fixtures/Sintel_360_10s_1MB_H264.mp4 b/tests/fixtures/Sintel_360_10s_1MB_H264.mp4
deleted file mode 100644
index d1c7c31..0000000
Binary files a/tests/fixtures/Sintel_360_10s_1MB_H264.mp4 and /dev/null differ

tests/fixtures/media/Landscape_3.jpg (0) → tests/fixtures/media/Landscape_3.jpg (348796)

diff --git a/tests/fixtures/media/Landscape_3.jpg b/tests/fixtures/media/Landscape_3.jpg
new file mode 100644
index 0000000..f508052
Binary files /dev/null and b/tests/fixtures/media/Landscape_3.jpg differ

tests/fixtures/media/Mars 2020 EDL Remastered [HHhyznZ2u4E].jpg (0) → tests/fixtures/media/Mars 2020 EDL Remastered [HHhyznZ2u4E].jpg (74308)

diff --git a/tests/fixtures/media/Mars 2020 EDL Remastered [HHhyznZ2u4E].jpg b/tests/fixtures/media/Mars 2020 EDL Remastered [HHhyznZ2u4E].jpg
new file mode 100644
index 0000000..c708b10
Binary files /dev/null and b/tests/fixtures/media/Mars 2020 EDL Remastered [HHhyznZ2u4E].jpg differ

tests/fixtures/media/Mars 2020 EDL Remastered [HHhyznZ2u4E].mp4 (0) → tests/fixtures/media/Mars 2020 EDL Remastered [HHhyznZ2u4E].mp4 (632306)

diff --git a/tests/fixtures/media/Mars 2020 EDL Remastered [HHhyznZ2u4E].mp4 b/tests/fixtures/media/Mars 2020 EDL Remastered [HHhyznZ2u4E].mp4
new file mode 100644
index 0000000..7864e2f
Binary files /dev/null and b/tests/fixtures/media/Mars 2020 EDL Remastered [HHhyznZ2u4E].mp4 differ

tests/fixtures/media/Sintel_360_10s_1MB_AV1.mp4 (0) → tests/fixtures/media/Sintel_360_10s_1MB_AV1.mp4 (1054253)

diff --git a/tests/fixtures/media/Sintel_360_10s_1MB_AV1.mp4 b/tests/fixtures/media/Sintel_360_10s_1MB_AV1.mp4
new file mode 100644
index 0000000..f42219e
Binary files /dev/null and b/tests/fixtures/media/Sintel_360_10s_1MB_AV1.mp4 differ

tests/fixtures/media/Sintel_360_10s_1MB_H264.mp4 (0) → tests/fixtures/media/Sintel_360_10s_1MB_H264.mp4 (1047614)

diff --git a/tests/fixtures/media/Sintel_360_10s_1MB_H264.mp4 b/tests/fixtures/media/Sintel_360_10s_1MB_H264.mp4
new file mode 100644
index 0000000..d1c7c31
Binary files /dev/null and b/tests/fixtures/media/Sintel_360_10s_1MB_H264.mp4 differ

tests/fixtures/media/Sintel_360_10s_1MB_H264.png (0) → tests/fixtures/media/Sintel_360_10s_1MB_H264.png (130991)

diff --git a/tests/fixtures/media/Sintel_360_10s_1MB_H264.png b/tests/fixtures/media/Sintel_360_10s_1MB_H264.png
new file mode 100644
index 0000000..f88e5e1
Binary files /dev/null and b/tests/fixtures/media/Sintel_360_10s_1MB_H264.png differ

tests/fixtures/media/asteroid_belt.png (0) → tests/fixtures/media/asteroid_belt.png (15517)

diff --git a/tests/fixtures/media/asteroid_belt.png b/tests/fixtures/media/asteroid_belt.png
new file mode 100644
index 0000000..4b3c6c7
Binary files /dev/null and b/tests/fixtures/media/asteroid_belt.png differ

tests/fixtures/media/asteroid_belt_P.png (0) → tests/fixtures/media/asteroid_belt_P.png (15537)

diff --git a/tests/fixtures/media/asteroid_belt_P.png b/tests/fixtures/media/asteroid_belt_P.png
new file mode 100644
index 0000000..77352de
Binary files /dev/null and b/tests/fixtures/media/asteroid_belt_P.png differ

tests/fixtures/media/blue.png (0) → tests/fixtures/media/blue.png (961)

diff --git a/tests/fixtures/media/blue.png b/tests/fixtures/media/blue.png
new file mode 100644
index 0000000..5c956ae
Binary files /dev/null and b/tests/fixtures/media/blue.png differ

tests/fixtures/media/blue_with_hole.png (0) → tests/fixtures/media/blue_with_hole.png (1200)

diff --git a/tests/fixtures/media/blue_with_hole.png b/tests/fixtures/media/blue_with_hole.png
new file mode 100644
index 0000000..d330048
Binary files /dev/null and b/tests/fixtures/media/blue_with_hole.png differ

tests/fixtures/media/checkerboard.png (0) → tests/fixtures/media/checkerboard.png (1317)

diff --git a/tests/fixtures/media/checkerboard.png b/tests/fixtures/media/checkerboard.png
new file mode 100644
index 0000000..50d9bdd
Binary files /dev/null and b/tests/fixtures/media/checkerboard.png differ

tests/fixtures/media/electric_field.gif (0) → tests/fixtures/media/electric_field.gif (288270)

diff --git a/tests/fixtures/media/electric_field.gif b/tests/fixtures/media/electric_field.gif
new file mode 100644
index 0000000..df9f6b4
Binary files /dev/null and b/tests/fixtures/media/electric_field.gif differ

tests/fixtures/media/space.jpg (0) → tests/fixtures/media/space.jpg (1813)

diff --git a/tests/fixtures/media/space.jpg b/tests/fixtures/media/space.jpg
new file mode 100644
index 0000000..1a00f4d
Binary files /dev/null and b/tests/fixtures/media/space.jpg differ

tests/fixtures/media/underlined_text.png (0) → tests/fixtures/media/underlined_text.png (18212)

diff --git a/tests/fixtures/media/underlined_text.png b/tests/fixtures/media/underlined_text.png
new file mode 100644
index 0000000..30da11c
Binary files /dev/null and b/tests/fixtures/media/underlined_text.png differ

tests/fixtures/media/wings_tracking_shot.jpg (0) → tests/fixtures/media/wings_tracking_shot.jpg (85821)

diff --git a/tests/fixtures/media/wings_tracking_shot.jpg b/tests/fixtures/media/wings_tracking_shot.jpg
new file mode 100644
index 0000000..6c781d3
Binary files /dev/null and b/tests/fixtures/media/wings_tracking_shot.jpg differ

tests/fixtures/media/wings_tracking_shot.mp4 (0) → tests/fixtures/media/wings_tracking_shot.mp4 (106711)

diff --git a/tests/fixtures/media/wings_tracking_shot.mp4 b/tests/fixtures/media/wings_tracking_shot.mp4
new file mode 100644
index 0000000..2cd16a4
Binary files /dev/null and b/tests/fixtures/media/wings_tracking_shot.mp4 differ

tests/test_media.py (445) → tests/test_media.py (14290)

diff --git a/tests/test_media.py b/tests/test_media.py
index 0dd7b7b..d59c75d 100644
--- a/tests/test_media.py
+++ b/tests/test_media.py
@@ -1,12 +1,408 @@
 """Tests for `chives.media`."""
 
-from chives.media import is_av1_video
+from pathlib import Path
+from typing import Any
 
+from PIL import Image
+import pytest
 
-def test_is_av1_video() -> None:
+from chives.media import (
+    create_image_entity,
+    create_video_entity,
+    get_media_paths,
+    is_av1_video,
+)
+
+
+@pytest.fixture
+def fixtures_dir() -> Path:
+    """
+    Returns the directory where media fixtures are stored.
+    """
+    return Path("tests/fixtures/media")
+
+
+def test_is_av1_video(fixtures_dir: Path) -> None:
     """is_av1_video correctly detects AV1 videos."""
     # These two videos were downloaded from
     # https://test-videos.co.uk/sintel/mp4-h264 and
     # https://test-videos.co.uk/sintel/mp4-av1
-    assert not is_av1_video("tests/fixtures/Sintel_360_10s_1MB_H264.mp4")
-    assert is_av1_video("tests/fixtures/Sintel_360_10s_1MB_AV1.mp4")
+    assert not is_av1_video(fixtures_dir / "Sintel_360_10s_1MB_H264.mp4")
+    assert is_av1_video(fixtures_dir / "Sintel_360_10s_1MB_AV1.mp4")
+
+
+class TestCreateImageEntity:
+    """
+    Tests for create_image_entity().
+    """
+
+    def test_basic_image(self, fixtures_dir: Path) -> None:
+        """
+        Get an image entity for a basic blue square.
+        """
+        entity = create_image_entity(fixtures_dir / "blue.png")
+        assert entity == {
+            "type": "image",
+            "path": "tests/fixtures/media/blue.png",
+            "width": 32,
+            "height": 16,
+            "tint_colour": "#0000ff",
+        }
+
+    @pytest.mark.parametrize(
+        "filename",
+        [
+            # This is a solid blue image with a section in the middle deleted
+            "blue_with_hole.png",
+            #
+            # An asteroid belt drawn in TikZ by TeX.SE user Qrrbrbirlbel,
+            # which has `transparency` in its im.info.
+            # Downloaded from http://tex.stackexchange.com/a/111974/9668
+            "asteroid_belt.png",
+        ],
+    )
+    def test_image_with_transparency(self, fixtures_dir: Path, filename: str) -> None:
+        """
+        If an image has transparent pixels, then the entity has
+        `has_transparency=True`.
+        """
+        entity = create_image_entity(fixtures_dir / filename)
+        assert entity["has_transparency"]
+
+    @pytest.mark.parametrize(
+        "filename",
+        [
+            "blue.png",
+            "space.jpg",
+            #
+            # An animated electric field drawn in TikZ.
+            # Downloaded from https://tex.stackexchange.com/a/158930/9668
+            "electric_field.gif",
+        ],
+    )
+    def test_image_without_transparency(
+        self, fixtures_dir: Path, filename: str
+    ) -> None:
+        """
+        If an image has no transparent pixels, then the entity doesn't
+        have a `has_transparency` key.
+        """
+        entity = create_image_entity(fixtures_dir / filename)
+        assert "has_transparency" not in entity
+
+    def test_accounts_for_exif_orientation(self, fixtures_dir: Path) -> None:
+        """
+        The dimensions are the display dimensions, which accounts for
+        the EXIF orientation.
+        """
+        # This test file was downloaded from Dave Perrett repo:
+        # https://github.com/recurser/exif-orientation-examples
+        entity = create_image_entity(fixtures_dir / "Landscape_3.jpg")
+        assert (entity["width"], entity["height"]) == (1800, 1200)
+
+    def test_animated_image(self, fixtures_dir: Path) -> None:
+        """
+        If an image is animated, the entity has `is_animated=True`.
+        """
+        # An animated electric field drawn in TikZ.
+        # Downloaded from https://tex.stackexchange.com/a/158930/9668
+        entity = create_image_entity(fixtures_dir / "electric_field.gif")
+        assert entity["is_animated"]
+
+    def test_other_attrs_are_forwarded(self, fixtures_dir: Path) -> None:
+        """
+        The `alt_text` and `source_url` values are forwarded to the
+        final entity.
+        """
+        entity = create_image_entity(
+            fixtures_dir / "blue.png",
+            alt_text="This is the alt text",
+            source_url="https://example.com/blue.png",
+        )
+
+        assert entity["alt_text"] == "This is the alt text"
+        assert entity["source_url"] == "https://example.com/blue.png"
+
+    def test_alt_text_and_generate_transcript_is_error(
+        self, fixtures_dir: Path
+    ) -> None:
+        """
+        You can't pass `alt_text` and `generate_transcript` at the same time.
+        """
+        with pytest.raises(TypeError):
+            create_image_entity(
+                fixtures_dir / "blue.png",
+                alt_text="This is the alt text",
+                generate_transcript=True,
+            )
+
+    def test_generate_transcript(self, fixtures_dir: Path) -> None:
+        """
+        If you pass `generate_transcript=True`, the image is OCR'd for alt text.
+        """
+        entity = create_image_entity(
+            fixtures_dir / "underlined_text.png", generate_transcript=True
+        )
+        assert entity["alt_text"] == "I visited Berlin in Germany."
+
+    def test_generate_transcript_if_no_text(self, fixtures_dir: Path) -> None:
+        """
+        If you pass `generate_transcript=True` for an image with no text,
+        you don't get any alt text.
+        """
+        entity = create_image_entity(
+            fixtures_dir / "blue.png", generate_transcript=True
+        )
+        assert "alt_text" not in entity
+
+    def test_create_thumbnail_by_width(
+        self, fixtures_dir: Path, tmp_path: Path
+    ) -> None:
+        """
+        Create a thumbnail by width.
+        """
+        entity = create_image_entity(
+            fixtures_dir / "blue.png",
+            thumbnail_config={"out_dir": tmp_path / "thumbnails", "width": 10},
+        )
+
+        assert Path(entity["thumbnail_path"]).exists()
+
+        with Image.open(entity["thumbnail_path"]) as im:
+            assert im.width == 10
+
+    def test_create_thumbnail_by_height(
+        self, fixtures_dir: Path, tmp_path: Path
+    ) -> None:
+        """
+        Create a thumbnail by height.
+        """
+        entity = create_image_entity(
+            fixtures_dir / "blue.png",
+            thumbnail_config={"out_dir": tmp_path / "thumbnails", "height": 5},
+        )
+
+        assert Path(entity["thumbnail_path"]).exists()
+
+        with Image.open(entity["thumbnail_path"]) as im:
+            assert im.height == 5
+
+    @pytest.mark.parametrize(
+        "background, tint_colour",
+        [
+            ("white", "#005493"),
+            ("black", "#b3fdff"),
+            ("#111111", "#b3fdff"),
+        ],
+    )
+    def test_tint_colour_is_based_on_background(
+        self, fixtures_dir: Path, background: str, tint_colour: str
+    ) -> None:
+        """
+        The tint colour is based to suit the background.
+        """
+        # This is a checkerboard pattern made of 2 different shades of
+        # turquoise, a light and a dark.
+        entity = create_image_entity(
+            fixtures_dir / "checkerboard.png", background=background
+        )
+        assert entity["tint_colour"] == tint_colour
+
+
+class TestCreateVideoEntity:
+    """
+    Tests for `create_video_entity()`.
+    """
+
+    def test_basic_video(self, fixtures_dir: Path) -> None:
+        """
+        Get a video entity for a basic video.
+        """
+        # This video was downloaded from
+        # https://test-videos.co.uk/sintel/mp4-h264
+        entity = create_video_entity(
+            fixtures_dir / "Sintel_360_10s_1MB_H264.mp4",
+            poster_path=fixtures_dir / "Sintel_360_10s_1MB_H264.png",
+        )
+        assert entity == {
+            "type": "video",
+            "path": "tests/fixtures/media/Sintel_360_10s_1MB_H264.mp4",
+            "width": 640,
+            "height": 360,
+            "duration": "0:00:10.000000",
+            "poster": {
+                "type": "image",
+                "path": "tests/fixtures/media/Sintel_360_10s_1MB_H264.png",
+                "tint_colour": "#020202",
+                "width": 640,
+                "height": 360,
+            },
+        }
+
+    def test_other_attrs_are_forwarded(self, fixtures_dir: Path) -> None:
+        """
+        The `subtitles_path`, `source_url` and `autoplay` values are
+        forwarded to the final entity.
+        """
+        entity = create_video_entity(
+            fixtures_dir / "Sintel_360_10s_1MB_H264.mp4",
+            poster_path=fixtures_dir / "Sintel_360_10s_1MB_H264.png",
+            subtitles_path=fixtures_dir / "Sintel_360_10s_1MB_H264.en.vtt",
+            source_url="https://test-videos.co.uk/sintel/mp4-h264",
+            autoplay=True,
+        )
+
+        assert (
+            entity["subtitles_path"]
+            == "tests/fixtures/media/Sintel_360_10s_1MB_H264.en.vtt"
+        )
+        assert entity["source_url"] == "https://test-videos.co.uk/sintel/mp4-h264"
+        assert entity["autoplay"]
+
+    def test_gets_display_dimensions(self, fixtures_dir: Path) -> None:
+        """
+        The width/height dimensions are based on the display aspect ratio,
+        not the storage aspect ratio.
+
+        See https://alexwlchan.net/2025/square-pixels/
+        """
+        # This is a short clip of https://www.youtube.com/watch?v=HHhyznZ2u4E
+        entity = create_video_entity(
+            fixtures_dir / "Mars 2020 EDL Remastered [HHhyznZ2u4E].mp4",
+            poster_path=fixtures_dir / "Mars 2020 EDL Remastered [HHhyznZ2u4E].jpg",
+        )
+
+        assert entity["width"] == 1350
+        assert entity["height"] == 1080
+
+    def test_video_without_sample_aspect_ratio(self, fixtures_dir: Path) -> None:
+        """
+        Get the width/height dimensions of a video that doesn't have
+        `sample_aspect_ratio` in its metadata.
+        """
+        # This is a short clip from Wings (1927).
+        entity = create_video_entity(
+            fixtures_dir / "wings_tracking_shot.mp4",
+            poster_path=fixtures_dir / "wings_tracking_shot.jpg",
+        )
+
+        assert entity["width"] == 960
+        assert entity["height"] == 720
+
+    @pytest.mark.parametrize(
+        "background, tint_colour",
+        [
+            ("white", "#005493"),
+            ("black", "#b3fdff"),
+            ("#111111", "#b3fdff"),
+        ],
+    )
+    def test_tint_colour_is_based_on_background(
+        self, fixtures_dir: Path, background: str, tint_colour: str
+    ) -> None:
+        """
+        The tint colour is based to suit the background.
+        """
+        # The poster image is a checkerboard pattern made of 2 different
+        # shades of turquoise, a light and a dark.
+        entity = create_video_entity(
+            fixtures_dir / "Sintel_360_10s_1MB_H264.mp4",
+            poster_path=fixtures_dir / "checkerboard.png",
+            background=background,
+        )
+        assert entity["poster"]["tint_colour"] == tint_colour
+
+    def test_video_with_thumbnail(self, fixtures_dir: Path, tmp_path: Path) -> None:
+        """
+        Create a low-resolution thumbnail of the poster image.
+        """
+        entity = create_video_entity(
+            fixtures_dir / "Sintel_360_10s_1MB_H264.mp4",
+            poster_path=fixtures_dir / "Sintel_360_10s_1MB_H264.png",
+            thumbnail_config={"out_dir": tmp_path / "thumbnails", "width": 300},
+        )
+
+        assert entity["poster"]["thumbnail_path"] == str(
+            tmp_path / "thumbnails/Sintel_360_10s_1MB_H264.png"
+        )
+        assert Path(entity["poster"]["thumbnail_path"]).exists()
+
+
+class TestGetMediaPaths:
+    """
+    Tests for `get_media_paths`.
+    """
+
+    def test_basic_image(self, fixtures_dir: Path) -> None:
+        """
+        An image with no thumbnail only has one path: the image.
+        """
+        entity = create_image_entity(fixtures_dir / "blue.png")
+        assert get_media_paths(entity) == {fixtures_dir / "blue.png"}
+
+    def test_image_with_thumbnail(self, fixtures_dir: Path, tmp_path: Path) -> None:
+        """
+        An image with a thumbnail has two paths: the video and the
+        thumbnail.
+        """
+        entity = create_image_entity(
+            fixtures_dir / "blue.png",
+            thumbnail_config={"out_dir": tmp_path / "thumbnails", "width": 300},
+        )
+        assert get_media_paths(entity) == {
+            fixtures_dir / "blue.png",
+            tmp_path / "thumbnails/blue.png",
+        }
+
+    def test_video(self, fixtures_dir: Path) -> None:
+        """
+        A video has two paths: the video and the poster image.
+        """
+        entity = create_video_entity(
+            fixtures_dir / "Sintel_360_10s_1MB_H264.mp4",
+            poster_path=fixtures_dir / "Sintel_360_10s_1MB_H264.png",
+        )
+        assert get_media_paths(entity) == {
+            fixtures_dir / "Sintel_360_10s_1MB_H264.mp4",
+            fixtures_dir / "Sintel_360_10s_1MB_H264.png",
+        }
+
+    def test_video_with_subtitles(self, fixtures_dir: Path) -> None:
+        """
+        A video with subtitles has three paths: the video, the subtitles,
+        and the poster image.
+        """
+        entity = create_video_entity(
+            fixtures_dir / "Sintel_360_10s_1MB_H264.mp4",
+            poster_path=fixtures_dir / "Sintel_360_10s_1MB_H264.png",
+            subtitles_path=fixtures_dir / "Sintel_360_10s_1MB_H264.en.vtt",
+        )
+        assert get_media_paths(entity) == {
+            fixtures_dir / "Sintel_360_10s_1MB_H264.mp4",
+            fixtures_dir / "Sintel_360_10s_1MB_H264.png",
+            fixtures_dir / "Sintel_360_10s_1MB_H264.en.vtt",
+        }
+
+    def test_video_with_thumbnail(self, fixtures_dir: Path, tmp_path: Path) -> None:
+        """
+        A video with a poster thumbnail has three paths: the video,
+        the poster image, and the poster thumbnail.
+        """
+        entity = create_video_entity(
+            fixtures_dir / "Sintel_360_10s_1MB_H264.mp4",
+            poster_path=fixtures_dir / "Sintel_360_10s_1MB_H264.png",
+            thumbnail_config={"out_dir": tmp_path / "thumbnails", "width": 300},
+        )
+        assert get_media_paths(entity) == {
+            fixtures_dir / "Sintel_360_10s_1MB_H264.mp4",
+            fixtures_dir / "Sintel_360_10s_1MB_H264.png",
+            tmp_path / "thumbnails/Sintel_360_10s_1MB_H264.png",
+        }
+
+    @pytest.mark.parametrize("bad_entity", [{}, {"type": "shape"}])
+    def test_unrecognised_entity_is_error(self, bad_entity: Any) -> None:
+        """
+        Getting media paths for an unrecognised entity type is a TypeError.
+        """
+        with pytest.raises(TypeError):
+            get_media_paths(bad_entity)