A full corner used to swap its single still in place, so a fresh screenshot was indistinguishable from the one it replaced. Each corner now holds three stills nudged inward by 9% of the frame per depth, and the newest one moves to the top of the layer, leaving the edges of the ones below visible. Fill order is slot-major, so a new shot never lands where the previous shot at that corner was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
352 lines
11 KiB
JavaScript
352 lines
11 KiB
JavaScript
// Frame motion. Everything here writes to #frameMover's transform and nothing
|
||
// else, so additional animation layers can be nested inside without conflict.
|
||
//
|
||
// An animation is `start(context) => stop()`. `context` carries the element to
|
||
// move and a capture handle (possibly null). `stop()` must undo everything the
|
||
// animation set up — timers, listeners, DOM — because the next animation takes
|
||
// over the same element immediately.
|
||
|
||
import {
|
||
FRAME_SCALE,
|
||
BOUNCE_SPEED,
|
||
CORNER_INTERVAL_SECONDS,
|
||
CORNER_MARGIN,
|
||
AUTO_ANIMATION,
|
||
} from '/config.js';
|
||
|
||
const ASPECT = 9 / 16;
|
||
|
||
/**
|
||
* Frame size for a viewport: FRAME_SCALE of the width, but never taller than
|
||
* FRAME_SCALE of the height, so a wide screen does not get a letterbox strip.
|
||
*/
|
||
export function frameSize(viewportWidth, viewportHeight) {
|
||
let width = viewportWidth * FRAME_SCALE;
|
||
let height = width * ASPECT;
|
||
|
||
const maxHeight = viewportHeight * FRAME_SCALE;
|
||
if (height > maxHeight) {
|
||
height = maxHeight;
|
||
width = height / ASPECT;
|
||
}
|
||
|
||
return { width, height };
|
||
}
|
||
|
||
/** Size the frame to the current viewport and report the size back. */
|
||
function applyFrameSize(element) {
|
||
const size = frameSize(window.innerWidth, window.innerHeight);
|
||
element.style.width = `${size.width}px`;
|
||
element.style.height = `${size.height}px`;
|
||
return size;
|
||
}
|
||
|
||
function place(element, x, y) {
|
||
element.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0)`;
|
||
}
|
||
|
||
// --- picker -------------------------------------------------------------
|
||
|
||
/**
|
||
* Shuffle bag over 0..count-1: every animation comes up exactly once per pass,
|
||
* so selection stays even, and a swap at the bag boundary keeps the same one
|
||
* from landing twice in a row. Pure apart from the injected RNG.
|
||
*/
|
||
export function createPicker(count, random = Math.random) {
|
||
let bag = [];
|
||
let last = -1;
|
||
|
||
function refill() {
|
||
bag = Array.from({ length: count }, (_, i) => i);
|
||
|
||
// Fisher–Yates.
|
||
for (let i = bag.length - 1; i > 0; i--) {
|
||
const j = Math.floor(random() * (i + 1));
|
||
[bag[i], bag[j]] = [bag[j], bag[i]];
|
||
}
|
||
|
||
// next() pops off the end, so bag[length-1] is served first. A repeat can
|
||
// only happen right here, at the seam between two bags.
|
||
if (bag.length > 1 && bag[bag.length - 1] === last) {
|
||
const j = Math.floor(random() * (bag.length - 1));
|
||
[bag[bag.length - 1], bag[j]] = [bag[j], bag[bag.length - 1]];
|
||
}
|
||
}
|
||
|
||
return {
|
||
next() {
|
||
if (count <= 1) return 0;
|
||
if (bag.length === 0) refill();
|
||
last = bag.pop();
|
||
return last;
|
||
},
|
||
};
|
||
}
|
||
|
||
// --- bounce -------------------------------------------------------------
|
||
|
||
/**
|
||
* Diagonal drift with reflection off the viewport edges. Pure state + step, so
|
||
* the physics can be exercised without a browser and without waiting on rAF.
|
||
*/
|
||
export function createBouncer({ x = 0, y = 0 } = {}) {
|
||
const bouncer = {
|
||
x,
|
||
y,
|
||
// A shallow diagonal reads better than 45°, and never retraces its path.
|
||
vx: BOUNCE_SPEED,
|
||
vy: BOUNCE_SPEED * 0.62,
|
||
|
||
step(dt, bounds) {
|
||
const maxX = Math.max(0, bounds.width);
|
||
const maxY = Math.max(0, bounds.height);
|
||
|
||
bouncer.x += bouncer.vx * dt;
|
||
bouncer.y += bouncer.vy * dt;
|
||
|
||
// Reflect, mirroring the overshoot back inside the viewport.
|
||
if (bouncer.x <= 0) {
|
||
bouncer.x = Math.min(-bouncer.x, maxX);
|
||
bouncer.vx = Math.abs(bouncer.vx);
|
||
} else if (bouncer.x >= maxX) {
|
||
bouncer.x = Math.max(maxX - (bouncer.x - maxX), 0);
|
||
bouncer.vx = -Math.abs(bouncer.vx);
|
||
}
|
||
|
||
if (bouncer.y <= 0) {
|
||
bouncer.y = Math.min(-bouncer.y, maxY);
|
||
bouncer.vy = Math.abs(bouncer.vy);
|
||
} else if (bouncer.y >= maxY) {
|
||
bouncer.y = Math.max(maxY - (bouncer.y - maxY), 0);
|
||
bouncer.vy = -Math.abs(bouncer.vy);
|
||
}
|
||
|
||
return bouncer;
|
||
},
|
||
};
|
||
|
||
return bouncer;
|
||
}
|
||
|
||
export function startBounce({ element }) {
|
||
let size = applyFrameSize(element);
|
||
const bouncer = createBouncer({
|
||
x: (window.innerWidth - size.width) / 2,
|
||
y: (window.innerHeight - size.height) / 2,
|
||
});
|
||
|
||
function bounds() {
|
||
return {
|
||
width: window.innerWidth - size.width,
|
||
height: window.innerHeight - size.height,
|
||
};
|
||
}
|
||
|
||
function measure() {
|
||
size = applyFrameSize(element);
|
||
|
||
const limit = bounds();
|
||
bouncer.x = Math.min(bouncer.x, Math.max(0, limit.width));
|
||
bouncer.y = Math.min(bouncer.y, Math.max(0, limit.height));
|
||
}
|
||
|
||
window.addEventListener('resize', measure);
|
||
|
||
let last = performance.now();
|
||
let handle = 0;
|
||
let stopped = false;
|
||
|
||
function frame(now) {
|
||
if (stopped) return;
|
||
|
||
// Clamped so a stalled tab does not teleport the frame on resume.
|
||
const dt = Math.min((now - last) / 1000, 0.05);
|
||
last = now;
|
||
|
||
bouncer.step(dt, bounds());
|
||
place(element, bouncer.x, bouncer.y);
|
||
handle = requestAnimationFrame(frame);
|
||
}
|
||
|
||
handle = requestAnimationFrame(frame);
|
||
|
||
return function stop() {
|
||
// The flag matters as much as the cancel: a frame already queued would
|
||
// otherwise fire once more and fight whatever animation comes next.
|
||
stopped = true;
|
||
cancelAnimationFrame(handle);
|
||
window.removeEventListener('resize', measure);
|
||
};
|
||
}
|
||
|
||
// --- corners ------------------------------------------------------------
|
||
|
||
// Clockwise from the top left.
|
||
const CORNERS = 4;
|
||
|
||
/** Top-left offset for a corner, inset by `margin` from the viewport edge. */
|
||
export function cornerPosition(index, size, viewport, margin) {
|
||
const right = index === 1 || index === 2;
|
||
const bottom = index === 2 || index === 3;
|
||
|
||
return {
|
||
x: right ? Math.max(margin, viewport.width - size.width - margin) : margin,
|
||
y: bottom ? Math.max(margin, viewport.height - size.height - margin) : margin,
|
||
};
|
||
}
|
||
|
||
// How many stills each corner holds, and how far apart they sit as a fraction
|
||
// of the frame size. Deep enough that a corner reads as a pile, shallow enough
|
||
// that the stack stays in its own quarter of the screen.
|
||
const CORNER_STACK = 3;
|
||
const CORNER_STACK_STEP = 0.09;
|
||
|
||
/**
|
||
* Top-left offset for the `depth`-th still of a corner: the same spot as the
|
||
* corner itself, nudged inward (toward the middle of the screen) once per
|
||
* depth. Successive stills therefore never land on top of each other — each
|
||
* one leaves the edges of the ones below it showing.
|
||
*/
|
||
export function cornerStackPosition(index, depth, size, viewport, margin) {
|
||
const at = cornerPosition(index, size, viewport, margin);
|
||
const right = index === 1 || index === 2;
|
||
const bottom = index === 2 || index === 3;
|
||
const step = depth * CORNER_STACK_STEP;
|
||
|
||
return {
|
||
x: at.x + (right ? -1 : 1) * step * size.width,
|
||
y: at.y + (bottom ? -1 : 1) * step * size.height,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The video parks in one random corner and stays there; every few seconds a
|
||
* screenshot of it lands in the next of the three *other* corners, cycling
|
||
* clockwise. Because the video's own corner is never a slot, a still can never
|
||
* cover the video — and since the crop only ever covers the video's rect, a
|
||
* still never contains another still either.
|
||
*
|
||
* Each corner stacks CORNER_STACK stills at staggered offsets, and the newest
|
||
* shot always moves to the top of the layer, so a full corner still reads as
|
||
* something new arriving rather than as an unchanged picture.
|
||
*/
|
||
export function startCorners(context) {
|
||
const { element } = context;
|
||
const layer = document.getElementById('stills');
|
||
const home = Math.floor(Math.random() * CORNERS);
|
||
|
||
// The three non-home corners, in clockwise order starting after the video.
|
||
const slots = [1, 2, 3].map((step) => (home + step) % CORNERS);
|
||
|
||
// Slot-major: consecutive shots move to the next corner, and the depth only
|
||
// advances once every corner at the current depth has been filled.
|
||
const cells = [];
|
||
for (let depth = 0; depth < CORNER_STACK; depth += 1) {
|
||
for (const corner of slots) {
|
||
const img = document.createElement('img');
|
||
layer.appendChild(img);
|
||
cells.push({ img, corner, depth });
|
||
}
|
||
}
|
||
|
||
let next = 0;
|
||
let size;
|
||
|
||
function layout() {
|
||
size = applyFrameSize(element);
|
||
const viewport = { width: window.innerWidth, height: window.innerHeight };
|
||
const margin = Math.min(viewport.width, viewport.height) * CORNER_MARGIN;
|
||
|
||
const spot = cornerPosition(home, size, viewport, margin);
|
||
place(element, spot.x, spot.y);
|
||
|
||
for (const cell of cells) {
|
||
const at = cornerStackPosition(cell.corner, cell.depth, size, viewport, margin);
|
||
cell.img.style.width = `${size.width}px`;
|
||
cell.img.style.height = `${size.height}px`;
|
||
place(cell.img, at.x, at.y);
|
||
}
|
||
}
|
||
|
||
function shoot() {
|
||
// Read late: the capture handle may still be waiting on the operator to
|
||
// answer the screen-share prompt when this animation starts.
|
||
if (!context.capture) return;
|
||
|
||
const rect = element.getBoundingClientRect();
|
||
const shot = context.capture.grab(rect);
|
||
if (!shot) return;
|
||
|
||
const cell = cells[next];
|
||
cell.img.src = shot;
|
||
cell.img.classList.add('visible');
|
||
// Re-append so the freshest still paints over the rest of its pile.
|
||
layer.appendChild(cell.img);
|
||
next = (next + 1) % cells.length;
|
||
}
|
||
|
||
layout();
|
||
window.addEventListener('resize', layout);
|
||
const timer = setInterval(shoot, CORNER_INTERVAL_SECONDS * 1000);
|
||
|
||
return function stop() {
|
||
clearInterval(timer);
|
||
window.removeEventListener('resize', layout);
|
||
for (const cell of cells) cell.img.remove();
|
||
};
|
||
}
|
||
|
||
// --- registry -----------------------------------------------------------
|
||
|
||
// Keyed by the names in config.js, which is what travels over the socket.
|
||
export const ANIMATIONS = {
|
||
bounce: startBounce,
|
||
corners: startCorners,
|
||
};
|
||
|
||
const NAMES = Object.keys(ANIMATIONS);
|
||
|
||
/**
|
||
* Run one animation at a time. In auto mode next() swaps to a freshly picked
|
||
* one on every track change; pinned to a name (from the queue page, for
|
||
* testing) it stays put until the mode changes again.
|
||
*/
|
||
export function startAnimationCycle(context, mode = AUTO_ANIMATION) {
|
||
const picker = createPicker(NAMES.length);
|
||
let stop = null;
|
||
let current = null;
|
||
|
||
function run(name) {
|
||
stop?.();
|
||
current = name;
|
||
stop = ANIMATIONS[name](context);
|
||
return current;
|
||
}
|
||
|
||
function next() {
|
||
// Pinned: the track changed, but the operator asked for this one.
|
||
if (mode !== AUTO_ANIMATION) return current;
|
||
return run(NAMES[picker.next()]);
|
||
}
|
||
|
||
function setMode(value) {
|
||
mode = ANIMATIONS[value] ? value : AUTO_ANIMATION;
|
||
// Always restarts, so switching back to auto takes effect immediately
|
||
// rather than waiting out the current track.
|
||
return mode === AUTO_ANIMATION ? run(NAMES[picker.next()]) : run(mode);
|
||
}
|
||
|
||
setMode(mode);
|
||
|
||
return {
|
||
next,
|
||
setMode,
|
||
get mode() {
|
||
return mode;
|
||
},
|
||
get name() {
|
||
return current;
|
||
},
|
||
};
|
||
}
|