Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10949dfa18 | |||
| 561c930620 | |||
| 04963c929a | |||
| fac282bcbc |
@@ -6,6 +6,7 @@
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@remotion/cli": "4.0.522",
|
||||
"@remotion/media-utils": "4.0.522",
|
||||
"@remotion/tailwind-v4": "4.0.522",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
|
||||
+56
-14
@@ -18,8 +18,23 @@ import {
|
||||
type Frame,
|
||||
} from "./camera";
|
||||
import type { Cue, RosterItem, TransectData } from "./data";
|
||||
import { extendBounds, GROUND, prefixBounds, projectGround, routePath } from "./layout";
|
||||
import { isAwake, nodeLookAt } from "./nodeAnimation";
|
||||
import {
|
||||
extendBounds,
|
||||
GROUND,
|
||||
nodePositions,
|
||||
prefixBounds,
|
||||
projectGround,
|
||||
routePath,
|
||||
} from "./layout";
|
||||
import { useWindowedAudioData } from "@remotion/media-utils";
|
||||
import { isAwake, mixLooks, nodeLookAt, spectrumLook } from "./nodeAnimation";
|
||||
import {
|
||||
AUDIO_WINDOW_S,
|
||||
audioWindowFrame,
|
||||
bandLevels,
|
||||
shuffledBandFrequencies,
|
||||
spectrumWeight,
|
||||
} from "./spectrum";
|
||||
import type { TransectPalette } from "./palette";
|
||||
import {
|
||||
coveredRoute,
|
||||
@@ -69,18 +84,20 @@ export const Transect: React.FC<TransectProps> = ({ palette, audio, data }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width, height } = useVideoConfig();
|
||||
const projection = useMemo(() => (data ? projectGround(data.route) : null), [data]);
|
||||
// The main road plus the east continuation. The paint runs into the latter
|
||||
// after 東に続く; the camera and overview stay on the main road, so that run
|
||||
// leaves the screen.
|
||||
const paintRoute = useMemo(() => (data ? paintableRoute(data) : []), [data]);
|
||||
// The dimmed road ahead includes the east continuation, so it shows all along.
|
||||
const fullRoad = useMemo(
|
||||
() => (data && projection ? routePath(data.route, projection) : ""),
|
||||
[data, projection],
|
||||
() => (projection && paintRoute.length > 1 ? routePath(paintRoute, projection) : ""),
|
||||
[paintRoute, projection],
|
||||
);
|
||||
const bounds = useMemo(
|
||||
() => (data && projection ? prefixBounds(data.route, projection) : []),
|
||||
[data, projection],
|
||||
);
|
||||
const kmAt = useMemo(() => (data ? monotoneCurve(progressKeyframes(data)) : null), [data]);
|
||||
// The paint may run past the main road into the east continuation; the camera
|
||||
// and overview stay on the main road, so that run leaves the screen.
|
||||
const paintRoute = useMemo(() => (data ? paintableRoute(data) : []), [data]);
|
||||
const westRoad = useMemo(
|
||||
() =>
|
||||
data && projection && data.routeWest.length > 1
|
||||
@@ -90,8 +107,24 @@ export const Transect: React.FC<TransectProps> = ({ palette, audio, data }) => {
|
||||
);
|
||||
const parts = useMemo(() => narrationParts(data ? data.lines : []), [data]);
|
||||
const cueMap = useMemo(() => cuesByObject(data ? data.cues : []), [data]);
|
||||
// Nodes at identical coordinates are spread slightly apart along the road.
|
||||
const positions = useMemo(
|
||||
() => (data && projection ? nodePositions(data.items, data.route, projection) : null),
|
||||
[data, projection],
|
||||
);
|
||||
// Each node gets one log-spaced band, dealt out in a seeded shuffle.
|
||||
const frequencies = useMemo(
|
||||
() => shuffledBandFrequencies(data ? data.items.length : 0),
|
||||
[data],
|
||||
);
|
||||
const { audioData, dataOffsetInSeconds } = useWindowedAudioData({
|
||||
src: staticFile(audio),
|
||||
frame: audioWindowFrame(frame, fps),
|
||||
fps,
|
||||
windowInSeconds: AUDIO_WINDOW_S,
|
||||
});
|
||||
|
||||
if (!data || !projection || !kmAt) {
|
||||
if (!data || !projection || !kmAt || !positions) {
|
||||
return <AbsoluteFill style={{ backgroundColor: palette.background }} />;
|
||||
}
|
||||
|
||||
@@ -127,16 +160,25 @@ export const Transect: React.FC<TransectProps> = ({ palette, audio, data }) => {
|
||||
view,
|
||||
);
|
||||
|
||||
const nodes = data.items.map((item: RosterItem) => ({
|
||||
item,
|
||||
look: nodeLookAt(currentCue(cueMap.get(item.object), t), t),
|
||||
}));
|
||||
// In the outro the nodes hand over from their lyric looks to the spectrum.
|
||||
const spectrum = spectrumWeight(t);
|
||||
const levels =
|
||||
spectrum > 0 && audioData
|
||||
? bandLevels({ audioData, dataOffsetInSeconds, frame, fps, frequencies })
|
||||
: null;
|
||||
const nodes = data.items.map((item: RosterItem, i) => {
|
||||
const lyricLook = nodeLookAt(currentCue(cueMap.get(item.object), t), t);
|
||||
return {
|
||||
item,
|
||||
look: levels ? mixLooks(lyricLook, spectrumLook(levels[i]), spectrum) : lyricLook,
|
||||
};
|
||||
});
|
||||
|
||||
const draw = ({ item, look }: (typeof nodes)[number]) => (
|
||||
<circle
|
||||
key={item.object}
|
||||
cx={projection.x(item.lon)}
|
||||
cy={projection.y(item.lat)}
|
||||
cx={positions.get(item.object)?.x ?? projection.x(item.lon)}
|
||||
cy={positions.get(item.object)?.y ?? projection.y(item.lat)}
|
||||
r={look.radius}
|
||||
fill={palette[item.family]}
|
||||
opacity={look.opacity}
|
||||
|
||||
@@ -11,6 +11,8 @@ export type RosterItem = {
|
||||
object: string;
|
||||
kind: string;
|
||||
family: FeatureFamily;
|
||||
/** Distance along the road; the nearest road point for off-road objects. */
|
||||
km: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
};
|
||||
@@ -163,6 +165,7 @@ export const loadTransectData = async (): Promise<TransectData> => {
|
||||
object: r.object,
|
||||
kind: r.kind,
|
||||
family: familyOf(r.kind, r.object),
|
||||
km: Number(r.km),
|
||||
lat: Number(r.lat),
|
||||
lon: Number(r.lon),
|
||||
});
|
||||
|
||||
+67
-1
@@ -1,4 +1,4 @@
|
||||
import type { RoutePoint } from "./data";
|
||||
import type { RosterItem, RoutePoint } from "./data";
|
||||
|
||||
/**
|
||||
* The "Ground — true shape" projection of `274-transect.html`, in the page's
|
||||
@@ -38,6 +38,72 @@ export const projectGround = (route: RoutePoint[]): Projection => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Distance, in map units, between nodes that share exactly the same roster
|
||||
* coordinates (草木橋 / 滝の上こ線橋, ポン久留喜橋 / 久留喜橋, モトツ橋 /
|
||||
* モトツシェルター): small, so they read as a doubled dot on the road. Grows with
|
||||
* zoom like the nodes themselves. (1 map unit ≈ 44 m on the ground here.)
|
||||
*/
|
||||
export const COINCIDENT_SPREAD = 1.5;
|
||||
|
||||
export type NodePosition = { x: number; y: number };
|
||||
|
||||
/** Direction of the road at `km`, as a unit vector in map units. */
|
||||
const roadDirection = (route: RoutePoint[], km: number, proj: Projection) => {
|
||||
const at = (k: number) => {
|
||||
let i = 1;
|
||||
while (i < route.length - 1 && route[i][0] < k) {
|
||||
i++;
|
||||
}
|
||||
const [k0, lat0, lon0] = route[i - 1];
|
||||
const [k1, lat1, lon1] = route[i];
|
||||
const p = k1 === k0 ? 0 : Math.min(1, Math.max(0, (k - k0) / (k1 - k0)));
|
||||
return { x: proj.x(lon0 + (lon1 - lon0) * p), y: proj.y(lat0 + (lat1 - lat0) * p) };
|
||||
};
|
||||
const a = at(km - 0.1);
|
||||
const b = at(km + 0.1);
|
||||
const length = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
return length > 0 ? { x: (b.x - a.x) / length, y: (b.y - a.y) / length } : { x: 1, y: 0 };
|
||||
};
|
||||
|
||||
/**
|
||||
* Where each node is drawn, by object name. Usually its roster coordinate; nodes
|
||||
* sharing exactly the same coordinate are spread COINCIDENT_SPREAD apart along
|
||||
* the road there, in road order, centred on the shared point. All such pairs are
|
||||
* on-road structures, so they stay on the road. Keep the spread small: at 6,
|
||||
* モトツシェルター landed on 福山トンネル, only 90 m further on.
|
||||
*/
|
||||
export const nodePositions = (
|
||||
items: RosterItem[],
|
||||
route: RoutePoint[],
|
||||
proj: Projection,
|
||||
): Map<string, NodePosition> => {
|
||||
const groups = new Map<string, RosterItem[]>();
|
||||
for (const item of items) {
|
||||
const key = `${item.lat},${item.lon}`;
|
||||
const group = groups.get(key) ?? [];
|
||||
group.push(item);
|
||||
groups.set(key, group);
|
||||
}
|
||||
|
||||
const positions = new Map<string, NodePosition>();
|
||||
groups.forEach((group) => {
|
||||
const x = proj.x(group[0].lon);
|
||||
const y = proj.y(group[0].lat);
|
||||
if (group.length === 1) {
|
||||
positions.set(group[0].object, { x, y });
|
||||
return;
|
||||
}
|
||||
const ordered = group.slice().sort((a, b) => a.km - b.km);
|
||||
const dir = roadDirection(route, ordered[0].km, proj);
|
||||
ordered.forEach((item, i) => {
|
||||
const offset = (i - (ordered.length - 1) / 2) * COINCIDENT_SPREAD;
|
||||
positions.set(item.object, { x: x + dir.x * offset, y: y + dir.y * offset });
|
||||
});
|
||||
});
|
||||
return positions;
|
||||
};
|
||||
|
||||
export type Bounds = { minX: number; minY: number; maxX: number; maxY: number };
|
||||
|
||||
/** Bounds of the projected route from its start up to each point, inclusive. */
|
||||
|
||||
@@ -75,6 +75,20 @@ export const nodeLookAt = (cue: Cue | undefined, t: number): NodeLook => {
|
||||
return switchOff(t - offS, lightUp(offS - cue.startS));
|
||||
};
|
||||
|
||||
/** A node at full amplitude in the outro spectrum (see spectrum.ts). */
|
||||
export const SPECTRUM_PEAK: NodeLook = { radius: 9, opacity: 1 };
|
||||
|
||||
/** level scaling helper */
|
||||
export const levelExponential = (level: number, exponent: number = 2.0): number =>
|
||||
Math.pow(clamp01(level), exponent);
|
||||
|
||||
/**
|
||||
* A node in the outro spectrum.
|
||||
* @param level its band's level, 0 (silence: resting) to 1 (SPECTRUM_PEAK)
|
||||
*/
|
||||
export const spectrumLook = (level: number): NodeLook =>
|
||||
mixLooks(RESTING, SPECTRUM_PEAK, clamp01(levelExponential(level)));
|
||||
|
||||
/** 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;
|
||||
|
||||
@@ -40,15 +40,15 @@ export type FeatureFamily =
|
||||
export const darkPalette: TransectPalette = {
|
||||
background: "#28323d", // --paper
|
||||
roadAhead: "#49565f", // --rule
|
||||
roadCovered: "#9dcde4", // --road
|
||||
bridge: "#79d1c5", // --bridge
|
||||
roadCovered: "#fdb021", // --road
|
||||
bridge: "#9be9de", // --bridge
|
||||
tunnel: "#b6c5c1", // --tunnel
|
||||
shed: "#eba9eb", // --shed
|
||||
watercourse: "#5FB6D6", // --water
|
||||
place: "#129252", // --place
|
||||
railway: "#129252", // --railway
|
||||
shrine: "#b32c2c", // --shrine
|
||||
other: "#ff9900", // --util
|
||||
place: "#68ad7f", // --place
|
||||
railway: "#68ad7f", // --railway
|
||||
shrine: "#a05050", // --shrine
|
||||
other: "#a7b7d6", // --util
|
||||
};
|
||||
|
||||
export const lightPalette: TransectPalette = {
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { RoutePoint, TransectData } from "./data";
|
||||
export type Keyframe = { t: number; km: number };
|
||||
|
||||
/** How long the paint takes to run the east continuation once 東に続く starts. */
|
||||
export const EAST_RUN_S = 4;
|
||||
export const EAST_RUN_S = 75.0;
|
||||
|
||||
/**
|
||||
* Pins, in time order: the start of the route at 0 s and each sung line's
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { visualizeAudio, type MediaUtilsAudioData } from "@remotion/media-utils";
|
||||
import { random } from "remotion";
|
||||
|
||||
/**
|
||||
* The outro spectrum: from SPECTRUM_START_S the nodes stop following the lyric
|
||||
* and act as a spectrum analyser. The log-spaced frequency bands are dealt out
|
||||
* to the nodes in a seeded shuffle (see BAND_SEED), with no relation to where
|
||||
* a node is on the road; each node's size follows its band's amplitude.
|
||||
*
|
||||
* Everything here is a pure function of the frame, so Studio previews and
|
||||
* renders agree. Starting values were calibrated on the outro of
|
||||
* 274_release2.wav (8:20 to the end, left channel).
|
||||
*/
|
||||
|
||||
/** When the spectrum takes over from the lyric-driven looks. */
|
||||
export const SPECTRUM_START_S = 504.4;
|
||||
/** How long the hand-over from lyric looks to spectrum looks takes. */
|
||||
export const SPECTRUM_FADE_S = 1.5;
|
||||
|
||||
/** Band centres are log-spaced across this range, one band per node. */
|
||||
export const FREQ_LOW_HZ = 40;
|
||||
export const FREQ_HIGH_HZ = 12000;
|
||||
|
||||
/**
|
||||
* Seed for the node-to-band shuffle. Any string or number; change it to deal a
|
||||
* different mapping. Seeded (Remotion's random()) rather than Math.random(), so
|
||||
* every render tab and the Studio agree on which node gets which band.
|
||||
*/
|
||||
export const BAND_SEED = "274-spectrum";
|
||||
|
||||
/**
|
||||
* Raw amplitude → level (0–1): (raw × GAIN × (f / FREQ_LOW_HZ)^TREBLE_TILT)^CURVE.
|
||||
* High bands are far quieter than bass, so TREBLE_TILT lifts them; CURVE below 1
|
||||
* lifts quiet passages so more than the loudest bands move.
|
||||
*
|
||||
* On the release2 outro these give a median level of 0.35 (10–90% range
|
||||
* 0.18–0.67), with nodes pinned at full size 0.6% of the time. GAIN 10 pinned
|
||||
* them 11% of the time with the bass nodes nearly always full; CURVE 0.8 with
|
||||
* GAIN 8 is livelier (0.15–0.85) but pins 6%.
|
||||
*/
|
||||
export const GAIN = 5;
|
||||
export const TREBLE_TILT = 0.5;
|
||||
export const CURVE = 0.6;
|
||||
|
||||
/**
|
||||
* Release: a node's level is the loudest of its last RELEASE_FRAMES frames, each
|
||||
* frame back multiplied by RELEASE_DECAY once more. Hits jump up at once and
|
||||
* fall back smoothly, without any state carried between frames.
|
||||
*/
|
||||
export const RELEASE_FRAMES = 6;
|
||||
export const RELEASE_DECAY = 0.75;
|
||||
|
||||
/** FFT size for visualizeAudio: bins are (sample rate / 2) / FFT_SAMPLES wide (23.4 Hz). */
|
||||
export const FFT_SAMPLES = 1024;
|
||||
/** Seconds of audio useWindowedAudioData keeps loaded around the current frame. */
|
||||
export const AUDIO_WINDOW_S = 10;
|
||||
|
||||
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
|
||||
|
||||
const easeInOutCubic = (p: number) =>
|
||||
p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
|
||||
|
||||
/** 0 before the spectrum, 1 once the hand-over has finished. */
|
||||
export const spectrumWeight = (t: number): number =>
|
||||
easeInOutCubic(clamp01((t - SPECTRUM_START_S) / SPECTRUM_FADE_S));
|
||||
|
||||
/**
|
||||
* The frame to load audio around: the real frame once the spectrum is active,
|
||||
* otherwise the spectrum's start, so nothing is fetched for the rest of the song.
|
||||
*/
|
||||
export const audioWindowFrame = (frame: number, fps: number): number =>
|
||||
Math.max(frame, Math.floor((SPECTRUM_START_S - SPECTRUM_FADE_S) * fps));
|
||||
|
||||
/** `count` log-spaced band centres from FREQ_LOW_HZ to FREQ_HIGH_HZ. */
|
||||
export const bandFrequencies = (count: number): number[] =>
|
||||
Array.from({ length: count }, (_, i) =>
|
||||
count === 1
|
||||
? FREQ_LOW_HZ
|
||||
: FREQ_LOW_HZ * Math.pow(FREQ_HIGH_HZ / FREQ_LOW_HZ, i / (count - 1)),
|
||||
);
|
||||
|
||||
/**
|
||||
* One band centre per node, in node order: bandFrequencies(count) dealt out in a
|
||||
* Fisher–Yates shuffle seeded by BAND_SEED.
|
||||
*/
|
||||
export const shuffledBandFrequencies = (count: number): number[] => {
|
||||
const frequencies = bandFrequencies(count);
|
||||
for (let i = frequencies.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(random(`${BAND_SEED}-${i}`) * (i + 1));
|
||||
const swap = frequencies[i];
|
||||
frequencies[i] = frequencies[j];
|
||||
frequencies[j] = swap;
|
||||
}
|
||||
return frequencies;
|
||||
};
|
||||
|
||||
/**
|
||||
* A band's raw amplitude from one spectrum: the mean over the bins between its
|
||||
* geometric edges, or, for bands narrower than a bin (the bass end), the value
|
||||
* interpolated at its centre, so neighbouring bass nodes still differ.
|
||||
*/
|
||||
const bandValue = (spectrum: number[], centreHz: number, ratio: number, binHz: number) => {
|
||||
const half = Math.sqrt(ratio);
|
||||
const low = centreHz / half / binHz;
|
||||
const high = centreHz * half / binHz;
|
||||
const first = Math.max(1, Math.ceil(low));
|
||||
const last = Math.min(spectrum.length - 1, Math.floor(high));
|
||||
if (last >= first) {
|
||||
let sum = 0;
|
||||
for (let k = first; k <= last; k++) {
|
||||
sum += spectrum[k];
|
||||
}
|
||||
return sum / (last - first + 1);
|
||||
}
|
||||
const pos = Math.min(spectrum.length - 1.001, Math.max(0, centreHz / binHz));
|
||||
const k = Math.floor(pos);
|
||||
return spectrum[k] + (spectrum[k + 1] - spectrum[k]) * (pos - k);
|
||||
};
|
||||
|
||||
/** Every band's level (0–1) at `frame`, release included. */
|
||||
export const bandLevels = (args: {
|
||||
audioData: MediaUtilsAudioData;
|
||||
dataOffsetInSeconds: number;
|
||||
frame: number;
|
||||
fps: number;
|
||||
frequencies: number[];
|
||||
}): number[] => {
|
||||
const { audioData, dataOffsetInSeconds, frame, fps, frequencies } = args;
|
||||
const binHz = audioData.sampleRate / 2 / FFT_SAMPLES;
|
||||
const ratio =
|
||||
frequencies.length > 1 ? Math.pow(FREQ_HIGH_HZ / FREQ_LOW_HZ, 1 / (frequencies.length - 1)) : 2;
|
||||
const levels = frequencies.map(() => 0);
|
||||
|
||||
for (let back = 0; back < RELEASE_FRAMES && frame - back >= 0; back++) {
|
||||
const spectrum = visualizeAudio({
|
||||
audioData,
|
||||
frame: frame - back,
|
||||
fps,
|
||||
numberOfSamples: FFT_SAMPLES,
|
||||
dataOffsetInSeconds,
|
||||
optimizeFor: "accuracy",
|
||||
});
|
||||
const decay = Math.pow(RELEASE_DECAY, back);
|
||||
frequencies.forEach((hz, i) => {
|
||||
const raw = bandValue(spectrum, hz, ratio, binHz);
|
||||
const level =
|
||||
Math.pow(clamp01(raw * GAIN * Math.pow(hz / FREQ_LOW_HZ, TREBLE_TILT)), CURVE) * decay;
|
||||
levels[i] = Math.max(levels[i], level);
|
||||
});
|
||||
}
|
||||
return levels;
|
||||
};
|
||||
Reference in New Issue
Block a user