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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
Reference in New Issue
Block a user