diff --git a/beefy_player/artwork.py b/beefy_player/artwork.py index 8d8ffe4..f6b1bce 100644 --- a/beefy_player/artwork.py +++ b/beefy_player/artwork.py @@ -230,22 +230,38 @@ async def fetch(client: httpx.AsyncClient, url: str) -> bytes | None: def fit_image(data: bytes, width: int, height: int): - """Decode and center-crop an image to exactly ``width`` x ``height`` cells.""" + """Decode and letterbox an image into exactly ``width`` x ``height`` cells. + + Scale to fit, never crop. Square album art fills the box either way, but a + wide station wordmark (Bassdrive's logo is 3:1) used to be centre-cropped + down to a zoomed slice of its middle. The padding stays transparent, so + the mosaic behind the artwork shows through instead of a black bar. + """ try: from PIL import Image, ImageOps with Image.open(BytesIO(data)) as source: + # A PNG with transparency is drawn *over* the mosaic, which reads + # as noise behind a logo; composite those onto black instead. + has_alpha = source.mode in ("RGBA", "LA", "PA", "RGBa", "La") or ( + "transparency" in source.info + ) image = source.convert("RGBA") cell_width, cell_height = cell_size() target = ( max(1, width * cell_width), max(1, height * cell_height), ) - return ImageOps.fit( - image, - target, - method=Image.Resampling.LANCZOS, - centering=(0.5, 0.5), + fitted = ImageOps.contain(image, target, method=Image.Resampling.LANCZOS) + background = (0, 0, 0, 255) if has_alpha else (0, 0, 0, 0) + canvas = Image.new("RGBA", target, background) + canvas.alpha_composite( + fitted, + ( + (target[0] - fitted.width) // 2, + (target[1] - fitted.height) // 2, + ), ) + return canvas except Exception: return None diff --git a/tests/test_artwork.py b/tests/test_artwork.py index ff0fc4e..8a99b7d 100644 --- a/tests/test_artwork.py +++ b/tests/test_artwork.py @@ -9,9 +9,20 @@ from PIL import Image from beefy_player import artwork -def png_bytes(size=(400, 200)) -> bytes: +def png_bytes(size=(400, 200), alpha: int = 180) -> bytes: buffer = BytesIO() - Image.new("RGBA", size, (20, 180, 220, 180)).save(buffer, format="PNG") + Image.new("RGBA", size, (20, 180, 220, alpha)).save(buffer, format="PNG") + return buffer.getvalue() + + +def ink_bbox(image: Image.Image): + """Where the artwork itself is, ignoring transparent or black padding.""" + return image.convert("RGB").getbbox() + + +def opaque_png_bytes(size=(400, 200)) -> bytes: + buffer = BytesIO() + Image.new("RGB", size, (20, 180, 220)).save(buffer, format="PNG") return buffer.getvalue() @@ -35,12 +46,58 @@ def test_protocol_detection() -> None: assert artwork.detect_protocol({"SOMAFM_IMAGES": "0"}) is None -def test_fit_image_crops_to_the_cell_box() -> None: - fitted = artwork.fit_image(png_bytes(), 13, 8) +def test_fit_image_letterboxes_inside_the_cell_box() -> None: + fitted = artwork.fit_image(png_bytes((400, 200)), 13, 8) assert fitted is not None cell_width, cell_height = artwork.cell_size() - expected = (13 * cell_width) / (8 * cell_height) - assert fitted.width / fitted.height == pytest.approx(expected, rel=0.01) + assert (fitted.width, fitted.height) == (13 * cell_width, 8 * cell_height) + + # the ink keeps its own 2:1 aspect instead of being cropped to the box + ink = ink_bbox(fitted) + assert ink is not None + left, top, right, bottom = ink + assert (right - left) / (bottom - top) == pytest.approx(2.0, rel=0.02) + # a wide image is padded above and below, and spans the full width + assert left == 0 and right == fitted.width + assert top > 0 and bottom < fitted.height + + +def test_an_image_with_alpha_gets_a_black_backing() -> None: + """Transparent artwork is drawn over the mosaic; black keeps it readable.""" + fitted = artwork.fit_image(png_bytes((400, 200)), 13, 8) + assert fitted is not None + # every pixel is opaque now, and the padding is black rather than mosaic + assert fitted.getchannel("A").getextrema() == (255, 255) + assert fitted.getpixel((0, 0)) == (0, 0, 0, 255) + # the ink is still letterboxed, not cropped: its own 2:1 aspect survives + ink = ink_bbox(fitted) + assert ink is not None + left, top, right, bottom = ink + assert (right - left) / (bottom - top) == pytest.approx(2.0, rel=0.02) + + +def test_an_opaque_image_keeps_the_transparent_padding() -> None: + """No alpha channel: nothing to hide, so the mosaic shows through.""" + fitted = artwork.fit_image(opaque_png_bytes((400, 200)), 13, 8) + assert fitted is not None + assert fitted.getpixel((0, 0))[3] == 0 + ink = ink_bbox(fitted) + assert ink is not None + left, top, right, bottom = ink + assert (right - left) / (bottom - top) == pytest.approx(2.0, rel=0.02) + + +def test_fit_image_centres_a_tall_image() -> None: + fitted = artwork.fit_image(png_bytes((100, 400)), 13, 8) + assert fitted is not None + ink = ink_bbox(fitted) + assert ink is not None + left, _, right, _ = ink + assert left > 0 and right < fitted.width + assert right - left == pytest.approx(fitted.height / 4, rel=0.05) + + +def test_fit_image_rejects_garbage() -> None: assert artwork.fit_image(b"not an image", 13, 8) is None