Files
party-player/public/player/backdrop.js
T
simonandClaude Opus 5 b846146992 Nudge the logo off centre
The lettering does not sit in the middle of its own image, so centring
the image leaves the logo looking pushed to one side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9v2Xoa6eoeGYpK6gp95k9
2026-08-10 23:50:42 +02:00

226 lines
7.4 KiB
JavaScript

// 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,
LOGO_SRC,
LOGO_WIDTH,
LOGO_OFFSET,
} from '/config.js';
import { createPicker } from './motion.js';
const KEYS = Object.keys(BACKGROUNDS);
/**
* Point the logo overlay at the configured file, size it against the screen
* and nudge it off centre. The element is in the page from the start — only
* the values that belong to the config are set here. The offset is a fraction
* of the logo's own width, which is what a percentage in the translate means.
*/
export function showLogo(element) {
if (!element) return;
element.src = LOGO_SRC;
element.style.width = `${LOGO_WIDTH * 100}vw`;
element.style.setProperty('--logo-shift', `${LOGO_OFFSET * 100}%`);
}
/**
* 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;
},
};
}