From 223292708f9afbea6139b9d0fb96be6a440ff91a Mon Sep 17 00:00:00 2001 From: simon Date: Sun, 9 Aug 2026 23:11:42 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB --- README.md | 6 +++- config.js | 13 ++++++- public/player/motion.js | 76 ++++++++++++++++++++++++++++++++++++++++ public/player/player.css | 21 +++++++++++ server/index.js | 6 ++++ 5 files changed, 120 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 841c741..5f684aa 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. 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. diff --git a/config.js b/config.js index 7d501fb..beb0690 100644 --- a/config.js +++ b/config.js @@ -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 diff --git a/public/player/motion.js b/public/player/motion.js index 9841ba4..f964177 100644 --- a/public/player/motion.js +++ b/public/player/motion.js @@ -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); diff --git a/public/player/player.css b/public/player/player.css index 5a604f6..6f6ddaf 100644 --- a/public/player/player.css +++ b/public/player/player.css @@ -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. */ diff --git a/server/index.js b/server/index.js index 9bc9fff..0278a01 100644 --- a/server/index.js +++ b/server/index.js @@ -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`,