feat(274): transect composition synced to the lyric
Song274Transect draws Route 274 in its true shape (the "Ground" view of song-274/route/274-transect.html) and plays it along with the song. - data.ts: reads song-274/route/ at load time (lyrics index, roster, route and the optional east/west stubs); nothing is copied into the code. Only sung nodes are shown; districts and 東に続く light nothing. - progress.ts: a "covered so far" paint over the dimmed road. Its head is pinned to each sung object's km as that line starts and glides between pins on a monotone cubic, so it never runs backwards. After 東に続く it runs out along the east stub; the west stub is always painted. - camera.ts: fixed close zoom during each narration part (lines under 20 s apart), following a Gaussian-smoothed head; pull back to frame the covered road plus some road ahead during breaks, and to the full map after the lyric. - nodeAnimation.ts: lightUp / switchOff as reusable functions over a NodeLook, with long lines switching off after MAX_LIT_S. - palette.ts: every colour as a named token (dark default, light alongside), including shrine and railway families. remotion.config.ts sets song-274/ as the public dir so staticFile() reaches the song's audio/ and route/ in place. Verified: eslint + tsc clean; stills checked across parts, breaks and the ending. Render with --muted and add audio with ffmpeg: Remotion's own AAC lands 42.75 ms late. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import type { Cue } from "./data";
|
||||
|
||||
/**
|
||||
* How one node looks at one instant. The animation functions below produce
|
||||
* these, and Transect.tsx only draws them, so adding a property here (a colour,
|
||||
* a stroke, a glow) is the only change the drawing needs to follow.
|
||||
*/
|
||||
export type NodeLook = {
|
||||
radius: number;
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A node that is not being sung. Radius is in map units (the page's pixels),
|
||||
* so nodes grow with the camera's zoom. (The page itself uses 3 at 0.85.)
|
||||
*/
|
||||
export const RESTING: NodeLook = { radius: 2.5, opacity: 0.9 };
|
||||
/** A node while its line is sung: the page's selected mark. */
|
||||
export const LIT: NodeLook = { radius: 7, opacity: 1 };
|
||||
|
||||
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
||||
|
||||
/** Slightly slower lighting up and switching off. Relatively slower decay. */
|
||||
const LIGHT_UP_S = 0.12;
|
||||
const SWITCH_OFF_S = 0.5;
|
||||
|
||||
|
||||
/** Blend two looks; p = 0 is `from`, p = 1 is `to`. */
|
||||
export const mixLooks = (from: NodeLook, to: NodeLook, p: number): NodeLook => ({
|
||||
radius: from.radius + (to.radius - from.radius) * p,
|
||||
opacity: from.opacity + (to.opacity - from.opacity) * p,
|
||||
});
|
||||
|
||||
/**
|
||||
* A node being switched on.
|
||||
* @param elapsedS seconds since its line started (0 at the lyric timestamp)
|
||||
* @param from the look it starts from
|
||||
*/
|
||||
export const lightUp = (elapsedS: number, from: NodeLook = RESTING): NodeLook =>
|
||||
mixLooks(from, LIT, clamp01(elapsedS / LIGHT_UP_S));
|
||||
|
||||
/**
|
||||
* A node being switched off.
|
||||
* @param elapsedS seconds since its line ended (0 at the next lyric timestamp)
|
||||
* @param from the look it had when it was switched off
|
||||
*/
|
||||
export const switchOff = (elapsedS: number, from: NodeLook = LIT): NodeLook =>
|
||||
mixLooks(from, RESTING, clamp01(elapsedS / SWITCH_OFF_S));
|
||||
|
||||
/** Longest a node holds fully lit, after lighting up, before it switches off. */
|
||||
export const MAX_LIT_S = 0.9;
|
||||
|
||||
/**
|
||||
* When a cue's nodes start switching off: when the line ends, or once they have
|
||||
* lit up and held for MAX_LIT_S, whichever comes first, so a long line does not
|
||||
* keep a node lit for too long.
|
||||
*/
|
||||
export const switchOffAt = (cue: Cue): number =>
|
||||
Math.min(cue.stopS, cue.startS + LIGHT_UP_S + MAX_LIT_S);
|
||||
|
||||
/**
|
||||
* The look of a node at time `t`, given the cue that lights it (or none).
|
||||
* Before the line: resting. Then lighting up until switchOffAt(). After that:
|
||||
* switching off, starting from however far the light-up got, so a short line
|
||||
* never jumps.
|
||||
*/
|
||||
export const nodeLookAt = (cue: Cue | undefined, t: number): NodeLook => {
|
||||
if (!cue || t < cue.startS) {
|
||||
return RESTING;
|
||||
}
|
||||
const offS = switchOffAt(cue);
|
||||
if (t < offS) {
|
||||
return lightUp(t - cue.startS);
|
||||
}
|
||||
return switchOff(t - offS, lightUp(offS - cue.startS));
|
||||
};
|
||||
|
||||
/** Whether a look is anything other than resting (drawn on top if so). */
|
||||
export const isAwake = (look: NodeLook): boolean =>
|
||||
look.radius !== RESTING.radius || look.opacity !== RESTING.opacity;
|
||||
Reference in New Issue
Block a user