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.github/workflows/test.ymlCHANGELOG.mddev_requirements.txtpyproject.tomlsrc/chives/__init__.pysrc/chives/media.pytests/fixtures/Sintel_360_10s_1MB_AV1.mp4tests/fixtures/Sintel_360_10s_1MB_H264.mp4tests/fixtures/media/Landscape_3.jpgtests/fixtures/media/Mars 2020 EDL Remastered [HHhyznZ2u4E].jpgtests/fixtures/media/Mars 2020 EDL Remastered [HHhyznZ2u4E].mp4tests/fixtures/media/Sintel_360_10s_1MB_AV1.mp4tests/fixtures/media/Sintel_360_10s_1MB_H264.mp4tests/fixtures/media/Sintel_360_10s_1MB_H264.pngtests/fixtures/media/asteroid_belt.pngtests/fixtures/media/asteroid_belt_P.pngtests/fixtures/media/blue.pngtests/fixtures/media/blue_with_hole.pngtests/fixtures/media/checkerboard.pngtests/fixtures/media/electric_field.giftests/fixtures/media/space.jpgtests/fixtures/media/underlined_text.pngtests/fixtures/media/wings_tracking_shot.jpgtests/fixtures/media/wings_tracking_shot.mp4tests/test_media.py
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)