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
223 lines
7.2 KiB
JavaScript
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;
|
|
},
|
|
};
|
|
}
|