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