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

@@ -95,34 +95,35 @@ seeking is the one thing a real player can do that the mockup could not.
## The Raspberry Pi profile
`?pi=1` loads the same app with the ambient canvas painting a quarter of the pixels. It
exists for musicdolphin - a Pi 4 driving a 1920x1080 kiosk screen - where Firefox
rasterizes 2D canvas in the content process, and the canvas repaints the whole screen
every frame whether or not anything is playing.
`?pi=1` loads the same app with nothing on screen animating itself. It exists for
musicdolphin - a Pi 4 driving a 1920x1080 kiosk screen.
Everything in `src/lib/lowPower.ts` was measured on the device. Sum of the Firefox
process tree, idle on the browse screen, 15 s average:
| | Full | `?pi=1` |
|---|---|---|
| Ambient canvas (gradient + bubbles) | on | **not rendered** |
| Decorative CSS loops, view transitions | on | off |
| Typing game bubbles, next-key pulse | on | off |
| Typing game pets | swimming | placed, held still |
| `backdrop-filter` on cards and list rows | on | off |
| `backdrop-filter` on panels | on | **on** |
| Progress-bar re-renders | every animation frame | 10 per second |
| | idle CPU |
|---|---|
| full app | 53.5% |
| + ambient canvas at half resolution | **25.0%** |
| + 30 fps cap on top of that | 25.0% |
| + decorative CSS animation loops off | 24.6% |
| + `backdrop-filter` glass blur off | **118.1%** |
Two of those rows are worth knowing the reasons for, because both are the opposite of
what they look like.
Two things to take from that table. The canvas resolution is the whole win, and
**turning off the backdrop blur makes it five times worse** - the blur is what promotes
each glass panel to its own compositing layer, so a canvas frame underneath repaints
only the canvas. Without it the canvas and the album grid above it share one layer and
every frame repaints all of it. The most expensive-looking CSS in the app is what keeps
the rest of it cheap; `SHOW_GLASS_BLUR` stays on everywhere, and so do the decorative
loops, which measured as noise.
**The panel blur stays on.** Dropping it measured *five times worse* on the device -
118% of a core against 25%. `backdrop-filter` is what promotes each glass panel to its
own compositing layer; without it the panels and everything under them collapse into
one layer that repaints wholesale. The card blur goes because that is a different
problem: three hundred of them in one grid, not one per panel.
So the profile is one real change plus three caps that only bite while something is
playing, and are therefore not in the table: 30 fps on the canvas, 60 bubbles alive at
once, and ten progress-bar re-renders a second instead of sixty (`usePlaybackClock`
pushes a React `setState` per animation frame, into both `PlayView` and `PlayerBar`).
**The canvas goes away rather than getting cheaper.** Half resolution and 30 fps took
it from 53.5% of a core to 25.0%, 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. `.stage` keeps its own static CSS gradient, so there is still a
sea behind everything - it just no longer moves or follows the track.
`AMBIENCE_QUALITY` in `src/lib/lowPower.ts` is kept accurate for whoever turns the
canvas back on.
The flag is read once at module load from the URL and nowhere else - no persistence, no
auto-detection. That is what makes "is this the profile or the hardware?" answerable by