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
|
## 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:
|
today:
|
||||||
|
|
||||||
- **bounce** — diagonal drift, reflecting off the screen edges.
|
- **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
|
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
|
stay where they were taken and fade over a few seconds, leaving a comet tail of
|
||||||
frozen video behind the live one.
|
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
|
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.
|
||||||
|
|||||||
@@ -44,7 +44,17 @@ export const TRAIL_SPACING = 0.6;
|
|||||||
export const TRAIL_LENGTH = 8;
|
export const TRAIL_LENGTH = 8;
|
||||||
|
|
||||||
// A still's life, from the moment it lands to fully faded.
|
// 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
|
// 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.
|
||||||
@@ -53,6 +63,7 @@ export const ANIMATIONS = {
|
|||||||
corners: { label: 'Corners' },
|
corners: { label: 'Corners' },
|
||||||
drag: { label: 'Drag & resize' },
|
drag: { label: 'Drag & resize' },
|
||||||
trail: { label: 'Comet trail' },
|
trail: { label: 'Comet trail' },
|
||||||
|
wash: { label: 'Colour wash' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import {
|
|||||||
TRAIL_SPACING,
|
TRAIL_SPACING,
|
||||||
TRAIL_LENGTH,
|
TRAIL_LENGTH,
|
||||||
TRAIL_FADE_SECONDS,
|
TRAIL_FADE_SECONDS,
|
||||||
|
WASH_CYCLE_SECONDS,
|
||||||
|
WASH_SPREAD,
|
||||||
|
WASH_STRENGTH,
|
||||||
AUTO_ANIMATION,
|
AUTO_ANIMATION,
|
||||||
} from '/config.js';
|
} 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 -----------------------------------------------------------
|
// --- registry -----------------------------------------------------------
|
||||||
|
|
||||||
// Keyed by the names in config.js, which is what travels over the socket.
|
// Keyed by the names in config.js, which is what travels over the socket.
|
||||||
@@ -911,6 +986,7 @@ export const ANIMATIONS = {
|
|||||||
corners: startCorners,
|
corners: startCorners,
|
||||||
drag: startDragResize,
|
drag: startDragResize,
|
||||||
trail: startTrail,
|
trail: startTrail,
|
||||||
|
wash: startWash,
|
||||||
};
|
};
|
||||||
|
|
||||||
const NAMES = Object.keys(ANIMATIONS);
|
const NAMES = Object.keys(ANIMATIONS);
|
||||||
|
|||||||
@@ -41,6 +41,20 @@ body {
|
|||||||
opacity: 1;
|
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 ------------------------------------------------ */
|
/* --- frozen screenshots ------------------------------------------------ */
|
||||||
|
|
||||||
#stills {
|
#stills {
|
||||||
@@ -108,6 +122,13 @@ body {
|
|||||||
0 40px 100px rgba(0, 0, 0, 0.85);
|
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 -------------------------------------------------- */
|
/* --- the drawn pointer -------------------------------------------------- */
|
||||||
|
|
||||||
/* Built and removed by the drag animation; nothing else on the page has one. */
|
/* Built and removed by the drag animation; nothing else on the page has one. */
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import {
|
|||||||
TRAIL_SPACING,
|
TRAIL_SPACING,
|
||||||
TRAIL_LENGTH,
|
TRAIL_LENGTH,
|
||||||
TRAIL_FADE_SECONDS,
|
TRAIL_FADE_SECONDS,
|
||||||
|
WASH_CYCLE_SECONDS,
|
||||||
|
WASH_SPREAD,
|
||||||
|
WASH_STRENGTH,
|
||||||
BACKGROUNDS,
|
BACKGROUNDS,
|
||||||
ANIMATIONS,
|
ANIMATIONS,
|
||||||
AUTO_ANIMATION,
|
AUTO_ANIMATION,
|
||||||
@@ -61,6 +64,9 @@ app.get('/config.js', (_req, res) => {
|
|||||||
`export const TRAIL_SPACING = ${TRAIL_SPACING};\n` +
|
`export const TRAIL_SPACING = ${TRAIL_SPACING};\n` +
|
||||||
`export const TRAIL_LENGTH = ${TRAIL_LENGTH};\n` +
|
`export const TRAIL_LENGTH = ${TRAIL_LENGTH};\n` +
|
||||||
`export const TRAIL_FADE_SECONDS = ${TRAIL_FADE_SECONDS};\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 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