Add a comet trail animation

The frame glides from waypoint to waypoint and drops a screenshot of
itself every frame-width or so. The stills stay where they were taken
and fade over a few seconds, so the live video reads as the head of a
comet whose tail is its own recent past.

The steering caps how fast the heading may change: an instant turn would
lay the stills out in straight dotted lines, and it is the curve that
makes a tail read as one. Drops are spaced by distance travelled rather
than by a timer, so they stay even however the frame is turning.

Since the capture is a crop of the tab, a still lying under the frame
would be photographed into the next one. A drop waits until the frame is
clear of the tail, keeping the invariant corners already relies on.

Also picks up the FRAME_SCALE, BOUNCE_SPEED and CORNER_INTERVAL_SECONDS
tuning sitting in the tree.

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:58:08 +02:00
co-authored by Claude Opus 5
parent 2066966694
commit 6d306d1b98
5 changed files with 268 additions and 10 deletions
+11 -7
View File
@@ -39,7 +39,7 @@ autoplay, so the first click is what unlocks audio. One click per session.
## Animations ## Animations
The video frame's movement changes with every track. Three animations ship The video frame's movement changes with every track. Four animations ship
today: today:
- **bounce** — diagonal drift, reflecting off the screen edges. - **bounce** — diagonal drift, reflecting off the screen edges.
@@ -51,18 +51,22 @@ today:
one corner and pulls the frame to a new size. The aspect ratio stays locked one corner and pulls the frame to a new size. The aspect ratio stays locked
and the frame stays on screen, so the resize reads as a window resize rather and the frame stays on screen, so the resize reads as a window resize rather
than a stretch. than a stretch.
- **trail** — the frame glides along a curving path, steering from waypoint to
waypoint, and drops a screenshot of itself every frame-width or so. The stills
stay where they were taken and fade over a few seconds, leaving a comet tail of
frozen video behind the live one.
The queue page's **Animation** buttons pin the player to one of them, which is The queue page's **Animation** buttons pin the player to one of them, which is
how you look at a single animation without waiting for the cycle to reach it. how you look at a single animation without waiting for the cycle to reach it.
**Auto** (the default) hands selection back to the shuffle bag. Switching takes **Auto** (the default) hands selection back to the shuffle bag. Switching takes
effect immediately; while pinned, track changes leave the animation running. effect immediately; while pinned, track changes leave the animation running.
**The corners animation asks to share your screen.** The decks are cross-origin **The corners and trail animations ask to share your screen.** The decks are
YouTube iframes, so nothing on the page can read their pixels — capturing the cross-origin YouTube iframes, so nothing on the page can read their pixels —
tab and cropping out the frame is the only way to photograph what is playing. capturing the tab and cropping out the frame is the only way to photograph what
The prompt appears once, on the first click of **Start**; choose *this tab*. is playing. The prompt appears once, on the first click of **Start**; choose
Declining is fine: the frame still parks in its corner, just without the *this tab*. Declining is fine: the frame still parks in its corner and still
screenshots. The prompt is skipped entirely on a plain-HTTP origin (i.e. when glides, just without the screenshots. The prompt is skipped entirely on a plain-HTTP origin (i.e. when
you open the player from another machine over the network), for the same reason. you open the player from another machine over the network), for the same reason.
## Adding animations ## Adding animations
+17 -3
View File
@@ -12,13 +12,13 @@ export const HARD_CUT_SECONDS = 0.4;
// The video frame takes up this fraction of the screen. Keep this below 0.5: // The video frame takes up this fraction of the screen. Keep this below 0.5:
// the corners animation puts a frame-sized image in all four corners, which // the corners animation puts a frame-sized image in all four corners, which
// only stays overlap-free while two frames fit side by side. // only stays overlap-free while two frames fit side by side.
export const FRAME_SCALE = 0.3; export const FRAME_SCALE = 0.35;
// Pixels per second the frame drifts across the screen. // Pixels per second the frame drifts across the screen.
export const BOUNCE_SPEED = 90; export const BOUNCE_SPEED = 120;
// The corners animation: seconds between screenshots. // The corners animation: seconds between screenshots.
export const CORNER_INTERVAL_SECONDS = 5; export const CORNER_INTERVAL_SECONDS = 4;
// Gap between the frame and the screen edge, as a fraction of the short side. // Gap between the frame and the screen edge, as a fraction of the short side.
export const CORNER_MARGIN = 0.025; export const CORNER_MARGIN = 0.025;
@@ -33,12 +33,26 @@ export const DRAG_FRAME_SCALE = 0.6;
export const DRAG_MIN_SCALE = 0.55; export const DRAG_MIN_SCALE = 0.55;
export const DRAG_MAX_SCALE = 1.6; export const DRAG_MAX_SCALE = 1.6;
// The trail animation: how fast the frame glides, and how sharply it may turn.
// The turn cap is what bends the path into an arc — without it the frame would
// snap onto each new heading and the tail would read as a dotted line.
export const TRAIL_SPEED = 150;
export const TRAIL_TURN_RATE = 0.7;
// Gap between two dropped stills, in frame widths, and how many the tail holds.
export const TRAIL_SPACING = 0.6;
export const TRAIL_LENGTH = 8;
// A still's life, from the moment it lands to fully faded.
export const TRAIL_FADE_SECONDS = 4;
// The frame animations, keyed by the id sent over the socket. The names must // The frame animations, keyed by the id sent over the socket. The names must
// match the registry in public/player/motion.js, which supplies the code. // match the registry in public/player/motion.js, which supplies the code.
export const ANIMATIONS = { export const ANIMATIONS = {
bounce: { label: 'Bounce' }, bounce: { label: 'Bounce' },
corners: { label: 'Corners' }, corners: { label: 'Corners' },
drag: { label: 'Drag & resize' }, drag: { label: 'Drag & resize' },
trail: { label: 'Comet trail' },
}; };
// Cycle a fresh random animation on every track change. Anything else pins the // Cycle a fresh random animation on every track change. Anything else pins the
+224
View File
@@ -14,6 +14,11 @@ import {
DRAG_FRAME_SCALE, DRAG_FRAME_SCALE,
DRAG_MIN_SCALE, DRAG_MIN_SCALE,
DRAG_MAX_SCALE, DRAG_MAX_SCALE,
TRAIL_SPEED,
TRAIL_TURN_RATE,
TRAIL_SPACING,
TRAIL_LENGTH,
TRAIL_FADE_SECONDS,
AUTO_ANIMATION, AUTO_ANIMATION,
} from '/config.js'; } from '/config.js';
@@ -299,6 +304,224 @@ export function startCorners(context) {
}; };
} }
// --- trail --------------------------------------------------------------
// The frame glides along a curving path and drops a photograph of itself every
// so often. Each still stays where it was taken and fades, so the live video
// reads as the head of a comet whose tail is its own recent past.
/**
* Steering toward a waypoint at constant speed, with a cap on how fast the
* heading may change. The cap is the whole point: an instant turn would lay the
* stills out in straight dotted lines, and it is the curve that makes a tail
* read as one. Pure apart from the injected RNG, so the path can be exercised
* without a browser.
*/
export function createGlider({ x = 0, y = 0 } = {}, random = Math.random) {
// How close counts as arrived. Anything tighter than the turn radius
// (speed / turn rate) risks the glider circling a waypoint forever, unable to
// steer hard enough to land on it.
const reach = TRAIL_SPEED / TRAIL_TURN_RATE;
function normalise(angle) {
return Math.atan2(Math.sin(angle), Math.cos(angle));
}
const glider = {
x,
y,
heading: random() * Math.PI * 2,
target: null,
retarget(bounds) {
glider.target = {
x: random() * Math.max(0, bounds.width),
y: random() * Math.max(0, bounds.height),
};
return glider.target;
},
step(dt, bounds) {
const maxX = Math.max(0, bounds.width);
const maxY = Math.max(0, bounds.height);
if (!glider.target) glider.retarget(bounds);
if (Math.hypot(glider.target.x - glider.x, glider.target.y - glider.y) < reach) {
glider.retarget(bounds);
}
// Shortest way round to the bearing of the waypoint, then take at most
// this step's share of the turn rate.
const bearing = Math.atan2(glider.target.y - glider.y, glider.target.x - glider.x);
const turn = normalise(bearing - glider.heading);
glider.heading = normalise(
glider.heading + clamp(turn, -TRAIL_TURN_RATE * dt, TRAIL_TURN_RATE * dt),
);
glider.x += Math.cos(glider.heading) * TRAIL_SPEED * dt;
glider.y += Math.sin(glider.heading) * TRAIL_SPEED * dt;
// Reflect off the edges rather than clamping and sliding along them. The
// waypoint is always inside the bounds, so steering picks up from there
// and pulls the glider back inward instead of straight into the wall.
if (glider.x < 0 || glider.x > maxX) {
glider.x = clamp(glider.x, 0, maxX);
glider.heading = normalise(Math.PI - glider.heading);
}
if (glider.y < 0 || glider.y > maxY) {
glider.y = clamp(glider.y, 0, maxY);
glider.heading = normalise(-glider.heading);
}
return glider;
},
};
return glider;
}
export function startTrail(context) {
const { element } = context;
const layer = document.getElementById('stills');
let size = applyFrameSize(element);
const glider = createGlider({
x: (window.innerWidth - size.width) / 2,
y: (window.innerHeight - size.height) / 2,
});
// A ring buffer: the tail is a fixed number of images, and the oldest one is
// what the next drop reuses. `rect` doubles as the "currently showing" flag.
const cells = [];
for (let i = 0; i < TRAIL_LENGTH; i += 1) {
const img = document.createElement('img');
img.classList.add('trail');
layer.appendChild(img);
cells.push({ img, born: 0, rect: null });
}
let next = 0;
let travelled = 0;
function bounds() {
return {
width: window.innerWidth - size.width,
height: window.innerHeight - size.height,
};
}
function measure() {
size = applyFrameSize(element);
const limit = bounds();
glider.x = Math.min(glider.x, Math.max(0, limit.width));
glider.y = Math.min(glider.y, Math.max(0, limit.height));
}
/** True when the frame overlaps nothing that is still showing in the tail. */
function clearOfTail(rect) {
for (const cell of cells) {
const other = cell.rect;
if (!other) continue;
if (
rect.left < other.right &&
rect.right > other.left &&
rect.top < other.bottom &&
rect.bottom > other.top
) {
return false;
}
}
return true;
}
/** Photograph the frame where it stands. Reports whether a still landed. */
function drop(now) {
// 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 false;
const rect = element.getBoundingClientRect();
// The capture is a crop of the tab, so an older still lying under the frame
// would be photographed into the new one. Skip this drop instead; the
// travelled distance is not spent, so it happens as soon as the frame is
// back in the clear.
if (!clearOfTail(rect)) return false;
const shot = context.capture.grab(rect);
if (!shot) return false;
const cell = cells[next];
cell.img.src = shot;
cell.img.style.width = `${rect.width}px`;
cell.img.style.height = `${rect.height}px`;
place(cell.img, rect.left, rect.top);
cell.born = now;
cell.rect = { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
// Re-append so the freshest still paints over the rest of the tail.
layer.appendChild(cell.img);
next = (next + 1) % cells.length;
return true;
}
function fade(now) {
for (const cell of cells) {
if (!cell.rect) continue;
const life = 1 - (now - cell.born) / 1000 / TRAIL_FADE_SECONDS;
if (life <= 0) {
cell.img.style.opacity = '0';
// Gone, so it stops holding up drops that pass over its old spot.
cell.rect = null;
continue;
}
// Squared: a still holds its brightness for a moment and then goes
// quickly, which thins the tail out instead of trailing a long grey smear.
cell.img.style.opacity = (life * life).toFixed(3);
}
}
place(element, glider.x, glider.y);
window.addEventListener('resize', measure);
let last = performance.now();
let handle = 0;
let stopped = false;
function frame(now) {
if (stopped) return;
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
const wasX = glider.x;
const wasY = glider.y;
glider.step(dt, bounds());
place(element, glider.x, glider.y);
// Spacing by distance rather than by a timer keeps the stills evenly spread
// however the frame is turning.
travelled += Math.hypot(glider.x - wasX, glider.y - wasY);
if (travelled >= size.width * TRAIL_SPACING && drop(now)) travelled = 0;
fade(now);
handle = requestAnimationFrame(frame);
}
handle = requestAnimationFrame(frame);
return function stop() {
stopped = true;
cancelAnimationFrame(handle);
window.removeEventListener('resize', measure);
for (const cell of cells) cell.img.remove();
};
}
// --- drag and resize ---------------------------------------------------- // --- drag and resize ----------------------------------------------------
// Someone shoving a video window around a desktop: a pointer travels to the // Someone shoving a video window around a desktop: a pointer travels to the
@@ -687,6 +910,7 @@ export const ANIMATIONS = {
bounce: startBounce, bounce: startBounce,
corners: startCorners, corners: startCorners,
drag: startDragResize, drag: startDragResize,
trail: startTrail,
}; };
const NAMES = Object.keys(ANIMATIONS); const NAMES = Object.keys(ANIMATIONS);
+6
View File
@@ -70,6 +70,12 @@ body {
opacity: 1; opacity: 1;
} }
/* The trail ramps opacity frame-by-frame as a still ages, so the CSS transition
above would only fight it. */
#stills img.trail {
transition: none;
}
/* --- the moving frame -------------------------------------------------- */ /* --- the moving frame -------------------------------------------------- */
#frameMover { #frameMover {
+10
View File
@@ -18,6 +18,11 @@ import {
DRAG_FRAME_SCALE, DRAG_FRAME_SCALE,
DRAG_MIN_SCALE, DRAG_MIN_SCALE,
DRAG_MAX_SCALE, DRAG_MAX_SCALE,
TRAIL_SPEED,
TRAIL_TURN_RATE,
TRAIL_SPACING,
TRAIL_LENGTH,
TRAIL_FADE_SECONDS,
BACKGROUNDS, BACKGROUNDS,
ANIMATIONS, ANIMATIONS,
AUTO_ANIMATION, AUTO_ANIMATION,
@@ -51,6 +56,11 @@ app.get('/config.js', (_req, res) => {
`export const DRAG_FRAME_SCALE = ${DRAG_FRAME_SCALE};\n` + `export const DRAG_FRAME_SCALE = ${DRAG_FRAME_SCALE};\n` +
`export const DRAG_MIN_SCALE = ${DRAG_MIN_SCALE};\n` + `export const DRAG_MIN_SCALE = ${DRAG_MIN_SCALE};\n` +
`export const DRAG_MAX_SCALE = ${DRAG_MAX_SCALE};\n` + `export const DRAG_MAX_SCALE = ${DRAG_MAX_SCALE};\n` +
`export const TRAIL_SPEED = ${TRAIL_SPEED};\n` +
`export const TRAIL_TURN_RATE = ${TRAIL_TURN_RATE};\n` +
`export const TRAIL_SPACING = ${TRAIL_SPACING};\n` +
`export const TRAIL_LENGTH = ${TRAIL_LENGTH};\n` +
`export const TRAIL_FADE_SECONDS = ${TRAIL_FADE_SECONDS};\n` +
`export const BACKGROUNDS = ${JSON.stringify(BACKGROUNDS)};\n` + `export const BACKGROUNDS = ${JSON.stringify(BACKGROUNDS)};\n` +
`export const ANIMATIONS = ${JSON.stringify(ANIMATIONS)};\n` + `export const ANIMATIONS = ${JSON.stringify(ANIMATIONS)};\n` +
`export const AUTO_ANIMATION = ${JSON.stringify(AUTO_ANIMATION)};\n`, `export const AUTO_ANIMATION = ${JSON.stringify(AUTO_ANIMATION)};\n`,