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

View File

@@ -14,6 +14,7 @@
import { useEffect, useRef } from "react";
import { LOW_POWER } from "../../lib/lowPower";
import { creatureById, createSwimmer, pose, stepSwimmer } from "../../lib/tippen/aquarium";
import type { CreatureId, Swimmer } from "../../lib/tippen/aquarium";
@@ -37,8 +38,11 @@ export function AquariumCreatures({ creatures, opacity }: Props) {
const present = useRef<ReadonlySet<CreatureId>>(new Set(creatures));
useEffect(() => {
// Reduced motion: every pet is still placed and shown, it just holds still.
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Reduced motion: every pet is still placed and shown, it just holds still. `?pi=1`
// takes the same door: on that device the point is that nothing runs a frame loop,
// and the pets are the reward - they should be *there*, they just need not swim.
const reducedMotion =
LOW_POWER || window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let frame = 0;
let lastTime = performance.now();

View File

@@ -1,5 +1,7 @@
/** The gradient stage every screen sits on - the app's one full-height container.
* `data-blur` is read by app.css to drop the backdrop filters wholesale.
* `data-blur` is read by app.css to drop the backdrop filters wholesale, and `data-anim`
* by tippen.css to stop the one CSS animation loop that is not a component's to switch
* off - the pulse on the next key to press.
*
* The pets swim here rather than on the home screen, so they stay with her on the lesson
* map and during a run too. */
@@ -7,6 +9,7 @@
import type { ReactNode } from "react";
import type { CreatureId } from "../../lib/tippen/aquarium";
import { LOW_POWER } from "../../lib/lowPower";
import { SHOW_AQUARIUM_CREATURES, SHOW_BUBBLES, SHOW_GLASS_BLUR } from "../../lib/tippen/theme";
import { AquariumCreatures } from "./AquariumCreatures";
import { Bubbles } from "./Bubbles";
@@ -21,7 +24,11 @@ interface Props {
export function Stage({ children, creatures, dimmed }: Props) {
return (
<div className="tp-stage" data-blur={SHOW_GLASS_BLUR ? "on" : "off"}>
<div
className="tp-stage"
data-blur={SHOW_GLASS_BLUR ? "on" : "off"}
data-anim={LOW_POWER ? "off" : "on"}
>
{SHOW_AQUARIUM_CREATURES && <AquariumCreatures creatures={creatures} opacity={dimmed ? 0.25 : 1} />}
{SHOW_BUBBLES && <Bubbles />}
{children}

View File

@@ -24,9 +24,18 @@
* grid included. The most expensive-looking CSS in the app is the thing keeping the
* rest of it cheap. Do not "optimize" it away without re-running the numbers above.
*
* So the profile is one real change - paint a quarter of the pixels - plus three caps
* that only bite while something is playing (bubbles spawn, curves are sampled per
* frame, the progress bar re-renders) and that are therefore not in the table above.
* That got it from 53.5% to 25%, and 25% was still not enough to use. The profile now
* goes further and stops the animation instead of thinning it: under `?pi=1` the
* ambient canvas is not rendered at all (`SHOW_AMBIENCE`), no bubbles rise anywhere,
* the decorative CSS loops are off, the view transitions are off, and the typing game's
* pets are placed but held still. A requestAnimationFrame loop that repaints the whole
* viewport is a floor you cannot get under while it runs at all, and on a Pi 4 that
* floor is too high. With it gone the browser has nothing to do between one keypress
* and the next. `.stage` keeps its own static CSS gradient, so the screen still has a
* sea behind it - it just no longer moves or follows the track.
*
* What stays: both blurs on the panels (see the table - dropping those was five times
* worse), and the card blur off, which is a count problem rather than a blur problem.
*
* Read once at module load, from the URL and nowhere else: no persistence, no
* auto-detection. The kiosk points Firefox at `http://localhost:8080/?pi=1` (see the
@@ -67,6 +76,10 @@ export interface AmbienceQuality {
maxBubbles: number;
}
/** Unreachable under `?pi=1` as things stand, because `SHOW_AMBIENCE` switches the
* canvas off there entirely. Kept, and kept accurate, because it is the setting that
* matters the moment anyone turns the canvas back on for a weak device - the half-scale
* backing store is what took it from 53.5% to 25%. */
export const AMBIENCE_QUALITY: AmbienceQuality = LOW_POWER
? { resolutionScale: 0.5, maxBackingStorePx: 1280, maxFps: 30, maxBubbles: 60 }
: { resolutionScale: 1, maxBackingStorePx: 4096, maxFps: 0, maxBubbles: 0 };

View File

@@ -33,7 +33,7 @@ export const ROW_SPACING = 25;
/** Fade the album/category grid in (with a slight upward slide) when a group or
* category is entered, and the album modal in when it opens. One animation per
* container, not per card, so cost stays flat regardless of grid size. */
export const ANIMATE_VIEW_TRANSITIONS = true;
export const ANIMATE_VIEW_TRANSITIONS = !LOW_POWER;
/** Frost the glass panels/cards/rows with a real backdrop blur.
*
@@ -45,13 +45,16 @@ export const ANIMATE_VIEW_TRANSITIONS = true;
export const SHOW_GLASS_BLUR = true;
/** Render the play view's animated canvas background (gradient + bubbles, both
* redrawn every frame at 60fps). Off falls back to `.stage`'s own static CSS
* gradient, which is still underneath the canvas either way.
* redrawn every frame). Off falls back to `.stage`'s own static CSS gradient, which is
* still underneath the canvas either way - so the screen keeps a background, it just
* stops being a per-track one and stops moving.
*
* Deliberately still on under `?pi=1`: it is the app's whole look, and
* `AMBIENCE_QUALITY` in lib/lowPower.ts makes it affordable (quarter of the pixels,
* half the frames) rather than making it go away. */
export const SHOW_AMBIENCE = true;
* Off under `?pi=1`. Halving its resolution and its frame rate first (see
* `AMBIENCE_QUALITY`) was not enough on the device this exists for: a requestAnimationFrame
* loop that repaints the full viewport is a floor you cannot get under while it runs at
* all, and on a Pi 4 that floor is too high. Nothing else in the app needs a frame loop,
* so with this off the browser has nothing to do between one keypress and the next. */
export const SHOW_AMBIENCE = !LOW_POWER;
/** Frost the *repeated* glass surfaces - every album card in the grid, every row in a
* track list - as opposed to the handful of panels wrapped around them.
@@ -65,10 +68,11 @@ export const SHOW_AMBIENCE = true;
export const SHOW_CARD_BLUR = !LOW_POWER;
/** Run the purely decorative CSS animation loops: the room page's bubble field and
* the dolphin mascot's bob/swim. Left on even under `?pi=1`: switching them off on the
* Pi moved the idle figure by 0.4 percentage points, which is noise, and they are the
* kind of detail this app is for. */
export const SHOW_DECORATIVE_ANIMATIONS = true;
* the dolphin mascot's bob/swim. Off under `?pi=1`. On their own they measured as
* noise, but "nothing on this screen moves by itself" is a property worth having
* outright rather than a sum of small wins - a compositor with no animation to service
* has nothing to wake up for. */
export const SHOW_DECORATIVE_ANIMATIONS = !LOW_POWER;
// ------------------------------------------------------------------ colors --

View File

@@ -4,6 +4,8 @@
* through components. The hue lives in styles/app.css because CSS is where it is used;
* it is repeated here only for the canvas, which cannot read a custom property. */
import { LOW_POWER } from "../lowPower";
/** The turquoise lagoon. Music is 210, Hörbücher 55, "Mein Zimmer" 300. */
export const HUE = 175;
@@ -14,15 +16,20 @@ export const HUE = 175;
export const SHOW_GLASS_BLUR = true;
/** The decorative rising bubbles behind everything. A dozen elements on a CSS transform
* loop; measured as noise on the Pi, so they stay. */
export const SHOW_BUBBLES = true;
* loop - off under `?pi=1`, like every other loop in the app. */
export const SHOW_BUBBLES = !LOW_POWER;
/** The earned pets swimming behind every screen. The reward that is always in view - off
* only to rule it out when chasing a performance problem. */
* only to rule it out when chasing a performance problem.
*
* Stays on under `?pi=1`, but the pets hold still there: `AquariumCreatures` already has
* that exact mode for `prefers-reduced-motion`, where every pet is placed and shown and
* simply does not swim. Taking the animation away is the point; taking away what she
* earned is not. */
export const SHOW_AQUARIUM_CREATURES = true;
/** Fade screens in on entry. */
export const ANIMATE_VIEW_TRANSITIONS = true;
export const ANIMATE_VIEW_TRANSITIONS = !LOW_POWER;
/** Show the on-screen keyboard with the finger colours. "auto" fades it out key by key
* as each one is mastered - the scaffold that removes itself, which is the whole point

View File

@@ -214,6 +214,12 @@
animation: keyPulse 1.3s ease-out infinite;
}
/* `?pi=1` (`data-anim="off"`, set in Stage.tsx): the key still lifts and lightens, it
just stops breathing. The last animation loop left on that device. */
.tp-stage[data-anim="off"] .tp-kb-key[data-next="true"] {
animation: none;
}
.tp-kb-key[data-home="true"]::after {
/* The tactile bump on F and J, drawn so it can be pointed at on screen too. */
content: "";