From a3b55fe8d3c6e30e02c871c4d1b201a92045a3e9 Mon Sep 17 00:00:00 2001 From: Lemmy Date: Mon, 13 Jul 2026 22:40:45 -0400 Subject: [PATCH] ci(validate): require 960x540 plugin thumbnails --- .github/workflows/validate-plugins.py | 68 ++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate-plugins.py b/.github/workflows/validate-plugins.py index 7e3516e..6fe3c8d 100644 --- a/.github/workflows/validate-plugins.py +++ b/.github/workflows/validate-plugins.py @@ -24,8 +24,15 @@ RESERVED_NAMES = {"license", "readme", "index", "api", "admin", "static", "asset # the thumbnail as its card, and the English catalog backs every label_key. REQUIRED_PLUGIN_FILES = ("README.md", "thumbnail.webp", "translations/en.json") THUMBNAIL_MAX_BYTES = 512 * 1024 +# The store and the website lay out plugin cards on a fixed 16:9 grid, so the thumbnail +# generator exports exactly this. +THUMBNAIL_SIZE = (960, 540) +THUMBNAIL_GENERATOR_URL = "https://assets.noctalia.dev/plugins/thumbnail-generator.html" WEBP_MAGIC_PREFIX = b"RIFF" WEBP_MAGIC_FORMAT = b"WEBP" +# Enough of the file to cover the RIFF header plus the first chunk header and the widest +# dimension field of any WebP variant. +WEBP_HEADER_BYTES = 32 ROOT_STRING_FIELDS = ( "id", @@ -106,6 +113,47 @@ def rel(root: Path, path: Path) -> str: return path.as_posix() +def webp_dimensions(header: bytes) -> tuple[int, int] | None: + """Width and height from a WebP header, or None if it is not one we can read. + + The three container variants each store the size differently, and the generator's + output is only one of them, so all three are handled. + """ + if len(header) < 16 or header[:4] != WEBP_MAGIC_PREFIX or header[8:12] != WEBP_MAGIC_FORMAT: + return None + + fourcc = header[12:16] + payload = header[20:] + + if fourcc == b"VP8X": + # Extended: 4 bytes of flags, then canvas width and height as 24-bit values, + # each stored as size minus one. + if len(payload) < 10: + return None + width = int.from_bytes(payload[4:7], "little") + 1 + height = int.from_bytes(payload[7:10], "little") + 1 + return width, height + + if fourcc == b"VP8 ": + # Lossy: a 3-byte frame tag, the start code, then two 14-bit dimensions. + if len(payload) < 10 or payload[3:6] != b"\x9d\x01\x2a": + return None + width = int.from_bytes(payload[6:8], "little") & 0x3FFF + height = int.from_bytes(payload[8:10], "little") & 0x3FFF + return width, height + + if fourcc == b"VP8L": + # Lossless: a signature byte, then both dimensions minus one packed into 28 bits. + if len(payload) < 5 or payload[0] != 0x2F: + return None + bits = int.from_bytes(payload[1:5], "little") + width = (bits & 0x3FFF) + 1 + height = ((bits >> 14) & 0x3FFF) + 1 + return width, height + + return None + + def has_key_path(data: Any, dotted_key: str) -> bool: if isinstance(data, dict) and dotted_key in data: return True @@ -679,9 +727,27 @@ class Validator: ) with thumbnail.open("rb") as handle: - header = handle.read(12) + header = handle.read(WEBP_HEADER_BYTES) if header[:4] != WEBP_MAGIC_PREFIX or header[8:12] != WEBP_MAGIC_FORMAT: self.add_error(manifest_path, "thumbnail.webp is not a WebP image") + return + + dimensions = webp_dimensions(header) + expected = f"{THUMBNAIL_SIZE[0]}x{THUMBNAIL_SIZE[1]}" + if dimensions is None: + self.add_error( + manifest_path, + f"thumbnail.webp dimensions could not be read; export a {expected} WebP " + f"with {THUMBNAIL_GENERATOR_URL}", + ) + return + + if dimensions != THUMBNAIL_SIZE: + self.add_error( + manifest_path, + f"thumbnail.webp is {dimensions[0]}x{dimensions[1]}; it must be {expected}. " + f"Export one with {THUMBNAIL_GENERATOR_URL}", + ) def validate_no_symlinks(self, manifest_path: Path, plugin_dir: Path) -> None: for path in plugin_dir.rglob("*"):