Rotate the background with every track

The poster behind the frame was one of two, picked by hand and then left
there all night. It now turns over on every track change the way the
animations do, and never as the same picture twice: a shuffle bag over
whatever images sit in assets/, each shown at a random zoom between 1.15
and 1.9 and cropped around a randomly chosen point of the poster.

The list is read from the directory rather than written out in config.js.
The old one named two files that are no longer there, which is the failure
mode a hand-kept list has; now a poster joins the rotation, with its own
button on the queue page, by being dropped in the folder.

Zoom never goes below 1.15. These are portrait prints and the screen is
wide, so cover fits the poster's full width and crops vertically — at 1
the paper margin shows as a grey stripe down both sides. 1.15 cuts at
least 13% off the width of every crop a 1920x1080 screen can produce.

The crop is drawn into a screen-sized canvas rather than handed to CSS as
an object-fit rule on a full-size <img>. The scans are 3508x4950, which is
a 70MB texture per layer and two of them composited for the length of the
fade; instead each pick is fetched, resized off the main thread to roughly
screen resolution, drawn once, and closed. A swap costs about 400ms of
async work, all of it before the fade begins, and no full-size bitmap is
held anywhere. Nothing about the posters' resolution reaches the
compositor, so assets/ can keep the 300 dpi originals.

Backgrounds pin and unpin like animations: DEFAULT_BACKGROUND is now the
auto cycle, the queue page's row leads with Auto, and isBackground()
guards the socket handler and the restored queue.json against a key that
no longer names a file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9v2Xoa6eoeGYpK6gp95k9
This commit is contained in:
simon
2026-08-10 20:54:10 +02:00
co-authored by Claude Opus 5
parent bb5f87ff05
commit db672b8b32
8 changed files with 334 additions and 43 deletions
+56 -6
View File
@@ -1,5 +1,9 @@
// Central knobs for the party player.
import { readdirSync } from 'node:fs';
import { dirname, extname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
export const PORT = Number(process.env.PORT) || 3000;
// Seconds of overlap between the outgoing and incoming video.
@@ -104,10 +108,56 @@ export const ANIMATIONS = {
export const AUTO_ANIMATION = 'auto';
export const DEFAULT_ANIMATION = AUTO_ANIMATION;
// Selectable fullscreen backgrounds, keyed by the id sent over the socket.
export const BACKGROUNDS = {
pink: { label: 'Pink', src: '/assets/PLAKAT_A2_pink_426x600mm.png' },
plain: { label: 'Classic', src: '/assets/PLAKAT_A2_426x600mm.png' },
};
// Selectable fullscreen backgrounds: whatever images sit in assets/, keyed by
// filename. Read from the directory rather than listed here so that dropping a
// poster in is all it takes — the list travels to both frontends over the
// generated /config.js.
const ASSET_DIR = join(dirname(fileURLToPath(import.meta.url)), 'assets');
const IMAGE_TYPES = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif']);
export const DEFAULT_BACKGROUND = 'pink';
function readBackgrounds() {
let files;
try {
files = readdirSync(ASSET_DIR);
} catch {
return {};
}
return Object.fromEntries(
files
.filter((file) => IMAGE_TYPES.has(extname(file).toLowerCase()))
.sort()
.map((file) => {
const key = file.slice(0, -extname(file).length);
const label = key.charAt(0).toUpperCase() + key.slice(1);
return [key, { label, src: `/assets/${encodeURIComponent(file)}` }];
}),
);
}
export const BACKGROUNDS = readBackgrounds();
// Cycle a fresh random background — and a fresh crop of it — on every track
// change, the way the animations rotate. Anything else pins the player to that
// one image, which is what the queue page's buttons are for.
export const AUTO_BACKGROUND = 'auto';
export const DEFAULT_BACKGROUND = AUTO_BACKGROUND;
// How far into a background a crop may zoom. The posters are portrait and the
// screen is wide, so a crop is mostly a question of which horizontal band of
// the poster to keep. The minimum sits above 1 because at exactly 1 a wide
// screen shows the poster's full width — paper margin and all, which reads as
// a grey stripe down each side.
export const BACKGROUND_MIN_ZOOM = 1.15;
export const BACKGROUND_MAX_ZOOM = 1.9;
// How much of the image the focal point may sit away from the centre, as a
// fraction of the full 0–100% range. Kept below 1 because a focal point right
// on an edge crops to the poster's margin, which is just empty paper.
export const BACKGROUND_FOCUS_SPREAD = 0.7;
// How many source pixels a crop may carry per screen pixel. The posters are
// 300 dpi print scans — far more detail than any screen shows — and the whole
// point of resizing them before drawing is to keep the crossfade cheap. A
// little above 1:1 keeps a steep zoom from looking soft.
export const BACKGROUND_MAX_OVERSAMPLE = 1.3;