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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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