Cycle the frame through animations, add a corners effect
The frame had exactly one behaviour, started once and run forever. Make animations a start(context) => stop() lifecycle held in a registry, and draw a new one on every completed crossfade. Selection uses a shuffle bag: each animation comes up exactly once per pass, so they stay evenly used, and a swap at the pass boundary keeps the same one from running twice in a row. Written for a registry larger than today's two entries. The new corners animation parks the frame in a random corner and drops a screenshot of it into the next of the other three corners every 5s, going clockwise. The frame's own corner is never a slot, so a still cannot cover it by construction rather than by clamping. This holds while FRAME_SCALE stays under 0.5, now noted where that knob lives. Screenshots need getDisplayMedia: the decks are cross-origin YouTube iframes, so canvas taints and there is no other way to read the playing pixels. The prompt has to be requested before start() yields, or the click's transient activation is gone. Declining, or serving over plain HTTP where mediaDevices is undefined, leaves the frame parked without stills instead of failing. The capture sink is a 1x1 element in the document, like #probe: detached video elements are never composited and get throttled hard enough to make every screenshot seconds stale. startBounce also gains the stop() it always needed — it was leaking its rAF loop and resize listener, and a queued frame would fight whichever animation came next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
This commit is contained in:
+194
-11
@@ -1,7 +1,17 @@
|
||||
// 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 } from '/config.js';
|
||||
import {
|
||||
FRAME_SCALE,
|
||||
BOUNCE_SPEED,
|
||||
CORNER_INTERVAL_SECONDS,
|
||||
CORNER_MARGIN,
|
||||
} from '/config.js';
|
||||
|
||||
const ASPECT = 9 / 16;
|
||||
|
||||
@@ -22,6 +32,58 @@ export function frameSize(viewportWidth, viewportHeight) {
|
||||
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.
|
||||
@@ -65,8 +127,8 @@ export function createBouncer({ x = 0, y = 0 } = {}) {
|
||||
return bouncer;
|
||||
}
|
||||
|
||||
export function startBounce(element) {
|
||||
let size = frameSize(window.innerWidth, window.innerHeight);
|
||||
export function startBounce({ element }) {
|
||||
let size = applyFrameSize(element);
|
||||
const bouncer = createBouncer({
|
||||
x: (window.innerWidth - size.width) / 2,
|
||||
y: (window.innerHeight - size.height) / 2,
|
||||
@@ -80,31 +142,152 @@ export function startBounce(element) {
|
||||
}
|
||||
|
||||
function measure() {
|
||||
size = frameSize(window.innerWidth, window.innerHeight);
|
||||
element.style.width = `${size.width}px`;
|
||||
element.style.height = `${size.height}px`;
|
||||
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));
|
||||
}
|
||||
|
||||
measure();
|
||||
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());
|
||||
element.style.transform = `translate3d(${bouncer.x.toFixed(1)}px, ${bouncer.y.toFixed(1)}px, 0)`;
|
||||
requestAnimationFrame(frame);
|
||||
place(element, bouncer.x, bouncer.y);
|
||||
handle = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
handle = requestAnimationFrame(frame);
|
||||
|
||||
return bouncer;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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);
|
||||
const images = slots.map(() => {
|
||||
const img = document.createElement('img');
|
||||
layer.appendChild(img);
|
||||
return img;
|
||||
});
|
||||
|
||||
let slot = 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);
|
||||
|
||||
images.forEach((img, i) => {
|
||||
const at = cornerPosition(slots[i], size, viewport, margin);
|
||||
img.style.width = `${size.width}px`;
|
||||
img.style.height = `${size.height}px`;
|
||||
place(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 img = images[slot];
|
||||
img.src = shot;
|
||||
img.classList.add('visible');
|
||||
slot = (slot + 1) % images.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 img of images) img.remove();
|
||||
};
|
||||
}
|
||||
|
||||
// --- registry -----------------------------------------------------------
|
||||
|
||||
export const ANIMATIONS = [
|
||||
{ name: 'bounce', start: startBounce },
|
||||
{ name: 'corners', start: startCorners },
|
||||
];
|
||||
|
||||
/**
|
||||
* Run one animation at a time, swapping to a freshly picked one on next().
|
||||
* Returns the handle the player drives from its track changes.
|
||||
*/
|
||||
export function startAnimationCycle(context) {
|
||||
const picker = createPicker(ANIMATIONS.length);
|
||||
let stop = null;
|
||||
let current = null;
|
||||
|
||||
function next() {
|
||||
stop?.();
|
||||
current = ANIMATIONS[picker.next()];
|
||||
stop = current.start(context);
|
||||
return current.name;
|
||||
}
|
||||
|
||||
next();
|
||||
|
||||
return {
|
||||
next,
|
||||
get name() {
|
||||
return current?.name ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user