Spaces:
Running
Running
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** | ✅
|
| 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-
|
| 11 |
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
| 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": {
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
| 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
|
| 37 |
const [t, setT] = useState(0)
|
| 38 |
const [playing, setPlaying] = useState(false)
|
| 39 |
-
const [
|
| 40 |
const [fontsReady, setFontsReady] = useState(false)
|
| 41 |
-
const
|
|
|
|
| 42 |
|
| 43 |
useEffect(() => {
|
| 44 |
loadFonts().then(() => setFontsReady(true))
|
| 45 |
-
renderSoundtrack().then((b) =>
|
| 46 |
}, [])
|
| 47 |
|
| 48 |
-
//
|
| 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 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 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 -
|
| 81 |
-
raf = requestAnimationFrame(loop)
|
| 82 |
}
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
| 87 |
|
| 88 |
-
const
|
| 89 |
-
const
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
}
|
| 94 |
-
const
|
| 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
|
| 113 |
-
|
| 114 |
-
|
| 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 (
|
| 131 |
-
if (e.code === 'ArrowRight') seek(
|
| 132 |
-
if (e.code === 'ArrowLeft') seek(
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
{!playing && (
|
| 144 |
<button className="bigplay" onClick={play} disabled={!audioReady}>
|
| 145 |
-
{audioReady ? (t > 0.1 && t < TOTAL - 0.
|
| 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
|
| 16 |
-
.player.land .stage
|
| 17 |
-
.player.port .stage
|
| 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
|
| 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)}
|