From 6d306d1b98fb0dae9478bd0669e7a826684f6dd5 Mon Sep 17 00:00:00 2001 From: simon Date: Sun, 9 Aug 2026 22:58:08 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB --- README.md | 18 ++-- config.js | 20 +++- public/player/motion.js | 224 +++++++++++++++++++++++++++++++++++++++ public/player/player.css | 6 ++ server/index.js | 10 ++ 5 files changed, 268 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fcdc588..841c741 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.js b/config.js index 46ca8e2..7d501fb 100644 --- a/config.js +++ b/config.js @@ -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 diff --git a/public/player/motion.js b/public/player/motion.js index cbee528..9841ba4 100644 --- a/public/player/motion.js +++ b/public/player/motion.js @@ -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); diff --git a/public/player/player.css b/public/player/player.css index 4caaa58..5a604f6 100644 --- a/public/player/player.css +++ b/public/player/player.css @@ -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 { diff --git a/server/index.js b/server/index.js index 810cfe2..9bc9fff 100644 --- a/server/index.js +++ b/server/index.js @@ -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`,