Ethscriptions commited on
Commit
71ce0f9
·
verified ·
1 Parent(s): 624ddb0

v8:远光照亮整块路面并闪白;喷射气体改为自然粒子气体;新增动感歌词(LRC / 打轴工具,随导出)

Browse files
.gitattributes CHANGED
@@ -92,3 +92,4 @@ public/mvfonts/neon.woff2 filter=lfs diff=lfs merge=lfs -text
92
  素材/longshot20261021193923.png filter=lfs diff=lfs merge=lfs -text
93
  素材/longshot20261021193931.png filter=lfs diff=lfs merge=lfs -text
94
  素材/longshot20261021193942.png filter=lfs diff=lfs merge=lfs -text
 
 
92
  素材/longshot20261021193923.png filter=lfs diff=lfs merge=lfs -text
93
  素材/longshot20261021193931.png filter=lfs diff=lfs merge=lfs -text
94
  素材/longshot20261021193942.png filter=lfs diff=lfs merge=lfs -text
95
+ public/mvfonts/lyric.woff2 filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -33,7 +33,8 @@ pinned: false
33
  | 5 | ~~适配手机竖屏~~(v5 按要求取消,只保留 16:9) | 横屏 16:9 / 竖屏 9:16 两套构图;所有场景按画布宽高自适应排版;手机端默认竖屏 |
34
  | 6 | 导出帧率 **24 / 30 / 60** 可选(16:9,1080P / 720P) | 在导出面板中单选;第 i 帧的时间 = i / fps,保证和音频严格对齐 |
35
  | 7 | 可复制的**标题、描述、标签** | 小红书 / 抖音 / 微信视频号 / 哔哩哔哩 四套文案,带字数和话题数校验,一键复制 |
36
- | 8 | **封面**:4:3、3:4、16:9,每种比例多张,可多选 | 用 MV 关键帧渲染并叠加封面排版,每种比例 8 款设计,可勾选多张批量下载 PNG |
 
37
 
38
  ### 平台文案约束
39
 
@@ -256,3 +257,8 @@ npm run build && npm start # 生产(http://localhost:3000)
256
  - Drop2:去掉大货车;Roadster 座舱加上清晰可见的方向盘(平底、带轮辐与中心标志,双手握持);01:56 改为从车辆正后方拍摄起飞;去掉左上角遥测文字;冷气改为灰白色常规混合的雾团(不再发光叠加),去掉发光的喷口芯;Roadster 侧面比例加高(车顶、腰线、轮径),解决 02:02 看起来「扁」的问题;
257
  - 音乐:未修改音频文件(线上文件与素材 MD5 一致)。检测到原始母带峰值为 +0.49 dBFS(超过 0 dB),浏览器解码后会削波、听起来发沙;现在播放器音量设为 0.89(约 -1 dB 余量),导出时同样留 -1 dB 余量,AAC 码率提高到 256 kbps;
258
  - 校验:飞越净空 0.78 m,对决最近 0.70 m;全时间轴扫描无报错。
 
 
 
 
 
 
33
  | 5 | ~~适配手机竖屏~~(v5 按要求取消,只保留 16:9) | 横屏 16:9 / 竖屏 9:16 两套构图;所有场景按画布宽高自适应排版;手机端默认竖屏 |
34
  | 6 | 导出帧率 **24 / 30 / 60** 可选(16:9,1080P / 720P) | 在导出面板中单选;第 i 帧的时间 = i / fps,保证和音频严格对齐 |
35
  | 7 | 可复制的**标题、描述、标签** | 小红书 / 抖音 / 微信视频号 / 哔哩哔哩 四套文案,带字数和话题数校验,一键复制 |
36
+ | 8 | **封面**:4:3、3:4、16:9,每种比例多张,可多选 |
37
+ | 9 | **动感歌词**(v8) | 页面「歌词」区粘贴 LRC 或纯文本 + 打轴工具;逐字弹出、卡拉OK 填色、随鼓点缩放 / 抖动 / 色散,随视频一起导出 | 用 MV 关键帧渲染并叠加封面排版,每种比例 8 款设计,可勾选多张批量下载 PNG |
38
 
39
  ### 平台文案约束
40
 
 
257
  - Drop2:去掉大货车;Roadster 座舱加上清晰可见的方向盘(平底、带轮辐与中心标志,双手握持);01:56 改为从车辆正后方拍摄起飞;去掉左上角遥测文字;冷气改为灰白色常规混合的雾团(不再发光叠加),去掉发光的喷口芯;Roadster 侧面比例加高(车顶、腰线、轮径),解决 02:02 看起来「扁」的问题;
258
  - 音乐:未修改音频文件(线上文件与素材 MD5 一致)。检测到原始母带峰值为 +0.49 dBFS(超过 0 dB),浏览器解码后会削波、听起来发沙;现在播放器音量设为 0.89(约 -1 dB 余量),导出时同样留 -1 dB 余量,AAC 码率提高到 256 kbps;
259
  - 校验:飞越净空 0.78 m,对决最近 0.70 m;全时间轴扫描无报错。
260
+ - **步骤 13(v8:远光爆闪 / 自然喷射气体 / 动感歌词)** ✅
261
+ - 58–59 秒:甩尾结束瞬间打开远光——整块路面(含护栏)被照亮,随后全屏闪白再回落;之后远光跟随鼓点和低频照亮整条路面,隔拍闪白,节奏更明显;
262
+ - Drop2 喷射气体重做为粒子模型 `gas.ts`:气体从四个推力口高速喷出,受空气阻力减速,撞到路面后沿地面向四周翻滚扩散,再受浮力缓慢上升、膨胀、消散;向后喷射时形成长长的尾流;用缓存的柔和云团贴图(带自阴影、边缘不规则)以常规混合绘制,不发光;气体在车身前后正确分层;侧面镜头使用同一套粒子;
263
+ - 新增动感歌词 `lyrics.ts` + 页面「02 歌词」区:支持粘贴 LRC(含 offset)或纯文本,「打轴」模式下音乐从头播放,每唱到一句按 Enter / J 自动生成时间轴,可 ±0.1 s 微调、复制 LRC;歌词逐字弹出(回弹)、已唱部分红橙渐变填色、鼓点时整句缩放、字符抖动并出现红青色散、结束时逐字上飘消失;歌词会画进画面,导出 MP4 时一并带上;首次使用时按需加载含 6763 个常用汉字的歌词字体(约 950 KB);
264
+ - 说明:歌词受版权保护,项目不内置歌词原文,需要使用者自行粘贴。
public/mvfonts/black.woff2 CHANGED
Binary files a/public/mvfonts/black.woff2 and b/public/mvfonts/black.woff2 differ
 
public/mvfonts/lyric.woff2 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0de22a63845a5484586a717b4ce9265b5295758cd54e7e8ebba5ddb0dd7147bc
3
+ size 949644
public/mvfonts/neon.woff2 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:f3b543db0e6ed2617414c7f1235ce3b372af24b30b78cd1e72d56b48e6921688
3
- size 112996
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:20d820c00590a0a27a935085f18e1df27987f669d1f92950468802ec9efcbada
3
+ size 118748
src/mv/MvApp.tsx CHANGED
@@ -4,10 +4,12 @@ import './styles.css'
4
  import { DURATION } from './engine/audio'
5
  import { Renderer } from './engine/renderer'
6
  import { SCENES, sceneAt } from './engine/timeline'
7
- import { AUDIO_URL, loadFonts } from './fonts'
8
  import { exportMp4, exportRenderer, webCodecsSupported, type ExportResult } from './export/exportMp4'
9
  import { DESIGNS, RATIOS, RATIO_SIZE, renderCover, type CoverItem, type Ratio } from './covers/covers'
10
  import { PLATFORMS, countChars, formatTags } from './copy/platforms'
 
 
11
 
12
  type Orient = 'landscape' | 'portrait'
13
 
@@ -79,6 +81,11 @@ export default function MvApp() {
79
  return { url: r.out.toDataURL('image/jpeg', 0.85), ms, webgl: r.webgl }
80
  }
81
  ;(window as unknown as { __mvExport: unknown }).__mvExport = exportMp4
 
 
 
 
 
82
  ;(window as unknown as { __mvSheet: unknown }).__mvSheet = async (which: string, w: number, h: number) => {
83
  const H = await import('./engine/hero')
84
  const c = document.createElement('canvas')
@@ -114,12 +121,14 @@ export default function MvApp() {
114
  </div>
115
  <nav className="mv-nav">
116
  <a href="#player">播放</a>
 
117
  <a href="#export">导出</a>
118
  <a href="#copy">文案</a>
119
  <a href="#covers">封面</a>
120
  </nav>
121
  </header>
122
  <Player ready={ready} orient={orient} setOrient={setOrient} />
 
123
  <ExportPanel orient={orient} ready={ready} />
124
  <CopyPanel toast={toast.show} />
125
  <CoverPanel ready={ready} toast={toast.show} />
@@ -221,6 +230,19 @@ function Player({ ready, orient, setOrient }: { ready: boolean; orient: Orient;
221
  setTime(v)
222
  }, [])
223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  useEffect(() => {
225
  const onKey = (e: KeyboardEvent) => {
226
  if ((e.target as HTMLElement)?.closest('input,textarea')) return
@@ -378,7 +400,7 @@ function ExportPanel({ orient, ready }: { orient: Orient; ready: boolean }) {
378
  return (
379
  <section id="export" className="mv-section">
380
  <h2>
381
- <span className="mv-h-idx">02</span> 导出 MP4 <small>浏览器本地渲染 · 不上传</small>
382
  </h2>
383
  <div className="mv-card mv-export">
384
  <div className="mv-opt">
@@ -474,7 +496,7 @@ function CopyPanel({ toast }: { toast: (m: string) => void }) {
474
  return (
475
  <section id="copy" className="mv-section">
476
  <h2>
477
- <span className="mv-h-idx">03</span> 发布文案 <small>标题 · 描述 · 话题,一键复制</small>
478
  </h2>
479
  <div className="mv-tabs">
480
  {PLATFORMS.map((x) => (
@@ -608,7 +630,7 @@ function CoverPanel({ ready, toast }: { ready: boolean; toast: (m: string) => vo
608
  return (
609
  <section id="covers" className="mv-section">
610
  <h2>
611
- <span className="mv-h-idx">04</span> 封面 <small>16:9 · 4:3 · 3:4 · 每种比例 {DESIGNS.length} 款,可多选</small>
612
  </h2>
613
  <div className="mv-cover-bar">
614
  <div className="mv-seg">
@@ -673,3 +695,159 @@ function CoverPanel({ ready, toast }: { ready: boolean; toast: (m: string) => vo
673
  </section>
674
  )
675
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import { DURATION } from './engine/audio'
5
  import { Renderer } from './engine/renderer'
6
  import { SCENES, sceneAt } from './engine/timeline'
7
+ import { AUDIO_URL, ensureLyricFont, loadFonts } from './fonts'
8
  import { exportMp4, exportRenderer, webCodecsSupported, type ExportResult } from './export/exportMp4'
9
  import { DESIGNS, RATIOS, RATIO_SIZE, renderCover, type CoverItem, type Ratio } from './covers/covers'
10
  import { PLATFORMS, countChars, formatTags } from './copy/platforms'
11
+ import { getLyricOffset, loadSavedLyrics, lyricCount, parseLrc, setLyricOffset, setLyrics } from './engine/lyrics'
12
+ import { bus } from './playerBus'
13
 
14
  type Orient = 'landscape' | 'portrait'
15
 
 
81
  return { url: r.out.toDataURL('image/jpeg', 0.85), ms, webgl: r.webgl }
82
  }
83
  ;(window as unknown as { __mvExport: unknown }).__mvExport = exportMp4
84
+ ;(window as unknown as { __mvSetLyrics: unknown }).__mvSetLyrics = async (src: string) => {
85
+ const n = setLyrics(src, false)
86
+ await ensureLyricFont()
87
+ return n
88
+ }
89
  ;(window as unknown as { __mvSheet: unknown }).__mvSheet = async (which: string, w: number, h: number) => {
90
  const H = await import('./engine/hero')
91
  const c = document.createElement('canvas')
 
121
  </div>
122
  <nav className="mv-nav">
123
  <a href="#player">播放</a>
124
+ <a href="#lyrics">歌词</a>
125
  <a href="#export">导出</a>
126
  <a href="#copy">文案</a>
127
  <a href="#covers">封面</a>
128
  </nav>
129
  </header>
130
  <Player ready={ready} orient={orient} setOrient={setOrient} />
131
+ <LyricsPanel toast={toast.show} />
132
  <ExportPanel orient={orient} ready={ready} />
133
  <CopyPanel toast={toast.show} />
134
  <CoverPanel ready={ready} toast={toast.show} />
 
230
  setTime(v)
231
  }, [])
232
 
233
+ useEffect(() => {
234
+ bus.time = () => audioRef.current?.currentTime ?? tRef.current
235
+ bus.seek = seek
236
+ bus.play = () => {
237
+ setStarted(true)
238
+ audioRef.current?.play().catch(() => {})
239
+ }
240
+ bus.pause = () => audioRef.current?.pause()
241
+ const onLyrics = () => (dirty.current = true)
242
+ window.addEventListener('mv-lyrics', onLyrics)
243
+ return () => window.removeEventListener('mv-lyrics', onLyrics)
244
+ }, [seek])
245
+
246
  useEffect(() => {
247
  const onKey = (e: KeyboardEvent) => {
248
  if ((e.target as HTMLElement)?.closest('input,textarea')) return
 
400
  return (
401
  <section id="export" className="mv-section">
402
  <h2>
403
+ <span className="mv-h-idx">03</span> 导出 MP4 <small>浏览器本地渲染 · 不上传</small>
404
  </h2>
405
  <div className="mv-card mv-export">
406
  <div className="mv-opt">
 
496
  return (
497
  <section id="copy" className="mv-section">
498
  <h2>
499
+ <span className="mv-h-idx">04</span> 发布文案 <small>标题 · 描述 · 话题,一键复制</small>
500
  </h2>
501
  <div className="mv-tabs">
502
  {PLATFORMS.map((x) => (
 
630
  return (
631
  <section id="covers" className="mv-section">
632
  <h2>
633
+ <span className="mv-h-idx">05</span> 封面 <small>16:9 · 4:3 · 3:4 · 每种比例 {DESIGNS.length} 款,可多选</small>
634
  </h2>
635
  <div className="mv-cover-bar">
636
  <div className="mv-seg">
 
695
  </section>
696
  )
697
  }
698
+
699
+ // ====================================================================== Lyrics
700
+ const fmtLrc = (t: number) => {
701
+ const m = Math.floor(t / 60)
702
+ const sec = t - m * 60
703
+ return `[${String(m).padStart(2, '0')}:${sec.toFixed(2).padStart(5, '0')}]`
704
+ }
705
+
706
+ function LyricsPanel({ toast }: { toast: (m: string) => void }) {
707
+ const [text, setText] = useState('')
708
+ const [count, setCount] = useState(0)
709
+ const [offset, setOffset] = useState(0)
710
+ const [tap, setTap] = useState<{ lines: string[]; times: number[] } | null>(null)
711
+ useEffect(() => {
712
+ const saved = loadSavedLyrics()
713
+ setText(saved)
714
+ setCount(lyricCount())
715
+ if (lyricCount()) ensureLyricFont()
716
+ }, [])
717
+ const notify = () => window.dispatchEvent(new Event('mv-lyrics'))
718
+ const apply = (src = text) => {
719
+ const n = setLyrics(src)
720
+ setCount(n)
721
+ notify()
722
+ if (n) ensureLyricFont()
723
+ toast(n ? `已应用 ${n} 句歌词` : '没有识别到时间轴,请用「打轴」逐句标记')
724
+ }
725
+ const plainLines = () =>
726
+ text
727
+ .split(/\r?\n/)
728
+ .map((l) => l.replace(/\[[^\]]*\]/g, '').trim())
729
+ .filter(Boolean)
730
+ const startTap = () => {
731
+ const ls = plainLines()
732
+ if (!ls.length) {
733
+ toast('请先粘贴歌词文本')
734
+ return
735
+ }
736
+ setTap({ lines: ls, times: [] })
737
+ bus.seek(0)
738
+ bus.play()
739
+ }
740
+ const stamp = useCallback(() => {
741
+ setTap((cur) => {
742
+ if (!cur) return cur
743
+ const times = [...cur.times, bus.time()]
744
+ if (times.length >= cur.lines.length) {
745
+ const lrc = cur.lines.map((l, i) => fmtLrc(times[i]) + l).join('\n')
746
+ setText(lrc)
747
+ const n = setLyrics(lrc)
748
+ setCount(n)
749
+ notify()
750
+ ensureLyricFont()
751
+ bus.pause()
752
+ toast(`打轴完成:${n} 句`)
753
+ return null
754
+ }
755
+ return { ...cur, times }
756
+ })
757
+ }, [toast])
758
+ useEffect(() => {
759
+ if (!tap) return
760
+ const onKey = (e: KeyboardEvent) => {
761
+ if (e.code === 'Enter' || e.code === 'KeyJ') {
762
+ e.preventDefault()
763
+ stamp()
764
+ }
765
+ }
766
+ window.addEventListener('keydown', onKey)
767
+ return () => window.removeEventListener('keydown', onKey)
768
+ }, [tap, stamp])
769
+ const nudge = (d: number) => {
770
+ const v = Math.round((getLyricOffset() + d) * 10) / 10
771
+ setLyricOffset(v)
772
+ setOffset(v)
773
+ notify()
774
+ }
775
+ const copyLrc = async () => {
776
+ const ok = await copyText(text)
777
+ toast(ok ? '已复制 LRC' : '复制失败')
778
+ }
779
+ const preview = parseLrc(text).length
780
+ return (
781
+ <section id="lyrics" className="mv-section">
782
+ <h2>
783
+ <span className="mv-h-idx">02</span> 歌词 <small>逐字弹出 · 卡拉OK 填色 · 跟随鼓点跳动 · 会一起导出到 MP4</small>
784
+ </h2>
785
+ <div className="mv-card mv-lyrics">
786
+ <p className="mv-hint">
787
+ 粘贴带时间轴的 LRC 歌词(音乐平台一般都能下载),或者粘贴纯文本歌词后点「打轴」:音乐会从头播放,每唱到一句按一下 Enter(或 J 键 / 下方按钮),就会自动生成时间轴。
788
+ </p>
789
+ {!tap ? (
790
+ <>
791
+ <textarea className="mv-input" rows={8} value={text} placeholder={'[00:12.30]第一句歌词\n[00:16.80]第二句歌词\n……'} onChange={(e) => setText(e.target.value)} />
792
+ <div className="mv-lyrics-bar">
793
+ <button className="mv-primary sm" onClick={() => apply()}>
794
+ 应用歌词{preview ? `(${preview} 句)` : ''}
795
+ </button>
796
+ <button className="mv-ghost" onClick={startTap}>
797
+ 打轴(边听边按)
798
+ </button>
799
+ <button className="mv-ghost" onClick={copyLrc} disabled={!text}>
800
+ 复制 LRC
801
+ </button>
802
+ <button
803
+ className="mv-ghost"
804
+ onClick={() => {
805
+ setText('')
806
+ apply('')
807
+ }}
808
+ >
809
+ 清空
810
+ </button>
811
+ <span className="mv-lyrics-offset">
812
+ 时间微调
813
+ <button className="mv-ghost" onClick={() => nudge(-0.1)}>
814
+ −0.1s
815
+ </button>
816
+ <b>{offset >= 0 ? '+' : ''}{offset.toFixed(1)}s</b>
817
+ <button className="mv-ghost" onClick={() => nudge(0.1)}>
818
+ +0.1s
819
+ </button>
820
+ </span>
821
+ <span className="mv-hint">当前已应用 {count} 句</span>
822
+ </div>
823
+ </>
824
+ ) : (
825
+ <div className="mv-tap">
826
+ <div className="mv-tap-prog">
827
+ 第 {tap.times.length + 1} / {tap.lines.length} 句
828
+ </div>
829
+ <div className="mv-tap-now">{tap.lines[tap.times.length]}</div>
830
+ <div className="mv-tap-next">{tap.lines[tap.times.length + 1] ?? '(最后一句)'}</div>
831
+ <div className="mv-lyrics-bar">
832
+ <button className="mv-primary" onClick={stamp}>
833
+ 唱到这一句了(Enter)
834
+ </button>
835
+ <button className="mv-ghost" onClick={() => setTap((c) => (c && c.times.length ? { ...c, times: c.times.slice(0, -1) } : c))}>
836
+ 撤销上一句
837
+ </button>
838
+ <button
839
+ className="mv-ghost"
840
+ onClick={() => {
841
+ setTap(null)
842
+ bus.pause()
843
+ }}
844
+ >
845
+ 退出打轴
846
+ </button>
847
+ </div>
848
+ </div>
849
+ )}
850
+ </div>
851
+ </section>
852
+ )
853
+ }
src/mv/covers/covers.ts CHANGED
@@ -185,6 +185,7 @@ let shared: Renderer | null = null
185
  export async function renderCover(ratio: Ratio, design: CoverDesign): Promise<CoverItem> {
186
  const [W, H] = RATIO_SIZE[ratio]
187
  shared ??= new Renderer()
 
188
  shared.setSize(W, H)
189
  shared.render(design.t)
190
  const c = document.createElement('canvas')
 
185
  export async function renderCover(ratio: Ratio, design: CoverDesign): Promise<CoverItem> {
186
  const [W, H] = RATIO_SIZE[ratio]
187
  shared ??= new Renderer()
188
+ shared.lyrics = false
189
  shared.setSize(W, H)
190
  shared.render(design.t)
191
  const c = document.createElement('canvas')
src/mv/engine/gas.ts ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Natural-looking thruster gas: a particle model with drag, ground impact,
2
+ // lateral roll-out, buoyant rise and expansion — drawn with a cached soft
3
+ // cloud sprite (normal blending, no glow) so hundreds of puffs stay cheap.
4
+ import { hash } from './util'
5
+
6
+ export interface GasState {
7
+ /** world z of the car */
8
+ z: number
9
+ /** height of the car's underside above the road (m) */
10
+ y: number
11
+ /** forward speed of the car (m/s) */
12
+ vz: number
13
+ power: number
14
+ /** π/2 = blowing down, π = blowing backwards */
15
+ angle: number
16
+ }
17
+
18
+ export interface GasP {
19
+ x: number
20
+ y: number
21
+ z: number
22
+ r: number
23
+ a: number
24
+ warm: boolean
25
+ }
26
+
27
+ const sprites: Record<string, HTMLCanvasElement> = {}
28
+
29
+ function sprite(warm: boolean): HTMLCanvasElement {
30
+ const key = warm ? 'w' : 'c'
31
+ if (sprites[key]) return sprites[key]
32
+ const c = document.createElement('canvas')
33
+ c.width = c.height = 128
34
+ const g = c.getContext('2d')!
35
+ // soft body
36
+ const body = g.createRadialGradient(64, 58, 4, 64, 64, 62)
37
+ const base = warm ? [196, 186, 180] : [186, 192, 204]
38
+ body.addColorStop(0, `rgba(${base[0] + 20},${base[1] + 20},${base[2] + 20},1)`)
39
+ body.addColorStop(0.45, `rgba(${base[0]},${base[1]},${base[2]},0.75)`)
40
+ body.addColorStop(0.8, `rgba(${base[0] - 30},${base[1] - 30},${base[2] - 25},0.25)`)
41
+ body.addColorStop(1, `rgba(${base[0] - 40},${base[1] - 40},${base[2] - 35},0)`)
42
+ g.fillStyle = body
43
+ g.fillRect(0, 0, 128, 128)
44
+ // self-shadowed underside
45
+ const sh = g.createLinearGradient(0, 40, 0, 128)
46
+ sh.addColorStop(0, 'rgba(40,46,60,0)')
47
+ sh.addColorStop(1, 'rgba(40,46,60,0.35)')
48
+ g.globalCompositeOperation = 'source-atop'
49
+ g.fillStyle = sh
50
+ g.fillRect(0, 0, 128, 128)
51
+ // a few lumps so it reads as vapour, not a disc
52
+ g.globalCompositeOperation = 'destination-out'
53
+ for (let i = 0; i < 7; i++) {
54
+ const a = (i / 7) * Math.PI * 2
55
+ g.fillStyle = 'rgba(0,0,0,0.35)'
56
+ g.beginPath()
57
+ g.arc(64 + Math.cos(a) * 54, 64 + Math.sin(a) * 54, 14 + 6 * hash(i), 0, Math.PI * 2)
58
+ g.fill()
59
+ }
60
+ sprites[key] = c
61
+ return c
62
+ }
63
+
64
+ /**
65
+ * Particles alive at tNow, emitted from `nozzles` (car-relative x, z) while
66
+ * the thrusters are on. `stateAt(te)` gives the car state at emission time.
67
+ */
68
+ export function gasParticles(tNow: number, from: number, stateAt: (te: number) => GasState, nozzles: [number, number][], rate = 46): GasP[] {
69
+ const out: GasP[] = []
70
+ const dt = 1 / rate
71
+ const maxLife = 3.6
72
+ const start = Math.max(from, tNow - maxLife)
73
+ for (let te = Math.ceil(start / dt) * dt; te <= tNow; te += dt) {
74
+ const s = stateAt(te)
75
+ if (s.power < 0.15) continue
76
+ const a = tNow - te
77
+ nozzles.forEach(([nx, nz], n) => {
78
+ const h1 = hash(te * 91.7 + n * 13.1)
79
+ const h2 = hash(te * 37.3 + n * 7.7)
80
+ const h3 = hash(te * 53.9 + n * 3.3)
81
+ const life = 2.4 + 1.2 * h1
82
+ if (a > life) return
83
+ const drag = 2.2
84
+ const sd = (1 - Math.exp(-drag * a)) / drag
85
+ const back = s.angle > 2.2
86
+ let x = nx
87
+ let y: number
88
+ let z: number
89
+ if (!back) {
90
+ const vj = 15 * s.power * (0.8 + 0.4 * h2)
91
+ const y0 = s.y + 0.2
92
+ const drop = vj * sd
93
+ z = s.z + nz + s.vz * sd
94
+ if (drop < y0 - 0.25) {
95
+ y = y0 - drop
96
+ x += (h3 - 0.5) * 0.6 * a
97
+ } else {
98
+ // hit the road: roll outwards along the surface, then lift
99
+ const sHit = (y0 - 0.25) / vj
100
+ const spread = Math.max(0, sd - sHit) * 10
101
+ const ang = h3 * Math.PI * 2
102
+ x += Math.cos(ang) * spread * 1.2
103
+ z += Math.sin(ang) * spread * 0.8
104
+ y = 0.3 + 0.55 * Math.max(0, a - sHit * 1.2) + h2 * 0.4
105
+ }
106
+ } else {
107
+ const vj = 22 * s.power * (0.8 + 0.4 * h2)
108
+ z = s.z + nz + (s.vz - vj) * sd
109
+ y = s.y + 0.4 + 0.35 * a + (h2 - 0.5) * 0.5 * a
110
+ x += (h3 - 0.5) * 1.6 * a
111
+ }
112
+ // turbulence
113
+ x += Math.sin(a * 2.7 + h1 * 6) * 0.25 * a
114
+ y += Math.sin(a * 3.1 + h2 * 6) * 0.12 * a
115
+ const r = 0.22 + 1.5 * Math.pow(a, 0.75) * (0.7 + 0.6 * h2)
116
+ const fadeIn = Math.min(1, a / 0.06)
117
+ const alpha = 0.62 * s.power * Math.pow(1 - a / life, 1.4) * fadeIn
118
+ out.push({ x, y, z, r, a: alpha, warm: h1 > 0.72 })
119
+ })
120
+ }
121
+ return out
122
+ }
123
+
124
+ type Proj = (x: number, y: number, z: number) => { x: number; y: number; k: number; z: number } | null
125
+
126
+ /** draw particles with a projection; `maxR` caps sprite size on screen */
127
+ export function drawGas(ctx: CanvasRenderingContext2D, ps: GasP[], project: Proj, maxR: number, nearFade = 2) {
128
+ const list: { x: number; y: number; r: number; a: number; d: number; warm: boolean }[] = []
129
+ for (const p of ps) {
130
+ const q = project(p.x, p.y, p.z)
131
+ if (!q) continue
132
+ const r = Math.min(maxR, p.r * q.k)
133
+ if (r < 0.6) continue
134
+ const near = Math.min(1, Math.max(0, (q.z - nearFade) / 5))
135
+ const a = p.a * near
136
+ if (a < 0.01) continue
137
+ list.push({ x: q.x, y: q.y, r, a, d: q.z, warm: p.warm })
138
+ }
139
+ list.sort((p, q) => q.d - p.d)
140
+ const cool = sprite(false)
141
+ const warm = sprite(true)
142
+ for (const g of list) {
143
+ ctx.globalAlpha = Math.min(1, g.a)
144
+ ctx.drawImage(g.warm ? warm : cool, g.x - g.r, g.y - g.r, g.r * 2, g.r * 2)
145
+ }
146
+ ctx.globalAlpha = 1
147
+ }
src/mv/engine/lyrics.ts ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Kinetic, beat-reactive lyrics. Lyrics are supplied by the user (LRC with
2
+ // timestamps, or plain lines stamped with the in-page timing tool) and are
3
+ // rendered into the frame, so they also appear in the exported MP4.
4
+ import type { AudioFrame } from './audio'
5
+ import { clamp, easeOutBack, hash } from './util'
6
+
7
+ export interface LyricLine {
8
+ t: number
9
+ end: number
10
+ text: string
11
+ }
12
+
13
+ let lines: LyricLine[] = []
14
+ let offset = 0
15
+ const KEY = 'mv-lyrics-lrc'
16
+
17
+ export function parseLrc(src: string): LyricLine[] {
18
+ const out: { t: number; text: string }[] = []
19
+ let off = 0
20
+ for (const raw of src.split(/\r?\n/)) {
21
+ const om = /^\[offset:([+-]?\d+)\]/i.exec(raw.trim())
22
+ if (om) off = parseInt(om[1], 10) / 1000
23
+ const tags = [...raw.matchAll(/\[(\d{1,2}):(\d{1,2}(?:\.\d{1,3})?)\]/g)]
24
+ if (!tags.length) continue
25
+ const text = raw.replace(/\[[^\]]*\]/g, '').trim()
26
+ if (!text) continue
27
+ for (const m of tags) out.push({ t: parseInt(m[1], 10) * 60 + parseFloat(m[2]) - off, text })
28
+ }
29
+ out.sort((a, b) => a.t - b.t)
30
+ return out.map((l, i) => {
31
+ const next = out[i + 1]?.t ?? l.t + 6
32
+ return { t: l.t, end: Math.min(next - 0.05, l.t + 7), text: l.text }
33
+ })
34
+ }
35
+
36
+ export function setLyrics(src: string, persist = true): number {
37
+ lines = parseLrc(src)
38
+ if (persist) {
39
+ try {
40
+ localStorage.setItem(KEY, src)
41
+ } catch {
42
+ /* private mode */
43
+ }
44
+ }
45
+ return lines.length
46
+ }
47
+
48
+ export function loadSavedLyrics(): string {
49
+ try {
50
+ const s = localStorage.getItem(KEY) ?? ''
51
+ if (s) lines = parseLrc(s)
52
+ return s
53
+ } catch {
54
+ return ''
55
+ }
56
+ }
57
+
58
+ export function setLyricOffset(sec: number) {
59
+ offset = sec
60
+ }
61
+ export function getLyricOffset() {
62
+ return offset
63
+ }
64
+ export function lyricCount() {
65
+ return lines.length
66
+ }
67
+
68
+ const FONT_STACK = '"MV Lyric", "MV Black", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif'
69
+
70
+ export function drawLyrics(ctx: CanvasRenderingContext2D, W: number, H: number, tIn: number, a: AudioFrame) {
71
+ if (!lines.length) return
72
+ const t = tIn - offset
73
+ const line = lines.find((l) => t >= l.t - 0.05 && t < l.end + 0.45)
74
+ if (!line) return
75
+ const chars = Array.from(line.text)
76
+ const n = chars.length
77
+ const dur = Math.max(0.6, line.end - line.t)
78
+ let size = H * 0.066
79
+ ctx.save()
80
+ ctx.font = `900 ${size}px ${FONT_STACK}`
81
+ let widths = chars.map((c) => ctx.measureText(c).width)
82
+ const spacing = () => size * 0.06
83
+ let total = widths.reduce((s, w) => s + w + spacing(), -spacing())
84
+ if (total > W * 0.88) {
85
+ size *= (W * 0.88) / total
86
+ ctx.font = `900 ${size}px ${FONT_STACK}`
87
+ widths = chars.map((c) => ctx.measureText(c).width)
88
+ total = widths.reduce((s, w) => s + w + spacing(), -spacing())
89
+ }
90
+ const baseY = H * 0.86
91
+ const pulse = 1 + 0.07 * a.kick
92
+ // readability band
93
+ const band = ctx.createLinearGradient(0, baseY - size * 1.3, 0, baseY + size * 0.9)
94
+ band.addColorStop(0, 'rgba(0,0,0,0)')
95
+ band.addColorStop(0.5, 'rgba(0,0,0,0.38)')
96
+ band.addColorStop(1, 'rgba(0,0,0,0)')
97
+ ctx.fillStyle = band
98
+ ctx.fillRect(0, baseY - size * 1.3, W, size * 2.2)
99
+ ctx.translate(W / 2, baseY)
100
+ ctx.scale(pulse, pulse)
101
+ ctx.textBaseline = 'middle'
102
+ ctx.textAlign = 'left'
103
+ const stagger = Math.min(0.09, (dur * 0.45) / Math.max(1, n))
104
+ const sung = clamp((t - line.t) / (dur * 0.92)) * n
105
+ let x = -total / 2
106
+ for (let i = 0; i < n; i++) {
107
+ const c = chars[i]
108
+ const w = widths[i]
109
+ const ti = line.t + i * stagger
110
+ const kIn = clamp((t - ti) / 0.24)
111
+ const kOut = clamp((t - line.end - i * 0.025) / 0.32)
112
+ if (kIn <= 0) {
113
+ x += w + spacing()
114
+ continue
115
+ }
116
+ const pop = easeOutBack(kIn)
117
+ const jitter = a.kick * size * 0.035
118
+ const jx = (hash(i * 3.1 + a.beat) - 0.5) * jitter
119
+ const jy = (hash(i * 7.7 + a.beat) - 0.5) * jitter - Math.sin(i * 0.9 + a.beatPos * Math.PI) * size * 0.05 * a.pulse
120
+ const y = (1 - pop) * size * 0.55 + jy - kOut * size * 0.9
121
+ const sc = (0.4 + 0.6 * pop) * (1 + kOut * 0.35)
122
+ const alpha = clamp(kIn * 1.4) * (1 - kOut)
123
+ const cx = x + w / 2 + jx
124
+ ctx.save()
125
+ ctx.translate(cx, y)
126
+ ctx.scale(sc, sc)
127
+ ctx.rotate((1 - pop) * (hash(i) - 0.5) * 0.6 + kOut * (hash(i * 2) - 0.5) * 0.8)
128
+ ctx.globalAlpha = alpha
129
+ // chromatic split on the kick
130
+ if (a.kick > 0.2) {
131
+ ctx.globalCompositeOperation = 'lighter'
132
+ ctx.fillStyle = `rgba(255,40,80,${0.55 * a.kick})`
133
+ ctx.fillText(c, -w / 2 - size * 0.05 * a.kick, 0)
134
+ ctx.fillStyle = `rgba(40,220,255,${0.55 * a.kick})`
135
+ ctx.fillText(c, -w / 2 + size * 0.05 * a.kick, 0)
136
+ ctx.globalCompositeOperation = 'source-over'
137
+ }
138
+ ctx.lineJoin = 'round'
139
+ ctx.strokeStyle = 'rgba(0,0,0,0.85)'
140
+ ctx.lineWidth = size * 0.14
141
+ ctx.strokeText(c, -w / 2, 0)
142
+ const done = i < Math.floor(sung)
143
+ const partial = i === Math.floor(sung) ? sung - i : 0
144
+ if (done || partial > 0) {
145
+ const g = ctx.createLinearGradient(0, -size / 2, 0, size / 2)
146
+ g.addColorStop(0, '#ffd27a')
147
+ g.addColorStop(1, '#ff2d55')
148
+ ctx.shadowColor = '#ff2d55'
149
+ ctx.shadowBlur = size * 0.35
150
+ ctx.fillStyle = done ? g : '#f4ece0'
151
+ ctx.fillText(c, -w / 2, 0)
152
+ if (partial > 0) {
153
+ ctx.save()
154
+ ctx.beginPath()
155
+ ctx.rect(-w / 2, -size, w * partial, size * 2)
156
+ ctx.clip()
157
+ ctx.fillStyle = g
158
+ ctx.fillText(c, -w / 2, 0)
159
+ ctx.restore()
160
+ }
161
+ } else {
162
+ ctx.shadowColor = 'rgba(255,255,255,0.6)'
163
+ ctx.shadowBlur = size * 0.2
164
+ ctx.fillStyle = '#f4ece0'
165
+ ctx.fillText(c, -w / 2, 0)
166
+ }
167
+ ctx.restore()
168
+ x += w + spacing()
169
+ }
170
+ ctx.restore()
171
+ }
src/mv/engine/renderer.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { DROP1, DROP2, DURATION, analyse, type AudioFrame } from './audio'
2
  import { Post, type PostParams } from './post'
3
  import { PULSE } from './draw'
 
4
  import { CUTS, sceneAt } from './timeline'
5
  import type { SceneCtx } from './types'
6
  import { clamp, hash, noise1, smoothstep } from './util'
@@ -18,6 +19,8 @@ export class Renderer {
18
  private out2d: CanvasRenderingContext2D | null = null
19
  private cache = new Map<string, unknown>()
20
  private warned = new Set<string>()
 
 
21
  W = 0
22
  H = 0
23
 
@@ -88,6 +91,13 @@ export class Renderer {
88
  ctx.setTransform(1, 0, 0, 1, 0, 0)
89
  ctx.globalAlpha = 1
90
  ctx.shadowBlur = 0
 
 
 
 
 
 
 
91
 
92
  const pp = postParams(t, a)
93
  if (this.post) {
 
1
  import { DROP1, DROP2, DURATION, analyse, type AudioFrame } from './audio'
2
  import { Post, type PostParams } from './post'
3
  import { PULSE } from './draw'
4
+ import { drawLyrics } from './lyrics'
5
  import { CUTS, sceneAt } from './timeline'
6
  import type { SceneCtx } from './types'
7
  import { clamp, hash, noise1, smoothstep } from './util'
 
19
  private out2d: CanvasRenderingContext2D | null = null
20
  private cache = new Map<string, unknown>()
21
  private warned = new Set<string>()
22
+ /** draw the user's lyrics into the frame (off for covers) */
23
+ lyrics = true
24
  W = 0
25
  H = 0
26
 
 
91
  ctx.setTransform(1, 0, 0, 1, 0, 0)
92
  ctx.globalAlpha = 1
93
  ctx.shadowBlur = 0
94
+ if (this.lyrics) {
95
+ try {
96
+ drawLyrics(ctx, W, H, t, a)
97
+ } catch (e) {
98
+ console.warn('lyrics error', e)
99
+ }
100
+ }
101
 
102
  const pp = postParams(t, a)
103
  if (this.post) {
src/mv/engine/scenesCar.ts CHANGED
@@ -7,7 +7,8 @@
7
  // nozzles swivel backwards → boost away.
8
  import { BAR, BEAT } from './audio'
9
  import { carRear, carSide, heroCarFront, roadsterSide, truckSide } from './cars'
10
- import { drawActors, drawHighway, drawPuffs, emit, makeCam, plume3, proj, rearMirror, type Actor, type Cam, type Puff } from './road3d'
 
11
  import { glowBlob, lightCone, skyline, speedLines, splitText, vGradient } from './draw'
12
  import { HERO, HERO_H, armPiece, capsule, circle, heroHead, ik, ink, torso, withPalette, RIVAL } from './hero'
13
  import { resetCam, type SceneCtx } from './types'
@@ -718,17 +719,56 @@ export function sDuel(s: SceneCtx) {
718
  handbrakeCU(s, seg(inBar, BEAT * 1.6, BEAT * 2.1))
719
  return
720
  }
721
- // from 1:00 his high beams breathe with the music (kick + bass)
722
- const beam = bar >= 7 && bar <= 9 ? clamp(a.kick * 1.15 + a.low * 0.35 + a.high * 0.15) : 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
723
  if (bar === 6 || bar === 7 || bar === 10) {
724
  // POV through the spin: the whole world swings 180° across the windscreen
725
  const spinning = (bar === 6 || bar === 10) && inBar > BEAT * 0.4 && inBar < BEAT * 2.7
726
  const roll = spinning ? Math.sin(inBar * 9) * 0.03 : shake
727
  const c = heroPOV(s, st.hero, 0, 0.02, 1, roll)
728
  drawHighway(s, c)
 
729
  const acts = actorsDuel(tau, beam, beam)
730
- const puffs = spinSmoke(tau)
731
- drawPuffs(s, c, puffs.filter((p) => true))
732
  drawActors(s, c, acts)
733
  windshield(s, false)
734
  if (spinning) {
@@ -736,30 +776,24 @@ export function sDuel(s: SceneCtx) {
736
  ctx.fillRect(0, 0, W, H)
737
  speedLines(ctx, W / 2, H / 2, Math.max(W, H) * 0.9, t, 80, '#ffffff', 0.12, 0.45)
738
  }
 
739
  return
740
  }
741
  if (bar === 8 || bar === 9) {
742
- // flashing the high beams, on the beat: POV ↔ the hand on the stalk
743
- const flash = beam
744
  const view = Math.floor((tau - 8 * BAR) / BEAT) % 4
745
  if (view === 2) {
746
- stalkCU(s, flash)
 
747
  return
748
  }
749
  const c = heroPOV(s, st.hero, 0, 0.02, 1.1, shake)
750
  drawHighway(s, c)
751
- const acts = actorsDuel(tau, flash, flash)
752
  drawPuffs(s, c, spinSmoke(tau))
753
- // his beams on the road
754
- if (flash > 0.05) {
755
- ctx.save()
756
- ctx.globalCompositeOperation = 'lighter'
757
- const r = proj(c, s, st.rival.x, 0.6, st.rival.z)
758
- if (r) glowBlob(ctx, r.x, r.y, W * 0.16, '#fff6e0', 0.3 * flash)
759
- ctx.restore()
760
- }
761
- drawActors(s, c, acts)
762
  windshield(s, false)
 
763
  return
764
  }
765
  // floor it: POV forward, the red car shrinking in the mirror; then a low road camera
@@ -810,26 +844,31 @@ const PORTS: [number, number][] = [
810
  [0.75, 1.3],
811
  ]
812
 
813
- /** cold-gas thrusters: huge white clouds, in world space */
814
- function fogFx(s: SceneCtx, c: Cam, tj: number) {
815
- const puffs: Puff[] = []
816
- PORTS.forEach(([px, pz], idx) => {
817
- // downward thrust → gas slams into the road and rolls out
818
- emit(puffs, tj, 0.3, 20, 24, (te) => {
819
- const { st, hz } = jw(te)
820
- if (st.jetAngle > 2.2 || st.jetPower < 0.3) return null
821
- const y = st.y - st.compress
822
- return { x: px * 1.2, y: y < 4.5 ? 0.5 : y - 0.6, z: hz + pz }
823
- }, { spread: 4.2, rise: 1.3, grow: 3.4, life: 3.6, r0: 1.2, a0: 0.36, col: idx % 2 ? [176, 182, 196] : [196, 186, 196], drift: { x: px * 2.6, z: -11 } })
824
- // backward thrust behind the plate → long white wake
825
- emit(puffs, tj, 0.3, 20, 26, (te) => {
826
- const { st, hz } = jw(te)
827
- if (st.jetAngle < 2.2 || st.jetPower < 0.2) return null
828
- return { x: px * 0.6, y: st.y - st.compress + 0.45, z: hz - 2.3 }
829
- }, { spread: 2.2, rise: 0.8, grow: 3.0, life: 3.0, r0: 0.7, a0: 0.3, col: idx % 2 ? [180, 186, 200] : [200, 180, 190], drift: { x: 0, z: -16 } })
830
- })
831
- drawPuffs(s, c, puffs)
832
 
 
 
 
 
 
 
 
 
 
833
  }
834
 
835
  /** telemetry HUD like the demo footage */
@@ -1043,36 +1082,11 @@ function sideJumpRoadster(s: SceneCtx, tj: number) {
1043
  }
1044
  ctx.fillStyle = '#0d0e12'
1045
  ctx.fillRect(0, gy, W, H - gy)
1046
- // gas clouds (white, lit pink by the tail lights)
1047
- for (let te = Math.max(0.3, tj - 3.2); te <= tj; te += 1 / 36) {
1048
- const q = jumpState(te)
1049
- if (q.jetPower < 0.25) continue
1050
- const age = tj - te
1051
- const back = q.jetAngle > 2.2
1052
- for (let n = 0; n < 3; n++) {
1053
- const hx = hash(te * 53 + n) - 0.5
1054
- const hy2 = hash(te * 29 + n)
1055
- let px: number
1056
- let py: number
1057
- if (!back) {
1058
- px = q.dx + (n - 1) * 1.2 + hx * 3 * (0.4 + age * 2) - age * 10
1059
- py = Math.min(q.y, 0.6) + age * (0.8 + hy2 * 1.2)
1060
- } else {
1061
- px = q.dx - 2.3 - age * 16 + hx * age * 2
1062
- py = q.y + 0.5 + hx * age * 1.4 + age * 0.4
1063
- }
1064
- const r = (0.8 + age * 2.4) * k
1065
- const al = 0.3 * (1 - age / 3.2) * q.jetPower
1066
- const X = toX(px)
1067
- const Y = gy - py * k
1068
- const gr = ctx.createRadialGradient(X, Y - r * 0.25, 0, X, Y, r)
1069
- const col = n === 1 ? '196,184,194' : '180,186,200'
1070
- gr.addColorStop(0, `rgba(${col},${al})`)
1071
- gr.addColorStop(1, `rgba(${col},0)`)
1072
- ctx.fillStyle = gr
1073
- ctx.fillRect(X - r, Y - r, r * 2, r * 2)
1074
- }
1075
- }
1076
  carSide(ctx, 'rival', toX(0), gy, 4.5 * k, 0, { spin: -(scroll / 0.5), head: 1, brake: 0.1, t, look: st.look })
1077
  const hx = toX(st.dx)
1078
  const hy = gy - st.y * k + st.compress * k
@@ -1081,6 +1095,7 @@ function sideJumpRoadster(s: SceneCtx, tj: number) {
1081
  ctx.ellipse(hx, gy + 2, k * 2, k * 0.15, 0, 0, Math.PI * 2)
1082
  ctx.fill()
1083
  roadsterSide(ctx, hx, hy, 4.3 * k, st.pitch, { spin: -(st.dist / 0.45), head: 1, t, nozzle: 1 })
 
1084
 
1085
  }
1086
 
@@ -1114,17 +1129,22 @@ export function sJump(s: SceneCtx) {
1114
  // straight behind his car: thrusters fire down, the Roadster lifts off the road
1115
  const c = makeCam(s, 0, 1.6, w.hz - 7, 0, 0.0, 1, shake * 0.5, 0.03)
1116
  drawHighway(s, c)
1117
- fogFx(s, c, tj)
1118
- drawActors(s, c, acts)
 
 
 
1119
  return
1120
  }
1121
  if (bar === 4) {
1122
  // low on the shoulder, looking up: it sails over the red car on a column of gas
1123
  const c = makeCam(s, 8.5, 0.5, w.rz - 0.5, -1.45, 0, 0.9, 0, -0.26)
1124
  drawHighway(s, c)
 
1125
  drawActors(s, c, acts.filter((x) => x.kind === 'rival'))
1126
- fogFx(s, c, tj)
1127
  drawActors(s, c, acts.filter((x) => x.kind === 'roadster'))
 
1128
  return
1129
  }
1130
  if (bar === 5) {
@@ -1136,16 +1156,20 @@ export function sJump(s: SceneCtx) {
1136
  const g = Math.max(0, tj - 12 * BEAT)
1137
  const c = makeCam(s, 0.9, 1.5, w.hz - 8 - 3 * g * g, -0.04, 0.03, 1, shake)
1138
  drawHighway(s, c)
 
 
1139
  drawActors(s, c, [roadsterActor(tj)])
1140
- fogFx(s, c, tj)
1141
  if (g > 0) speedLines(ctx, W / 2, H / 2, Math.max(W, H) * 0.9, t, 100, '#ffffff', 0.08 + g * 0.08, 0.35)
1142
  return
1143
  }
1144
  // the rival's POV: the Roadster disappears into its own cloud
1145
  const c = makeCam(s, -0.35, 1.1, w.rz + 0.2, 0, 0.02, 1, shake)
1146
  drawHighway(s, c)
 
 
1147
  drawActors(s, c, [roadsterActor(tj)])
1148
- fogFx(s, c, tj)
1149
  windshield(s, true)
1150
  void H
1151
  }
 
7
  // nozzles swivel backwards → boost away.
8
  import { BAR, BEAT } from './audio'
9
  import { carRear, carSide, heroCarFront, roadsterSide, truckSide } from './cars'
10
+ import { drawActors, drawHighway, drawPuffs, emit, makeCam, plume3, poly3, proj, rearMirror, type Actor, type Cam, type Puff } from './road3d'
11
+ import { drawGas, gasParticles, type GasP, type GasState } from './gas'
12
  import { glowBlob, lightCone, skyline, speedLines, splitText, vGradient } from './draw'
13
  import { HERO, HERO_H, armPiece, capsule, circle, heroHead, ik, ink, torso, withPalette, RIVAL } from './hero'
14
  import { resetCam, type SceneCtx } from './types'
 
719
  handbrakeCU(s, seg(inBar, BEAT * 1.6, BEAT * 2.1))
720
  return
721
  }
722
+ // the moment the spin ends he slams on the high beams: the whole road lights up,
723
+ // a white flash — then the beams pump with the music (kick + bass)
724
+ const beamOn = 6 * BAR + 2.6 * BEAT
725
+ const sinceOn = tau - beamOn
726
+ let beam = 0
727
+ if (sinceOn >= 0 && bar <= 9) beam = Math.max(clamp(1 - sinceOn / 0.6), bar >= 7 ? clamp(a.kick * 1.2 + a.low * 0.4 + a.high * 0.15) : 0.85)
728
+ const whiteOut = (sinceOn >= 0 && sinceOn < 1.2 ? Math.exp(-sinceOn * 3.2) : 0) + (bar >= 7 && bar <= 9 && a.beat % 2 === 0 ? clamp((beam - 0.72) * 3) * 0.85 : 0)
729
+ const flood = (c: Cam) => {
730
+ // light thrown over the entire road surface in the direction the car faces
731
+ if (beam <= 0.02) return
732
+ const h = st.hero
733
+ const fz = Math.cos(h.h)
734
+ const z0 = h.z + fz * 2
735
+ ctx.save()
736
+ ctx.globalCompositeOperation = 'lighter'
737
+ for (let d = 0; d < 90; d += 3) {
738
+ const fall = 1 / (1 + d / 22)
739
+ const za = z0 + fz * d
740
+ const zb = z0 + fz * (d + 3.05)
741
+ poly3(ctx, c, s, [
742
+ [-7.4, 0.02, za],
743
+ [7.4, 0.02, za],
744
+ [7.4, 0.02, zb],
745
+ [-7.4, 0.02, zb],
746
+ ], `rgba(255,246,228,${0.42 * beam * fall})`)
747
+ // guard rails catch the light too
748
+ for (const sd of [-1, 1])
749
+ poly3(ctx, c, s, [
750
+ [sd * 7.6, 0.55, za],
751
+ [sd * 7.6, 0.9, za],
752
+ [sd * 7.6, 0.9, zb],
753
+ [sd * 7.6, 0.55, zb],
754
+ ], `rgba(255,246,228,${0.5 * beam * fall})`)
755
+ }
756
+ ctx.restore()
757
+ }
758
+ const flashScreen = () => {
759
+ if (whiteOut <= 0.01) return
760
+ ctx.fillStyle = `rgba(255,252,245,${clamp(whiteOut)})`
761
+ ctx.fillRect(0, 0, W, H)
762
+ }
763
  if (bar === 6 || bar === 7 || bar === 10) {
764
  // POV through the spin: the whole world swings 180° across the windscreen
765
  const spinning = (bar === 6 || bar === 10) && inBar > BEAT * 0.4 && inBar < BEAT * 2.7
766
  const roll = spinning ? Math.sin(inBar * 9) * 0.03 : shake
767
  const c = heroPOV(s, st.hero, 0, 0.02, 1, roll)
768
  drawHighway(s, c)
769
+ flood(c)
770
  const acts = actorsDuel(tau, beam, beam)
771
+ drawPuffs(s, c, spinSmoke(tau))
 
772
  drawActors(s, c, acts)
773
  windshield(s, false)
774
  if (spinning) {
 
776
  ctx.fillRect(0, 0, W, H)
777
  speedLines(ctx, W / 2, H / 2, Math.max(W, H) * 0.9, t, 80, '#ffffff', 0.12, 0.45)
778
  }
779
+ flashScreen()
780
  return
781
  }
782
  if (bar === 8 || bar === 9) {
783
+ // high beams pumping on the beat: POV ↔ the hand on the stalk
 
784
  const view = Math.floor((tau - 8 * BAR) / BEAT) % 4
785
  if (view === 2) {
786
+ stalkCU(s, beam)
787
+ flashScreen()
788
  return
789
  }
790
  const c = heroPOV(s, st.hero, 0, 0.02, 1.1, shake)
791
  drawHighway(s, c)
792
+ flood(c)
793
  drawPuffs(s, c, spinSmoke(tau))
794
+ drawActors(s, c, actorsDuel(tau, beam, beam))
 
 
 
 
 
 
 
 
795
  windshield(s, false)
796
+ flashScreen()
797
  return
798
  }
799
  // floor it: POV forward, the red car shrinking in the mirror; then a low road camera
 
844
  [0.75, 1.3],
845
  ]
846
 
847
+ /** thruster gas (particle model) for the jump, in world coordinates */
848
+ function jumpGas(tj: number): GasP[] {
849
+ const st = (te: number, back: boolean): GasState => {
850
+ const a = jw(te)
851
+ const b = jw(te + 0.02)
852
+ const on = back ? a.st.jetAngle > 2.2 : a.st.jetAngle <= 2.2
853
+ return { z: a.hz, y: a.st.y - a.st.compress + 0.12, vz: (b.hz - a.hz) / 0.02, power: on ? a.st.jetPower : 0, angle: a.st.jetAngle }
854
+ }
855
+ const down = gasParticles(tj, 0.25, (te) => st(te, false), PORTS.map(([x, z]) => [x * 1.1, z] as [number, number]), 46)
856
+ const back = gasParticles(tj, 0.25, (te) => st(te, true), [
857
+ [-0.35, -2.25],
858
+ [0.35, -2.25],
859
+ ], 52)
860
+ return down.concat(back)
861
+ }
 
 
 
 
862
 
863
+ /** draw the gas split around the car so it wraps it correctly */
864
+ function gas3D(s: SceneCtx, c: Cam, ps: GasP[], carZ: number, part: 'far' | 'near') {
865
+ const carDepth = proj(c, s, 0, 0, carZ)?.z ?? 1e9
866
+ const sel = ps.filter((p) => {
867
+ const q = proj(c, s, p.x, p.y, p.z)
868
+ if (!q) return false
869
+ return part === 'far' ? q.z >= carDepth : q.z < carDepth
870
+ })
871
+ drawGas(s.ctx, sel, (x, y, z) => proj(c, s, x, y, z), s.W * 0.45, 1.5)
872
  }
873
 
874
  /** telemetry HUD like the demo footage */
 
1082
  }
1083
  ctx.fillStyle = '#0d0e12'
1084
  ctx.fillRect(0, gy, W, H - gy)
1085
+ // thruster gas — same particle model as the 3D shots, seen side-on
1086
+ const rz = vJ * tj
1087
+ const gp = jumpGas(tj)
1088
+ const sideProj = (x: number, y: number, z: number) => ({ x: toX(z - rz), y: gy - y * k, k, z: 20 - x })
1089
+ drawGas(ctx, gp.filter((p) => p.x < 0), sideProj, W * 0.3)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1090
  carSide(ctx, 'rival', toX(0), gy, 4.5 * k, 0, { spin: -(scroll / 0.5), head: 1, brake: 0.1, t, look: st.look })
1091
  const hx = toX(st.dx)
1092
  const hy = gy - st.y * k + st.compress * k
 
1095
  ctx.ellipse(hx, gy + 2, k * 2, k * 0.15, 0, 0, Math.PI * 2)
1096
  ctx.fill()
1097
  roadsterSide(ctx, hx, hy, 4.3 * k, st.pitch, { spin: -(st.dist / 0.45), head: 1, t, nozzle: 1 })
1098
+ drawGas(ctx, gp.filter((p) => p.x >= 0), sideProj, W * 0.3)
1099
 
1100
  }
1101
 
 
1129
  // straight behind his car: thrusters fire down, the Roadster lifts off the road
1130
  const c = makeCam(s, 0, 1.6, w.hz - 7, 0, 0.0, 1, shake * 0.5, 0.03)
1131
  drawHighway(s, c)
1132
+ const gp = jumpGas(tj)
1133
+ drawActors(s, c, acts.filter((x) => x.kind === 'rival'))
1134
+ gas3D(s, c, gp, w.hz, 'far')
1135
+ drawActors(s, c, acts.filter((x) => x.kind === 'roadster'))
1136
+ gas3D(s, c, gp, w.hz, 'near')
1137
  return
1138
  }
1139
  if (bar === 4) {
1140
  // low on the shoulder, looking up: it sails over the red car on a column of gas
1141
  const c = makeCam(s, 8.5, 0.5, w.rz - 0.5, -1.45, 0, 0.9, 0, -0.26)
1142
  drawHighway(s, c)
1143
+ const gp = jumpGas(tj)
1144
  drawActors(s, c, acts.filter((x) => x.kind === 'rival'))
1145
+ gas3D(s, c, gp, w.hz, 'far')
1146
  drawActors(s, c, acts.filter((x) => x.kind === 'roadster'))
1147
+ gas3D(s, c, gp, w.hz, 'near')
1148
  return
1149
  }
1150
  if (bar === 5) {
 
1156
  const g = Math.max(0, tj - 12 * BEAT)
1157
  const c = makeCam(s, 0.9, 1.5, w.hz - 8 - 3 * g * g, -0.04, 0.03, 1, shake)
1158
  drawHighway(s, c)
1159
+ const gp = jumpGas(tj)
1160
+ gas3D(s, c, gp, w.hz, 'far')
1161
  drawActors(s, c, [roadsterActor(tj)])
1162
+ gas3D(s, c, gp, w.hz, 'near')
1163
  if (g > 0) speedLines(ctx, W / 2, H / 2, Math.max(W, H) * 0.9, t, 100, '#ffffff', 0.08 + g * 0.08, 0.35)
1164
  return
1165
  }
1166
  // the rival's POV: the Roadster disappears into its own cloud
1167
  const c = makeCam(s, -0.35, 1.1, w.rz + 0.2, 0, 0.02, 1, shake)
1168
  drawHighway(s, c)
1169
+ const gp = jumpGas(tj)
1170
+ gas3D(s, c, gp, w.hz, 'far')
1171
  drawActors(s, c, [roadsterActor(tj)])
1172
+ gas3D(s, c, gp, w.hz, 'near')
1173
  windshield(s, true)
1174
  void H
1175
  }
src/mv/fonts.ts CHANGED
@@ -27,3 +27,20 @@ export function loadFonts(): Promise<void> {
27
  }
28
 
29
  export const AUDIO_URL = asset('heijie.mp3')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  }
28
 
29
  export const AUDIO_URL = asset('heijie.mp3')
30
+
31
+ let lyricFont: Promise<void> | null = null
32
+ /** the full common-character set (GB2312, ~950 KB) is only fetched when lyrics are used */
33
+ export function ensureLyricFont(): Promise<void> {
34
+ if (lyricFont) return lyricFont
35
+ lyricFont = (async () => {
36
+ try {
37
+ const f = new FontFace('MV Lyric', `url(${asset('mvfonts/lyric.woff2')}) format("woff2")`)
38
+ await f.load()
39
+ document.fonts.add(f)
40
+ window.dispatchEvent(new Event('mv-lyrics'))
41
+ } catch (e) {
42
+ console.warn('lyric font failed', e)
43
+ }
44
+ })()
45
+ return lyricFont
46
+ }
src/mv/playerBus.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ // tiny bridge so other panels (e.g. lyric timing) can read/drive the player
2
+ export const bus = {
3
+ time: () => 0,
4
+ seek: (_t: number) => {},
5
+ play: () => {},
6
+ pause: () => {},
7
+ }
src/mv/styles.css CHANGED
@@ -822,3 +822,50 @@
822
  border-radius: 999px;
823
  padding: 4px 10px;
824
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
822
  border-radius: 999px;
823
  padding: 4px 10px;
824
  }
825
+
826
+ .mv-lyrics {
827
+ display: grid;
828
+ gap: 12px;
829
+ }
830
+ .mv-lyrics-bar {
831
+ display: flex;
832
+ flex-wrap: wrap;
833
+ gap: 8px;
834
+ align-items: center;
835
+ }
836
+ .mv-lyrics-offset {
837
+ display: inline-flex;
838
+ align-items: center;
839
+ gap: 6px;
840
+ font-size: 13px;
841
+ color: var(--muted);
842
+ }
843
+ .mv-lyrics-offset b {
844
+ color: var(--fg);
845
+ font-variant-numeric: tabular-nums;
846
+ min-width: 44px;
847
+ text-align: center;
848
+ }
849
+ .mv-tap {
850
+ display: grid;
851
+ gap: 12px;
852
+ text-align: center;
853
+ padding: 10px 0;
854
+ }
855
+ .mv-tap-prog {
856
+ color: var(--muted);
857
+ font-size: 13px;
858
+ }
859
+ .mv-tap-now {
860
+ font-family: 'MV Black', 'PingFang SC', sans-serif;
861
+ font-size: clamp(22px, 4vw, 40px);
862
+ color: #fff;
863
+ text-shadow: 0 0 18px rgba(255, 45, 85, 0.7);
864
+ }
865
+ .mv-tap-next {
866
+ color: var(--muted);
867
+ font-size: 16px;
868
+ }
869
+ .mv-tap .mv-lyrics-bar {
870
+ justify-content: center;
871
+ }