Ethscriptions commited on
Commit
709fe2c
·
verified ·
1 Parent(s): d1028f6

v2-6: AE-style finishing (lens CA, light leaks, streaks), PBR coin sprites in pizza scene, end-card label fix

Browse files
bitcoin-saga/README.md CHANGED
@@ -102,7 +102,7 @@
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 逐帧打磨 + 最终推送。
108
 
@@ -116,3 +116,4 @@ cd bitcoin-saga/web && npm i && npm run build # 输出到 ../dist
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 播放器接入:画面与音轨共用一个帧时钟;横竖屏切换后接着播放,空格暂停/继续、播放结束可重播。
 
 
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 沙盒里运行;AE 常用的成片效果改由渲染器逐帧实现:镜头色差(转场时加强)、转场漏光、横向光条、高光辉光、暗角、胶片颗粒 |
106
 
107
  步骤 & 推送:v2-1 计划(本节)→ v2-2 Blender 资产 → v2-3 Three.js 金币接入 → v2-4 ElevenLabs/Voxtral 声部 → v2-5 Remotion 播放器 → v2-6 逐帧打磨 + 最终推送。
108
 
 
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 播放器接入:画面与音轨共用一个帧时钟;横竖屏切换后接着播放,空格暂停/继续、播放结束可重播。
119
+ - 推送 v2-6:AE 式成片后期(镜头色差、转场漏光与光条,字幕/HUD 不受影响保持清晰);披萨场景 420 枚金币换成 Three.js 光照金币(32 个旋转角度预渲染,下落时旋转、落定后停住);尾声区块链标签在片尾标题出现时自动隐去,不再与标题文字重叠;全片横竖屏逐秒排版检查 + 导出测试通过。
bitcoin-saga/dist/assets/index-biRZVsu1.js ADDED
The diff for this file is too large to render. See raw diff
 
bitcoin-saga/dist/index.html CHANGED
@@ -7,7 +7,7 @@
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>
 
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-biRZVsu1.js"></script>
11
  <link rel="stylesheet" crossorigin href="./assets/index-2e2AWXEI.css">
12
  </head>
13
  <body>
bitcoin-saga/web/src/bitcoin/BitcoinApp.tsx CHANGED
@@ -12,13 +12,13 @@ import { renderSoundtrack } from './engine/audio'
12
  import { exportMp4, exportSupported, type ExportOpts, type ExportProgress } from './engine/export'
13
  import { COVERS, COPY, drawCover, charLen, tagString, type CoverSpec } from './engine/covers'
14
  import { coin3d } from './engine/coin'
15
- import { coin3dGL, renderCoinGL } from './engine/coingl'
16
  import { SCENES, TOTAL, fmtTime, sceneIndexAt } from './timeline'
17
 
18
  type Orient = 'land' | 'port'
19
 
20
  // frame-inspection hook (used for automated QA screenshots)
21
- if (typeof window !== 'undefined') (window as unknown as Record<string, unknown>).__btc = { draw, drawCover, COVERS, SCENES, TOTAL, loadFonts, renderSoundtrack, exportMp4, coin3d, coin3dGL, renderCoinGL }
22
 
23
  function useIsPhonePortrait() {
24
  const q = '(max-width: 820px) and (orientation: portrait)'
 
12
  import { exportMp4, exportSupported, type ExportOpts, type ExportProgress } from './engine/export'
13
  import { COVERS, COPY, drawCover, charLen, tagString, type CoverSpec } from './engine/covers'
14
  import { coin3d } from './engine/coin'
15
+ import { coin3dGL, coinSprite, renderCoinGL } from './engine/coingl'
16
  import { SCENES, TOTAL, fmtTime, sceneIndexAt } from './timeline'
17
 
18
  type Orient = 'land' | 'port'
19
 
20
  // frame-inspection hook (used for automated QA screenshots)
21
+ if (typeof window !== 'undefined') (window as unknown as Record<string, unknown>).__btc = { draw, drawCover, COVERS, SCENES, TOTAL, loadFonts, renderSoundtrack, exportMp4, coin3d, coin3dGL, coinSprite, renderCoinGL }
22
 
23
  function useIsPhonePortrait() {
24
  const q = '(max-width: 820px) and (orientation: portrait)'
bitcoin-saga/web/src/bitcoin/engine/coingl.ts CHANGED
@@ -224,3 +224,36 @@ export function coin3dGL(ctx: Ctx, x: number, y: number, R: number, yaw: number,
224
  }
225
  ctx.restore()
226
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  }
225
  ctx.restore()
226
  }
227
+
228
+ // ---------------------------------------------------------------- sprite sheet for coin swarms
229
+ // Hundreds of small coins (e.g. the 10,000 BTC pizza) can't each get a live WebGL render, so the same lit PBR coin
230
+ // is rendered once at 32 spin angles into a sheet; each small coin then picks the frame for its own spin.
231
+ const SPR_N = 32, SPR_CELL = 96
232
+ let sheet: HTMLCanvasElement | null = null
233
+ function coinSheet() {
234
+ if (sheet || !gl) return sheet
235
+ const c = document.createElement('canvas')
236
+ c.width = SPR_CELL * 8; c.height = SPR_CELL * (SPR_N / 8)
237
+ const g = c.getContext('2d')!
238
+ g.imageSmoothingEnabled = true; g.imageSmoothingQuality = 'high'
239
+ for (let i = 0; i < SPR_N; i++) {
240
+ const src = renderCoinGL(300, (i / SPR_N) * Math.PI * 2, -0.28, 0.9, 1.25)
241
+ if (!src) return null
242
+ g.drawImage(src, 0, 0, gl.size, gl.size, (i % 8) * SPR_CELL, Math.floor(i / 8) * SPR_CELL, SPR_CELL, SPR_CELL)
243
+ }
244
+ sheet = c
245
+ return sheet
246
+ }
247
+
248
+ /** Small lit coin from the sprite sheet (face radius R). Returns false when WebGL is unavailable. */
249
+ export function coinSprite(ctx: Ctx, x: number, y: number, R: number, yaw: number, alpha = 1) {
250
+ const sh = coinSheet()
251
+ if (!sh || alpha <= 0) return false
252
+ const f = ((Math.round((yaw / (Math.PI * 2)) * SPR_N) % SPR_N) + SPR_N) % SPR_N
253
+ const S = R * FIT * 2
254
+ const a = ctx.globalAlpha
255
+ ctx.globalAlpha = a * alpha
256
+ ctx.drawImage(sh, (f % 8) * SPR_CELL, Math.floor(f / 8) * SPR_CELL, SPR_CELL, SPR_CELL, x - S / 2, y - S / 2, S, S)
257
+ ctx.globalAlpha = a
258
+ return true
259
+ }
bitcoin-saga/web/src/bitcoin/engine/render.ts CHANGED
@@ -144,6 +144,79 @@ function transition(ctx: Ctx, T: number, W: number, H: number) {
144
  }
145
  }
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  // ---------------------------------------------------------------- HUD
148
  function hud(ctx: Ctx, T: number, W: number, H: number, P: boolean, hudY: number) {
149
  const i = sceneIndexAt(T)
@@ -306,6 +379,8 @@ export function draw(ctx: Ctx, T: number, outW: number, outH: number) {
306
  }
307
 
308
  transition(ctx, T, W, H)
 
 
309
  const L = makeSC(ctx, SCENES[i], T, P).L
310
  hud(ctx, T, W, H, P, L.hudY)
311
  subtitles(ctx, T, W, P, L.subY)
 
144
  }
145
  }
146
 
147
+
148
+ // ---------------------------------------------------------------- "After Effects" style finishing
149
+ // Lens chromatic aberration (red channel scaled about the centre — fringes grow toward the edges, kick at cuts)
150
+ // and drifting light leaks that bloom through each scene change. Applied under HUD/subtitles so text stays crisp.
151
+ let caLayer: HTMLCanvasElement | null = null
152
+ function lensCA(ctx: Ctx, T: number) {
153
+ let k = 0.0011
154
+ for (let i = 1; i < SCENES.length; i++) {
155
+ const d = Math.abs(T - SCENES[i].start)
156
+ if (d < 0.4) k += 0.0075 * Math.pow(1 - d / 0.4, 2)
157
+ }
158
+ const cv = ctx.canvas, w = cv.width, h = cv.height
159
+ if (!caLayer) caLayer = document.createElement('canvas')
160
+ if (caLayer.width !== w || caLayer.height !== h) { caLayer.width = w; caLayer.height = h }
161
+ const g = caLayer.getContext('2d')!
162
+ g.globalCompositeOperation = 'copy'
163
+ g.drawImage(cv, 0, 0)
164
+ g.globalCompositeOperation = 'multiply'
165
+ g.fillStyle = '#ff0000'
166
+ g.fillRect(0, 0, w, h)
167
+ ctx.save()
168
+ ctx.setTransform(1, 0, 0, 1, 0, 0)
169
+ ctx.globalAlpha = 1
170
+ ctx.globalCompositeOperation = 'multiply'
171
+ ctx.fillStyle = '#00ffff'
172
+ ctx.fillRect(0, 0, w, h)
173
+ ctx.globalCompositeOperation = 'lighter'
174
+ const sw = w * (1 + k), sh = h * (1 + k)
175
+ ctx.drawImage(caLayer, (w - sw) / 2, (h - sh) / 2, sw, sh)
176
+ ctx.restore()
177
+ }
178
+
179
+ function lightLeaks(ctx: Ctx, T: number, W: number, H: number) {
180
+ ctx.save()
181
+ ctx.globalCompositeOperation = 'screen'
182
+ for (let i = 1; i < SCENES.length; i++) {
183
+ const d = T - SCENES[i].start
184
+ if (d < -0.5 || d > 2.2) continue
185
+ const e = d < 0 ? easeOut(inv(-0.5, 0, d)) : 1 - easeInOut(inv(0.1, 2.2, d))
186
+ if (e <= 0.002) continue
187
+ const tint = MOOD[SCENES[i].mood]
188
+ const side = rnd(i, 71) < 0.5 ? -1 : 1
189
+ const blobs: [number, number, number, string, number][] = [
190
+ // x, y, radius, colour, strength
191
+ [W * (0.5 + side * 0.42) - side * d * W * 0.07, H * (0.18 + rnd(i, 3) * 0.3), Math.max(W, H) * 0.55, '#ff8a2a', 0.34],
192
+ [W * (0.5 - side * 0.38) + side * d * W * 0.05, H * (0.62 + rnd(i, 5) * 0.25), Math.max(W, H) * 0.42, tint, 0.2],
193
+ [W * (0.5 + side * 0.5), H * 0.5, Math.max(W, H) * 0.25, '#fff1d6', 0.16],
194
+ ]
195
+ for (const [x, y, r, col, a] of blobs) {
196
+ const g = ctx.createRadialGradient(x, y, 0, x, y, r)
197
+ g.addColorStop(0, rgba(col, a * e))
198
+ g.addColorStop(0.45, rgba(col, a * e * 0.35))
199
+ g.addColorStop(1, rgba(col, 0))
200
+ ctx.fillStyle = g
201
+ ctx.fillRect(0, 0, W, H)
202
+ }
203
+ // thin anamorphic streak across the flash
204
+ const f = Math.max(0, 1 - Math.abs(d) / 0.35)
205
+ if (f > 0) {
206
+ const y = H * (0.42 + rnd(i, 9) * 0.16)
207
+ const g = ctx.createLinearGradient(0, y - 3, 0, y + 3)
208
+ g.addColorStop(0, rgba('#ffd27a', 0)); g.addColorStop(0.5, rgba('#ffd27a', 0.55 * f)); g.addColorStop(1, rgba('#ffd27a', 0))
209
+ ctx.fillStyle = g
210
+ ctx.fillRect(0, y - 3, W, 6)
211
+ const g2 = ctx.createLinearGradient(0, y - 40, 0, y + 40)
212
+ g2.addColorStop(0, rgba('#ff9a3a', 0)); g2.addColorStop(0.5, rgba('#ff9a3a', 0.16 * f)); g2.addColorStop(1, rgba('#ff9a3a', 0))
213
+ ctx.fillStyle = g2
214
+ ctx.fillRect(0, y - 40, W, 80)
215
+ }
216
+ }
217
+ ctx.restore()
218
+ }
219
+
220
  // ---------------------------------------------------------------- HUD
221
  function hud(ctx: Ctx, T: number, W: number, H: number, P: boolean, hudY: number) {
222
  const i = sceneIndexAt(T)
 
379
  }
380
 
381
  transition(ctx, T, W, H)
382
+ lightLeaks(ctx, T, W, H)
383
+ lensCA(ctx, T)
384
  const L = makeSC(ctx, SCENES[i], T, P).L
385
  hud(ctx, T, W, H, P, L.hudY)
386
  subtitles(ctx, T, W, P, L.subY)
bitcoin-saga/web/src/bitcoin/engine/scenes1.ts CHANGED
@@ -3,6 +3,7 @@ import { C, FONT, clamp, easeInOut, easeOut, easeOutBack, easeOutExpo, fmtInt, h
3
  import { bigNumber, cube, fieldRows, glowDot, panel, priceChart, text, typeText, wrap, type Cam, type Box } from './gfx'
4
  import { day } from './price'
5
  import type { SC } from './sc'
 
6
 
7
  const GENESIS_HASH = '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f'
8
 
@@ -250,6 +251,8 @@ export function s02(c: SC) {
250
  const r = lerp(11, 7, gi)
251
  const sp = Math.abs(Math.cos(t * 3 + i))
252
  ctx.globalAlpha = appear * (1 - inv(0.85, 1, gather) * 0.55)
 
 
253
  ctx.fillStyle = sp > 0.5 ? '#ffd27a' : '#e09a1c'
254
  ctx.beginPath(); ctx.ellipse(x, y, r * (0.35 + 0.65 * sp), r, 0, 0, Math.PI * 2); ctx.fill()
255
  ctx.strokeStyle = 'rgba(120,60,0,0.6)'
 
3
  import { bigNumber, cube, fieldRows, glowDot, panel, priceChart, text, typeText, wrap, type Cam, type Box } from './gfx'
4
  import { day } from './price'
5
  import type { SC } from './sc'
6
+ import { coinSprite } from './coingl'
7
 
8
  const GENESIS_HASH = '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f'
9
 
 
251
  const r = lerp(11, 7, gi)
252
  const sp = Math.abs(Math.cos(t * 3 + i))
253
  ctx.globalAlpha = appear * (1 - inv(0.85, 1, gather) * 0.55)
254
+ // lit PBR coin sprite: spins while falling, slows to a stop as it lands in the pizza
255
+ if (coinSprite(ctx, x, y, lerp(12, 7, gi), (t * 3 + i) * (1 - gi) + rnd(i, 6) * 6.28 * gi)) continue
256
  ctx.fillStyle = sp > 0.5 ? '#ffd27a' : '#e09a1c'
257
  ctx.beginPath(); ctx.ellipse(x, y, r * (0.35 + 0.65 * sp), r, 0, 0, Math.PI * 2); ctx.fill()
258
  ctx.strokeStyle = 'rgba(120,60,0,0.6)'
bitcoin-saga/web/src/bitcoin/engine/scenes3.ts CHANGED
@@ -310,11 +310,13 @@ export function s12(c: SC) {
310
  const fly = easeInOut(inv(0, B(2) + 0.5, v + 0.9))
311
  const cam: Cam = { x: 0, y: 1.2, z: lerp(-14, 9 * gap - 4, fly) + Math.max(0, t - (B(2) + 1.5)) * 0.6, yaw: Math.sin(t * 0.2) * 0.06, pitch: -0.06, f: P ? 900 : 1100, cx: W / 2, cy: H * (P ? 0.42 : 0.46) }
312
  const pts: V3[] = MILESTONES.map((_, i) => [Math.sin(i * 1.3) * 3.2, Math.cos(i * 0.9) * 1.4, i * gap])
 
 
313
  for (let i = MILESTONES.length - 1; i >= 0; i--) {
314
  if (i < MILESTONES.length - 1) beam(ctx, cam, pts[i], pts[i + 1], C.gold, 0.7, 1.8)
315
  const dz = pts[i][2] - cam.z
316
  const a = clamp(inv(0.5, 4, dz)) * clamp(1 - inv(70, 100, dz))
317
- cube(ctx, cam, pts[i], { size: 1.3, yaw: 0.6 + t * 0.3 + i, pitch: -0.3, label: MILESTONES[i][0], sub: MILESTONES[i][1], alpha: a, color: MILESTONES[i][0] === '?' ? C.white : C.gold })
318
  }
319
  // ---- 3D coin: rises out of the chain as a hero, then settles above the title
320
  const ca = inv(B(1) - 0.6, B(1) + 0.9, v)
 
310
  const fly = easeInOut(inv(0, B(2) + 0.5, v + 0.9))
311
  const cam: Cam = { x: 0, y: 1.2, z: lerp(-14, 9 * gap - 4, fly) + Math.max(0, t - (B(2) + 1.5)) * 0.6, yaw: Math.sin(t * 0.2) * 0.06, pitch: -0.06, f: P ? 900 : 1100, cx: W / 2, cy: H * (P ? 0.42 : 0.46) }
312
  const pts: V3[] = MILESTONES.map((_, i) => [Math.sin(i * 1.3) * 3.2, Math.cos(i * 0.9) * 1.4, i * gap])
313
+ // the chain recedes into the background once the closing title cards come in, so labels never fight the type
314
+ const recede = 1 - 0.85 * easeInOut(after(2, 1.6))
315
  for (let i = MILESTONES.length - 1; i >= 0; i--) {
316
  if (i < MILESTONES.length - 1) beam(ctx, cam, pts[i], pts[i + 1], C.gold, 0.7, 1.8)
317
  const dz = pts[i][2] - cam.z
318
  const a = clamp(inv(0.5, 4, dz)) * clamp(1 - inv(70, 100, dz))
319
+ cube(ctx, cam, pts[i], { size: 1.3, yaw: 0.6 + t * 0.3 + i, pitch: -0.3, label: MILESTONES[i][0], sub: MILESTONES[i][1], alpha: a * recede, color: MILESTONES[i][0] === '?' ? C.white : C.gold })
320
  }
321
  // ---- 3D coin: rises out of the chain as a hero, then settles above the title
322
  const ca = inv(B(1) - 0.6, B(1) + 0.9, v)