Spaces:
Paused
Paused
v8:远光照亮整块路面并闪白;喷射气体改为自然粒子气体;新增动感歌词(LRC / 打轴工具,随导出)
Browse files- .gitattributes +1 -0
- README.md +7 -1
- public/mvfonts/black.woff2 +0 -0
- public/mvfonts/lyric.woff2 +3 -0
- public/mvfonts/neon.woff2 +2 -2
- src/mv/MvApp.tsx +182 -4
- src/mv/covers/covers.ts +1 -0
- src/mv/engine/gas.ts +147 -0
- src/mv/engine/lyrics.ts +171 -0
- src/mv/engine/renderer.ts +10 -0
- src/mv/engine/scenesCar.ts +96 -72
- src/mv/fonts.ts +17 -0
- src/mv/playerBus.ts +7 -0
- src/mv/styles.css +47 -0
.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,每种比例多张,可多选 |
|
|
|
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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">
|
| 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">
|
| 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">
|
| 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 |
-
//
|
| 722 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
//
|
| 743 |
-
const flash = beam
|
| 744 |
const view = Math.floor((tau - 8 * BAR) / BEAT) % 4
|
| 745 |
if (view === 2) {
|
| 746 |
-
stalkCU(s,
|
|
|
|
| 747 |
return
|
| 748 |
}
|
| 749 |
const c = heroPOV(s, st.hero, 0, 0.02, 1.1, shake)
|
| 750 |
drawHighway(s, c)
|
| 751 |
-
|
| 752 |
drawPuffs(s, c, spinSmoke(tau))
|
| 753 |
-
|
| 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 |
-
/**
|
| 814 |
-
function
|
| 815 |
-
const
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 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
|
| 1047 |
-
|
| 1048 |
-
|
| 1049 |
-
|
| 1050 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
+
}
|