11 Commits

Author SHA1 Message Date
mikkeli e3c843d65c feat(274): name the モトツ pair with a slash in the transect subtitles
モトツ橋にモトツシェルター -> モトツ橋 / モトツシェルター, matching the two nodes the
visualiser lights side by side on the road. The rest of the diff is Aegisub's
saved cursor position.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-30 01:29:15 +09:00
mikkeli 530238a328 feat(274): render at 60 fps, with all timing in seconds
The composition is now 60 fps (37,157 frames). Everything was already written
against time in seconds except the spectrum's release, which counted frames and
so would have shortened from 0.2 s to 0.1 s at 60 fps.

- spectrum.ts: RELEASE_FRAMES -> RELEASE_WINDOW_S (0.2 s) sampled every
  RELEASE_STEP_S (1/30 s), RELEASE_DECAY per step. At 30, 60 and 120 fps this
  reads the same instants (0, 33.3, 66.7, 100, 133.3, 166.7 ms back) with the
  same weights, so the tail keeps its length and the work per frame stays
  constant instead of growing with the frame rate.
- time.ts: secondsToFrames / framesToSeconds, the one place the two meet —
  only Remotion's API counts frames.
- camera.ts: the smoothing window's hardcoded 1/30 is now FOLLOW_SAMPLE_S,
  a sampling interval in seconds rather than a frame count.
- Composition.tsx: FPS 30 -> 60; the duration still follows the audio length.

Changing FPS is now the only edit needed to try another frame rate.

Verified: eslint + tsc clean; the 60 fps export reports 60/1 and 37,157 frames,
with audio 0.00 ms off at 1:45 and 9:00.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-30 01:29:15 +09:00
mikkeli d7b3824e1e fix(274): end the fade-out where the black hold starts
FADE_OUT_POST holds full black over the last stretch of the video, but the ramp
still aimed to finish at lastFrameS - FADE_OUT_S, so it completed inside the
hold and the two overlapped. The ramp now runs from
lastFrameS - FADE_OUT_POST - FADE_OUT_S to lastFrameS - FADE_OUT_POST, giving
FADE_OUT_S of fade followed by FADE_OUT_POST of black.

Verified: with 0.4 / 0.3 / 0.2 / 0.4 the picture is clear from 0.70 s to
618.67 s, and the rendered file is pure black for the first and last 13 frames
at 30 fps (25 at 60).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-30 01:29:13 +09:00
mikkeli 2dfd52e33d style(274): retune tunnel, shrine and other colours
Dark palette, chosen while previewing in the Studio:
- tunnel #b6c5c1 -> #a6ccc2
- shrine #a05050 -> #b94141
- other #a7b7d6 -> #8b9ab8

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-18 02:00:29 +09:00
mikkeli fde2aee5d1 feat(274): subtitles for the transect visualiser
- 274-transect.ass: Aegisub subtitles for baking into the final transect video
  (274_transect.mp4) — addresses (274-addr), places (274-place) and the
  closing credits (274-credits), all in M PLUS 1 Code.
- BOM.md: the four static M PLUS 1 Code weights it needs (Regular, Medium,
  SemiBold, Bold; SIL OFL 1.1) from coz-m/MPLUS_FONTS at 2a968b8. Static per-
  weight files rather than Google Fonts' variable TTF, so libass matches the
  styles' full names exactly.

Verified: a libass test bake resolves every style to the real files — Medium
for 274-addr, SemiBold for 274-place, Regular and Bold (for {\b1}) for
274-credits — with no fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-18 02:00:29 +09:00
mikkeli e23772af17 feat(274): fade the transect from and to black
The visualiser now starts and ends on a completely black screen: a black layer
over everything whose opacity comes from fade.ts.

- FADE_IN_PRE (0.4 s): hold on full black at the very start.
- FADE_IN_S (0.3 s): then fade from black to the picture.
- FADE_OUT_S (0.2 s): fade to black at the end, reaching full black on the last
  frame ((durationInFrames - 1) / fps), not a frame after it.

Verified: eslint + tsc clean; stills show the first and last frames are pure
RGB (0, 0, 0) and brightness ramps as timed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-18 02:00:29 +09:00
mikkeli 2731d9d332 Merge pull request '274: transect composition, spectrum outro, and lyric fixes it exposed' (#1) from song274/transect into song274/master
Reviewed-on: #1
2026-09-17 01:36:53 +00:00
mikkeli 10949dfa18 style(274): retune the dark transect palette
Colour choices for the dark palette, made while previewing in the Studio:
- roadCovered #9dcde4 -> #fdb021 (the covered road now reads amber)
- bridge #79d1c5 -> #9be9de
- place / railway #129252 -> #68ad7f
- shrine #b32c2c -> #a05050
- other #ff9900 -> #a7b7d6

The light palette is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-17 10:32:20 +09:00
mikkeli 561c930620 fix(274): separate nodes that share exact coordinates
Three pairs of sung nodes have identical roster coordinates, so one circle fully
eclipsed the other: 草木橋 / 滝の上こ線橋, ポン久留喜橋 / 久留喜橋, and
モトツ橋 / モトツシェルター (a bridge roofed by a shelter). Editing the lyrics
index's lat/lon could not fix it: the video takes positions from the roster
(274-lookup.csv), joined on object.

- layout.ts: nodePositions() groups nodes by exact coordinate and spreads each
  group COINCIDENT_SPREAD (1.5 map units, about 35 m) apart along the road's
  direction there, in road order, centred on the shared point. All such pairs
  are on-road structures, so they stay on the road (within 0.06 units) and
  read as a doubled dot; partial overlap is intended, full eclipse is not.
  Along rather than across the road, and small: at 6 units モトツシェルター
  landed on 福山トンネル, 90 m further on.
- data.ts: RosterItem carries the roster's km, for road order and direction.
- Transect.tsx: draws nodes at those positions.

Verified: eslint + tsc clean on this commit's tree; close-ups at モトツ, resting
and lit, show both nodes on the road in order after モトツトンネル.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-17 10:32:19 +09:00
mikkeli 04963c929a feat(274): spectrum outro for the transect
From SPECTRUM_START_S the nodes stop following the lyric and behave as a
spectrum analyser: every sung node is given one log-spaced frequency band
(40 Hz–12 kHz), dealt out in a seeded shuffle, and sizes up and down with that
band's amplitude.

- spectrum.ts: band maths and every tunable. Levels come from
  @remotion/media-utils visualizeAudio on audio loaded by
  useWindowedAudioData (10 s windows, so the 227 MB WAV is never decoded
  whole; before the outro the window stays parked at its start). Bands average
  the FFT bins they span, or interpolate for bands narrower than a bin, so
  neighbouring bass nodes never move in lockstep. Level =
  (raw × GAIN × (f/40 Hz)^TREBLE_TILT)^CURVE with a release that looks back
  RELEASE_FRAMES frames at RELEASE_DECAY per frame. Defaults were calibrated
  on the release2 outro (nodes pinned at full size 0.6% of the time). The
  node→band mapping uses Remotion's seeded random() (BAND_SEED), not
  Math.random(), so render tabs and the Studio agree.
- nodeAnimation.ts: SPECTRUM_PEAK and spectrumLook(level), alongside lightUp
  and switchOff, plus a levelExponential shaping curve.
- Transect.tsx: hands each node over from its lyric look to its spectrum look
  over SPECTRUM_FADE_S.
- package.json: declare @remotion/media-utils 4.0.522 (already installed as a
  dependency of the CLI; now pinned alongside the other Remotion packages).

Verified: eslint + tsc clean on this commit's tree; outro stills show nodes
sized by band level, so the windowed audio loads during renders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-17 10:31:58 +09:00
mikkeli fac282bcbc feat(274): show the east stub ahead of time; slow its paint run
- Transect.tsx: the dimmed "road ahead" now includes the east continuation
  (274-route-east.json), so it is visible all along instead of appearing only
  once the paint reaches it after 東に続く.
- progress.ts: EAST_RUN_S 4 -> 75. The paint used to dash along the east stub
  (5.25 km in 4 s, about 1.3 km/s, after 0.55 km/s for the leg before it); it now
  takes 75 s from 東に続く, well into the outro.

Verified: eslint + tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-17 09:56:16 +09:00
14 changed files with 541 additions and 27 deletions
+1
View File
@@ -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",
+168
View File
@@ -0,0 +1,168 @@
[Script Info]
; Script generated by Aegisub 3.2.2
; http://www.aegisub.org/
Title: Default Aegisub file
ScriptType: v4.00+
WrapStyle: 0
ScaledBorderAndShadow: yes
YCbCr Matrix: PC.601
PlayResX: 1920
PlayResY: 1080
[Aegisub Project Garbage]
Audio File: 274_transect.mp4
Video File: 274_transect.mp4
Video AR Mode: 4
Video AR Value: 1.777778
Video Zoom Percent: 0.500000
Scroll Position: 74
Active Line: 82
Video Position: 8587
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
Style: rightoffset,Source Han Serif SC SemiBold,40,&H006C574D,&H000000FF,&H006E6E6E,&H00B4CBD7,-1,0,0,0,100,100,0,0,1,0,0,3,10,60,60,1
Style: main heavy,源ノ明朝 SemiBold,45,&H006C574D,&H000000FF,&H003D2D22,&HE56C574D,-1,0,0,0,100,100,5,0,1,0,5,2,10,10,125,1
Style: happy1551_base,Inziu Iosevka Slab SC,75,&H0000D2FF,&H000000FF,&H000000C8,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
Style: happy1551_zx,Inziu Iosevka Slab SC,75,&H000000FF,&H000000FF,&H0000006E,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
Style: happy1551_60,Inziu Iosevka Slab SC,75,&H00E0DDCE,&H000000FF,&H00353537,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
Style: happy1551_houye,Inziu Iosevka Slab SC,75,&H00DA7400,&H000000FF,&H00DFD1A8,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
Style: happy1551_s20,Inziu Iosevka Slab SC,75,&H00B38766,&H000000FF,&H00F1F1F1,&H00000000,0,0,0,0,100,100,0,0,1,3,0,2,10,10,70,1
Style: leftoffset,Source Han Serif SC Medium,65,&H00FFFFFF,&H000000FF,&H00343434,&H00000000,0,0,0,0,100,100,0,0,1,2,0,1,40,10,20,1
Style: main heavy - left,源ノ明朝 SemiBold,90,&H003F6CCD,&H000000FF,&H00221716,&H85AAB2B7,0,0,0,0,100,100,0,0,1,3,0,1,10,250,100,1
Style: credits,Source Han Serif SC Medium,35,&H00C2B3A9,&H000000FF,&H000D0C0E,&H00000000,0,0,0,0,100,100,0,0,1,2,0,9,0,45,30,1
Style: transparent_timesig,Noto Serif JP Medium,60,&H00000000,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,1,150,0,200,1
Style: 274-place,M PLUS 1 Code SemiBold,70,&H001AB4F9,&H000000FF,&H003C3228,&H00000000,0,0,0,0,100,100,2,0,1,4,0,1,128,0,180,1
Style: 274-credits,M PLUS 1 Code,50,&H001AB4F9,&H000000FF,&H003C3228,&H00000000,0,0,0,0,100,100,1,0,1,2,0,3,0,150,100,1
Style: 274-addr,M PLUS 1 Code Medium,50,&H001AB4F9,&H000000FF,&H003C3228,&H00000000,0,0,0,0,100,100,4,0,1,2,0,1,128,0,262,1
Style: main heavy right,源ノ明朝 SemiBold,45,&H006C574D,&H000000FF,&H003D2D22,&HE56C574D,-1,0,0,0,100,100,5,0,1,0,5,3,10,125,125,1
Style: credits_sponsor,源ノ角ゴシック Heavy,188,&H00FFFFFF,&H000000FF,&H000D0C0E,&H00000000,-1,0,0,0,100,100,0,0,1,4.5,4.5,5,0,0,0,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:14.54,0:00:19.67,274-addr,,0,0,0,,夕張郡由仁町川端
Dialogue: 0,0:00:17.89,0:00:19.67,274-place,,0,0,0,,川端橋
Dialogue: 0,0:00:19.67,0:00:25.04,274-place,,0,0,0,,
Dialogue: 0,0:00:25.04,0:00:42.01,274-addr,,0,0,0,,夕張郡栗山町滝下
Dialogue: 0,0:00:28.41,0:00:32.06,274-place,,0,0,0,,川端発電所
Dialogue: 0,0:00:32.06,0:00:35.44,274-place,,0,0,0,,滝下八幡神社
Dialogue: 0,0:00:35.44,0:00:39.84,274-place,,0,0,0,,滝ノ下信号場
Dialogue: 0,0:00:39.84,0:00:42.01,274-place,,0,0,0,,雨霧橋
Dialogue: 0,0:00:42.01,0:00:42.45,274-place,,0,0,0,,
Dialogue: 0,0:00:42.45,0:00:46.69,274-addr,,0,0,0,,夕張郡由仁町川端
Dialogue: 0,0:00:45.15,0:00:46.69,274-place,,0,0,0,,竜仙橋
Dialogue: 0,0:00:46.69,0:00:47.63,274-place,,0,0,0,,
Dialogue: 0,0:00:47.63,0:00:54.73,274-addr,,0,0,0,,夕張郡栗山町滝下
Dialogue: 0,0:00:50.08,0:00:52.88,274-place,,0,0,0,,草木橋
Dialogue: 0,0:00:52.88,0:00:54.73,274-place,,0,0,0,,滝の上こ線橋
Dialogue: 0,0:00:54.73,0:00:56.44,274-place,,0,0,0,,
Dialogue: 0,0:00:56.44,0:01:08.78,274-addr,,0,0,0,,夕張市滝ノ上
Dialogue: 0,0:00:59.95,0:01:03.47,274-place,,0,0,0,,滝の上発電所
Dialogue: 0,0:01:03.47,0:01:06.94,274-place,,0,0,0,,滝ノ上信号場
Dialogue: 0,0:01:06.94,0:01:08.78,274-place,,0,0,0,,滝ノ上神社
Dialogue: 0,0:01:08.78,0:01:10.50,274-place,,0,0,0,,
Dialogue: 0,0:01:10.50,0:01:45.03,274-addr,,0,0,0,,夕張市紅葉山
Dialogue: 0,0:01:12.05,0:01:13.86,274-place,,0,0,0,,十三里信号場
Dialogue: 0,0:01:13.86,0:01:16.44,274-place,,0,0,0,,十三里橋
Dialogue: 0,0:01:16.44,0:01:18.78,274-place,,0,0,0,,紅葉山駐車帯
Dialogue: 0,0:01:18.78,0:01:21.06,274-place,,0,0,0,,瑞穂橋
Dialogue: 0,0:01:21.06,0:01:24.44,274-place,,0,0,0,,セブンイレブン夕張紅葉山店
Dialogue: 0,0:01:24.44,0:01:27.06,274-place,,0,0,0,,新夕張駅
Dialogue: 0,0:01:27.06,0:01:30.02,274-place,,0,0,0,,紅葉山郵便局
Dialogue: 0,0:01:30.02,0:01:32.30,274-place,,0,0,0,,紅葉山神社
Dialogue: 0,0:01:32.30,0:01:35.24,274-place,,0,0,0,,セイコーマートもみじやま店
Dialogue: 0,0:01:35.24,0:01:38.48,274-place,,0,0,0,,紅楓橋
Dialogue: 0,0:01:38.48,0:01:40.25,274-place,,0,0,0,,ポン久留喜橋
Dialogue: 0,0:01:40.25,0:01:42.44,274-place,,0,0,0,,久留喜橋
Dialogue: 0,0:01:42.44,0:01:43.79,274-place,,0,0,0,,楓橋
Dialogue: 0,0:01:43.79,0:01:45.03,274-place,,0,0,0,,桂橋
Dialogue: 0,0:01:45.03,0:01:45.77,274-place,,0,0,0,,
Dialogue: 0,0:01:45.77,0:02:00.74,274-addr,,0,0,0,,夕張市楓
Dialogue: 0,0:01:49.12,0:01:50.76,274-place,,0,0,0,,千紫橋
Dialogue: 0,0:01:50.76,0:01:52.51,274-place,,0,0,0,,楓駐車帯
Dialogue: 0,0:01:52.51,0:01:53.80,274-place,,0,0,0,,万紅橋
Dialogue: 0,0:01:53.80,0:01:55.54,274-place,,0,0,0,,登川神社
Dialogue: 0,0:01:55.54,0:01:57.32,274-place,,0,0,0,,楓信号場
Dialogue: 0,0:01:57.32,0:01:59.00,274-place,,0,0,0,,登川橋
Dialogue: 0,0:01:59.00,0:02:00.74,274-place,,0,0,0,,登川郵便局
Dialogue: 0,0:02:00.74,0:02:01.26,274-place,,0,0,0,,
Dialogue: 0,0:02:01.26,0:02:06.62,274-addr,,0,0,0,,夕張市登川
Dialogue: 0,0:02:02.93,0:02:05.23,274-place,,0,0,0,,花園橋
Dialogue: 0,0:02:05.23,0:02:06.62,274-place,,0,0,0,,登川トンネル
Dialogue: 0,0:02:06.62,0:03:30.80,274-place,,0,0,0,,
Dialogue: 0,0:03:30.80,0:03:39.41,274-addr,,0,0,0,,勇払郡むかわ町穂別長和
Dialogue: 0,0:03:34.55,0:03:36.77,274-place,,0,0,0,,オマナイ橋
Dialogue: 0,0:03:36.77,0:03:39.41,274-place,,0,0,0,,桂木橋
Dialogue: 0,0:03:39.41,0:03:41.13,274-place,,0,0,0,,
Dialogue: 0,0:03:41.13,0:04:05.59,274-addr,,0,0,0,,勇払郡むかわ町穂別稲里
Dialogue: 0,0:03:44.62,0:03:46.67,274-place,,0,0,0,,大和橋
Dialogue: 0,0:03:46.67,0:03:48.38,274-place,,0,0,0,,豊進橋
Dialogue: 0,0:03:48.38,0:03:51.61,274-place,,0,0,0,,豊進神社
Dialogue: 0,0:03:51.61,0:03:53.14,274-place,,0,0,0,,豊里橋
Dialogue: 0,0:03:53.14,0:03:55.52,274-place,,0,0,0,,稲豊橋
Dialogue: 0,0:03:55.52,0:03:57.78,274-place,,0,0,0,,稲里橋
Dialogue: 0,0:03:57.78,0:04:01.08,274-place,,0,0,0,,稲里簡易郵便局
Dialogue: 0,0:04:01.08,0:04:03.50,274-place,,0,0,0,,佐主橋
Dialogue: 0,0:04:03.50,0:04:05.59,274-place,,0,0,0,,穂別ダム神社
Dialogue: 0,0:04:05.59,0:04:05.96,274-place,,0,0,0,,
Dialogue: 0,0:04:05.96,0:04:25.27,274-addr,,0,0,0,,勇払郡むかわ町穂別長和
Dialogue: 0,0:04:10.08,0:04:13.02,274-place,,0,0,0,,長和トンネル
Dialogue: 0,0:04:13.02,0:04:15.39,274-place,,0,0,0,,穂別大橋
Dialogue: 0,0:04:15.39,0:04:18.81,274-place,,0,0,0,,山水橋
Dialogue: 0,0:04:18.81,0:04:23.20,274-place,,0,0,0,,沼の沢橋
Dialogue: 0,0:04:23.20,0:04:25.27,274-place,,0,0,0,,稲里トンネル
Dialogue: 0,0:04:25.27,0:04:26.00,274-place,,0,0,0,,
Dialogue: 0,0:04:26.00,0:04:55.52,274-addr,,0,0,0,,勇払郡むかわ町穂別福山
Dialogue: 0,0:04:29.74,0:04:31.50,274-place,,0,0,0,,ペンケ橋
Dialogue: 0,0:04:31.50,0:04:33.62,274-place,,0,0,0,,下福山橋
Dialogue: 0,0:04:33.62,0:04:35.02,274-place,,0,0,0,,オロロップ橋
Dialogue: 0,0:04:35.02,0:04:37.07,274-place,,0,0,0,,福山橋
Dialogue: 0,0:04:37.07,0:04:38.70,274-place,,0,0,0,,福山神社
Dialogue: 0,0:04:38.70,0:04:41.02,274-place,,0,0,0,,福山大橋
Dialogue: 0,0:04:41.02,0:04:43.29,274-place,,0,0,0,,千坂橋
Dialogue: 0,0:04:43.29,0:04:45.12,274-place,,0,0,0,,オコタン橋
Dialogue: 0,0:04:45.12,0:04:46.22,274-place,,0,0,0,,モトツトンネル
Dialogue: 0,0:04:46.22,0:04:48.98,274-place,,0,0,0,,モトツ橋 / モトツシェルター
Dialogue: 0,0:04:48.98,0:04:51.39,274-place,,0,0,0,,福山トンネル
Dialogue: 0,0:04:51.39,0:04:53.61,274-place,,0,0,0,,福穂橋
Dialogue: 0,0:04:53.61,0:04:55.52,274-place,,0,0,0,,穂高トンネル
Dialogue: 0,0:04:55.52,0:05:22.60,274-place,,0,0,0,,
Dialogue: 0,0:05:22.60,0:05:50.73,274-addr,,0,0,0,,勇払郡占冠村中央
Dialogue: 0,0:05:25.76,0:05:28.88,274-place,,0,0,0,,穂高橋
Dialogue: 0,0:05:28.88,0:05:31.52,274-place,,0,0,0,,高翠橋
Dialogue: 0,0:05:31.52,0:05:34.95,274-place,,0,0,0,,占翠橋
Dialogue: 0,0:05:34.95,0:05:38.08,274-place,,0,0,0,,名石駐車場
Dialogue: 0,0:05:38.08,0:05:40.99,274-place,,0,0,0,,名石橋
Dialogue: 0,0:05:40.99,0:05:43.96,274-place,,0,0,0,,赤岩大橋
Dialogue: 0,0:05:43.96,0:05:48.62,274-place,,0,0,0,,渓流大橋
Dialogue: 0,0:05:48.62,0:05:50.73,274-place,,0,0,0,,日高トンネル
Dialogue: 0,0:05:50.73,0:07:11.39,274-place,,0,0,0,,
Dialogue: 0,0:07:11.39,0:07:16.38,274-addr,,0,0,0,,沙流郡日高町日高
Dialogue: 0,0:07:14.14,0:07:16.38,274-place,,0,0,0,,日夕橋
Dialogue: 0,0:07:16.38,0:07:28.85,274-place,,0,0,0,,
Dialogue: 0,0:07:28.85,0:07:34.09,274-addr,,0,0,0,,沙流郡日高町宮下町
Dialogue: 0,0:07:31.68,0:07:34.09,274-place,,0,0,0,,日高神社
Dialogue: 0,0:07:34.09,0:07:42.90,274-place,,0,0,0,,
Dialogue: 0,0:07:42.90,0:07:55.11,274-addr,,0,0,0,,沙流郡日高町本町東
Dialogue: 0,0:07:45.51,0:07:49.88,274-place,,0,0,0,,新川橋
Dialogue: 0,0:07:49.88,0:07:52.76,274-place,,0,0,0,,セイコーマート日高道の駅店
Dialogue: 0,0:07:52.76,0:07:55.11,274-place,,0,0,0,,日高郵便局
Dialogue: 0,0:07:55.11,0:07:56.69,274-place,,0,0,0,,
Dialogue: 0,0:07:56.69,0:08:02.15,274-addr,,0,0,0,,沙流郡日高町千栄
Dialogue: 0,0:07:59.36,0:08:02.15,274-place,,0,0,0,,北電日高発電所
Dialogue: 0,0:08:02.15,0:08:03.90,274-place,,0,0,0,,
Dialogue: 0,0:08:03.90,0:08:09.44,274-addr,,0,0,0,,沙流郡日高町日高
Dialogue: 0,0:08:06.38,0:08:09.44,274-place,,0,0,0,,右左府発電所
Dialogue: 0,0:08:09.44,0:08:10.93,274-place,,0,0,0,,
Dialogue: 0,0:08:10.93,0:08:12.66,274-credits,,0,0,0,,{\b1}東に続く{\b0}
Dialogue: 0,0:08:12.66,0:08:17.66,274-place,,0,0,0,,
Dialogue: 0,0:08:24.92,0:08:31.98,274-credits,,0,0,0,,{\b1}'274'{\b0}\Na song about (one stretch of) national route 274
Dialogue: 0,0:08:31.98,0:08:38.97,274-credits,,0,0,0,,music by Tomaš Orika / data aggregation by Mikkeli Matlock\Nvisualiser by Joroz Tosci / powered by Claude Opus 5
Dialogue: 0,0:08:38.97,0:08:46.00,274-credits,,0,0,0,,voiced by Yuzuki Yukari Shizuku (A.I.VOICE 2)
Dialogue: 0,0:08:46.00,0:08:52.98,274-credits,,0,0,0,,{\b1}thanks to:{\b0}\Nroute01.com\Nhgf03030.a.la9.jp
Dialogue: 0,0:08:52.98,0:09:00.00,274-credits,,0,0,0,,{\b1}thanks to:{\b0}\N全国道路施設点検データベース\N国土交通省北海道開発局 道路施設の老朽化対策\N国土数値情報 河川データセット
Dialogue: 0,0:09:00.00,0:09:06.98,274-credits,,0,0,0,,{\b1}thanks to:{\b0}\N日本郵便 郵便番号データ\N国土地理院 逆ジオコーディング API
Dialogue: 0,0:09:06.98,0:09:14.00,274-credits,,0,0,0,,{\b1}thanks to:{\b0}\NOpenStreetMap contributors\NGoogle Maps\NWikipedia
Dialogue: 0,0:09:14.00,0:09:20.96,274-credits,,0,0,0,,2026 northwich committee of magic researches
Dialogue: 0,0:09:20.96,0:09:22.98,274-credits,,0,0,0,,see you in Yuubari
+5 -1
View File
@@ -1,6 +1,6 @@
# Bill of materials — 274
5 file(s), 465.6 MiB. Generated by `scripts/bom_gen.py`.
9 file(s), 472.1 MiB. Generated by `scripts/bom_gen.py`.
## audio
@@ -19,5 +19,9 @@ file size hash description
file size hash description
-------------------------------- ------- -------------------------- ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
`fonts/MPLUS1Code-Bold.ttf` 1.6 MiB `2bb7c37e098719928fc47383` M PLUS 1 Code Bold (static TTF, SIL OFL 1.1) from coz-m/MPLUS_FONTS at 2a968b8 (fonts/MPLUS1Code/ttf). Used by 274-transect.ass for {\b1} runs in 274-credits.
`fonts/MPLUS1Code-Medium.ttf` 1.6 MiB `0c9342dd910f7daaea1d40ac` M PLUS 1 Code Medium (static TTF, SIL OFL 1.1) from coz-m/MPLUS_FONTS at 2a968b8 (fonts/MPLUS1Code/ttf). Used by 274-transect.ass style 274-addr.
`fonts/MPLUS1Code-Regular.ttf` 1.6 MiB `2f8308951632e89da5e3bcee` M PLUS 1 Code Regular (static TTF, SIL OFL 1.1) from coz-m/MPLUS_FONTS at 2a968b8 (fonts/MPLUS1Code/ttf). Used by 274-transect.ass style 274-credits.
`fonts/MPLUS1Code-SemiBold.ttf` 1.6 MiB `8d5d9c9d3e427a99b2b4e25e` M PLUS 1 Code SemiBold (static TTF, SIL OFL 1.1) from coz-m/MPLUS_FONTS at 2a968b8 (fonts/MPLUS1Code/ttf). Used by 274-transect.ass style 274-place.
`fonts/NotoSerifJP-Medium.otf` 6.0 MiB `b3aa1bf0e4aeb96623aeaab1` Noto Serif JP Medium (SubsetOTF/JP, v2.003, SIL OFL 1.1) from notofonts/noto-cjk release Serif2.003 (12_NotoSerifJP.zip). Used by 274.ass styles transparent_lyrics_en, transparent_lyrics_jp.
`fonts/NotoSerifJP-SemiBold.otf` 6.0 MiB `81863c254d5833df489433c4` Noto Serif JP SemiBold (SubsetOTF/JP, v2.003, SIL OFL 1.1) from notofonts/noto-cjk release Serif2.003 (12_NotoSerifJP.zip). Used by 274.ass style transparent_title.
+1 -1
View File
@@ -4,7 +4,7 @@ import { loadTransectData } from "./data";
import { darkPalette } from "./palette";
import { Transect, type TransectProps } from "./Transect";
const FPS = 30;
const FPS = 60;
/** Length of song-274/audio/274_release2.wav (see BOM.md). */
const AUDIO_DURATION_S = 619.270083;
+62 -14
View File
@@ -18,8 +18,24 @@ 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 { blackOpacity } from "./fade";
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,
@@ -67,20 +83,22 @@ const currentCue = (list: Cue[] | undefined, t: number): Cue | undefined => {
export const Transect: React.FC<TransectProps> = ({ palette, audio, data }) => {
const frame = useCurrentFrame();
const { fps, width, height } = useVideoConfig();
const { fps, width, height, durationInFrames } = 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,12 +108,29 @@ 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 }} />;
}
const t = frame / fps;
const fadeToBlack = blackOpacity(t, (durationInFrames - 1) / fps);
const headAt = (time: number) => {
const km = kmAt(time);
const pos = routeAtKm(data.route, km);
@@ -127,16 +162,25 @@ export const Transect: React.FC<TransectProps> = ({ palette, audio, data }) => {
view,
);
const nodes = data.items.map((item: RosterItem) => ({
// 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: nodeLookAt(currentCue(cueMap.get(item.object), t), t),
}));
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}
@@ -179,6 +223,10 @@ export const Transect: React.FC<TransectProps> = ({ palette, audio, data }) => {
{nodes.filter((n) => isAwake(n.look)).map(draw)}
</g>
</svg>
{/* Fade from and to black at the two ends (fade.ts). */}
{fadeToBlack > 0 ? (
<AbsoluteFill style={{ backgroundColor: "#000000", opacity: fadeToBlack }} />
) : null}
</AbsoluteFill>
);
};
+6 -1
View File
@@ -27,6 +27,11 @@ export const ZOOM_IN_S = 3;
* This is the standard deviation, in seconds; the window spans ±3 of these.
*/
export const FOLLOW_SMOOTH_S = 0.6;
/**
* How often that window is sampled, in seconds. Independent of fps: finer
* sampling costs more per frame and changes the average only slightly.
*/
export const FOLLOW_SAMPLE_S = 1 / 30;
/**
* An overview frames the covered road plus some road ahead of the head, so the
* head does not sit against the frame edge: this fraction of the distance
@@ -95,7 +100,7 @@ export const overviewWeight = (parts: Span[], t: number): number => {
/** The head's position averaged over a Gaussian window around `t`. */
export const smoothedFocus = (focusAt: (t: number) => Point, t: number): Point => {
const step = 1 / 30;
const step = FOLLOW_SAMPLE_S;
const reach = 3 * FOLLOW_SMOOTH_S;
let sumX = 0;
let sumY = 0;
+3
View File
@@ -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),
});
+28
View File
@@ -0,0 +1,28 @@
/**
* Fade from and to a completely black screen at the two ends of the video: a
* black layer over everything, fully opaque on the first and last frames.
*/
/** Seconds of complete black at start */
export const FADE_IN_PRE = 0.4;
/** Seconds of complete black at end */
export const FADE_OUT_POST = 0.4;
/** Seconds from black to the picture at the start. 0 disables the fade-in. */
export const FADE_IN_S = 0.3;
/** Seconds from the picture to black at the end. 0 disables the fade-out. */
export const FADE_OUT_S = 0.2;
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
/**
* Opacity of the black layer at time `t`.
* @param lastFrameS time of the video's last frame, (durationInFrames - 1) / fps,
* so that frame is fully black rather than nearly so
*/
export const blackOpacity = (t: number, lastFrameS: number): number => {
const fadeIn = (t < FADE_IN_PRE) ? 1 : (FADE_IN_S > 0 ? 1 - clamp01((t - FADE_IN_PRE) / FADE_IN_S) : 0);
// Black from here to the last frame; the fade-out ramp ends exactly here.
const blackFromS = lastFrameS - FADE_OUT_POST;
const fadeOut = (t >= blackFromS) ? 1 : (FADE_OUT_S > 0 ? clamp01((t - (blackFromS - FADE_OUT_S)) / FADE_OUT_S) : 0);
return Math.max(fadeIn, fadeOut);
};
+67 -1
View File
@@ -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. */
+14
View File
@@ -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;
+7 -7
View File
@@ -40,15 +40,15 @@ export type FeatureFamily =
export const darkPalette: TransectPalette = {
background: "#28323d", // --paper
roadAhead: "#49565f", // --rule
roadCovered: "#9dcde4", // --road
bridge: "#79d1c5", // --bridge
tunnel: "#b6c5c1", // --tunnel
roadCovered: "#fdb021", // --road
bridge: "#9be9de", // --bridge
tunnel: "#a6ccc2", // --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: "#b94141", // --shrine
other: "#8b9ab8", // --util
};
export const lightPalette: TransectPalette = {
+1 -1
View File
@@ -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
+163
View File
@@ -0,0 +1,163 @@
import { visualizeAudio, type MediaUtilsAudioData } from "@remotion/media-utils";
import { random } from "remotion";
import { secondsToFrames } from "./time";
/**
* 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 reading over the RELEASE_WINDOW_S
* before it, sampled every RELEASE_STEP_S and multiplied by RELEASE_DECAY once
* more per step back. Hits jump up at once and fall back smoothly, without any
* state carried between frames.
*
* Sampling by time rather than by frame keeps the tail the same length, and the
* work per frame the same, whatever the fps.
*/
export const RELEASE_WINDOW_S = 0.2;
export const RELEASE_STEP_S = 1 / 30;
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, secondsToFrames(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);
const steps = Math.max(1, Math.round(RELEASE_WINDOW_S / RELEASE_STEP_S));
for (let step = 0; step < steps; step++) {
const back = secondsToFrames(step * RELEASE_STEP_S, fps);
if (frame - back < 0) {
break;
}
const spectrum = visualizeAudio({
audioData,
frame: frame - back,
fps,
numberOfSamples: FFT_SAMPLES,
dataOffsetInSeconds,
optimizeFor: "accuracy",
});
const decay = Math.pow(RELEASE_DECAY, step);
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;
};
+14
View File
@@ -0,0 +1,14 @@
/**
* Frame-rate conversions.
*
* Every timing constant in this composition is in seconds, so the animation is
* identical at any fps. Only Remotion's own API counts frames, so convert here
* rather than writing frame counts into the constants.
*/
/** Seconds to a whole number of frames, rounded to the nearest. */
export const secondsToFrames = (seconds: number, fps: number): number =>
Math.round(seconds * fps);
/** A frame number back to its time in seconds. */
export const framesToSeconds = (frames: number, fps: number): number => frames / fps;