Ethscriptions commited on
Commit
d1028f6
·
verified ·
1 Parent(s): 15332a1

v2-5: Remotion player (frame clock + synced audio, orientation switch keeps playing)

Browse files
bitcoin-saga/README.md CHANGED
@@ -101,7 +101,7 @@
101
  | **ElevenLabs** | ❌ 实在用不了 | 沙盒里没有 ElevenLabs API Key;官方 HF Space(elevenlabs/tts)及镜像的共享额度已耗尽(RateLimitError:0 credits)。客户端脚本 tools/voices_en.py 已写好,额度恢复或提供 Key 后可一键生成英文声部 |
102
  | **Mistral Voxtral TTS** | ❌ 实在用不了 | 官方演示 mistralai/voxtral-tts-demo 强制要求浏览器里交互式 Hugging Face 登录(OAuth),且后端需要 MISTRAL_API_KEY,无法脚本化调用 |
103
  | **Mistral Voxtral(语音识别)** | ✅ 已接入 | tools/voxtral_align.py:13 段旁白逐段送 Voxtral 识别(文本相似度 0.62–0.97),用识别出的语音起点校准每一行字幕;由于画面节拍与音效都挂在字幕时间上,画面、音效、字幕三者与真实发音一起对齐(多数行修正 0.2–0.7 秒) |
104
- | **Remotion** | ✅(@remotion/player) | 接管网页播放器:按帧驱动画面、音画同步、全屏;原有章节/横竖屏/导出功能保留 |
105
  | **After Effects** | ❌ | Adobe 商业软件,仅支持 Windows / macOS 且需授权,无法在 Linux 沙盒里运行;其常用效果(辉光、光晕、色差、胶片颗粒)改用 Three.js / Canvas 后期实现 |
106
 
107
  步骤 & 推送:v2-1 计划(本节)→ v2-2 Blender 资产 → v2-3 Three.js 金币接入 → v2-4 ElevenLabs/Voxtral 声部 → v2-5 Remotion 播放器 → v2-6 逐帧打磨 + 最终推送。
@@ -114,3 +114,5 @@ cd bitcoin-saga/web && npm i && npm run build # 输出到 ../dist
114
  - 推送 v2-1:写入 v2 升级计划(六工具使用方案)。
115
  - 推送 v2-2:Blender 资产(GLB 金币、影棚 HDRI、光追封面图)+ 建模/渲染脚本;工具可用性如实更新(ElevenLabs、Voxtral TTS 无法使用)。
116
  - 推送 v2-3:Three.js 实时 PBR 金币接入(尾声主角金币:急转后停在 3/4 侧面缓慢呼吸,影棚光扫过镜面币心;2025 历史新高处 9 枚 3D 金币喷涌翻滚落下,小尺寸自动切换 Blender 减面 LOD);封面换成 Blender Cycles 光追金币;Voxtral 语音识别校准全部字幕/画面节拍/音效时间;母带加软限幅(峰值 1.16 → 0.999,响度不变)。
 
 
 
101
  | **ElevenLabs** | ❌ 实在用不了 | 沙盒里没有 ElevenLabs API Key;官方 HF Space(elevenlabs/tts)及镜像的共享额度已耗尽(RateLimitError:0 credits)。客户端脚本 tools/voices_en.py 已写好,额度恢复或提供 Key 后可一键生成英文声部 |
102
  | **Mistral Voxtral TTS** | ❌ 实在用不了 | 官方演示 mistralai/voxtral-tts-demo 强制要求浏览器里交互式 Hugging Face 登录(OAuth),且后端需要 MISTRAL_API_KEY,无法脚本化调用 |
103
  | **Mistral Voxtral(语音识别)** | ✅ 已接入 | tools/voxtral_align.py:13 段旁白逐段送 Voxtral 识别(文本相似度 0.62–0.97),用识别出的语音起点校准每一行字幕;由于画面节拍与音效都挂在字幕时间上,画面、音效、字幕三者与真实发音一起对齐(多数行修正 0.2–0.7 秒) |
104
+ | **Remotion** | ✅ 已接入 | 网页播放器改为 Remotion 合成(@remotion/player,60 帧时钟):画面逐帧由 Remotion 驱动,混音由 Remotion `<Audio>` 同步播放;章节跳转、横竖屏切换(播放不中断)、空格/方向键、全屏全部保留 |
105
  | **After Effects** | ❌ | Adobe 商业软件,仅支持 Windows / macOS 且需授权,无法在 Linux 沙盒里运行;其常用效果(辉光、光晕、色差、胶片颗粒)改用 Three.js / Canvas 后期实现 |
106
 
107
  步骤 & 推送:v2-1 计划(本节)→ v2-2 Blender 资产 → v2-3 Three.js 金币接入 → v2-4 ElevenLabs/Voxtral 声部 → v2-5 Remotion 播放器 → v2-6 逐帧打磨 + 最终推送。
 
114
  - 推送 v2-1:写入 v2 升级计划(六工具使用方案)。
115
  - 推送 v2-2:Blender 资产(GLB 金币、影棚 HDRI、光追封面图)+ 建模/渲染脚本;工具可用性如实更新(ElevenLabs、Voxtral TTS 无法使用)。
116
  - 推送 v2-3:Three.js 实时 PBR 金币接入(尾声主角金币:急转后停在 3/4 侧面缓慢呼吸,影棚光扫过镜面币心;2025 历史新高处 9 枚 3D 金币喷涌翻滚落下,小尺寸自动切换 Blender 减面 LOD);封面换成 Blender Cycles 光追金币;Voxtral 语音识别校准全部字幕/画面节拍/音效时间;母带加软限幅(峰值 1.16 → 0.999,响度不变)。
117
+ - 推送 v2-4:ElevenLabs / Voxtral TTS 实测不可用(见上表),旁白继续由 Qwen3-TTS 担任;Voxtral 语音识别已用于字幕对齐(并入 v2-3)。
118
+ - 推送 v2-5:Remotion 播放器接入:画面与音轨共用一个帧时钟;横竖屏切换后接着播放,空格暂停/继续、播放结束可重播。
bitcoin-saga/dist/assets/index-2e2AWXEI.css ADDED
@@ -0,0 +1 @@
 
 
1
+ @font-face{font-family:Playfair Display;font-style:normal;font-display:swap;font-weight:700;src:url(./playfair-display-latin-700-normal-CuDiGg7c.woff2) format("woff2"),url(./playfair-display-latin-700-normal-Bc_1Q1cG.woff) format("woff")}@font-face{font-family:Playfair Display;font-style:normal;font-display:swap;font-weight:900;src:url(./playfair-display-latin-900-normal-oSb7_CWx.woff2) format("woff2"),url(./playfair-display-latin-900-normal-DypQvTWf.woff) format("woff")}@font-face{font-family:Playfair Display;font-style:italic;font-display:swap;font-weight:700;src:url(./playfair-display-latin-700-italic-CB566L-e.woff2) format("woff2"),url(./playfair-display-latin-700-italic-DgrU7w_p.woff) format("woff")}@font-face{font-family:JetBrains Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./jetbrains-mono-latin-400-normal-V6pRDFza.woff2) format("woff2"),url(./jetbrains-mono-latin-400-normal-6-qcROiO.woff) format("woff")}@font-face{font-family:JetBrains Mono;font-style:normal;font-display:swap;font-weight:700;src:url(./jetbrains-mono-latin-700-normal-BYuf6tUa.woff2) format("woff2"),url(./jetbrains-mono-latin-700-normal-D3wTyLJW.woff) format("woff")}.btc-app{--gold:#f7a823;--gold2:#ffd27a;--ink:#07070a;--line:rgba(247,168,35,.22);--fg:#f5efe2;--dim:rgba(245,239,226,.55);min-height:100vh;background:radial-gradient(1200px 600px at 70% -10%,rgba(247,168,35,.1),transparent),var(--ink);color:var(--fg);font-family:Film Sans SC,PingFang SC,Noto Sans SC,Microsoft YaHei,system-ui,sans-serif;padding:16px 20px 60px;box-sizing:border-box}.btc-app *{box-sizing:border-box}.btc-app button{font:inherit;color:inherit;cursor:pointer}.btc-app button:disabled{opacity:.45;cursor:not-allowed}.btc-app header{max-width:1280px;margin:0 auto 14px;display:flex;align-items:center;justify-content:space-between}.brand{font-family:"Film Serif SC","Noto Serif SC",Songti SC,serif;font-weight:900;font-size:22px;letter-spacing:4px;display:flex;align-items:center;gap:10px;color:var(--gold2)}.brand small{font-family:JetBrains Mono,monospace;font-weight:400;font-size:12px;letter-spacing:1px;color:var(--dim)}.brand .dot{width:12px;height:12px;background:var(--gold);box-shadow:0 0 14px var(--gold)}.player{max-width:1280px;margin:0 auto}.player.port{max-width:min(520px,100%)}.stage{position:relative;border:1px solid var(--line);background:#000;box-shadow:0 30px 80px #0009,0 0 60px #f7a82312}.stage .rp{display:block;width:100%;cursor:pointer;background:#07070a}.player.land .stage .rp{aspect-ratio:16/9}.player.port .stage .rp{aspect-ratio:9/16}.player:fullscreen{max-width:none;background:#000;display:flex;flex-direction:column;justify-content:center;padding:0}.player:fullscreen .stage{border:0;flex:1;display:flex;align-items:center;justify-content:center;min-height:0}.player:fullscreen .stage .rp{width:auto;height:100%;max-width:100%;max-height:100%}.player:fullscreen .chapters{display:none}.bigplay{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);padding:14px 30px;border:1px solid var(--gold);background:#07070ab8;color:var(--gold2)!important;font-size:18px!important;letter-spacing:3px;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);box-shadow:0 0 30px #f7a82340}.bigplay:disabled{opacity:1!important}.spin{font-size:14px;letter-spacing:1px;animation:pulse 1.2s ease-in-out infinite}@keyframes pulse{50%{opacity:.4}}.controls{display:flex;align-items:center;gap:10px;padding:10px 0;flex-wrap:wrap}.ctl{width:38px;height:34px;border:1px solid var(--line);background:#f7a8230f}.time{font-family:JetBrains Mono,monospace;font-size:13px;color:var(--dim);min-width:92px}.seek{flex:1;min-width:160px;height:24px;position:relative;cursor:pointer;touch-action:none}.seek:before{content:"";position:absolute;left:0;right:0;top:10px;height:4px;background:#ffffff1a}.seek .fill{position:absolute;left:0;top:10px;height:4px;background:var(--gold);box-shadow:0 0 10px var(--gold)}.seek i{position:absolute;top:6px;width:2px;height:12px;background:#f7a82399}.seg{display:inline-flex;border:1px solid var(--line)}.seg button{border:0;background:transparent;padding:7px 12px;font-size:13px;color:var(--dim)}.seg button.on{background:var(--gold);color:#1a1205;font-weight:700}.chapters{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:6px;margin-top:4px}.chapters button{border:1px solid rgba(255,255,255,.07);background:#ffffff05;padding:7px 9px;text-align:left;display:flex;flex-direction:column;gap:2px}.chapters button b{font-family:JetBrains Mono,monospace;font-size:11px;color:var(--gold)}.chapters button span{font-size:12.5px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.chapters button.on{border-color:var(--gold);background:#f7a8231a}.chapters button.on span{color:var(--fg)}.tabs{max-width:1280px;margin:26px auto 14px;display:flex;gap:4px;border-bottom:1px solid var(--line);overflow-x:auto}.tabs button{border:0;background:transparent;padding:10px 16px;color:var(--dim);border-bottom:2px solid transparent;white-space:nowrap}.tabs button.on{color:var(--gold2);border-bottom-color:var(--gold)}.btc-app>div{max-width:1280px;margin:0 auto}.card{border:1px solid var(--line);background:#140f088c;padding:18px 20px;margin-bottom:14px}.card h2{margin:0 0 14px;font-size:18px;color:var(--gold2)}.card h2 small{font-size:12px;color:var(--dim);font-weight:400;margin-left:10px}.card h3{margin:0 0 12px;font-size:17px;color:var(--gold2);font-family:"Film Serif SC","Noto Serif SC",serif}.opts{display:flex;flex-wrap:wrap;gap:18px;margin-bottom:16px}.opts label{display:flex;flex-direction:column;gap:6px;font-size:12px;color:var(--dim)}.row{display:flex;align-items:center;gap:14px;flex-wrap:wrap}.primary{background:linear-gradient(180deg,var(--gold2),var(--gold));color:#1a1205!important;border:0;padding:10px 26px;font-weight:800;letter-spacing:2px}.ghost{border:1px solid var(--line);background:transparent;padding:10px 22px}.muted{font-size:12.5px;color:var(--dim);font-family:JetBrains Mono,monospace}.prog{margin-top:14px;display:flex;flex-direction:column;gap:6px;font-size:13px;color:var(--dim)}.prog .bar{height:6px;background:#ffffff14}.prog .bar div{height:100%;background:var(--gold);box-shadow:0 0 10px var(--gold);transition:width .2s}.warn{color:#ff7a6b;font-size:13px}.okmsg{color:#9be39b;font-size:13px}.okmsg a{color:var(--gold2);margin-left:10px}.covers{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px;align-items:start}.cover{margin:0;border:1px solid var(--line);background:#140f0880}.cover canvas{display:block;width:100%;height:auto;background:#000}.cover figcaption{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 10px;font-size:12.5px}.cover figcaption em{font-style:normal;color:var(--dim);margin-left:6px;font-family:JetBrains Mono,monospace;font-size:11px}.cover figcaption button,.copy{border:1px solid var(--gold);background:#f7a82314;color:var(--gold2);padding:4px 10px;font-size:12px!important;white-space:nowrap}.kit{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px}.kit .card{margin:0}.field{margin-bottom:12px}.fh{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dim);margin-bottom:5px}.fh span{flex:1}.fh em{font-style:normal;font-family:JetBrains Mono,monospace;color:#9be39b}.fh em.bad{color:#ff6b6b}.val{margin:0;font-size:14px;line-height:1.65;background:#0000004d;padding:8px 10px;border-left:2px solid var(--gold)}.val.title{font-weight:700}.val.pre{white-space:pre-wrap;font-size:13px;color:#f5efe2d9}.val.tags{display:flex;flex-wrap:wrap;gap:6px}.val.tags span{font-size:12.5px;color:var(--gold2);background:#f7a8231a;padding:1px 7px}.btc-app footer{max-width:1280px;margin:30px auto 0;font-size:11.5px;color:var(--dim);font-family:JetBrains Mono,monospace;text-align:center}@media(max-width:640px){.btc-app{padding:10px 10px 60px}.brand{font-size:18px}.controls{gap:6px}.seek{order:9;flex-basis:100%}.chapters{grid-template-columns:repeat(2,1fr)}}
bitcoin-saga/dist/assets/index-DbpHodyw.js ADDED
The diff for this file is too large to render. See raw diff
 
bitcoin-saga/dist/index.html CHANGED
@@ -7,8 +7,8 @@
7
  <title>账本永不眠 · 比特币发展历程 2008 → 2026</title>
8
  <meta name="description" content="网页实时渲染的比特币 17 年动画纪录片:旁白、配乐、音效,浏览器本地导出 MP4。" />
9
  <style>html,body{margin:0;background:#07070a}</style>
10
- <script type="module" crossorigin src="./assets/index-CfdkkPtS.js"></script>
11
- <link rel="stylesheet" crossorigin href="./assets/index-DW82ecAa.css">
12
  </head>
13
  <body>
14
  <div id="root"></div>
 
7
  <title>账本永不眠 · 比特币发展历程 2008 → 2026</title>
8
  <meta name="description" content="网页实时渲染的比特币 17 年动画纪录片:旁白、配乐、音效,浏览器本地导出 MP4。" />
9
  <style>html,body{margin:0;background:#07070a}</style>
10
+ <script type="module" crossorigin src="./assets/index-DbpHodyw.js"></script>
11
+ <link rel="stylesheet" crossorigin href="./assets/index-2e2AWXEI.css">
12
  </head>
13
  <body>
14
  <div id="root"></div>
bitcoin-saga/web/package.json CHANGED
@@ -3,19 +3,26 @@
3
  "private": true,
4
  "version": "1.0.0",
5
  "type": "module",
6
- "scripts": { "dev": "vite", "build": "vite build --outDir ../dist --emptyOutDir" },
 
 
 
7
  "dependencies": {
8
  "@fontsource/jetbrains-mono": "^5.3.0",
9
  "@fontsource/playfair-display": "^5.3.0",
10
  "mp4-muxer": "^5.2.2",
11
  "react": "19.2.4",
12
- "react-dom": "19.2.4"
 
 
 
13
  },
14
  "devDependencies": {
15
  "@types/react": "^19.0.0",
16
  "@types/react-dom": "^19.0.0",
17
  "@vitejs/plugin-react": "4.7.0",
18
  "typescript": "5.9.3",
19
- "vite": "6.4.2"
 
20
  }
21
  }
 
3
  "private": true,
4
  "version": "1.0.0",
5
  "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build --outDir ../dist --emptyOutDir"
9
+ },
10
  "dependencies": {
11
  "@fontsource/jetbrains-mono": "^5.3.0",
12
  "@fontsource/playfair-display": "^5.3.0",
13
  "mp4-muxer": "^5.2.2",
14
  "react": "19.2.4",
15
+ "react-dom": "19.2.4",
16
+ "@remotion/player": "^4.0.535",
17
+ "remotion": "^4.0.535",
18
+ "three": "^0.186.1"
19
  },
20
  "devDependencies": {
21
  "@types/react": "^19.0.0",
22
  "@types/react-dom": "^19.0.0",
23
  "@vitejs/plugin-react": "4.7.0",
24
  "typescript": "5.9.3",
25
+ "vite": "6.4.2",
26
+ "@types/three": "^0.186.0"
27
  }
28
  }
bitcoin-saga/web/src/bitcoin/BitcoinApp.tsx CHANGED
@@ -1,4 +1,6 @@
1
- import { useCallback, useEffect, useRef, useState } from 'react'
 
 
2
  import '@fontsource/playfair-display/latin-700.css'
3
  import '@fontsource/playfair-display/latin-900.css'
4
  import '@fontsource/playfair-display/latin-700-italic.css'
@@ -30,94 +32,102 @@ function useIsPhonePortrait() {
30
  return m
31
  }
32
 
33
- // ------------------------------------------------------------------ player
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  function Player({ orient, setOrient }: { orient: Orient; setOrient: (o: Orient) => void }) {
35
  const wrapRef = useRef<HTMLDivElement>(null)
36
- const canvasRef = useRef<HTMLCanvasElement>(null)
37
  const [t, setT] = useState(0)
38
  const [playing, setPlaying] = useState(false)
39
- const [audioReady, setAudioReady] = useState(false)
40
  const [fontsReady, setFontsReady] = useState(false)
41
- const st = useRef({ t: 0, playing: false, ac: null as AudioContext | null, src: null as AudioBufferSourceNode | null, buf: null as AudioBuffer | null, startAt: 0, offset: 0 })
 
42
 
43
  useEffect(() => {
44
  loadFonts().then(() => setFontsReady(true))
45
- renderSoundtrack().then((b) => { st.current.buf = b; setAudioReady(true) }).catch((e) => console.error('soundtrack', e))
46
  }, [])
47
 
48
- // size canvas to its box
49
- const resize = useCallback(() => {
50
- const c = canvasRef.current
51
- if (!c) return
52
- const r = c.getBoundingClientRect()
53
- const dpr = Math.min(2, window.devicePixelRatio || 1)
54
- const long = orient === 'land' ? 1920 : 1080
55
- const w = Math.min(long, Math.round(r.width * dpr))
56
- const h = Math.round(w * (orient === 'land' ? 9 / 16 : 16 / 9))
57
- if (c.width !== w || c.height !== h) { c.width = w; c.height = h }
58
- draw(c.getContext('2d')!, st.current.t, c.width, c.height)
59
- }, [orient])
60
  useEffect(() => {
61
- resize()
62
- const ro = new ResizeObserver(resize)
63
- if (canvasRef.current) ro.observe(canvasRef.current)
64
- return () => ro.disconnect()
65
- }, [resize, fontsReady])
66
-
67
- // render loop
68
- useEffect(() => {
69
- let raf = 0
70
- let lastUi = 0
71
- const loop = () => {
72
- const s = st.current
73
- if (s.playing && s.ac) {
74
- s.t = s.offset + (s.ac.currentTime - s.startAt)
75
- if (s.t >= TOTAL) { stop(); s.t = TOTAL; setPlaying(false) }
76
- }
77
- const c = canvasRef.current
78
- if (c && s.playing) draw(c.getContext('2d')!, s.t, c.width, c.height)
79
  const now = performance.now()
80
- if (now - lastUi > 120) { lastUi = now; setT(s.t) }
81
- raf = requestAnimationFrame(loop)
82
  }
83
- raf = requestAnimationFrame(loop)
84
- return () => cancelAnimationFrame(raf)
85
- // eslint-disable-next-line react-hooks/exhaustive-deps
86
- }, [])
 
 
87
 
88
- const stop = () => {
89
- const s = st.current
90
- try { s.src?.stop() } catch { /* */ }
91
- s.src = null
92
- s.playing = false
93
  }
94
- const play = async () => {
95
- const s = st.current
96
- if (!s.buf) return
97
- if (!s.ac) s.ac = new AudioContext({ sampleRate: 48000 })
98
- await s.ac.resume()
99
- if (s.t >= TOTAL - 0.05) s.t = 0
100
- const src = s.ac.createBufferSource()
101
- src.buffer = s.buf
102
- src.connect(s.ac.destination)
103
- s.offset = s.t
104
- s.startAt = s.ac.currentTime + 0.03
105
- src.start(s.startAt, s.t)
106
- s.src = src
107
- s.playing = true
108
- setPlaying(true)
109
- }
110
- const pause = () => { stop(); setPlaying(false) }
111
  const seek = (nt: number) => {
112
- const s = st.current
113
- const was = s.playing
114
- if (was) stop()
115
- s.t = Math.max(0, Math.min(TOTAL, nt))
116
- setT(s.t)
117
- const c = canvasRef.current
118
- if (c) draw(c.getContext('2d')!, s.t, c.width, c.height)
119
- if (was) play()
120
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  const fullscreen = () => {
122
  const el = wrapRef.current
123
  if (!el) return
@@ -127,9 +137,9 @@ function Player({ orient, setOrient }: { orient: Orient; setOrient: (o: Orient)
127
  useEffect(() => {
128
  const k = (e: KeyboardEvent) => {
129
  if ((e.target as HTMLElement)?.tagName === 'TEXTAREA' || (e.target as HTMLElement)?.tagName === 'INPUT') return
130
- if (e.code === 'Space') { e.preventDefault(); if (st.current.playing) pause(); else play() }
131
- if (e.code === 'ArrowRight') seek(st.current.t + 5)
132
- if (e.code === 'ArrowLeft') seek(st.current.t - 5)
133
  }
134
  window.addEventListener('keydown', k)
135
  return () => window.removeEventListener('keydown', k)
@@ -139,10 +149,26 @@ function Player({ orient, setOrient }: { orient: Orient; setOrient: (o: Orient)
139
  return (
140
  <div className={`player ${orient}`} ref={wrapRef}>
141
  <div className="stage">
142
- <canvas ref={canvasRef} onClick={() => (playing ? pause() : play())} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  {!playing && (
144
  <button className="bigplay" onClick={play} disabled={!audioReady}>
145
- {audioReady ? (t > 0.1 && t < TOTAL - 0.1 ? '▶ 继续' : '▶ 播放') : <span className="spin">正在合成配乐与音效…</span>}
146
  </button>
147
  )}
148
  </div>
 
1
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
2
+ import { Player as RemotionPlayer, type PlayerRef } from '@remotion/player'
3
+ import { AbsoluteFill, Audio, useCurrentFrame, useVideoConfig } from 'remotion'
4
  import '@fontsource/playfair-display/latin-700.css'
5
  import '@fontsource/playfair-display/latin-900.css'
6
  import '@fontsource/playfair-display/latin-700-italic.css'
 
32
  return m
33
  }
34
 
35
+ // ------------------------------------------------------------------ player (Remotion)
36
+ // The film is a Remotion composition: <Film> draws frame N of the deterministic canvas renderer
37
+ // (2D motion graphics + the Three.js PBR coin), and Remotion's <Audio> plays the mixed soundtrack,
38
+ // so picture and sound are driven by one frame clock. Our own controls drive the Remotion Player via its ref.
39
+ const FPS = 60
40
+
41
+ /** AudioBuffer → 16-bit PCM WAV blob URL (what Remotion's <Audio> streams from). */
42
+ function wavUrl(buf: AudioBuffer) {
43
+ const ch = buf.numberOfChannels, n = buf.length, sr = buf.sampleRate
44
+ const out = new DataView(new ArrayBuffer(44 + n * ch * 2))
45
+ const str = (o: number, s: string) => { for (let i = 0; i < s.length; i++) out.setUint8(o + i, s.charCodeAt(i)) }
46
+ str(0, 'RIFF'); out.setUint32(4, 36 + n * ch * 2, true); str(8, 'WAVE'); str(12, 'fmt ')
47
+ out.setUint32(16, 16, true); out.setUint16(20, 1, true); out.setUint16(22, ch, true); out.setUint32(24, sr, true)
48
+ out.setUint32(28, sr * ch * 2, true); out.setUint16(32, ch * 2, true); out.setUint16(34, 16, true); str(36, 'data'); out.setUint32(40, n * ch * 2, true)
49
+ const data = Array.from({ length: ch }, (_, c) => buf.getChannelData(c))
50
+ let o = 44
51
+ for (let i = 0; i < n; i++) for (let c = 0; c < ch; c++) { const v = Math.max(-1, Math.min(1, data[c][i])); out.setInt16(o, v < 0 ? v * 0x8000 : v * 0x7fff, true); o += 2 }
52
+ return URL.createObjectURL(new Blob([out.buffer], { type: 'audio/wav' }))
53
+ }
54
+
55
+ type FilmProps = { audio: string | null; ready: boolean }
56
+ function Film({ audio, ready }: FilmProps) {
57
+ const frame = useCurrentFrame()
58
+ const { width, height, fps } = useVideoConfig()
59
+ const ref = useRef<HTMLCanvasElement>(null)
60
+ useLayoutEffect(() => {
61
+ const c = ref.current
62
+ if (c) draw(c.getContext('2d')!, frame / fps, c.width, c.height)
63
+ }, [frame, fps, width, height, ready])
64
+ return (
65
+ <AbsoluteFill style={{ background: '#07070a' }}>
66
+ <canvas ref={ref} width={width} height={height} style={{ width: '100%', height: '100%', display: 'block' }} />
67
+ {audio && <Audio src={audio} />}
68
+ </AbsoluteFill>
69
+ )
70
+ }
71
+
72
  function Player({ orient, setOrient }: { orient: Orient; setOrient: (o: Orient) => void }) {
73
  const wrapRef = useRef<HTMLDivElement>(null)
74
+ const pref = useRef<PlayerRef>(null)
75
  const [t, setT] = useState(0)
76
  const [playing, setPlaying] = useState(false)
77
+ const [audio, setAudio] = useState<string | null>(null)
78
  const [fontsReady, setFontsReady] = useState(false)
79
+ const audioReady = !!audio
80
+ const tRef = useRef(0)
81
 
82
  useEffect(() => {
83
  loadFonts().then(() => setFontsReady(true))
84
+ renderSoundtrack().then((b) => setAudio(wavUrl(b))).catch((e) => console.error('soundtrack', e))
85
  }, [])
86
 
87
+ // mirror Remotion's clock into the UI
 
 
 
 
 
 
 
 
 
 
 
88
  useEffect(() => {
89
+ const p = pref.current
90
+ if (!p) return
91
+ let last = 0
92
+ const onFrame = (e: { detail: { frame: number } }) => {
93
+ tRef.current = e.detail.frame / FPS
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  const now = performance.now()
95
+ if (now - last > 120) { last = now; setT(tRef.current) }
 
96
  }
97
+ const onPlay = () => setPlaying(true)
98
+ const onPause = () => { setPlaying(false); setT(tRef.current) }
99
+ const onEnd = () => { setPlaying(false); setT(TOTAL) }
100
+ p.addEventListener('frameupdate', onFrame); p.addEventListener('play', onPlay); p.addEventListener('pause', onPause); p.addEventListener('ended', onEnd)
101
+ return () => { p.removeEventListener('frameupdate', onFrame); p.removeEventListener('play', onPlay); p.removeEventListener('pause', onPause); p.removeEventListener('ended', onEnd) }
102
+ }, [orient])
103
 
104
+ const play = () => {
105
+ const p = pref.current
106
+ if (!p || !audio) return
107
+ if (tRef.current >= TOTAL - 0.05) p.seekTo(0)
108
+ p.play()
109
  }
110
+ const pause = () => pref.current?.pause()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  const seek = (nt: number) => {
112
+ const tt = Math.max(0, Math.min(TOTAL, nt))
113
+ tRef.current = tt; setT(tt)
114
+ pref.current?.seekTo(Math.min(Math.ceil(TOTAL * FPS) - 1, Math.round(tt * FPS)))
 
 
 
 
 
115
  }
116
+ // keep the position when switching orientation (the Player remounts with new dimensions)
117
+ const playingRef = useRef(false)
118
+ playingRef.current = playing
119
+ const firstOrient = useRef(true)
120
+ useEffect(() => {
121
+ if (firstOrient.current) { firstOrient.current = false; return }
122
+ const tt = tRef.current, was = playingRef.current
123
+ setPlaying(false)
124
+ requestAnimationFrame(() => {
125
+ const p = pref.current
126
+ if (!p) return
127
+ p.seekTo(Math.min(Math.ceil(TOTAL * FPS) - 1, Math.round(tt * FPS)))
128
+ if (was) p.play()
129
+ })
130
+ }, [orient])
131
  const fullscreen = () => {
132
  const el = wrapRef.current
133
  if (!el) return
 
137
  useEffect(() => {
138
  const k = (e: KeyboardEvent) => {
139
  if ((e.target as HTMLElement)?.tagName === 'TEXTAREA' || (e.target as HTMLElement)?.tagName === 'INPUT') return
140
+ if (e.code === 'Space') { e.preventDefault(); if (pref.current?.isPlaying()) pause(); else play() }
141
+ if (e.code === 'ArrowRight') seek(tRef.current + 5)
142
+ if (e.code === 'ArrowLeft') seek(tRef.current - 5)
143
  }
144
  window.addEventListener('keydown', k)
145
  return () => window.removeEventListener('keydown', k)
 
149
  return (
150
  <div className={`player ${orient}`} ref={wrapRef}>
151
  <div className="stage">
152
+ <div className="rp" onClick={() => (playing ? pause() : play())}>
153
+ <RemotionPlayer
154
+ key={orient}
155
+ ref={pref}
156
+ component={Film}
157
+ inputProps={{ audio, ready: fontsReady }}
158
+ durationInFrames={Math.ceil(TOTAL * FPS)}
159
+ fps={FPS}
160
+ compositionWidth={orient === 'land' ? 1920 : 1080}
161
+ compositionHeight={orient === 'land' ? 1080 : 1920}
162
+ style={{ width: '100%', height: '100%' }}
163
+ controls={false}
164
+ clickToPlay={false}
165
+ spaceKeyToPlayOrPause={false}
166
+ acknowledgeRemotionLicense
167
+ />
168
+ </div>
169
  {!playing && (
170
  <button className="bigplay" onClick={play} disabled={!audioReady}>
171
+ {audioReady ? (t > 0.1 && t < TOTAL - 0.5 ? '▶ 继续' : '▶ 播放') : <span className="spin">正在合成配乐与音效…</span>}
172
  </button>
173
  )}
174
  </div>
bitcoin-saga/web/src/bitcoin/app.css CHANGED
@@ -12,12 +12,12 @@
12
  .player{max-width:1280px;margin:0 auto}
13
  .player.port{max-width:min(520px,100%)}
14
  .stage{position:relative;border:1px solid var(--line);background:#000;box-shadow:0 30px 80px rgba(0,0,0,.6),0 0 60px rgba(247,168,35,.07)}
15
- .stage canvas{display:block;width:100%;height:auto;cursor:pointer}
16
- .player.land .stage canvas{aspect-ratio:16/9}
17
- .player.port .stage canvas{aspect-ratio:9/16}
18
  .player:fullscreen{max-width:none;background:#000;display:flex;flex-direction:column;justify-content:center;padding:0}
19
  .player:fullscreen .stage{border:0;flex:1;display:flex;align-items:center;justify-content:center;min-height:0}
20
- .player:fullscreen .stage canvas{width:auto;height:auto;max-width:100%;max-height:100%}
21
  .player:fullscreen .chapters{display:none}
22
  .bigplay{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);padding:14px 30px;border:1px solid var(--gold);background:rgba(7,7,10,.72);color:var(--gold2)!important;
23
  font-size:18px!important;letter-spacing:3px;backdrop-filter:blur(6px);box-shadow:0 0 30px rgba(247,168,35,.25)}
 
12
  .player{max-width:1280px;margin:0 auto}
13
  .player.port{max-width:min(520px,100%)}
14
  .stage{position:relative;border:1px solid var(--line);background:#000;box-shadow:0 30px 80px rgba(0,0,0,.6),0 0 60px rgba(247,168,35,.07)}
15
+ .stage .rp{display:block;width:100%;cursor:pointer;background:#07070a}
16
+ .player.land .stage .rp{aspect-ratio:16/9}
17
+ .player.port .stage .rp{aspect-ratio:9/16}
18
  .player:fullscreen{max-width:none;background:#000;display:flex;flex-direction:column;justify-content:center;padding:0}
19
  .player:fullscreen .stage{border:0;flex:1;display:flex;align-items:center;justify-content:center;min-height:0}
20
+ .player:fullscreen .stage .rp{width:auto;height:100%;max-width:100%;max-height:100%}
21
  .player:fullscreen .chapters{display:none}
22
  .bigplay{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);padding:14px 30px;border:1px solid var(--gold);background:rgba(7,7,10,.72);color:var(--gold2)!important;
23
  font-size:18px!important;letter-spacing:3px;backdrop-filter:blur(6px);box-shadow:0 0 30px rgba(247,168,35,.25)}