Add a colour wash animation

The frame parks in the middle and the colour changes instead: a gradient
creeps round the colour wheel behind the poster, and the frame's glow
travels with it. Every other animation moves the frame, so the still one
reads as a rest between them rather than as a dead spot.

The wash layer blends with mix-blend-mode: color, which keeps the
poster's luminance and replaces only its hue. soft-light was the first
try and barely shifted the colour at all against a poster already knocked
back to 45% brightness.

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 23:11:42 +02:00
co-authored by Claude Opus 5
parent 6d306d1b98
commit 223292708f
5 changed files with 120 additions and 2 deletions
+5 -1
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. Four animations ship
The video frame's movement changes with every track. Five animations ship
today:
- **bounce** — diagonal drift, reflecting off the screen edges.
@@ -55,6 +55,10 @@ today:
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.
- **wash** — the frame parks in the middle and the colour changes instead: a
gradient creeps round the colour wheel behind the poster, taking a minute and
a half per lap, and the frame's glow travels with it. The one still animation,
which is what makes it worth having between the moving ones.
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.
+12 -1
View File
@@ -44,7 +44,17 @@ 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;
export const TRAIL_FADE_SECONDS = 8;
// The colour wash: seconds for one full trip round the colour wheel. Slow
// enough that the screen never looks like it is flashing — the colour should
// only be obviously different once you look away and back.
export const WASH_CYCLE_SECONDS = 90;
// How far apart the two ends of the wash gradient sit on the wheel, in degrees,
// and how strongly the wash tints the poster underneath it.
export const WASH_SPREAD = 70;
export const WASH_STRENGTH = 0.55;
// 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.
@@ -53,6 +63,7 @@ export const ANIMATIONS = {
corners: { label: 'Corners' },
drag: { label: 'Drag & resize' },
trail: { label: 'Comet trail' },
wash: { label: 'Colour wash' },
};
// Cycle a fresh random animation on every track change. Anything else pins the
+76
View File
@@ -19,6 +19,9 @@ import {
TRAIL_SPACING,
TRAIL_LENGTH,
TRAIL_FADE_SECONDS,
WASH_CYCLE_SECONDS,
WASH_SPREAD,
WASH_STRENGTH,
AUTO_ANIMATION,
} from '/config.js';
@@ -903,6 +906,78 @@ export function startDragResize({ element }) {
};
}
// --- colour wash --------------------------------------------------------
// The frame parks in the middle and the room changes colour around it: a wash
// creeps round the colour wheel behind the poster, and the frame's glow travels
// with it, so the whole screen shifts as one. It is the quiet one — everything
// else in the registry moves the frame, and after a few of those a still frame
// with a slow colour behind it is a relief rather than a dead spot.
/**
* The two ends of the wash gradient at `elapsed` seconds, as hues in degrees.
* One trip round the wheel takes WASH_CYCLE_SECONDS, and the second hue trails
* the first by WASH_SPREAD so the gradient always has somewhere to go. Pure, so
* the cycle can be exercised without a browser.
*/
export function washHues(elapsed) {
const turn = ((elapsed / WASH_CYCLE_SECONDS) * 360) % 360;
return [turn, (turn + WASH_SPREAD) % 360];
}
/** A wash stop as a colour: saturated and mid-bright, before the blend mode. */
function washStop(hue, alpha = 1) {
return `hsl(${hue.toFixed(1)} 85% 55% / ${alpha})`;
}
// The frame's glow is the wash colour held back to the same weight the fixed
// pink had, so a hue with more punch to it does not brighten the whole frame.
const GLOW_ALPHA = 0.55;
export function startWash({ element }) {
const wash = document.createElement('div');
wash.id = 'wash';
wash.style.opacity = String(WASH_STRENGTH);
// Behind the stills and the frame, in front of the poster it tints.
document.body.appendChild(wash);
element.classList.add('washing');
function centre() {
const size = applyFrameSize(element);
place(element, (window.innerWidth - size.width) / 2, (window.innerHeight - size.height) / 2);
}
centre();
window.addEventListener('resize', centre);
const started = performance.now();
let handle = 0;
let stopped = false;
function frame(now) {
if (stopped) return;
const [from, to] = washHues((now - started) / 1000);
wash.style.background = `linear-gradient(135deg, ${washStop(from)}, ${washStop(to)})`;
// The frame's glow rides the leading hue, so it belongs to the wash instead
// of sitting on top of it in a fixed pink.
element.style.setProperty('--wash', washStop(from, GLOW_ALPHA));
handle = requestAnimationFrame(frame);
}
handle = requestAnimationFrame(frame);
return function stop() {
stopped = true;
cancelAnimationFrame(handle);
window.removeEventListener('resize', centre);
wash.remove();
element.classList.remove('washing');
element.style.removeProperty('--wash');
};
}
// --- registry -----------------------------------------------------------
// Keyed by the names in config.js, which is what travels over the socket.
@@ -911,6 +986,7 @@ export const ANIMATIONS = {
corners: startCorners,
drag: startDragResize,
trail: startTrail,
wash: startWash,
};
const NAMES = Object.keys(ANIMATIONS);
+21
View File
@@ -41,6 +41,20 @@ body {
opacity: 1;
}
/* Built and removed by the wash animation. Sits between the poster and the
stills, and blends rather than covers, so the poster stays legible through
whatever colour is passing over it. The gradient itself is written per frame
from JS. */
#wash {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
/* Takes the poster's luminance and only replaces its hue, so the wash reads
as a colour change rather than as a sheet of colour laid over the top. */
mix-blend-mode: color;
}
/* --- frozen screenshots ------------------------------------------------ */
#stills {
@@ -108,6 +122,13 @@ body {
0 40px 100px rgba(0, 0, 0, 0.85);
}
/* The wash animation hands the frame the colour currently passing behind it. */
#frameMover.washing #frameSkin {
box-shadow:
0 0 0 4px var(--wash, rgba(255, 79, 195, 0.55)),
0 24px 70px rgba(0, 0, 0, 0.7);
}
/* --- the drawn pointer -------------------------------------------------- */
/* Built and removed by the drag animation; nothing else on the page has one. */
+6
View File
@@ -23,6 +23,9 @@ import {
TRAIL_SPACING,
TRAIL_LENGTH,
TRAIL_FADE_SECONDS,
WASH_CYCLE_SECONDS,
WASH_SPREAD,
WASH_STRENGTH,
BACKGROUNDS,
ANIMATIONS,
AUTO_ANIMATION,
@@ -61,6 +64,9 @@ app.get('/config.js', (_req, res) => {
`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 WASH_CYCLE_SECONDS = ${WASH_CYCLE_SECONDS};\n` +
`export const WASH_SPREAD = ${WASH_SPREAD};\n` +
`export const WASH_STRENGTH = ${WASH_STRENGTH};\n` +
`export const BACKGROUNDS = ${JSON.stringify(BACKGROUNDS)};\n` +
`export const ANIMATIONS = ${JSON.stringify(ANIMATIONS)};\n` +
`export const AUTO_ANIMATION = ${JSON.stringify(AUTO_ANIMATION)};\n`,