Downscale cached cover art, and stop every animation loop under ?pi=1

Covers. Art out of an ID3 APIC frame is sized for a record sleeve: this library
averaged 3000x3000 and 580 kB per cover, 140 MB across 284 albums. The browser was
decoding nine megapixels - around 36 MB of bitmap - for every cover it painted, to show
it in a 185 px card, on a Pi with 2 GB of RAM. The largest any screen in this app asks
for is 340 px (the play view), so cache.store_cover now downscales to a 640 px long
edge, which leaves room for a tablet at devicePixelRatio 2 and cuts the decode about
twentyfold. Pillow was already a hard dependency, for colour extraction.

scan_library reuses an album whose fingerprint is unchanged without re-reading its
tags, so covers already on disk would never be rewritten - hence shrink_stored_covers(),
a pass at the top of a scan. Reading a JPEG's dimensions only parses its header, so
after the first run it costs one small read per album. Art already small enough is
returned byte-identical rather than re-encoded, so repeated scans cannot slowly grind
it down, and anything Pillow cannot read is passed through untouched: a cover that is
too big is a performance problem, a cover that is missing is a visible one.

Animation. Halving the ambient canvas to a quarter of the pixels at 30fps took it from
53.5% of a core to 25%, and 25% was still not good enough to use. A requestAnimationFrame
loop repainting the viewport is a floor you cannot get under while it runs at all, so
?pi=1 now switches it off outright rather than thinning it, along with the decorative
CSS loops, the view transitions, the typing game's bubbles and its next-key pulse. The
pets stay on screen but hold still, through the same path prefers-reduced-motion already
took - taking the animation away is the point, taking away what she earned is not.
.stage keeps its own static gradient, so there is still a sea behind everything.

Both blurs on the panels stay on. Dropping those measured five times worse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 15:44:02 +02:00
parent 3cdad1e714
commit 0ef5a04cb9
10 changed files with 245 additions and 49 deletions

View File

@@ -5,7 +5,8 @@ different amounts to produce::
<cache_dir>/
├── index.json cheap: tags and structure. Thrown away freely.
├── covers/<album_id>.jpg medium: art pulled out of an ID3 APIC frame
├── covers/<album_id>.jpg medium: art pulled out of an ID3 APIC frame,
│ downscaled to MAX_COVER_PX on the way in
└── analysis/<track_key>.json expensive: minutes of DSP per track
analysis/<track_key>.beats.json
analysis/<track_key>.curve.json
@@ -18,12 +19,15 @@ folder or re-sorting a section then costs nothing.
from __future__ import annotations
import io
import json
import logging
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
from typing import Any, Final, cast
from PIL import Image, UnidentifiedImageError
from musicmouse.library.analysis import BeatGrid, TrackAnalysis, TrackCurves
from musicmouse.library.models import Album, LibraryTrack
@@ -86,9 +90,37 @@ class LibraryCache:
def store_cover(self, album_id: str, data: bytes) -> Path:
path = self.cover_path(album_id)
path.write_bytes(data)
path.write_bytes(shrink_cover(data))
return path
def shrink_stored_covers(self) -> int:
"""Rewrite any already-stored cover that predates :data:`MAX_COVER_PX`.
Needed because :func:`~musicmouse.library.scanner.scan_library` reuses an album
whose fingerprint is unchanged *without* re-reading its tags, so a cover written
by an older version would otherwise never be touched again. Reading a JPEG's
dimensions only parses its header, so once every file is within the limit this
costs one small read per album and nothing else.
Returns the number of files actually rewritten.
"""
rewritten = 0
for path in sorted(self.covers.glob("*.jpg")):
try:
with Image.open(path) as image:
oversized = max(image.size) > MAX_COVER_PX
except (OSError, UnidentifiedImageError):
continue
if not oversized:
continue
try:
shrunk = shrink_cover(path.read_bytes())
path.write_bytes(shrunk)
except OSError: # pragma: no cover - a cache we cannot write is not fatal
continue
rewritten += 1
return rewritten
# ------------------------------------------------------------------ analysis
def load_analysis(self, key: str) -> TrackAnalysis | None:
@@ -213,3 +245,44 @@ def _album_from_json(data: dict[str, Any]) -> Album:
for track in data["tracks"]
),
)
# ---------------------------------------------------------------------------- covers
#: Longest edge kept for cached album art, in pixels.
#:
#: The art that comes out of an ID3 APIC frame is sized for a record sleeve, not for a
#: screen: a real library here averaged 3000x3000 and 580 kB per cover, 140 MB for 284
#: albums. The browser was decoding nine megapixels - about 36 MB of bitmap - for every
#: cover it painted, to show it in a 185 px card on a Pi with 2 GB of RAM.
#:
#: 340 is the largest any of this app's screens asks for (the play view; the browse grid
#: asks for 180 and the player bar for 56), so 640 still leaves room for a tablet at
#: devicePixelRatio 2 and cuts the decode by about twenty times.
MAX_COVER_PX: Final = 640
#: Re-encode quality. At these dimensions the difference from 95 is invisible and the
#: file is a third of the size.
_COVER_JPEG_QUALITY: Final = 85
def shrink_cover(data: bytes) -> bytes:
"""Downscale cover art to :data:`MAX_COVER_PX` on its longest edge.
Art that is already small enough is returned untouched rather than re-encoded, so
repeated scans never degrade it. Anything Pillow cannot read is passed through
unchanged: a cover that is too big is a performance problem, a cover that is missing
is a visible one.
"""
try:
with Image.open(io.BytesIO(data)) as image:
if max(image.size) <= MAX_COVER_PX:
return data
# `thumbnail` keeps the aspect ratio and never scales up.
image = image.convert("RGB")
image.thumbnail((MAX_COVER_PX, MAX_COVER_PX), Image.Resampling.LANCZOS)
buffer = io.BytesIO()
image.save(buffer, format="JPEG", quality=_COVER_JPEG_QUALITY, optimize=True)
return buffer.getvalue()
except (OSError, UnidentifiedImageError, ValueError):
return data

View File

@@ -295,6 +295,13 @@ def scan_library(
figure name to what it holds, which is the one thing the folders cannot say.
"""
cache.prepare()
# Covers written before MAX_COVER_PX existed are still whatever size the tag held,
# and the reuse path below means an unchanged album never rewrites its own. One
# pass here catches them; after the first run every file is already small and this
# is 300-odd header reads.
shrunk = cache.shrink_stored_covers()
if shrunk:
_log.info("Downscaled %d oversized cover(s) in the cache", shrunk)
known = known or {}
out: dict[str, tuple[Album, Fingerprint]] = {}
last_report = time.monotonic()

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import contextlib
import io
import json
import os
import threading
@@ -726,3 +727,76 @@ async def test_a_parallel_pass_is_cancellable_mid_flight(config_dir: Path) -> No
def test_the_default_worker_count_leaves_a_core_for_everything_else() -> None:
count = default_worker_count()
assert 1 <= count <= max(1, (os.cpu_count() or 1) - 1)
class TestCoverDownscaling:
"""Album art arrives sized for a record sleeve; screens want a fraction of that."""
@staticmethod
def _jpeg(size: tuple[int, int]) -> bytes:
from PIL import Image
buffer = io.BytesIO()
Image.new("RGB", size, (120, 40, 200)).save(buffer, format="JPEG")
return buffer.getvalue()
def test_oversized_art_is_shrunk_to_the_long_edge(self) -> None:
from PIL import Image
from musicmouse.library.cache import MAX_COVER_PX, shrink_cover
original = self._jpeg((3000, 2000))
shrunk = shrink_cover(original)
with Image.open(io.BytesIO(shrunk)) as image:
width, height = image.size
assert max(width, height) == MAX_COVER_PX
# Aspect ratio survives, to within the rounding of a whole pixel.
assert abs(width / height - 3000 / 2000) < 0.01
assert len(shrunk) < len(original)
def test_art_already_small_enough_is_returned_untouched(self) -> None:
"""Byte-identical, not merely similar: repeated scans must not re-encode art
over and over, each pass losing a little more to JPEG."""
from musicmouse.library.cache import shrink_cover
original = self._jpeg((300, 300))
assert shrink_cover(original) is original
def test_unreadable_art_is_passed_through_rather_than_dropped(self) -> None:
"""A cover too big is a performance problem; a cover missing is a visible one."""
from musicmouse.library.cache import shrink_cover
assert shrink_cover(b"not an image at all") == b"not an image at all"
def test_store_cover_shrinks_on_the_way_in(self, tmp_path: Path) -> None:
from PIL import Image
from musicmouse.library.cache import MAX_COVER_PX, LibraryCache
cache = LibraryCache(tmp_path)
cache.prepare()
path = cache.store_cover("abc123", self._jpeg((2400, 2400)))
with Image.open(path) as image:
assert max(image.size) == MAX_COVER_PX
def test_covers_written_by_an_older_version_are_migrated(self, tmp_path: Path) -> None:
"""The scanner reuses an unchanged album without re-reading its tags, so a
cover stored before the limit existed would otherwise never be rewritten."""
from PIL import Image
from musicmouse.library.cache import MAX_COVER_PX, LibraryCache
cache = LibraryCache(tmp_path)
cache.prepare()
stale = cache.cover_path("old")
stale.write_bytes(self._jpeg((3000, 3000)))
fresh = cache.cover_path("new")
fresh.write_bytes(self._jpeg((320, 320)))
fresh_before = fresh.read_bytes()
assert cache.shrink_stored_covers() == 1
with Image.open(stale) as image:
assert max(image.size) == MAX_COVER_PX
assert fresh.read_bytes() == fresh_before
# Second pass has nothing left to do - the cheap steady state.
assert cache.shrink_stored_covers() == 0