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

@@ -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}