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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user