Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3c843d65c | |||
| 530238a328 | |||
| d7b3824e1e | |||
| 2dfd52e33d | |||
| fde2aee5d1 | |||
| e23772af17 | |||
| 2731d9d332 | |||
| 10949dfa18 | |||
| 561c930620 | |||
| 04963c929a | |||
| fac282bcbc |
@@ -6,6 +6,7 @@
|
|||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remotion/cli": "4.0.522",
|
"@remotion/cli": "4.0.522",
|
||||||
|
"@remotion/media-utils": "4.0.522",
|
||||||
"@remotion/tailwind-v4": "4.0.522",
|
"@remotion/tailwind-v4": "4.0.522",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
# Bill of materials — 274
|
# 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
|
## audio
|
||||||
|
|
||||||
@@ -19,5 +19,9 @@ file size hash description
|
|||||||
|
|
||||||
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-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.
|
`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.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { loadTransectData } from "./data";
|
|||||||
import { darkPalette } from "./palette";
|
import { darkPalette } from "./palette";
|
||||||
import { Transect, type TransectProps } from "./Transect";
|
import { Transect, type TransectProps } from "./Transect";
|
||||||
|
|
||||||
const FPS = 30;
|
const FPS = 60;
|
||||||
/** Length of song-274/audio/274_release2.wav (see BOM.md). */
|
/** Length of song-274/audio/274_release2.wav (see BOM.md). */
|
||||||
const AUDIO_DURATION_S = 619.270083;
|
const AUDIO_DURATION_S = 619.270083;
|
||||||
|
|
||||||
|
|||||||
+63
-15
@@ -18,8 +18,24 @@ import {
|
|||||||
type Frame,
|
type Frame,
|
||||||
} from "./camera";
|
} from "./camera";
|
||||||
import type { Cue, RosterItem, TransectData } from "./data";
|
import type { Cue, RosterItem, TransectData } from "./data";
|
||||||
import { extendBounds, GROUND, prefixBounds, projectGround, routePath } from "./layout";
|
import { blackOpacity } from "./fade";
|
||||||
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 type { TransectPalette } from "./palette";
|
||||||
import {
|
import {
|
||||||
coveredRoute,
|
coveredRoute,
|
||||||
@@ -67,20 +83,22 @@ const currentCue = (list: Cue[] | undefined, t: number): Cue | undefined => {
|
|||||||
|
|
||||||
export const Transect: React.FC<TransectProps> = ({ palette, audio, data }) => {
|
export const Transect: React.FC<TransectProps> = ({ palette, audio, data }) => {
|
||||||
const frame = useCurrentFrame();
|
const frame = useCurrentFrame();
|
||||||
const { fps, width, height } = useVideoConfig();
|
const { fps, width, height, durationInFrames } = useVideoConfig();
|
||||||
const projection = useMemo(() => (data ? projectGround(data.route) : null), [data]);
|
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(
|
const fullRoad = useMemo(
|
||||||
() => (data && projection ? routePath(data.route, projection) : ""),
|
() => (projection && paintRoute.length > 1 ? routePath(paintRoute, projection) : ""),
|
||||||
[data, projection],
|
[paintRoute, projection],
|
||||||
);
|
);
|
||||||
const bounds = useMemo(
|
const bounds = useMemo(
|
||||||
() => (data && projection ? prefixBounds(data.route, projection) : []),
|
() => (data && projection ? prefixBounds(data.route, projection) : []),
|
||||||
[data, projection],
|
[data, projection],
|
||||||
);
|
);
|
||||||
const kmAt = useMemo(() => (data ? monotoneCurve(progressKeyframes(data)) : null), [data]);
|
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(
|
const westRoad = useMemo(
|
||||||
() =>
|
() =>
|
||||||
data && projection && data.routeWest.length > 1
|
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 parts = useMemo(() => narrationParts(data ? data.lines : []), [data]);
|
||||||
const cueMap = useMemo(() => cuesByObject(data ? data.cues : []), [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 }} />;
|
return <AbsoluteFill style={{ backgroundColor: palette.background }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = frame / fps;
|
const t = frame / fps;
|
||||||
|
const fadeToBlack = blackOpacity(t, (durationInFrames - 1) / fps);
|
||||||
const headAt = (time: number) => {
|
const headAt = (time: number) => {
|
||||||
const km = kmAt(time);
|
const km = kmAt(time);
|
||||||
const pos = routeAtKm(data.route, km);
|
const pos = routeAtKm(data.route, km);
|
||||||
@@ -127,16 +162,25 @@ export const Transect: React.FC<TransectProps> = ({ palette, audio, data }) => {
|
|||||||
view,
|
view,
|
||||||
);
|
);
|
||||||
|
|
||||||
const nodes = data.items.map((item: RosterItem) => ({
|
// In the outro the nodes hand over from their lyric looks to the spectrum.
|
||||||
item,
|
const spectrum = spectrumWeight(t);
|
||||||
look: nodeLookAt(currentCue(cueMap.get(item.object), t), 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]) => (
|
const draw = ({ item, look }: (typeof nodes)[number]) => (
|
||||||
<circle
|
<circle
|
||||||
key={item.object}
|
key={item.object}
|
||||||
cx={projection.x(item.lon)}
|
cx={positions.get(item.object)?.x ?? projection.x(item.lon)}
|
||||||
cy={projection.y(item.lat)}
|
cy={positions.get(item.object)?.y ?? projection.y(item.lat)}
|
||||||
r={look.radius}
|
r={look.radius}
|
||||||
fill={palette[item.family]}
|
fill={palette[item.family]}
|
||||||
opacity={look.opacity}
|
opacity={look.opacity}
|
||||||
@@ -179,6 +223,10 @@ export const Transect: React.FC<TransectProps> = ({ palette, audio, data }) => {
|
|||||||
{nodes.filter((n) => isAwake(n.look)).map(draw)}
|
{nodes.filter((n) => isAwake(n.look)).map(draw)}
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
{/* Fade from and to black at the two ends (fade.ts). */}
|
||||||
|
{fadeToBlack > 0 ? (
|
||||||
|
<AbsoluteFill style={{ backgroundColor: "#000000", opacity: fadeToBlack }} />
|
||||||
|
) : null}
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ export const ZOOM_IN_S = 3;
|
|||||||
* This is the standard deviation, in seconds; the window spans ±3 of these.
|
* This is the standard deviation, in seconds; the window spans ±3 of these.
|
||||||
*/
|
*/
|
||||||
export const FOLLOW_SMOOTH_S = 0.6;
|
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
|
* 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
|
* 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`. */
|
/** The head's position averaged over a Gaussian window around `t`. */
|
||||||
export const smoothedFocus = (focusAt: (t: number) => Point, t: number): Point => {
|
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;
|
const reach = 3 * FOLLOW_SMOOTH_S;
|
||||||
let sumX = 0;
|
let sumX = 0;
|
||||||
let sumY = 0;
|
let sumY = 0;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export type RosterItem = {
|
|||||||
object: string;
|
object: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
family: FeatureFamily;
|
family: FeatureFamily;
|
||||||
|
/** Distance along the road; the nearest road point for off-road objects. */
|
||||||
|
km: number;
|
||||||
lat: number;
|
lat: number;
|
||||||
lon: number;
|
lon: number;
|
||||||
};
|
};
|
||||||
@@ -163,6 +165,7 @@ export const loadTransectData = async (): Promise<TransectData> => {
|
|||||||
object: r.object,
|
object: r.object,
|
||||||
kind: r.kind,
|
kind: r.kind,
|
||||||
family: familyOf(r.kind, r.object),
|
family: familyOf(r.kind, r.object),
|
||||||
|
km: Number(r.km),
|
||||||
lat: Number(r.lat),
|
lat: Number(r.lat),
|
||||||
lon: Number(r.lon),
|
lon: Number(r.lon),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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
|
* 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 };
|
export type Bounds = { minX: number; minY: number; maxX: number; maxY: number };
|
||||||
|
|
||||||
/** Bounds of the projected route from its start up to each point, inclusive. */
|
/** 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));
|
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). */
|
/** Whether a look is anything other than resting (drawn on top if so). */
|
||||||
export const isAwake = (look: NodeLook): boolean =>
|
export const isAwake = (look: NodeLook): boolean =>
|
||||||
look.radius !== RESTING.radius || look.opacity !== RESTING.opacity;
|
look.radius !== RESTING.radius || look.opacity !== RESTING.opacity;
|
||||||
|
|||||||
@@ -40,15 +40,15 @@ export type FeatureFamily =
|
|||||||
export const darkPalette: TransectPalette = {
|
export const darkPalette: TransectPalette = {
|
||||||
background: "#28323d", // --paper
|
background: "#28323d", // --paper
|
||||||
roadAhead: "#49565f", // --rule
|
roadAhead: "#49565f", // --rule
|
||||||
roadCovered: "#9dcde4", // --road
|
roadCovered: "#fdb021", // --road
|
||||||
bridge: "#79d1c5", // --bridge
|
bridge: "#9be9de", // --bridge
|
||||||
tunnel: "#b6c5c1", // --tunnel
|
tunnel: "#a6ccc2", // --tunnel
|
||||||
shed: "#eba9eb", // --shed
|
shed: "#eba9eb", // --shed
|
||||||
watercourse: "#5FB6D6", // --water
|
watercourse: "#5FB6D6", // --water
|
||||||
place: "#129252", // --place
|
place: "#68ad7f", // --place
|
||||||
railway: "#129252", // --railway
|
railway: "#68ad7f", // --railway
|
||||||
shrine: "#b32c2c", // --shrine
|
shrine: "#b94141", // --shrine
|
||||||
other: "#ff9900", // --util
|
other: "#8b9ab8", // --util
|
||||||
};
|
};
|
||||||
|
|
||||||
export const lightPalette: TransectPalette = {
|
export const lightPalette: TransectPalette = {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type { RoutePoint, TransectData } from "./data";
|
|||||||
export type Keyframe = { t: number; km: number };
|
export type Keyframe = { t: number; km: number };
|
||||||
|
|
||||||
/** How long the paint takes to run the east continuation once 東に続く starts. */
|
/** 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
|
* Pins, in time order: the start of the route at 0 s and each sung line's
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user