// 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; }, }; }