Files
party-player/public/player/backdrop.js
T
simonandClaude Opus 5 7706fe46c6 Lay the party logo over the background
Centred, spanning 60% of the screen width, on the poster's layer so the
video frame and the stills still pass in front of it. The file lives in
assets/ with the posters, so it has to be kept out of the background
rotation — it is the one image there that is never a background.

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

223 lines
7.2 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,
} from '/config.js';
import { createPicker } from './motion.js';
const KEYS = Object.keys(BACKGROUNDS);
/**
* Point the logo overlay at the configured file and size it against the
* screen. The element is in the page from the start — only the two values that
* belong to the config are set here.
*/
export function showLogo(element) {
if (!element) return;
element.src = LOGO_SRC;
element.style.width = `${LOGO_WIDTH * 100}vw`;
}
/**
* 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;
},
};
}