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:
simon
2026-08-09 22:04:33 +02:00
co-authored by Claude Opus 5
parent ad999e1c3e
commit ce1a6d6a47
8 changed files with 424 additions and 21 deletions
+94
View File
@@ -0,0 +1,94 @@
// Screen capture, used to photograph the video frame.
//
// The decks are cross-origin YouTube iframes, so nothing on the page can read
// their pixels — canvas taints, html2canvas sees an empty box. Capturing the
// tab through getDisplayMedia and cropping out the frame's rect is the only way
// to get the actually-playing image.
/**
* Ask for the screen-share stream. Resolves to a `{ grab, stop }` handle, or to
* null if the operator declines or the API is missing — callers degrade rather
* than fail.
*
* Must be called synchronously from a user gesture (getDisplayMedia needs
* transient activation), so kick it off before any await and keep the promise.
*/
export function requestCapture() {
// Undefined on a plain-HTTP origin, which is exactly how the README tells
// people to reach this page from other machines on the network.
if (!navigator.mediaDevices?.getDisplayMedia) return Promise.resolve(null);
try {
return navigator.mediaDevices
.getDisplayMedia({
// preferCurrentTab keeps the stream to this tab's viewport, so stream
// pixels map to CSS pixels by one scale factor. Picking a whole screen
// instead would put browser chrome in the frame and skew every crop.
video: { frameRate: 8 },
audio: false,
preferCurrentTab: true,
})
.then(attach)
.catch(() => null);
} catch {
// Some rejections arrive synchronously. Never let them escape into
// start(), which would abort playback before the gate even lifts.
return Promise.resolve(null);
}
}
async function attach(stream) {
const [track] = stream.getVideoTracks();
if (!track) return null;
const video = document.createElement('video');
video.srcObject = stream;
video.muted = true;
video.playsInline = true;
// Must be in the document, like #probe: a detached video is never composited
// and the browser throttles its frames hard, which would leave screenshots
// showing a moment several seconds stale. 1x1 and transparent, so it costs
// nothing and cannot show up in its own crop.
video.id = 'captureSink';
document.body.appendChild(video);
await video.play().catch(() => {});
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
return {
/**
* Crop a viewport rect out of the live stream. `rect` is in CSS pixels.
* Returns a data URL, or null while the stream has yet to deliver a frame.
*/
grab({ x, y, width, height }) {
if (video.readyState < 2 || !video.videoWidth) return null;
if (width <= 0 || height <= 0) return null;
const scaleX = video.videoWidth / window.innerWidth;
const scaleY = video.videoHeight / window.innerHeight;
canvas.width = Math.round(width * scaleX);
canvas.height = Math.round(height * scaleY);
context.drawImage(
video,
x * scaleX,
y * scaleY,
width * scaleX,
height * scaleY,
0,
0,
canvas.width,
canvas.height,
);
return canvas.toDataURL('image/jpeg', 0.85);
},
stop() {
for (const t of stream.getTracks()) t.stop();
video.srcObject = null;
video.remove();
},
};
}