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
+31 -6
View File
@@ -3,9 +3,11 @@
A YouTube playlist player for parties. Two browser windows run side by side:
- **Player** (`http://localhost:3000/`) — fullscreen poster background with a
small video frame that moves around, switching animation on every track (see
[Animations](#animations)). Consecutive videos overlap in a 5 second
audio/video crossfade, so there is never silence between tracks.
small video frame that moves around. Both the animation (see
[Animations](#animations)) and the poster behind it (see
[Backgrounds](#backgrounds)) change on every track. Consecutive videos
overlap in a 5 second audio/video crossfade, so there is never silence
between tracks.
- **Queue** (`http://localhost:3000/queue`) — add videos by URL, drag to
reorder, delete, see how much playtime is left, and pick the background and
animation.
@@ -33,9 +35,32 @@ autoplay, so the first click is what unlocks audio. One click per session.
whenever a length is still unknown.
- The queue is saved to `queue.json` on every change and restored at startup.
- Settings live in `config.js`: overlap length, frame size, drift speed, corner
screenshot interval and margin, the drag animation's size range, port, and
the selectable backgrounds and animations. Anything the browser needs must
also be mirrored in the `/config.js` route in `server/index.js`.
screenshot interval and margin, the drag animation's size range, the
background zoom range, port, and the animation list. Everything there except
`PORT` is mirrored to the browser by the generated `/config.js` route in
`server/index.js`, so a new constant needs no second list.
## Backgrounds
Every image file in `assets/` is a background — drop one in, restart, and it
joins the rotation with a button on the queue page named after the file. There
is no list to maintain.
Like the animations, the background changes on every track: a different poster,
at a random zoom between 1.15× and 1.9×, cropped around a randomly chosen point
of it, cross-fading over about a second. The posters are portrait prints and
the screen is wide, so the crop is mostly a question of which band of the
poster to keep — the zoom never goes below 1.15×, because at 1× a wide screen
shows the poster's full width, paper margin included.
Each crop is resized off the main thread and drawn into a screen-sized canvas
(`public/player/backdrop.js`), so the cross-fade never has to composite a
17-megapixel layer, and the full-size scans are never held in memory. This is
why `assets/` can hold 300 dpi print files without the player caring.
The queue page's **Background** buttons pin one poster the same way the
animation buttons pin one animation; **Auto** hands selection back to the same
shuffle bag the animations use.
## Animations
+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;
+209
View File
@@ -0,0 +1,209 @@
// The fullscreen poster behind everything else. Like the animations, the
// background rotates on every track change: a different poster, shown at a
// different zoom and cropped around a different point of it.
//
// The posters are portrait print scans and the screen is wide, so most of each
// image is thrown away before it is ever shown. Rather than hand the browser a
// 17-megapixel layer and let CSS crop it — which costs a texture upload per
// poster and stalls the compositor for seconds mid-crossfade — each crop is
// resized off the main thread and drawn once into a canvas the size of the
// screen. The fade then happens between two screen-sized layers, which is
// something the compositor can do without noticing.
import {
BACKGROUNDS,
AUTO_BACKGROUND,
BACKGROUND_MIN_ZOOM,
BACKGROUND_MAX_ZOOM,
BACKGROUND_FOCUS_SPREAD,
BACKGROUND_MAX_OVERSAMPLE,
} from '/config.js';
import { createPicker } from './motion.js';
const KEYS = Object.keys(BACKGROUNDS);
/**
* A zoom level and the point of the image the crop closes in on, in percent.
* The focal point is pulled toward the middle by BACKGROUND_FOCUS_SPREAD, so a
* crop never lands on the poster's blank margin. Pure apart from the RNG.
*/
export function randomFraming(random = Math.random) {
const axis = () => 50 + (random() - 0.5) * 100 * BACKGROUND_FOCUS_SPREAD;
return {
zoom: BACKGROUND_MIN_ZOOM + random() * (BACKGROUND_MAX_ZOOM - BACKGROUND_MIN_ZOOM),
x: axis(),
y: axis(),
};
}
/**
* The rectangle of the source image that ends up on screen. This is what
* `object-fit: cover` plus `object-position` would have picked, zoomed in on
* the focal point: the crop keeps the screen's aspect ratio, is as large as it
* can be at this zoom, and slides within the image so that the focal point of
* the crop sits over the same point of the image.
*/
export function cropRect(image, screen, { zoom, x, y }) {
const cover = Math.max(screen.width / image.width, screen.height / image.height);
const scale = cover * zoom;
// Never ask for more than the image has: at zoom 1 the crop is exactly the
// covering rectangle, so the clamp only ever bites on rounding.
const width = Math.min(screen.width / scale, image.width);
const height = Math.min(screen.height / scale, image.height);
return {
x: (image.width - width) * (x / 100),
y: (image.height - height) * (y / 100),
width,
height,
};
}
/**
* Load a poster already resized to the resolution this crop needs. Decoding
* and resizing both happen off the main thread; the full-size bitmap is never
* held anywhere we can see it.
*/
async function loadCrop(source, screen, framing) {
const response = await fetch(source);
if (!response.ok) throw new Error(`background ${source}: ${response.status}`);
const blob = await response.blob();
const probe = await createImageBitmap(blob);
const crop = cropRect(probe, screen, framing);
// The crop is drawn at screen width, so anything beyond that many pixels
// across the crop is detail nobody can see. Sampling a little above 1:1
// keeps a steep zoom from going soft; far above it is only work.
const wanted = Math.min(
probe.width,
Math.ceil((screen.width / crop.width) * probe.width * BACKGROUND_MAX_OVERSAMPLE),
);
if (wanted >= probe.width) return { bitmap: probe, crop };
const resized = await createImageBitmap(probe, {
resizeWidth: wanted,
resizeQuality: 'high',
});
// Measured rather than assumed: a browser that ignores the resize options
// hands back the full-size bitmap, and the crop has to match what it got.
const ratio = resized.width / probe.width;
probe.close();
return {
bitmap: resized,
crop: {
x: crop.x * ratio,
y: crop.y * ratio,
width: crop.width * ratio,
height: crop.height * ratio,
},
};
}
/**
* Two stacked canvases that cross-fade into each other. Only two exist however
* many posters there are, and neither holds a decoded poster once it has been
* drawn — the pixels on screen are all that is kept.
*/
export function startBackgroundCycle(layer, mode = AUTO_BACKGROUND) {
const canvases = [0, 1].map(() => {
const canvas = document.createElement('canvas');
layer.appendChild(canvas);
return canvas;
});
const picker = createPicker(KEYS.length);
let front = 0;
let current = null;
let framing = null;
// Guards against a slow load landing after a newer pick has been made — a
// track change during the load would otherwise fade in the older poster.
let token = 0;
function screenSize() {
// Capped: a 4K screen at devicePixelRatio 2 would ask for an 8K canvas to
// show a poster that was never that sharp to begin with.
const dpr = Math.min(window.devicePixelRatio || 1, 2);
return {
width: Math.max(1, Math.round(window.innerWidth * dpr)),
height: Math.max(1, Math.round(window.innerHeight * dpr)),
};
}
async function draw(canvas, key, wanted) {
const screen = screenSize();
const { bitmap, crop } = await loadCrop(BACKGROUNDS[key].src, screen, wanted);
canvas.width = screen.width;
canvas.height = screen.height;
canvas
.getContext('2d')
.drawImage(bitmap, crop.x, crop.y, crop.width, crop.height, 0, 0, screen.width, screen.height);
bitmap.close();
}
async function show(key, wanted = randomFraming()) {
if (!key || !BACKGROUNDS[key]) return null;
const mine = ++token;
const back = canvases[1 - front];
try {
await draw(back, key, wanted);
} catch {
// Missing or unreadable file: leave whatever is up there rather than
// fading to a blank screen.
return current;
}
if (mine !== token) return current;
back.classList.add('active');
canvases[front].classList.remove('active');
front = 1 - front;
current = key;
framing = wanted;
return current;
}
// A resize changes the crop's aspect ratio, so the poster has to be drawn
// again. Debounced: dragging a window edge would otherwise start a load per
// frame, and on the projector this fires once, at fullscreen.
let resizeTimer = 0;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (!current) return;
// Redrawn in place: the same poster at the same crop, so there is
// nothing to fade between.
draw(canvases[front], current, framing).catch(() => {});
}, 250);
});
function next() {
// Pinned: the track changed, but the operator asked for this one.
if (mode !== AUTO_BACKGROUND) return current;
return show(KEYS[picker.next()]);
}
function setMode(value) {
mode = BACKGROUNDS[value] ? value : AUTO_BACKGROUND;
return mode === AUTO_BACKGROUND ? show(KEYS[picker.next()]) : show(mode);
}
setMode(mode);
return {
next,
setMode,
get mode() {
return mode;
},
get key() {
return current;
},
};
}
+4 -3
View File
@@ -24,12 +24,13 @@ body {
inset: 0;
}
#backgrounds img {
/* Two canvases, cross-faded. The zoom and the crop are baked into the pixels
by backdrop.js, so these are always exactly screen-sized. */
#backgrounds canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 900ms ease;
/* Knock the poster back so the video frame stays the focal point — the
@@ -37,7 +38,7 @@ body {
filter: brightness(0.45) saturate(1.15);
}
#backgrounds img.active {
#backgrounds canvas.active {
opacity: 1;
}
+17 -20
View File
@@ -1,12 +1,13 @@
import { connect } from '/shared/socket.js';
import { OVERLAP_SECONDS, HARD_CUT_SECONDS, BACKGROUNDS, AUTO_ANIMATION } from '/config.js';
import { OVERLAP_SECONDS, HARD_CUT_SECONDS, AUTO_BACKGROUND, AUTO_ANIMATION } from '/config.js';
import { createPlayer, loadApi, STATE } from './yt.js';
import { startAnimationCycle } from './motion.js';
import { startBackgroundCycle } from './backdrop.js';
import { requestCapture } from './capture.js';
const TICK_MS = 250;
let state = { items: [], currentId: null, background: 'pink', animation: AUTO_ANIMATION };
let state = { items: [], currentId: null, background: AUTO_BACKGROUND, animation: AUTO_ANIMATION };
let decks = [];
let active = 0;
let transition = null; // { until, duration, from, to } while crossfading
@@ -17,22 +18,12 @@ const socket = connect({ onMessage, onStatus });
// --- backgrounds --------------------------------------------------------
const backgroundLayer = document.getElementById('backgrounds');
const backgroundImages = new Map();
for (const [key, { src }] of Object.entries(BACKGROUNDS)) {
const img = document.createElement('img');
img.src = src;
img.alt = '';
backgroundLayer.appendChild(img);
backgroundImages.set(key, img);
}
function applyBackground(key) {
for (const [name, img] of backgroundImages) img.classList.toggle('active', name === key);
}
applyBackground(state.background);
// Runs from page load, not from the start gate: the poster is what is on
// screen while the party waits for someone to click Start.
const backgrounds = startBackgroundCycle(
document.getElementById('backgrounds'),
state.background,
);
// --- start gate ---------------------------------------------------------
@@ -247,8 +238,9 @@ function finishTransition() {
decks[to].setOpacity(1);
active = to;
// New track, new animation.
// New track, new animation, new poster and crop.
animations?.next();
backgrounds.next();
if (advance) socket.send({ type: 'trackEnded', id: outgoingId });
}
@@ -326,8 +318,9 @@ function onMessage(msg) {
if (msg.type !== 'state') return;
const previousAnimation = state.animation;
const previousBackground = state.background;
state = msg.state;
applyBackground(state.background);
if (state.background !== previousBackground) backgrounds.setMode(state.background);
if (state.animation !== previousAnimation) animations?.setMode(state.animation);
updateGate();
pumpProbe();
@@ -349,6 +342,9 @@ window.__player = {
nextAnimation() {
return animations?.next() ?? null;
},
nextBackground() {
return backgrounds.next();
},
get state() {
return {
state,
@@ -356,6 +352,7 @@ window.__player = {
transition,
decks: decks.map((d) => d.itemId),
animation: animations?.name ?? null,
background: backgrounds.key,
};
},
};
+8 -5
View File
@@ -1,7 +1,7 @@
import { connect, formatDuration } from '/shared/socket.js';
import { BACKGROUNDS, ANIMATIONS, AUTO_ANIMATION } from '/config.js';
import { BACKGROUNDS, AUTO_BACKGROUND, ANIMATIONS, AUTO_ANIMATION } from '/config.js';
let state = { items: [], currentId: null, background: 'pink', animation: AUTO_ANIMATION };
let state = { items: [], currentId: null, background: AUTO_BACKGROUND, animation: AUTO_ANIMATION };
// Last playhead report, kept with its arrival time so the countdown can be
// extrapolated smoothly between the 1 Hz updates.
let progress = { id: null, currentTime: 0, at: 0 };
@@ -209,15 +209,18 @@ function buildSwitcher(box, options, type, currentKey) {
};
}
// Auto leads in both rows: it is the normal party setting, the named ones are
// for holding still on one while you look at it.
const renderBackground = buildSwitcher(
backgroundBox,
Object.entries(BACKGROUNDS).map(([key, { label }]) => [key, label]),
[
[AUTO_BACKGROUND, 'Auto'],
...Object.entries(BACKGROUNDS).map(([key, { label }]) => [key, label]),
],
'setBackground',
() => state.background,
);
// Auto leads: it is the normal party setting, the named ones are for testing
// a single animation without waiting for the cycle to land on it.
const renderAnimation = buildSwitcher(
animationBox,
[
+2 -2
View File
@@ -8,7 +8,7 @@ import express from 'express';
import { WebSocketServer } from 'ws';
import * as config from '../config.js';
import { PORT, BACKGROUNDS } from '../config.js';
import { PORT } from '../config.js';
import { parseVideoId, fetchTitle } from './youtube.js';
import * as store from './state.js';
@@ -101,7 +101,7 @@ const handlers = {
},
setBackground(_ws, msg) {
if (!(msg.key in BACKGROUNDS)) return;
if (!store.isBackground(msg.key)) return;
store.getState().background = msg.key;
commit();
},
+7 -1
View File
@@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto';
import {
DEFAULT_BACKGROUND,
BACKGROUNDS,
AUTO_BACKGROUND,
DEFAULT_ANIMATION,
ANIMATIONS,
AUTO_ANIMATION,
@@ -33,6 +34,11 @@ export function isAnimation(key) {
return key === AUTO_ANIMATION || key in ANIMATIONS;
}
/** The same, for the background rotation. */
export function isBackground(key) {
return key === AUTO_BACKGROUND || key in BACKGROUNDS;
}
export function load() {
try {
const saved = JSON.parse(readFileSync(STORE_PATH, 'utf8'));
@@ -46,7 +52,7 @@ export function load() {
duration: Number.isFinite(item.duration) ? item.duration : null,
}));
}
if (saved.background in BACKGROUNDS) state.background = saved.background;
if (isBackground(saved.background)) state.background = saved.background;
if (isAnimation(saved.animation)) state.animation = saved.animation;
// Resume at the item that was playing, otherwise at the top of the list.
state.currentId = state.items.some((i) => i.id === saved.currentId)