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
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:
- **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
and the frame stays on screen, so the resize reads as a window resize rather
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
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
effect immediately; while pinned, track changes leave the animation running.
**The corners animation asks to share your screen.** The decks are cross-origin
YouTube iframes, so nothing on the page can read their pixels — capturing the
tab and cropping out the frame is the only way to photograph what is playing.
The prompt appears once, on the first click of **Start**; choose *this tab*.
Declining is fine: the frame still parks in its corner, just without the
screenshots. The prompt is skipped entirely on a plain-HTTP origin (i.e. when
**The corners and trail animations ask to share your screen.** The decks are
cross-origin YouTube iframes, so nothing on the page can read their pixels —
capturing the tab and cropping out the frame is the only way to photograph what
is playing. The prompt appears once, on the first click of **Start**; choose
*this tab*. Declining is fine: the frame still parks in its corner and still
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.
## 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 corners animation puts a frame-sized image in all four corners, which
// 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.
export const BOUNCE_SPEED = 90;
export const BOUNCE_SPEED = 120;
// 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.
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_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
// match the registry in public/player/motion.js, which supplies the code.
export const ANIMATIONS = {
bounce: { label: 'Bounce' },
corners: { label: 'Corners' },
drag: { label: 'Drag & resize' },
trail: { label: 'Comet trail' },
};
// 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_MIN_SCALE,
DRAG_MAX_SCALE,
TRAIL_SPEED,
TRAIL_TURN_RATE,
TRAIL_SPACING,
TRAIL_LENGTH,
TRAIL_FADE_SECONDS,
AUTO_ANIMATION,
} 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 ----------------------------------------------------
// Someone shoving a video window around a desktop: a pointer travels to the
@@ -687,6 +910,7 @@ export const ANIMATIONS = {
bounce: startBounce,
corners: startCorners,
drag: startDragResize,
trail: startTrail,
};
const NAMES = Object.keys(ANIMATIONS);
+6
View File
@@ -70,6 +70,12 @@ body {
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 -------------------------------------------------- */
#frameMover {
+10
View File
@@ -18,6 +18,11 @@ import {
DRAG_FRAME_SCALE,
DRAG_MIN_SCALE,
DRAG_MAX_SCALE,
TRAIL_SPEED,
TRAIL_TURN_RATE,
TRAIL_SPACING,
TRAIL_LENGTH,
TRAIL_FADE_SECONDS,
BACKGROUNDS,
ANIMATIONS,
AUTO_ANIMATION,
@@ -51,6 +56,11 @@ app.get('/config.js', (_req, res) => {
`export const DRAG_FRAME_SCALE = ${DRAG_FRAME_SCALE};\n` +
`export const DRAG_MIN_SCALE = ${DRAG_MIN_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 ANIMATIONS = ${JSON.stringify(ANIMATIONS)};\n` +
`export const AUTO_ANIMATION = ${JSON.stringify(AUTO_ANIMATION)};\n`,