Ethscriptions commited on
Commit
1cba7be
·
verified ·
1 Parent(s): ffab7a5

Swiss edition: 13 typographic scenes, soundtrack, export, covers, copy, live photos, style switcher (part 2)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. earth-online-sun/web/public/swiss/vo/10.mp3 +0 -0
  2. earth-online-sun/web/public/swiss/vo/11.mp3 +0 -0
  3. earth-online-sun/web/public/swiss/vo/12.mp3 +0 -0
  4. earth-online-sun/web/public/swiss/vo/13.mp3 +0 -0
  5. earth-online-sun/web/public/swiss/vo/14.mp3 +0 -0
  6. earth-online-sun/web/public/swiss/vo/15.mp3 +0 -0
  7. earth-online-sun/web/public/swiss/vo/16.mp3 +0 -0
  8. earth-online-sun/web/public/swiss/vo/17.mp3 +0 -0
  9. earth-online-sun/web/public/swiss/vo/18.mp3 +0 -0
  10. earth-online-sun/web/public/swiss/vo/19.mp3 +0 -0
  11. earth-online-sun/web/public/swiss/vo/20.mp3 +0 -0
  12. earth-online-sun/web/public/swiss/vo/21.mp3 +0 -0
  13. earth-online-sun/web/public/swiss/vo/22.mp3 +0 -0
  14. earth-online-sun/web/public/swiss/vo/23.mp3 +0 -0
  15. earth-online-sun/web/public/swiss/vo/24.mp3 +0 -0
  16. earth-online-sun/web/public/swiss/vo/25.mp3 +0 -0
  17. earth-online-sun/web/public/swiss/vo/26.mp3 +0 -0
  18. earth-online-sun/web/public/swiss/vo/27.mp3 +0 -0
  19. earth-online-sun/web/public/swiss/vo/28.mp3 +0 -0
  20. earth-online-sun/web/public/swiss/vo/29.mp3 +0 -0
  21. earth-online-sun/web/public/swiss/vo/30.mp3 +0 -0
  22. earth-online-sun/web/public/swiss/vo/31.mp3 +0 -0
  23. earth-online-sun/web/public/swiss/vo/32.mp3 +0 -0
  24. earth-online-sun/web/public/swiss/vo/33.mp3 +0 -0
  25. earth-online-sun/web/public/swiss/vo/34.mp3 +0 -0
  26. earth-online-sun/web/public/swiss/vo/35.mp3 +0 -0
  27. earth-online-sun/web/public/swiss/vo/36.mp3 +0 -0
  28. earth-online-sun/web/public/swiss/vo/37.mp3 +0 -0
  29. earth-online-sun/web/public/swiss/vo/38.mp3 +0 -0
  30. earth-online-sun/web/public/swiss/vo/39.mp3 +0 -0
  31. earth-online-sun/web/public/swiss/vo/40.mp3 +0 -0
  32. earth-online-sun/web/public/swiss/vo/41.mp3 +0 -0
  33. earth-online-sun/web/public/swiss/vo/42.mp3 +0 -0
  34. earth-online-sun/web/public/swiss/vo/43.mp3 +0 -0
  35. earth-online-sun/web/public/swiss/vo/44.mp3 +0 -0
  36. earth-online-sun/web/public/swiss/vo/45.mp3 +0 -0
  37. earth-online-sun/web/public/swiss/vo/46.mp3 +0 -0
  38. earth-online-sun/web/public/swiss/vo/47.mp3 +0 -0
  39. earth-online-sun/web/src/App.tsx +24 -1
  40. earth-online-sun/web/src/film/export.ts +98 -0
  41. earth-online-sun/web/src/swiss/SwissEdition.tsx +320 -0
  42. earth-online-sun/web/src/swiss/audio.ts +182 -0
  43. earth-online-sun/web/src/swiss/copy.ts +74 -0
  44. earth-online-sun/web/src/swiss/covers.ts +73 -0
  45. earth-online-sun/web/src/swiss/fonts.ts +16 -0
  46. earth-online-sun/web/src/swiss/kit.ts +158 -0
  47. earth-online-sun/web/src/swiss/live.ts +39 -0
  48. earth-online-sun/web/src/swiss/render.ts +131 -0
  49. earth-online-sun/web/src/swiss/scenes.ts +734 -0
  50. earth-online-sun/web/src/swiss/timeline.ts +55 -0
earth-online-sun/web/public/swiss/vo/10.mp3 ADDED
Binary file (22.8 kB). View file
 
earth-online-sun/web/public/swiss/vo/11.mp3 ADDED
Binary file (26.8 kB). View file
 
earth-online-sun/web/public/swiss/vo/12.mp3 ADDED
Binary file (28.3 kB). View file
 
earth-online-sun/web/public/swiss/vo/13.mp3 ADDED
Binary file (56.2 kB). View file
 
earth-online-sun/web/public/swiss/vo/14.mp3 ADDED
Binary file (32 kB). View file
 
earth-online-sun/web/public/swiss/vo/15.mp3 ADDED
Binary file (28.6 kB). View file
 
earth-online-sun/web/public/swiss/vo/16.mp3 ADDED
Binary file (47.6 kB). View file
 
earth-online-sun/web/public/swiss/vo/17.mp3 ADDED
Binary file (30 kB). View file
 
earth-online-sun/web/public/swiss/vo/18.mp3 ADDED
Binary file (39.8 kB). View file
 
earth-online-sun/web/public/swiss/vo/19.mp3 ADDED
Binary file (47.6 kB). View file
 
earth-online-sun/web/public/swiss/vo/20.mp3 ADDED
Binary file (49.6 kB). View file
 
earth-online-sun/web/public/swiss/vo/21.mp3 ADDED
Binary file (33.2 kB). View file
 
earth-online-sun/web/public/swiss/vo/22.mp3 ADDED
Binary file (25.1 kB). View file
 
earth-online-sun/web/public/swiss/vo/23.mp3 ADDED
Binary file (37.2 kB). View file
 
earth-online-sun/web/public/swiss/vo/24.mp3 ADDED
Binary file (55.9 kB). View file
 
earth-online-sun/web/public/swiss/vo/25.mp3 ADDED
Binary file (25.1 kB). View file
 
earth-online-sun/web/public/swiss/vo/26.mp3 ADDED
Binary file (23.4 kB). View file
 
earth-online-sun/web/public/swiss/vo/27.mp3 ADDED
Binary file (24.8 kB). View file
 
earth-online-sun/web/public/swiss/vo/28.mp3 ADDED
Binary file (45.5 kB). View file
 
earth-online-sun/web/public/swiss/vo/29.mp3 ADDED
Binary file (44.4 kB). View file
 
earth-online-sun/web/public/swiss/vo/30.mp3 ADDED
Binary file (31.7 kB). View file
 
earth-online-sun/web/public/swiss/vo/31.mp3 ADDED
Binary file (18.8 kB). View file
 
earth-online-sun/web/public/swiss/vo/32.mp3 ADDED
Binary file (24.8 kB). View file
 
earth-online-sun/web/public/swiss/vo/33.mp3 ADDED
Binary file (18.8 kB). View file
 
earth-online-sun/web/public/swiss/vo/34.mp3 ADDED
Binary file (25.7 kB). View file
 
earth-online-sun/web/public/swiss/vo/35.mp3 ADDED
Binary file (26.8 kB). View file
 
earth-online-sun/web/public/swiss/vo/36.mp3 ADDED
Binary file (39.5 kB). View file
 
earth-online-sun/web/public/swiss/vo/37.mp3 ADDED
Binary file (48.7 kB). View file
 
earth-online-sun/web/public/swiss/vo/38.mp3 ADDED
Binary file (26.3 kB). View file
 
earth-online-sun/web/public/swiss/vo/39.mp3 ADDED
Binary file (51.3 kB). View file
 
earth-online-sun/web/public/swiss/vo/40.mp3 ADDED
Binary file (24.8 kB). View file
 
earth-online-sun/web/public/swiss/vo/41.mp3 ADDED
Binary file (35.8 kB). View file
 
earth-online-sun/web/public/swiss/vo/42.mp3 ADDED
Binary file (62.5 kB). View file
 
earth-online-sun/web/public/swiss/vo/43.mp3 ADDED
Binary file (22.5 kB). View file
 
earth-online-sun/web/public/swiss/vo/44.mp3 ADDED
Binary file (32.3 kB). View file
 
earth-online-sun/web/public/swiss/vo/45.mp3 ADDED
Binary file (7.82 kB). View file
 
earth-online-sun/web/public/swiss/vo/46.mp3 ADDED
Binary file (17.3 kB). View file
 
earth-online-sun/web/public/swiss/vo/47.mp3 ADDED
Binary file (38.1 kB). View file
 
earth-online-sun/web/src/App.tsx CHANGED
@@ -5,6 +5,7 @@ import { buildSoundtrack } from './film/audio'
5
  import { exportMP4, canWebCodecs } from './film/export'
6
  import { coverCanvas, RATIO_PX, type Ratio } from './film/covers'
7
  import { PLATFORMS, len, type Platform } from './film/copy'
 
8
 
9
  const BASE = import.meta.env.BASE_URL || './'
10
  const SIZE = { land: [384, 216], port: [216, 384] } as const
@@ -258,7 +259,7 @@ function LivePhotos() {
258
  )
259
  }
260
 
261
- export default function App() {
262
  const [orient, setOrient] = useState<Orient>('land')
263
  useEffect(() => { if (window.innerHeight > window.innerWidth) setOrient('port') }, [])
264
  const total = useMemo(() => fmt(DUR), [])
@@ -285,3 +286,25 @@ export default function App() {
285
  </div>
286
  )
287
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  import { exportMP4, canWebCodecs } from './film/export'
6
  import { coverCanvas, RATIO_PX, type Ratio } from './film/covers'
7
  import { PLATFORMS, len, type Platform } from './film/copy'
8
+ import SwissEdition from './swiss/SwissEdition'
9
 
10
  const BASE = import.meta.env.BASE_URL || './'
11
  const SIZE = { land: [384, 216], port: [216, 384] } as const
 
259
  )
260
  }
261
 
262
+ function PixelEdition() {
263
  const [orient, setOrient] = useState<Orient>('land')
264
  useEffect(() => { if (window.innerHeight > window.innerWidth) setOrient('port') }, [])
265
  const total = useMemo(() => fmt(DUR), [])
 
286
  </div>
287
  )
288
  }
289
+
290
+ // ---------------- style switcher (both editions live side by side) ----------------
291
+ type Edition = 'swiss' | 'pixel'
292
+ export default function App() {
293
+ const [ed, setEd] = useState<Edition>(() => (typeof location !== 'undefined' && location.hash === '#pixel' ? 'pixel' : 'swiss'))
294
+ useEffect(() => { history.replaceState(null, '', `#${ed}`); window.scrollTo(0, 0) }, [ed])
295
+ const dark = ed === 'pixel'
296
+ return (
297
+ <>
298
+ <nav className={`sticky top-0 z-50 border-b ${dark ? 'bg-[#0b0d1a]/95 border-neutral-800 text-neutral-200' : 'bg-[#EDECE8]/95 border-[#111] text-[#111]'} backdrop-blur`}>
299
+ <div className="mx-auto max-w-6xl px-3 sm:px-8 h-11 flex items-center justify-between text-sm">
300
+ <span className="font-bold truncate">孙宇晨访谈 · 两种风格</span>
301
+ <div className="flex shrink-0">
302
+ <button onClick={() => setEd('swiss')} className={`px-3 py-1 border ${dark ? 'border-neutral-600' : 'border-[#111]'} ${ed === 'swiss' ? 'bg-[#111] text-[#EDECE8] border-[#111]' : ''}`}>瑞士排版 · 规则</button>
303
+ <button onClick={() => setEd('pixel')} className={`px-3 py-1 border border-l-0 ${dark ? 'border-neutral-600' : 'border-[#111]'} ${ed === 'pixel' ? 'bg-yellow-400 text-black border-yellow-400' : ''}`}>像素 RPG</button>
304
+ </div>
305
+ </div>
306
+ </nav>
307
+ {ed === 'swiss' ? <SwissEdition /> : <PixelEdition />}
308
+ </>
309
+ )
310
+ }
earth-online-sun/web/src/film/export.ts CHANGED
@@ -140,3 +140,101 @@ async function recordRealtime(o: ExportOpts): Promise<{ blob: Blob; ext: string
140
  const isMp4 = mime.startsWith('video/mp4')
141
  return { blob: new Blob(chunks, { type: isMp4 ? 'video/mp4' : 'video/webm' }), ext: isMp4 ? 'mp4' : 'webm' }
142
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  const isMp4 = mime.startsWith('video/mp4')
141
  return { blob: new Blob(chunks, { type: isMp4 ? 'video/mp4' : 'video/webm' }), ext: isMp4 ? 'mp4' : 'webm' }
142
  }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Generic vector-canvas export (used by the Swiss edition). `draw(ctx, t)` paints one frame
146
+ // in logical units; the context is pre-scaled so the output is crisp at any size.
147
+ export interface CanvasExportOpts {
148
+ w: number; h: number // output pixels
149
+ lw: number; lh: number // logical size the draw function uses
150
+ fps: 24 | 30 | 60
151
+ dur: number
152
+ audio: AudioBuffer
153
+ draw: (c: CanvasRenderingContext2D, t: number) => void
154
+ onProgress: (p: number, msg: string) => void
155
+ signal: { aborted: boolean }
156
+ limit?: number
157
+ }
158
+ export async function exportCanvasMP4(o: CanvasExportOpts): Promise<{ blob: Blob; ext: string }> {
159
+ const { w, h, lw, lh, fps, audio, onProgress, signal, draw } = o
160
+ const LEN = Math.min(o.dur, o.limit ?? o.dur)
161
+ const cv = document.createElement('canvas'); cv.width = w; cv.height = h
162
+ const c = cv.getContext('2d', { alpha: false })!
163
+ const paint = (t: number) => { c.setTransform(w / lw, 0, 0, h / lh, 0, 0); draw(c, t) }
164
+ const vcfg = await pickVideo(w, h, fps)
165
+ const acfg = await pickAudio()
166
+ if (!vcfg || !acfg) return recordCanvasRealtime(o, cv, paint, LEN)
167
+ const target = new ArrayBufferTarget()
168
+ const muxer = new Muxer({
169
+ target, fastStart: 'in-memory', firstTimestampBehavior: 'offset',
170
+ video: { codec: 'avc', width: w, height: h, frameRate: fps },
171
+ audio: { codec: acfg.kind, numberOfChannels: 2, sampleRate: 48000 },
172
+ })
173
+ let err: unknown = null
174
+ const venc = new VideoEncoder({ output: (ch, m) => muxer.addVideoChunk(ch, m), error: e => { err = e } })
175
+ venc.configure({ ...vcfg, bitrate: Math.round(w * h * fps * 0.1) })
176
+ const aenc = new AudioEncoder({ output: (ch, m) => muxer.addAudioChunk(ch, m), error: e => { err = e } })
177
+ aenc.configure(acfg.cfg)
178
+ onProgress(0, '编码音频')
179
+ const SR = 48000, total = Math.min(audio.length, Math.ceil(LEN * SR)), CH = 4800
180
+ const L = audio.getChannelData(0), R = audio.numberOfChannels > 1 ? audio.getChannelData(1) : L
181
+ for (let i = 0; i < total; i += CH) {
182
+ const n = Math.min(CH, total - i), data = new Float32Array(n * 2)
183
+ data.set(L.subarray(i, i + n), 0); data.set(R.subarray(i, i + n), n)
184
+ const ad = new AudioData({ format: 'f32-planar', sampleRate: SR, numberOfFrames: n, numberOfChannels: 2, timestamp: Math.round((i / SR) * 1e6), data })
185
+ aenc.encode(ad); ad.close()
186
+ }
187
+ await aenc.flush()
188
+ const N = Math.round(LEN * fps), fd = Math.round(1e6 / fps), t0 = performance.now()
189
+ for (let i = 0; i < N; i++) {
190
+ if (signal.aborted) { try { venc.close(); aenc.close() } catch { /* */ } throw new Error('已取消') }
191
+ if (err) throw err
192
+ paint(i / fps)
193
+ const vf = new VideoFrame(cv, { timestamp: Math.round((i * 1e6) / fps), duration: fd })
194
+ venc.encode(vf, { keyFrame: i % (fps * 2) === 0 }); vf.close()
195
+ while (venc.encodeQueueSize > 6) await new Promise(r => setTimeout(r, 1))
196
+ if (i % 10 === 0) {
197
+ const el = (performance.now() - t0) / 1000, eta = (el / (i + 1)) * (N - i - 1)
198
+ onProgress((i + 1) / N, `渲染画面 ${i + 1}/${N} 帧 · 剩余约 ${Math.ceil(eta)} 秒`)
199
+ await new Promise(r => setTimeout(r, 0))
200
+ }
201
+ }
202
+ onProgress(1, '封装 MP4')
203
+ await venc.flush()
204
+ if (err) throw err
205
+ muxer.finalize()
206
+ return { blob: new Blob([target.buffer], { type: 'video/mp4' }), ext: 'mp4' }
207
+ }
208
+ async function recordCanvasRealtime(o: CanvasExportOpts, cv: HTMLCanvasElement, paint: (t: number) => void, LEN: number): Promise<{ blob: Blob; ext: string }> {
209
+ const { fps, audio, onProgress, signal } = o
210
+ if (typeof MediaRecorder === 'undefined') throw new Error('此浏览器不支持本地导出,请使用最新版 Chrome / Edge / Safari')
211
+ const ac = new AudioContext({ sampleRate: 48000 })
212
+ const dst = ac.createMediaStreamDestination()
213
+ const src = ac.createBufferSource(); src.buffer = audio; src.connect(dst)
214
+ const stream = new MediaStream([...cv.captureStream(fps).getVideoTracks(), ...dst.stream.getAudioTracks()])
215
+ const types = ['video/mp4;codecs=avc1.640028,mp4a.40.2', 'video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm']
216
+ const mime = types.find(t => MediaRecorder.isTypeSupported(t)) || ''
217
+ const rec = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 12_000_000 })
218
+ const chunks: Blob[] = []
219
+ rec.ondataavailable = e => e.data.size && chunks.push(e.data)
220
+ const done = new Promise<void>(r => (rec.onstop = () => r()))
221
+ rec.start(1000)
222
+ const start = ac.currentTime + 0.1
223
+ src.start(start)
224
+ await new Promise<void>(resolve => {
225
+ const tick = () => {
226
+ if (signal.aborted) { resolve(); return }
227
+ const t = ac.currentTime - start
228
+ if (t >= LEN) { resolve(); return }
229
+ paint(Math.max(0, t))
230
+ onProgress(Math.max(0, t) / LEN, `实时录���中(此浏览器不支持快速导出)${Math.floor(Math.max(0, t))}/${Math.floor(LEN)} 秒`)
231
+ requestAnimationFrame(tick)
232
+ }
233
+ tick()
234
+ })
235
+ if (rec.state !== 'inactive') rec.stop()
236
+ await done; src.stop(); ac.close()
237
+ if (signal.aborted) throw new Error('已取消')
238
+ const isMp4 = mime.startsWith('video/mp4')
239
+ return { blob: new Blob(chunks, { type: isMp4 ? 'video/mp4' : 'video/webm' }), ext: isMp4 ? 'mp4' : 'webm' }
240
+ }
earth-online-sun/web/src/swiss/SwissEdition.tsx ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { Play, Pause, Download, Copy, Check, Maximize, Loader2, X } from 'lucide-react'
3
+ import { drawFrame, DUR, SCENES } from './render'
4
+ import { buildSwissSoundtrack } from './audio'
5
+ import { loadSwissFonts } from './fonts'
6
+ import { exportCanvasMP4, canWebCodecs } from '../film/export'
7
+ import { swissCoverCanvas, SRATIO_PX, type SRatio } from './covers'
8
+ import { SPLATFORMS, type SPlatform } from './copy'
9
+
10
+ const BASE = import.meta.env.BASE_URL || './'
11
+ type Orient = 'land' | 'port'
12
+ const LOG = { land: [1920, 1080], port: [1080, 1920] } as const
13
+ const fmt = (t: number) => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, '0')}`
14
+ const len = (s: string) => [...s].length
15
+
16
+ let trackP: Promise<AudioBuffer> | null = null
17
+ const getTrack = (onP: (s: string, p: number) => void) => (trackP ??= buildSwissSoundtrack(onP).catch(e => { trackP = null; throw e }))
18
+
19
+ const ink = 'text-[#111]', mute = 'text-[#8A877F]'
20
+ const btn = 'px-3 py-1.5 border border-[#111] text-sm transition-colors'
21
+ const on = 'bg-[#111] text-[#EDECE8]'
22
+
23
+ function Label({ n, children }: { n: string; children: React.ReactNode }) {
24
+ return (
25
+ <div className="flex items-baseline gap-4 border-t border-[#111] pt-3">
26
+ <span className={`text-xs font-bold tabular-nums ${mute}`}>{n}</span>
27
+ <h2 className="text-xl sm:text-2xl font-black tracking-tight">{children}</h2>
28
+ </div>
29
+ )
30
+ }
31
+
32
+ // ---------------- player ----------------
33
+ function Player({ orient, setOrient }: { orient: Orient; setOrient: (o: Orient) => void }) {
34
+ const [LW, LH] = LOG[orient]
35
+ const canvas = useRef<HTMLCanvasElement>(null)
36
+ const box = useRef<HTMLDivElement>(null)
37
+ const [playing, setPlaying] = useState(false)
38
+ const [time, setTime] = useState(0)
39
+ const [loading, setLoading] = useState<string | null>(null)
40
+ const [fontsOk, setFontsOk] = useState(false)
41
+ const st = useRef({ ctx: null as AudioContext | null, buf: null as AudioBuffer | null, src: null as AudioBufferSourceNode | null, startAt: 0, offset: 0, playing: false })
42
+ useEffect(() => { loadSwissFonts().then(() => setFontsOk(true)) }, [])
43
+
44
+ const now = useCallback(() => {
45
+ const s = st.current
46
+ return s.playing && s.ctx ? Math.min(DUR, s.ctx.currentTime - s.startAt) : s.offset
47
+ }, [])
48
+ const stopSrc = () => { const s = st.current; if (s.src) { try { s.src.stop() } catch { /* */ } s.src.disconnect(); s.src = null } }
49
+
50
+ useEffect(() => {
51
+ const cv = canvas.current!
52
+ const g = cv.getContext('2d', { alpha: false })!
53
+ let raf = 0, last = -1, lw = 0
54
+ const loop = () => {
55
+ const dpr = Math.min(2, window.devicePixelRatio || 1)
56
+ const w = Math.max(320, Math.round(cv.clientWidth * dpr))
57
+ if (w !== lw) { cv.width = w; cv.height = Math.round(w * LH / LW); lw = w; last = -1 }
58
+ const t = now()
59
+ if (st.current.playing && t >= DUR - 0.01) { stopSrc(); st.current.offset = 0; st.current.playing = false; setPlaying(false) }
60
+ if (t !== last) {
61
+ g.setTransform(cv.width / LW, 0, 0, cv.height / LH, 0, 0)
62
+ drawFrame(g, t, orient === 'port'); last = t; setTime(t)
63
+ }
64
+ raf = requestAnimationFrame(loop)
65
+ }
66
+ raf = requestAnimationFrame(loop)
67
+ return () => cancelAnimationFrame(raf)
68
+ }, [LW, LH, orient, now, fontsOk])
69
+
70
+ const startSrc = (offset: number) => {
71
+ const s = st.current
72
+ if (!s.ctx || !s.buf) return
73
+ stopSrc()
74
+ const src = s.ctx.createBufferSource(); src.buffer = s.buf; src.connect(s.ctx.destination)
75
+ src.start(0, offset); s.src = src; s.startAt = s.ctx.currentTime - offset
76
+ }
77
+ const toggle = async () => {
78
+ const s = st.current
79
+ if (s.playing) { s.offset = now(); stopSrc(); s.playing = false; setPlaying(false); return }
80
+ if (!s.ctx) s.ctx = new AudioContext({ sampleRate: 48000 })
81
+ await s.ctx.resume()
82
+ if (!s.buf) {
83
+ setLoading('准备声音 0%')
84
+ try { s.buf = await getTrack((n, p) => setLoading(`准备${n} ${Math.round(p * 100)}%`)) } catch (e) { setLoading(null); alert('声音加载失败:' + (e as Error).message); return }
85
+ setLoading(null)
86
+ }
87
+ if (s.offset >= DUR - 0.05) s.offset = 0
88
+ startSrc(s.offset); s.playing = true; setPlaying(true)
89
+ }
90
+ const seek = (t: number) => {
91
+ const s = st.current
92
+ s.offset = Math.max(0, Math.min(DUR, t))
93
+ if (s.playing) startSrc(s.offset); else setTime(s.offset)
94
+ }
95
+ const full = () => { const el = box.current; if (!el) return; if (document.fullscreenElement) document.exitFullscreen(); else el.requestFullscreen?.() }
96
+ const cur = SCENES.findIndex(s => time < s.end)
97
+ const maxW = orient === 'land' ? 1100 : 440
98
+
99
+ return (
100
+ <div className="flex flex-col gap-3">
101
+ <div ref={box} className="relative mx-auto w-full bg-[#EDECE8] flex items-center justify-center shadow-[0_0_0_1px_#111]" style={{ maxWidth: maxW }}>
102
+ <canvas ref={canvas} onClick={toggle} className="w-full h-auto block cursor-pointer" style={{ aspectRatio: `${LW} / ${LH}`, maxHeight: '100vh', objectFit: 'contain' }} />
103
+ {!playing && !loading && (
104
+ <button onClick={toggle} aria-label="播放" className="absolute left-0 bottom-0 m-4 flex items-center gap-3 bg-[#111] text-[#EDECE8] pl-4 pr-5 py-3 hover:bg-[#E5231B] transition-colors">
105
+ <Play className="w-5 h-5" fill="currentColor" /><span className="text-sm font-bold tracking-wide">播放 · {fmt(DUR)}</span>
106
+ </button>
107
+ )}
108
+ {loading && <div className="absolute left-0 bottom-0 m-4 px-3 py-2 bg-[#111] text-[#EDECE8] text-sm flex items-center gap-2"><Loader2 className="w-4 h-4 animate-spin" />{loading}</div>}
109
+ </div>
110
+ <div className="mx-auto w-full flex items-center gap-3" style={{ maxWidth: maxW }}>
111
+ <button onClick={toggle} className="p-2 bg-[#111] text-[#EDECE8]" aria-label={playing ? '暂停' : '播放'}>{playing ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}</button>
112
+ <input type="range" min={0} max={DUR} step={0.05} value={time} onChange={e => seek(+e.target.value)} className="flex-1 accent-[#E5231B]" />
113
+ <span className={`text-xs tabular-nums w-20 text-right ${mute}`}>{fmt(time)} / {fmt(DUR)}</span>
114
+ <button onClick={full} className="p-2 border border-[#111]" aria-label="全屏"><Maximize className="w-4 h-4" /></button>
115
+ </div>
116
+ <div className="mx-auto w-full flex flex-col gap-2" style={{ maxWidth: maxW }}>
117
+ <div className="flex">
118
+ <button onClick={() => setOrient('land')} className={`${btn} ${orient === 'land' ? on : ''}`}>横屏 16:9</button>
119
+ <button onClick={() => setOrient('port')} className={`${btn} border-l-0 ${orient === 'port' ? on : ''}`}>竖屏 9:16</button>
120
+ </div>
121
+ <div className="grid grid-cols-4 sm:grid-cols-7 lg:grid-cols-13 gap-px bg-[#111] border border-[#111]" style={{ gridTemplateColumns: undefined }}>
122
+ {SCENES.map((s, i) => (
123
+ <button key={s.id} onClick={() => seek(s.start + 0.01)} className={`text-left px-2 py-1.5 ${i === cur ? 'bg-[#111] text-[#EDECE8]' : 'bg-[#EDECE8] hover:bg-[#DCDAD4]'}`}>
124
+ <span className={`block text-[10px] tabular-nums ${i === cur ? 'text-[#E5231B]' : mute}`}>{s.no}</span>
125
+ <span className="block text-xs font-bold truncate">{s.title}</span>
126
+ </button>
127
+ ))}
128
+ </div>
129
+ </div>
130
+ </div>
131
+ )
132
+ }
133
+
134
+ // ---------------- export ----------------
135
+ function Exporter({ orient }: { orient: Orient }) {
136
+ const [fps, setFps] = useState<24 | 30 | 60>(30)
137
+ const [o, setO] = useState<Orient>(orient)
138
+ const [busy, setBusy] = useState(false)
139
+ const [msg, setMsg] = useState('')
140
+ const [p, setP] = useState(0)
141
+ const [out, setOut] = useState<{ url: string; name: string; size: number } | null>(null)
142
+ const sig = useRef({ aborted: false })
143
+ useEffect(() => setO(orient), [orient])
144
+ const go = async () => {
145
+ setBusy(true); setOut(null); setP(0); sig.current = { aborted: false }
146
+ try {
147
+ await loadSwissFonts()
148
+ setMsg('准备声音…')
149
+ const audio = await getTrack((n, q) => setMsg(`准备${n} ${Math.round(q * 100)}%`))
150
+ const [w, h] = LOG[o]
151
+ const { blob, ext } = await exportCanvasMP4({
152
+ w, h, lw: w, lh: h, fps, dur: DUR, audio, signal: sig.current,
153
+ draw: (c, t) => drawFrame(c, t, o === 'port'),
154
+ onProgress: (q, m) => { setP(q); setMsg(m) },
155
+ })
156
+ const name = `十二条规则_孙宇晨访谈_${o === 'land' ? '横屏1920x1080' : '竖屏1080x1920'}_${fps}fps.${ext}`
157
+ const url = URL.createObjectURL(blob)
158
+ setOut({ url, name, size: blob.size })
159
+ const a = document.createElement('a'); a.href = url; a.download = name; a.click()
160
+ setMsg('完成')
161
+ } catch (e) { setMsg('导出中断:' + (e as Error).message) }
162
+ setBusy(false)
163
+ }
164
+ return (
165
+ <section className="flex flex-col gap-4">
166
+ <Label n="02">导出 MP4</Label>
167
+ <p className={`text-sm ${mute}`}>在你的浏览器里本地渲染,矢量画面直接输出 1080p。</p>
168
+ <div className="flex flex-wrap gap-4 items-center text-sm">
169
+ <div className="flex">
170
+ {(['land', 'port'] as Orient[]).map((k, i) => (
171
+ <button key={k} disabled={busy} onClick={() => setO(k)} className={`${btn} ${i ? 'border-l-0' : ''} ${o === k ? on : ''}`}>{k === 'land' ? '横屏 1920×1080' : '竖屏 1080×1920'}</button>
172
+ ))}
173
+ </div>
174
+ <div className="flex">
175
+ {([24, 30, 60] as const).map((f, i) => (
176
+ <button key={f} disabled={busy} onClick={() => setFps(f)} className={`${btn} ${i ? 'border-l-0' : ''} tabular-nums ${fps === f ? on : ''}`}>{f} fps</button>
177
+ ))}
178
+ </div>
179
+ </div>
180
+ <div className="flex gap-4 items-center">
181
+ {!busy
182
+ ? <button onClick={go} className="px-5 py-2.5 bg-[#E5231B] text-white font-bold flex items-center gap-2 hover:bg-[#111] transition-colors"><Download className="w-4 h-4" />开始导出</button>
183
+ : <button onClick={() => { sig.current.aborted = true }} className="px-5 py-2.5 border border-[#111] flex items-center gap-2"><X className="w-4 h-4" />取消</button>}
184
+ {out && <a href={out.url} download={out.name} className="text-sm underline underline-offset-4">再次下载({(out.size / 1048576).toFixed(1)} MB)</a>}
185
+ </div>
186
+ {(busy || msg) && (
187
+ <div className="flex flex-col gap-1">
188
+ <div className="h-1 bg-[#DCDAD4]"><div className="h-full bg-[#111]" style={{ width: `${p * 100}%` }} /></div>
189
+ <div className={`text-xs ${mute}`}>{msg}</div>
190
+ </div>
191
+ )}
192
+ <p className={`text-xs ${mute}`}>{canWebCodecs() ? '使用硬件编码快速导出。导出期间请保持本页在前台。' : '当前浏览器不支持快速导出,将以实时录制方式导出。推荐最新版 Chrome / Edge / Safari。'}</p>
193
+ </section>
194
+ )
195
+ }
196
+
197
+ // ---------------- covers + copy ----------------
198
+ function useCopy() {
199
+ const [k, setK] = useState('')
200
+ const copy = async (key: string, s: string) => {
201
+ try { await navigator.clipboard.writeText(s) } catch {
202
+ const ta = document.createElement('textarea'); ta.value = s; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove()
203
+ }
204
+ setK(key); setTimeout(() => setK(''), 1200)
205
+ }
206
+ return { k, copy }
207
+ }
208
+ function CopyBtn({ id, text, c }: { id: string; text: string; c: ReturnType<typeof useCopy> }) {
209
+ return <button onClick={() => c.copy(id, text)} className="shrink-0 px-2 py-1 border border-[#111] text-xs flex items-center gap-1 hover:bg-[#111] hover:text-[#EDECE8]">{c.k === id ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}{c.k === id ? '已复制' : '复制'}</button>
210
+ }
211
+ function Cover({ p, ratio, label, ready }: { p: SPlatform; ratio: SRatio; label: string; ready: boolean }) {
212
+ const [url, setUrl] = useState('')
213
+ useEffect(() => { if (ready) setUrl(swissCoverCanvas(ratio, p.cover).toDataURL('image/jpeg', 0.94)) }, [p, ratio, ready])
214
+ const [w, h] = SRATIO_PX[ratio]
215
+ return (
216
+ <figure className="flex flex-col gap-1">
217
+ {url ? <img src={url} alt={`${p.name} ${label}`} className="w-full shadow-[0_0_0_1px_#BFBCB4]" style={{ aspectRatio: `${w}/${h}` }} /> : <div style={{ aspectRatio: `${w}/${h}` }} className="bg-[#DCDAD4]" />}
218
+ <figcaption className={`flex items-center justify-between text-xs ${mute}`}>
219
+ <span>{label} · {w}×{h}</span>
220
+ {url && <a href={url} download={`${p.name}_封面_瑞士_${ratio.replace(':', 'x')}.jpg`} className="flex items-center gap-1 text-[#111] font-bold"><Download className="w-3 h-3" />下载</a>}
221
+ </figcaption>
222
+ </figure>
223
+ )
224
+ }
225
+ function PlatformCard({ p, ready }: { p: SPlatform; ready: boolean }) {
226
+ const c = useCopy()
227
+ const topics = p.topics.map(t => `#${t}`).join(' ')
228
+ const tl = len(p.title)
229
+ const all = `${p.title}\n\n${p.desc}\n\n${topics}`
230
+ return (
231
+ <div className="border-t-2 border-[#111] pt-3 flex flex-col gap-3">
232
+ <div className="flex items-center justify-between">
233
+ <h3 className="font-black text-lg">{p.name}</h3>
234
+ <CopyBtn id="all" text={all} c={c} />
235
+ </div>
236
+ <div className={`grid gap-3 ${p.covers.some(x => x.ratio === '3:4') ? 'grid-cols-[3fr_4fr]' : 'grid-cols-[4fr_5.33fr]'} items-start`}>
237
+ {p.covers.map(x => <Cover key={x.ratio} p={p} ratio={x.ratio} label={x.label} ready={ready} />)}
238
+ </div>
239
+ <div className="flex flex-col gap-1">
240
+ <div className={`flex items-center justify-between text-xs ${mute}`}><span>标题</span><span className={tl > p.titleMax ? 'text-[#E5231B]' : ''}>{tl}/{p.titleMax} 字</span></div>
241
+ <div className="flex gap-2 items-start"><p className="flex-1 text-sm font-bold bg-[#F7F6F3] p-2">{p.title}</p><CopyBtn id="t" text={p.title} c={c} /></div>
242
+ </div>
243
+ <div className="flex flex-col gap-1">
244
+ <div className={`text-xs ${mute}`}>描述</div>
245
+ <div className="flex gap-2 items-start"><p className="flex-1 text-sm bg-[#F7F6F3] p-2 whitespace-pre-line max-h-40 overflow-auto">{p.desc}</p><CopyBtn id="d" text={p.desc} c={c} /></div>
246
+ </div>
247
+ <div className="flex flex-col gap-1">
248
+ <div className={`flex items-center justify-between text-xs ${mute}`}><span>话题</span><span>{p.topics.length}{p.topicMax ? `/${p.topicMax}` : ' 个(不限)'}</span></div>
249
+ <div className="flex gap-2 items-start"><p className="flex-1 text-sm bg-[#F7F6F3] p-2">{topics}</p><CopyBtn id="g" text={topics} c={c} /></div>
250
+ </div>
251
+ </div>
252
+ )
253
+ }
254
+
255
+ // ---------------- live photos ----------------
256
+ interface LiveItem { name: string; title: string; caption?: string; jpg: string; mp4?: string }
257
+ function LivePhotos() {
258
+ const [items, setItems] = useState<LiveItem[] | null>(null)
259
+ useEffect(() => { fetch(`${BASE}swiss/live/manifest.json`).then(r => (r.ok ? r.json() : [])).then(setItems).catch(() => setItems([])) }, [])
260
+ return (
261
+ <section className="flex flex-col gap-4">
262
+ <Label n="04">实况图 · Live Photo</Label>
263
+ <div className="flex flex-wrap items-center justify-between gap-2">
264
+ <p className={`text-sm ${mute}`}>每组为同名 .jpg + .mov(3:4,1080×1440)。导入 iPhone 相册即为实况照片,可直接发小红书 / 抖音图文。</p>
265
+ <a href={`${BASE}swiss/livephotos.zip`} download className="px-5 py-2.5 bg-[#111] text-[#EDECE8] font-bold text-sm flex items-center gap-2 hover:bg-[#E5231B] transition-colors"><Download className="w-4 h-4" />下载全部(zip)</a>
266
+ </div>
267
+ {items === null ? <div className={`text-sm ${mute}`}>加载中…</div> : (
268
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
269
+ {items.map(it => (
270
+ <figure key={it.name} className="flex flex-col gap-1">
271
+ {it.mp4
272
+ ? <video src={`${BASE}swiss/live/${it.mp4}`} poster={`${BASE}swiss/live/${it.jpg}`} muted loop playsInline autoPlay className="w-full shadow-[0_0_0_1px_#BFBCB4]" style={{ aspectRatio: '3/4' }} />
273
+ : <img src={`${BASE}swiss/live/${it.jpg}`} alt={it.title} className="w-full" style={{ aspectRatio: '3/4' }} />}
274
+ <figcaption className="text-xs leading-snug font-bold">{it.title}{it.caption && <span className={`block font-normal ${mute}`}>{it.caption}</span>}</figcaption>
275
+ </figure>
276
+ ))}
277
+ </div>
278
+ )}
279
+ </section>
280
+ )
281
+ }
282
+
283
+ export default function SwissEdition() {
284
+ const [orient, setOrient] = useState<Orient>('land')
285
+ const [ready, setReady] = useState(false)
286
+ useEffect(() => { if (window.innerHeight > window.innerWidth) setOrient('port'); loadSwissFonts().then(() => setReady(true)) }, [])
287
+ return (
288
+ <div className={`min-h-screen bg-[#EDECE8] ${ink}`} style={{ fontFamily: 'SwissSC, "PingFang SC", "Noto Sans SC", sans-serif' }}>
289
+ <main className="mx-auto max-w-6xl px-4 sm:px-8 py-6 sm:py-10 flex flex-col gap-10">
290
+ <header className="grid grid-cols-12 gap-x-6 gap-y-2 items-end">
291
+ <div className="col-span-12 flex justify-between text-xs border-b border-[#111] pb-2">
292
+ <span className="font-bold">孙宇晨 · 2026 长访谈</span><span className={mute}>N° 01—12 · {fmt(DUR)}</span>
293
+ </div>
294
+ <div className="col-span-12 sm:col-span-7 flex items-end gap-4">
295
+ <span className="text-[88px] sm:text-[140px] leading-[0.8] font-black tracking-[-0.04em]">12</span>
296
+ <span className="w-10 h-10 sm:w-14 sm:h-14 bg-[#E5231B] mb-1 shrink-0" />
297
+ </div>
298
+ <div className="col-span-12 sm:col-span-5 flex flex-col gap-1">
299
+ <h1 className="text-3xl sm:text-4xl font-black tracking-tight">十二条规则</h1>
300
+ <p className={`text-sm ${mute}`}>瑞士国际主义版式 · 12 栏网格 · 112 BPM · 旁白 + 原创配乐</p>
301
+ </div>
302
+ </header>
303
+ <section className="flex flex-col gap-4">
304
+ <Label n="01">影片</Label>
305
+ <Player orient={orient} setOrient={setOrient} />
306
+ </section>
307
+ <Exporter orient={orient} />
308
+ <section className="flex flex-col gap-4">
309
+ <Label n="03">发布文案与封面</Label>
310
+ <div className="grid md:grid-cols-2 gap-x-8 gap-y-10">{SPLATFORMS.map(p => <PlatformCard key={p.id} p={p} ready={ready} />)}</div>
311
+ </section>
312
+ <LivePhotos />
313
+ <footer className={`text-xs ${mute} pb-16 flex flex-col gap-1 border-t border-[#111] pt-3`}>
314
+ <p>本片为访谈观点整理与解读,不构成任何投资建议。访谈:孙宇晨 × 邵艾伦(文字整理:律动BlockBeats)。</p>
315
+ <p>画面与音乐由程序实时生成;旁白为 AI 合成;字体 Noto Sans SC(SIL OFL 1.1);地图数据 Natural Earth(公有领域)。</p>
316
+ </footer>
317
+ </main>
318
+ </div>
319
+ )
320
+ }
earth-online-sun/web/src/swiss/audio.ts ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 《规则 RULES》soundtrack — a mechanical piano/marimba ostinato at 112 BPM, one layer per rule,
2
+ // print-room foley (cuts, stamps, slides) and narration, mixed offline into one stereo buffer.
3
+ import { SCENES, LINES, DUR, BEAT, BAR } from './timeline'
4
+ import { allCues } from './render'
5
+
6
+ export const SR = 48000
7
+ const hz = (m: number) => 440 * Math.pow(2, (m - 69) / 12)
8
+ // Am(add9) · Fmaj7 · Cmaj7 · G(add6) — one bar each
9
+ const CHORDS = [[57, 60, 64, 71], [53, 57, 60, 64], [48, 52, 55, 59], [55, 59, 62, 64]]
10
+ const ROOTS = [33, 29, 36, 31]
11
+ const ORDER = [0, 2, 1, 3, 2, 1, 3, 2]
12
+
13
+ class Mix {
14
+ L: Float32Array; R: Float32Array; n: number
15
+ constructor(n: number) { this.n = n; this.L = new Float32Array(n); this.R = new Float32Array(n) }
16
+ add(t: number, d: Float32Array, g: number, pan = 0) {
17
+ const o = Math.round(t * SR), gl = g * Math.cos((pan + 1) * Math.PI / 4), gr = g * Math.sin((pan + 1) * Math.PI / 4)
18
+ for (let k = 0; k < d.length; k++) { const i = o + k; if (i < 0) continue; if (i >= this.n) break; this.L[i] += d[k] * gl; this.R[i] += d[k] * gr }
19
+ }
20
+ }
21
+ const buf = (sec: number) => new Float32Array(Math.round(sec * SR))
22
+ let seed = 7
23
+ const noise = () => { seed = (seed * 1664525 + 1013904223) | 0; return (seed >>> 0) / 2147483648 - 1 }
24
+
25
+ // ---------- instruments (cached per pitch) ----------
26
+ const cache = new Map<string, Float32Array>()
27
+ function memo(key: string, f: () => Float32Array) { let b = cache.get(key); if (!b) { b = f(); cache.set(key, b) } return b }
28
+ const piano = (m: number) => memo('p' + m, () => {
29
+ const f = hz(m), d = buf(1.4), amp = [1, 0.45, 0.22, 0.1, 0.05]
30
+ for (let i = 0; i < d.length; i++) {
31
+ const t = i / SR, a = Math.min(1, t / 0.003)
32
+ let s = 0
33
+ for (let k = 0; k < amp.length; k++) s += amp[k] * Math.sin(2 * Math.PI * f * (k + 1) * (1 + 0.0004 * k * k) * t) * Math.exp(-t * (2.6 + k * 1.8))
34
+ d[i] = s * a * 0.32
35
+ }
36
+ return d
37
+ })
38
+ const marimba = (m: number) => memo('m' + m, () => {
39
+ const f = hz(m), d = buf(0.6)
40
+ for (let i = 0; i < d.length; i++) { const t = i / SR; d[i] = (Math.sin(2 * Math.PI * f * t) * Math.exp(-t * 7) + 0.35 * Math.sin(2 * Math.PI * f * 3.93 * t) * Math.exp(-t * 26)) * Math.min(1, t / 0.002) * 0.35 }
41
+ return d
42
+ })
43
+ const bassN = (m: number) => memo('b' + m, () => {
44
+ const f = hz(m), d = buf(BEAT * 1.9)
45
+ for (let i = 0; i < d.length; i++) { const t = i / SR; d[i] = (Math.sin(2 * Math.PI * f * t) + 0.25 * Math.sin(4 * Math.PI * f * t)) * Math.min(1, t / 0.006) * Math.exp(-t * 2.2) * 0.5 }
46
+ return d
47
+ })
48
+ const padN = (ch: number[]) => memo('pad' + ch.join(), () => {
49
+ const d = buf(BAR + 0.6)
50
+ for (let i = 0; i < d.length; i++) {
51
+ const t = i / SR, env = Math.min(1, t / 0.7) * (t > BAR ? Math.max(0, 1 - (t - BAR) / 0.6) : 1)
52
+ let s = 0
53
+ for (const m of ch) { const f = hz(m + 12); s += Math.sin(2 * Math.PI * f * t) + Math.sin(2 * Math.PI * f * 1.003 * t + 1) }
54
+ d[i] = s * env * 0.045
55
+ }
56
+ return d
57
+ })
58
+ const kick = memo('kick', () => { const d = buf(0.4); let ph = 0; for (let i = 0; i < d.length; i++) { const t = i / SR; ph += 2 * Math.PI * (45 + 80 * Math.exp(-t * 28)) / SR; d[i] = Math.sin(ph) * Math.exp(-t * 9) * 0.9 } return d })
59
+ const hat = memo('hat', () => { const d = buf(0.07); let p = 0; for (let i = 0; i < d.length; i++) { const t = i / SR, n = noise(); d[i] = (n - p) * Math.exp(-t * 60) * 0.18; p = n } return d })
60
+ const rim = memo('rim', () => { const d = buf(0.05); for (let i = 0; i < d.length; i++) { const t = i / SR; d[i] = (Math.sin(2 * Math.PI * 1750 * t) * 0.6 + noise() * 0.4) * Math.exp(-t * 90) * 0.35 } return d })
61
+
62
+ // ---------- foley ----------
63
+ function foley(id: string): Float32Array {
64
+ return memo('fx' + id, () => {
65
+ let d: Float32Array
66
+ switch (id) {
67
+ case 'clack': d = buf(0.09); for (let i = 0; i < d.length; i++) { const t = i / SR; d[i] = (noise() * 0.7 + Math.sin(2 * Math.PI * 2100 * t) * 0.5) * Math.exp(-t * 70) * 0.5 } break
68
+ case 'tss': { d = buf(0.22); let lp = 0; for (let i = 0; i < d.length; i++) { const t = i / SR, n = noise(); lp += 0.5 * (n - lp); d[i] = (n - lp) * Math.sin(Math.PI * t / 0.22) * 0.06 } break }
69
+ case 'snick': { d = buf(0.16); let p = 0; for (let i = 0; i < d.length; i++) { const t = i / SR, n = noise(); const hi = n - p; p = n; d[i] = (hi * Math.exp(-t * 30) * 0.35 + Math.sin(2 * Math.PI * (3200 + 4000 * t) * t) * Math.exp(-t * 50) * 0.25) } break }
70
+ case 'slide': { d = buf(0.5); let lp = 0; for (let i = 0; i < d.length; i++) { const t = i / SR, a = 0.04 + 0.25 * Math.sin(Math.PI * t / 0.5); lp += a * (noise() - lp); d[i] = lp * Math.sin(Math.PI * t / 0.5) * 0.5 } break }
71
+ case 'thunk': d = buf(0.3); for (let i = 0; i < d.length; i++) { const t = i / SR; d[i] = (Math.sin(2 * Math.PI * 82 * t) * Math.exp(-t * 14) * 0.7 + noise() * Math.exp(-t * 120) * 0.25) } break
72
+ case 'stamp': d = buf(0.45); for (let i = 0; i < d.length; i++) { const t = i / SR; d[i] = (Math.sin(2 * Math.PI * 58 * t) * Math.exp(-t * 8) * 0.8 + noise() * Math.exp(-t * 60) * 0.35) } break
73
+ case 'tick': d = buf(0.02); for (let i = 0; i < d.length; i++) { const t = i / SR; d[i] = Math.sin(2 * Math.PI * 1200 * t) * Math.exp(-t * 200) * 0.35 } break
74
+ case 'glide': { d = buf(1.0); let ph = 0; for (let i = 0; i < d.length; i++) { const t = i / SR; ph += 2 * Math.PI * (440 + 440 * t) / SR; d[i] = Math.sin(ph) * Math.sin(Math.PI * t) * 0.07 } break }
75
+ default: d = buf(0.01)
76
+ }
77
+ return d
78
+ })
79
+ }
80
+
81
+ // ---------- score ----------
82
+ function writeMusic(mx: Mix) {
83
+ const idx = (id: string) => SCENES.findIndex(s => s.id === id)
84
+ const S7 = SCENES[idx('r07')], S12 = SCENES[idx('r12')]
85
+ const silenceA = S7.lines[2].start, silenceB = S7.end
86
+ const dropA = S7.lines[2].start - BAR
87
+ const level = (t: number) => { // how many layers are active at time t (0 = piano only)
88
+ const i = SCENES.findIndex(s => t < s.end)
89
+ return i < 0 ? 0 : i
90
+ }
91
+ const start = 0.4
92
+ const bars = Math.ceil(DUR / BAR)
93
+ for (let b = 0; b < bars; b++) {
94
+ const t0 = start + b * BAR
95
+ if (t0 >= DUR) break
96
+ const ci = b % 4, ch = CHORDS[ci]
97
+ for (let e = 0; e < 8; e++) {
98
+ const t = t0 + e * BEAT / 2
99
+ if (t >= DUR - 0.2) continue
100
+ if (t >= silenceA && t < silenceB) continue
101
+ const lv = level(t)
102
+ const final = t >= S12.start
103
+ const m = ch[ORDER[e]] + 12
104
+ // piano ostinato (always)
105
+ mx.add(t, piano(m), e % 2 ? 0.55 : 0.75, e % 2 ? 0.25 : -0.25)
106
+ if (final) continue
107
+ const drums = lv >= 1 && !(t >= dropA && t < silenceB)
108
+ if (drums && e % 2 === 0) mx.add(t, kick, e === 0 || e === 4 ? 0.85 : 0.0)
109
+ if (lv >= 2 && drums && e % 2 === 1) mx.add(t, hat, 0.9, 0.4)
110
+ if (lv >= 3 && e % 2 === 0) mx.add(t, bassN(ROOTS[ci] + (e === 6 ? 7 : 0)), e === 0 ? 0.85 : 0.6)
111
+ if (lv >= 4 && (e === 3 || e === 7)) mx.add(t + BEAT / 4, marimba(ch[(e + b) % 4] + 24), 0.45, 0.5)
112
+ if (lv >= 5 && drums && (e === 2 || e === 6)) mx.add(t, rim, 0.8, -0.4)
113
+ if (lv >= 6 && e === 0 && !(t >= dropA && t < silenceB)) mx.add(t, padN(ch), 1)
114
+ }
115
+ }
116
+ // final chord, let it ring
117
+ const fin = S12.lines[2].start + S12.lines[2].dur + 0.4
118
+ const qf = Math.ceil((fin - start) / BEAT) * BEAT + start
119
+ for (const m of [45, 57, 60, 64, 71, 76]) mx.add(qf, piano(m), 0.55)
120
+ }
121
+
122
+ // ---------- VO ----------
123
+ const base = () => (import.meta as unknown as { env?: { BASE_URL?: string } }).env?.BASE_URL ?? './'
124
+ async function loadVO(onProgress?: (p: number) => void) {
125
+ let done = 0
126
+ const ctx = new OfflineAudioContext(1, 1, SR)
127
+ return Promise.all(LINES.map(async l => {
128
+ const ab = await (await fetch(`${base()}swiss/vo/${String(l.i).padStart(2, '0')}.mp3`)).arrayBuffer()
129
+ const b = await ctx.decodeAudioData(ab)
130
+ onProgress?.(++done / LINES.length)
131
+ return b.getChannelData(0)
132
+ }))
133
+ }
134
+ const tick = () => new Promise(r => setTimeout(r, 0))
135
+
136
+ export async function buildSwissSoundtrack(onProgress?: (stage: string, p: number) => void): Promise<AudioBuffer> {
137
+ const len = Math.ceil(DUR * SR)
138
+ onProgress?.('旁白', 0)
139
+ const vo = await loadVO(p => onProgress?.('旁白', p))
140
+ onProgress?.('音乐', 0.3); await tick()
141
+ const music = new Mix(len), fx = new Mix(len)
142
+ writeMusic(music)
143
+ onProgress?.('音效', 0.6); await tick()
144
+ for (const [t, id, g] of allCues()) fx.add(t, foley(id), g ?? 1, 0)
145
+ const voB = new Float32Array(len)
146
+ LINES.forEach((l, i) => { const d = vo[i], o = Math.round(l.start * SR); for (let k = 0; k < d.length && o + k < len; k++) voB[o + k] += d[k] })
147
+ onProgress?.('混音', 0.85); await tick()
148
+ // duck music ~-10 dB under narration
149
+ const BASE = 0.6, DUCK = 0.32
150
+ const duck = new Float32Array(len).fill(1)
151
+ for (const l of LINES) {
152
+ const a = Math.round((l.start - 0.12) * SR), b = Math.round(l.start * SR), c = Math.round(l.end * SR), d = Math.round((l.end + 0.4) * SR)
153
+ for (let i = Math.max(0, a); i < Math.min(len, d); i++) {
154
+ const g = i < b ? 1 - (1 - DUCK) * (i - a) / (b - a) : i < c ? DUCK : DUCK + (1 - DUCK) * (i - c) / (d - c)
155
+ if (g < duck[i]) duck[i] = g
156
+ }
157
+ }
158
+ const L = new Float32Array(len), R = new Float32Array(len)
159
+ let hp = 0
160
+ const aH = 1 - Math.exp(-2 * Math.PI * 90 / SR)
161
+ for (let i = 0; i < len; i++) {
162
+ hp += aH * (voB[i] - hp)
163
+ const v = (voB[i] - hp) * 1.05, m = BASE * duck[i]
164
+ L[i] = music.L[i] * m + fx.L[i] * 0.8 + v
165
+ R[i] = music.R[i] * m + fx.R[i] * 0.8 + v
166
+ }
167
+ // fade out last 1.2 s, then limiter at -1 dBFS
168
+ const fo = Math.round(1.2 * SR)
169
+ for (let i = len - fo; i < len; i++) { const g = (len - i) / fo; L[i] *= g; R[i] *= g }
170
+ const TH = 0.89, rel = Math.exp(-1 / (0.08 * SR))
171
+ let env = 0
172
+ for (let i = 0; i < len; i++) {
173
+ const pk = Math.max(Math.abs(L[i]), Math.abs(R[i]))
174
+ env = pk > env ? pk : env * rel + pk * (1 - rel)
175
+ const g = env > TH ? TH / env : 1
176
+ L[i] *= g; R[i] *= g
177
+ }
178
+ const out = new AudioBuffer({ length: len, numberOfChannels: 2, sampleRate: SR })
179
+ out.copyToChannel(L, 0); out.copyToChannel(R, 1)
180
+ onProgress?.('完成', 1)
181
+ return out
182
+ }
earth-online-sun/web/src/swiss/copy.ts ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Swiss-edition copy per platform (titles / descriptions / topics within each platform's limits).
2
+ import type { SRatio, SCoverText } from './covers'
3
+
4
+ export interface SPlatform {
5
+ id: string; name: string
6
+ titleMax: number; topicMax: number | null
7
+ covers: { ratio: SRatio; label: string }[]
8
+ title: string; desc: string; topics: string[]
9
+ cover: SCoverText
10
+ }
11
+
12
+ const RULES = `01 认知决定一切
13
+ 02 先做三件零成本的事:孙学、薄肌、黄毛
14
+ 03 把日常交给 AI 托管:九成交给 AI,一成自己微调
15
+ 04 寿命是唯一的硬通货
16
+ 05 先开图,再决策:看一百套,前三套只做基准
17
+ 06 在全球寻找商机
18
+ 07 把货币翻译成机器的语言
19
+ 08 世界只会向前更新,版本不能回滚
20
+ 09 没有智商,只有信息量
21
+ 10 先自由,后财富
22
+ 11 体验大于意义
23
+ 12 成为正确答案之前,没有人知道自己是正确的`
24
+
25
+ const FOOT = `访谈:孙宇晨 × 邵艾伦(文字整理:律动BlockBeats)
26
+ 本片为访谈观点整理与解读,不构成任何投资建议。`
27
+
28
+ export const SPLATFORMS: SPlatform[] = [
29
+ {
30
+ id: 'xhs', name: '小红书', titleMax: 20, topicMax: 10,
31
+ covers: [{ ratio: '3:4', label: '竖版 3:4' }, { ratio: '4:3', label: '横版 4:3' }],
32
+ title: '孙宇晨的十二条人生规则',
33
+ desc: `2016 年他写下《财富自由之路》,2026 年的长访谈里,他交出了新的答案。
34
+ 用一张网格、一个红色方块,把它整理成十二条规则:
35
+
36
+ ${RULES}
37
+
38
+ ${FOOT}`,
39
+ topics: ['孙宇晨', '财富自由', '认知升级', '人生规则', '个人成长', 'AI', '访谈解读', '平面设计', '瑞士风格', '自我提升'],
40
+ cover: { kicker: 'N° 01—12', title: '十二条规则', sub: '认知 · 寿命 · 信息量', list: [['01', '认知决定一切'], ['04', '寿命是硬通货'], ['11', '体验大于意义']] },
41
+ },
42
+ {
43
+ id: 'douyin', name: '抖音', titleMax: 30, topicMax: 5,
44
+ covers: [{ ratio: '3:4', label: '竖版 3:4' }, { ratio: '4:3', label: '横版 4:3' }],
45
+ title: '孙宇晨2026长访谈:十二条规则,一条比一条清醒',
46
+ desc: `认知决定一切|寿命是唯一的硬通货|先开图再决策|版本不能回滚|体验大于意义。
47
+ 两分钟,十二条。
48
+ 本片为访谈观点整理与解读,不构成任何投资建议。`,
49
+ topics: ['孙宇晨', '财富自由', '认知', '人生建议', 'AI'],
50
+ cover: { kicker: 'N° 01—12', title: '十二条规则', sub: '一条比一条清醒', list: [['05', '先开图再决策'], ['08', '只会向前更新'], ['10', '先自由后财富']] },
51
+ },
52
+ {
53
+ id: 'bili', name: 'B站', titleMax: 80, topicMax: 10,
54
+ covers: [{ ratio: '4:3', label: '视频封面 4:3' }, { ratio: '16:9', label: '个人空间 16:9' }],
55
+ title: '【瑞士排版】孙宇晨2026长访谈整理:十二条规则——认知、寿命、开图、全球商机与small ego',
56
+ desc: `一部用瑞士国际主义版式做的观点整理:12 栏网格、一套字体、一个红色方块,112 BPM。
57
+
58
+ ${RULES}
59
+
60
+ 画面与音乐均由程序原创生成;旁白为 AI 合成;字体 Noto Sans SC(SIL OFL 1.1)。
61
+ ${FOOT}`,
62
+ topics: ['孙宇晨', '访谈', '财富自由', '人工智能', '认知', '平面设计', '瑞士风格', '动态排版', '知识分享', '人生'],
63
+ cover: { kicker: 'N° 01—12', title: '十二条规则', sub: '孙宇晨 2026 长访谈 · 观点整理', list: [['01', '认知决定一切'], ['07', '货币的翻译'], ['12', '正确答案']] },
64
+ },
65
+ {
66
+ id: 'wx', name: '视频号', titleMax: 16, topicMax: null,
67
+ covers: [{ ratio: '3:4', label: '个人主页卡片 3:4' }, { ratio: '4:3', label: '分享卡片 4:3' }],
68
+ title: '孙宇晨的十二条规则',
69
+ desc: `认知决定一切,寿命是硬通货,先自由后财富,体验大于意义。
70
+ 本片为访谈观点整理与解读,不构成任何投资建议。`,
71
+ topics: ['孙宇晨', '财富自由', '认知升级', '人生规则', 'AI', '访谈', '个人成长', '终身学习', '平面设计'],
72
+ cover: { kicker: 'N° 01—12', title: '十二条规则', sub: '孙宇晨 2026 长访谈', list: [['01', '认知决定一切'], ['04', '寿命是硬通货'], ['10', '先自由后财富']] },
73
+ },
74
+ ]
earth-online-sun/web/src/swiss/covers.ts ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Swiss-edition covers: grid, one giant numeral, one red rectangle, nothing else.
2
+ import { PAPER, INK, G2, G3, RED, T, measure, box, hline } from './kit'
3
+
4
+ export type SRatio = '4:3' | '3:4' | '16:9'
5
+ export const SRATIO_PX: Record<SRatio, [number, number]> = { '4:3': [1440, 1080], '3:4': [1080, 1440], '16:9': [1920, 1080] }
6
+ export interface SCoverText { kicker: string; title: string; sub: string; list: [string, string][] }
7
+
8
+ export function drawSwissCover(c: CanvasRenderingContext2D, r: SRatio, v: SCoverText) {
9
+ const [W, H] = SRATIO_PX[r]
10
+ const por = r === '3:4'
11
+ const cols = por ? 6 : 12, m = por ? 72 : r === '16:9' ? 96 : 72, g = 24
12
+ const cw = (W - 2 * m - (cols - 1) * g) / cols
13
+ const X = (i: number) => m + i * (cw + g)
14
+ c.fillStyle = PAPER; c.fillRect(0, 0, W, H)
15
+ for (let i = 0; i < cols; i++) { box(c, X(i), 0, 1, H, '#E3E1DB'); box(c, X(i) + cw - 1, 0, 1, H, '#E3E1DB') }
16
+ // running head
17
+ T(c, '孙宇晨 · 2026 长访谈', m, 40, 22, 700, INK)
18
+ T(c, v.kicker, W - m, 40, 22, 500, INK, 'right')
19
+ hline(c, m, 88, W - 2 * m, 1, INK)
20
+ if (r === '16:9') {
21
+ T(c, '12', m - 14, 130, 640, 900, INK)
22
+ box(c, X(4) + 40, 150, 150, 150, RED)
23
+ const tx = X(6)
24
+ T(c, v.title, tx, 150, 128, 900, INK)
25
+ T(c, v.sub, tx, 320, 36, 500, INK)
26
+ v.list.forEach(([n, s], i) => {
27
+ const y = 470 + i * 112
28
+ hline(c, tx, y, W - m - tx, 1, i === 0 ? INK : G2)
29
+ T(c, n, tx, y + 22, 22, 700, G3)
30
+ T(c, s, tx + 80, y + 18, 48, 900, INK)
31
+ })
32
+ hline(c, m, H - 72, W - 2 * m, 1, G2)
33
+ T(c, '十二条规则 · 观点整理', m, H - 56, 18, 500, G3)
34
+ T(c, '不构成任何投资建议', W - m, H - 56, 18, 500, G3, 'right')
35
+ return
36
+ }
37
+ const big = por ? 600 : 520
38
+ T(c, '12', m - 12, 128, big, 900, INK)
39
+ const rs = por ? 170 : 150
40
+ box(c, W - m - rs, 128, rs, rs, RED)
41
+ const ty = 128 + big * 0.735 + (por ? 70 : 60)
42
+ let ts = por ? 130 : 120
43
+ while (measure(c, v.title, ts, 900) > W - 2 * m && ts > 60) ts -= 4
44
+ T(c, v.title, m, ty, ts, 900, INK)
45
+ T(c, v.sub, m, ty + ts + 34, por ? 38 : 34, 500, INK)
46
+ if (por) {
47
+ const ly = ty + ts + 140
48
+ v.list.forEach(([n, s], i) => {
49
+ const y = ly + i * 112
50
+ hline(c, m, y, W - 2 * m, 1, i === 0 ? INK : G2)
51
+ T(c, n, m, y + 24, 22, 700, G3)
52
+ T(c, s, m + 90, y + 20, 52, 900, INK)
53
+ })
54
+ } else {
55
+ const ly = ty + ts + 120, w = (W - 2 * m - 2 * g) / 3
56
+ v.list.forEach(([n, s], i) => {
57
+ const x = m + i * (w + g)
58
+ hline(c, x, ly, w, 1, INK, 2)
59
+ T(c, n, x, ly + 18, 20, 700, G3)
60
+ T(c, s, x, ly + 52, 40, 900, INK)
61
+ })
62
+ }
63
+ hline(c, m, H - 72, W - 2 * m, 1, G2)
64
+ T(c, '十二条规则 · 观点整理', m, H - 56, 18, 500, G3)
65
+ T(c, '不构成任何投资建议', W - m, H - 56, 18, 500, G3, 'right')
66
+ }
67
+
68
+ export function swissCoverCanvas(r: SRatio, v: SCoverText) {
69
+ const [W, H] = SRATIO_PX[r]
70
+ const cv = document.createElement('canvas'); cv.width = W; cv.height = H
71
+ drawSwissCover(cv.getContext('2d')!, r, v)
72
+ return cv
73
+ }
earth-online-sun/web/src/swiss/fonts.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Load the subset Noto Sans SC weights used by the Swiss edition (canvas text needs them ready before drawing).
2
+ const BASE = import.meta.env.BASE_URL || './'
3
+ let ready: Promise<void> | null = null
4
+ export function loadSwissFonts(): Promise<void> {
5
+ if (ready) return ready
6
+ ready = (async () => {
7
+ const ws = [400, 500, 700, 900]
8
+ await Promise.all(ws.map(async w => {
9
+ try {
10
+ const f = new FontFace('SwissSC', `url(${BASE}swiss/fonts/sc-${w}.woff2)`, { weight: String(w) })
11
+ await f.load(); document.fonts.add(f)
12
+ } catch { /* fall back to system CJK */ }
13
+ }))
14
+ })()
15
+ return ready
16
+ }
earth-online-sun/web/src/swiss/kit.ts ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Swiss kit: palette, the two motion curves, the modular grid and typographic primitives.
2
+ export const PAPER = '#EDECE8', SHEET = '#F7F6F3', INK = '#111111', G1 = '#DCDAD4', G2 = '#BFBCB4', G3 = '#8A877F', RED = '#E5231B'
3
+ export const FONT = 'SwissSC'
4
+
5
+ // ---------- curves ----------
6
+ export const clamp = (v: number, a = 0, b = 1) => (v < a ? a : v > b ? b : v)
7
+ export const lerp = (a: number, b: number, t: number) => a + (b - a) * t
8
+ /** cubic-bezier(.7,0,.2,1) — the only easing in the film besides linear */
9
+ export function ease(x: number) {
10
+ x = clamp(x)
11
+ if (x === 0 || x === 1) return x
12
+ const x1 = 0.7, y1 = 0, x2 = 0.2, y2 = 1
13
+ const bx = (t: number) => 3 * (1 - t) * (1 - t) * t * x1 + 3 * (1 - t) * t * t * x2 + t * t * t
14
+ const by = (t: number) => 3 * (1 - t) * (1 - t) * t * y1 + 3 * (1 - t) * t * t * y2 + t * t * t
15
+ let lo = 0, hi = 1, t = x
16
+ for (let i = 0; i < 22; i++) { t = (lo + hi) / 2; if (bx(t) < x) lo = t; else hi = t }
17
+ return by(t)
18
+ }
19
+ export const prog = (t: number, a: number, d: number) => clamp((t - a) / d)
20
+ /** eased snap from 0→1 that *arrives* at time `arrive` */
21
+ export const snap = (t: number, arrive: number, d = 0.45) => ease(prog(t, arrive - d, d))
22
+ /** stepped climb: n eased steps (a numeral rising one module at a time) */
23
+ export function steps(p: number, n: number) {
24
+ p = clamp(p) * n
25
+ const k = Math.floor(p)
26
+ return Math.min(1, (k + ease(p - k)) / n)
27
+ }
28
+ export function hash(n: number) { n = Math.imul(n ^ 0x9e3779b9, 0x85ebca6b); n ^= n >>> 13; n = Math.imul(n, 0xc2b2ae35); n ^= n >>> 16; return (n >>> 0) / 4294967296 }
29
+
30
+ // ---------- grid ----------
31
+ export interface Rect { x: number; y: number; w: number; h: number }
32
+ export interface Layout {
33
+ W: number; H: number; por: boolean
34
+ m: number; g: number; cols: number; cw: number
35
+ x: (c: number) => number // left edge of column c (0-based)
36
+ span: (n: number) => number // width of n columns incl. gutters
37
+ head: number
38
+ num: { x: number; y: number; size: number }
39
+ title: { x: number; y: number; size: number; w: number }
40
+ en: { x: number; y: number }
41
+ notes: { x: number; y: number; w: number }
42
+ A: Rect
43
+ sub: { x: number; y: number; w: number; size: number; lh: number }
44
+ foot: number
45
+ park: Rect
46
+ }
47
+ export function layout(por: boolean): Layout {
48
+ const W = por ? 1080 : 1920, H = por ? 1920 : 1080
49
+ const m = por ? 72 : 96, g = 24, cols = por ? 6 : 12
50
+ const cw = (W - 2 * m - (cols - 1) * g) / cols
51
+ const x = (c: number) => m + c * (cw + g)
52
+ const span = (n: number) => n * cw + (n - 1) * g
53
+ if (!por) return {
54
+ W, H, por, m, g, cols, cw, x, span, head: 88,
55
+ num: { x: m, y: 132, size: 300 },
56
+ title: { x: m, y: 468, size: 58, w: span(5) },
57
+ en: { x: m, y: 548 },
58
+ notes: { x: m, y: 604, w: span(4) },
59
+ A: { x: x(5), y: 132, w: x(11) + cw - x(5), h: 696 },
60
+ sub: { x: m, y: 878, w: span(10), size: 44, lh: 62 },
61
+ foot: 1036,
62
+ park: { x: x(3) + 24, y: 136, w: 40, h: 40 },
63
+ }
64
+ return {
65
+ W, H, por, m, g, cols, cw, x, span, head: 88,
66
+ num: { x: m, y: 132, size: 250 },
67
+ title: { x: x(3), y: 138, size: 54, w: span(3) },
68
+ en: { x: x(3), y: 214 },
69
+ notes: { x: x(3), y: 268, w: span(3) },
70
+ A: { x: m, y: 520, w: W - 2 * m, h: 816 },
71
+ sub: { x: m, y: 1404, w: W - 2 * m, size: 50, lh: 70 },
72
+ foot: 1856,
73
+ park: { x: x(2) + 40, y: 136, w: 36, h: 36 },
74
+ }
75
+ }
76
+
77
+ // ---------- type ----------
78
+ type C2 = CanvasRenderingContext2D
79
+ export function font(c: C2, size: number, w = 700) {
80
+ c.font = `${w} ${size}px ${FONT}, "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif`
81
+ const ls = (c as unknown as { letterSpacing?: string })
82
+ if ('letterSpacing' in c) ls.letterSpacing = size >= 90 ? `${(-0.025 * size).toFixed(1)}px` : size <= 22 ? '0.5px' : '0px'
83
+ }
84
+ /** ascent from the top of the line box to the baseline (CJK ideographic box ≈ 0.88em; digits/caps ≈ 0.735em) */
85
+ const ASC = (s: string) => (/^[\d\s.,:%+\-×/$·>]+$/.test(s) ? 0.735 : 0.86)
86
+ export function measure(c: C2, s: string, size: number, w = 700) { font(c, size, w); return c.measureText(s).width }
87
+ /** draw text with its visual top at y (flush-left by default) */
88
+ export function T(c: C2, s: string, x: number, y: number, size: number, w: number, col: string, align: CanvasTextAlign = 'left') {
89
+ font(c, size, w); c.fillStyle = col; c.textAlign = align; c.textBaseline = 'alphabetic'
90
+ c.fillText(s, x, y + size * ASC(s))
91
+ }
92
+ /** clip-reveal: rises out of a clip box from below its baseline (pin), leaves upward (pout). */
93
+ export function R(c: C2, s: string, x: number, y: number, size: number, w: number, col: string, pin: number, pout = 0, align: CanvasTextAlign = 'left') {
94
+ if (pin <= 0 || pout >= 1) return
95
+ const h = size * 1.14
96
+ const dy = (1 - ease(pin)) * h - ease(pout) * h
97
+ font(c, size, w)
98
+ const tw = c.measureText(s).width
99
+ const x0 = align === 'left' ? x : align === 'right' ? x - tw : x - tw / 2
100
+ c.save(); c.beginPath(); c.rect(x0 - size * 0.2, y - size * 0.1, tw + size * 0.4, h + size * 0.06); c.clip()
101
+ T(c, s, x, y + dy, size, w, col, align)
102
+ c.restore()
103
+ }
104
+ const NO_START = ',。、:;!?」》)…,.!?:;'
105
+ /** wrap Chinese/latin mixed text to maxW (keeps latin words together, no punctuation at line start) */
106
+ export function wrap(c: C2, s: string, maxW: number, size: number, w = 700): string[] {
107
+ font(c, size, w)
108
+ const tokens = s.match(/[A-Za-z0-9.%$+\-]+|\s+|./gu) || []
109
+ const out: string[] = []
110
+ let line = ''
111
+ for (const tk of tokens) {
112
+ const nxt = line + tk
113
+ if (line && c.measureText(nxt).width > maxW && !NO_START.includes(tk[0])) { out.push(line.trimEnd()); line = tk.trimStart() }
114
+ else line = nxt
115
+ }
116
+ if (line) out.push(line)
117
+ return out
118
+ }
119
+
120
+ // ---------- marks ----------
121
+ export function box(c: C2, x: number, y: number, w: number, h: number, col: string) {
122
+ if (w <= 0 || h <= 0) return
123
+ c.fillStyle = col; c.fillRect(Math.round(x) - 0.5, Math.round(y) - 0.5, Math.round(w) + 1, Math.round(h) + 1)
124
+ }
125
+ /** rect growing along one axis: dir 'r' | 'l' | 'd' | 'u' */
126
+ export function wipe(c: C2, x: number, y: number, w: number, h: number, p: number, col: string, dir: 'r' | 'l' | 'd' | 'u' = 'r') {
127
+ p = clamp(p); if (p <= 0) return
128
+ if (dir === 'r') box(c, x, y, w * p, h, col)
129
+ else if (dir === 'l') box(c, x + w * (1 - p), y, w * p, h, col)
130
+ else if (dir === 'd') box(c, x, y, w, h * p, col)
131
+ else box(c, x, y + h * (1 - p), w, h * p, col)
132
+ }
133
+ export function hline(c: C2, x: number, y: number, w: number, p: number, col: string = INK, th = 1) { if (p > 0) box(c, x, y, w * clamp(p), th, col) }
134
+ export function vline(c: C2, x: number, y: number, h: number, p: number, col: string = INK, th = 1) { if (p > 0) box(c, x, y, th, h * clamp(p), col) }
135
+ export function frame(c: C2, x: number, y: number, w: number, h: number, col: string, th = 1) {
136
+ box(c, x, y, w, th, col); box(c, x, y + h - th, w, th, col); box(c, x, y, th, h, col); box(c, x + w - th, y, th, h, col)
137
+ }
138
+ /** knife cut: a thin line crosses the element along its midline, then the element collapses onto it */
139
+ export function knife(c: C2, r: Rect, p: number, draw: () => void) {
140
+ if (p >= 1) return
141
+ const my = r.y + r.h / 2
142
+ const cut = prog(p, 0, 0.42), col = ease(prog(p, 0.42, 0.4)), gone = prog(p, 0.82, 0.18)
143
+ c.save()
144
+ if (col > 0) { c.translate(0, my); c.scale(1, Math.max(0.001, 1 - col)); c.translate(0, -my) }
145
+ if (col < 1) draw()
146
+ c.restore()
147
+ if (p > 0) {
148
+ const x0 = r.x - 16, w = r.w + 32
149
+ const a = gone > 0 ? x0 + w * ease(gone) : x0
150
+ box(c, a, my - 1, x0 + w * cut - a, 2.5, INK)
151
+ }
152
+ }
153
+ /** a small technical note: 1px rule above, text below (caption row) */
154
+ export function note(c: C2, s: string, x: number, y: number, w: number, p: number, size = 19, col = INK) {
155
+ if (p <= 0) return
156
+ hline(c, x, y, w, ease(p), col === INK ? INK : G2)
157
+ R(c, s, x, y + 8, size, 500, col, prog(p, 0.3, 0.7))
158
+ }
earth-online-sun/web/src/swiss/live.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Live-photo vignettes (3:4, 1080×1440): the portrait film set as a sheet on paper, with margin captions.
2
+ import { drawFrame } from './render'
3
+ import { SCENES } from './timeline'
4
+ import { PAPER, INK, G3, T, box, hline } from './kit'
5
+
6
+ export const LIVE_W = 1080, LIVE_H = 1440, LIVE_SEC = 3, LIVE_FPS = 30
7
+ export interface LiveDef { name: string; title: string; caption: string; scene: string; line: number; off: number }
8
+ export const SLIVE: LiveDef[] = [
9
+ { name: '01-twelve-rules', title: '十二条规则', caption: '孙宇晨 2026 长访谈', scene: 'cover', line: 3, off: -0.2 },
10
+ { name: '02-cognition', title: '认知决定一切', caption: '疯话 → 答案', scene: 'r01', line: 2, off: 0.6 },
11
+ { name: '03-lifespan', title: '寿命是硬通货', caption: '心肺 25 · 肌肉 25', scene: 'r04', line: 2, off: 0.6 },
12
+ { name: '04-scout', title: '先开图再决策', caption: '看一百套,前三套只做基准', scene: 'r05', line: 2, off: 0.5 },
13
+ { name: '05-small-ego', title: '只会向前更新', caption: '版本不能回滚 · small ego', scene: 'r08', line: 3, off: 0.4 },
14
+ { name: '06-off-grid', title: '正确答案', caption: '成为答案之前,没人知道', scene: 'r12', line: 2, off: 0.0 },
15
+ ]
16
+ export const liveStart = (d: LiveDef) => SCENES.find(s => s.id === d.scene)!.lines[d.line].start + d.off
17
+
18
+ /** draw vignette d at local time t (0..LIVE_SEC) onto a 1080×1440 context */
19
+ export function drawLive(c: CanvasRenderingContext2D, d: LiveDef, t: number) {
20
+ const S = SCENES.find(s => s.id === d.scene)!
21
+ c.setTransform(1, 0, 0, 1, 0, 0)
22
+ c.fillStyle = PAPER; c.fillRect(0, 0, LIVE_W, LIVE_H)
23
+ const k = 0.7, sw = 1080 * k, sh = 1920 * k, sx = (LIVE_W - sw) / 2, sy = 48
24
+ c.save(); c.beginPath(); c.rect(sx, sy, sw, sh); c.clip()
25
+ c.setTransform(k, 0, 0, k, sx, sy)
26
+ drawFrame(c, liveStart(d) + t, true)
27
+ c.restore(); c.setTransform(1, 0, 0, 1, 0, 0)
28
+ // sheet edge + margin captions
29
+ box(c, sx, sy, sw, 1, INK); box(c, sx, sy + sh - 1, sw, 1, INK); box(c, sx, sy, 1, sh, INK); box(c, sx + sw - 1, sy, 1, sh, INK)
30
+ c.save(); c.translate(sx - 30, sy); c.rotate(Math.PI / 2)
31
+ T(c, `N° ${S.no} — ${d.title}`, 0, -18, 18, 700, INK)
32
+ c.restore()
33
+ c.save(); c.translate(sx + sw + 30, sy + sh); c.rotate(-Math.PI / 2)
34
+ T(c, d.caption, 0, -18, 18, 500, G3)
35
+ c.restore()
36
+ hline(c, sx, LIVE_H - 40, sw, 1, INK)
37
+ T(c, '孙宇晨 · 十二条规则', sx, LIVE_H - 30, 14, 700, INK)
38
+ T(c, 'LIVE · 3"', sx + sw, LIVE_H - 30, 14, 500, G3, 'right')
39
+ }
earth-online-sun/web/src/swiss/render.ts ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 《规则 RULES》renderer — one vector frame at time t, in logical units (1920×1080 or 1080×1920).
2
+ // The caller scales the context to the output size; nothing here depends on resolution.
3
+ import { PAPER, INK, G2, G3, layout, lerp, ease, prog, R, T, wrap, hline, box } from './kit'
4
+ import type { Layout, Rect } from './kit'
5
+ import { SCENES, LINES, DUR, BPM, BAR, sceneAt } from './timeline'
6
+ import type { SceneSpan } from './timeline'
7
+ import { SCENE_DEFS, NO_HEAD } from './scenes'
8
+ import type { SCtx, Cue } from './scenes'
9
+
10
+ export { DUR, SCENES }
11
+ type C2 = CanvasRenderingContext2D
12
+ const GUIDE = '#E3E1DB'
13
+ const PAN = 0.7, RED_MOVE = 0.9
14
+
15
+ const L_CACHE: Record<string, Layout> = {}
16
+ const lay = (por: boolean) => (L_CACHE[por ? 'p' : 'l'] ??= layout(por))
17
+
18
+ function ctx(c: C2, t: number, L: Layout, S: SceneSpan): SCtx {
19
+ return { c, t, L, S, ls: S.lines.map(l => l.start), le: S.lines.map(l => l.end) }
20
+ }
21
+ const fmt = (s: number) => {
22
+ s = Math.max(0, s)
23
+ const m = Math.floor(s / 60), ss = Math.floor(s % 60), cs = Math.floor((s * 100) % 100)
24
+ return `${String(m).padStart(2, '0')}:${String(ss).padStart(2, '0')}.${String(cs).padStart(2, '0')}`
25
+ }
26
+
27
+ function guides(c: C2, L: Layout, t: number) {
28
+ for (let i = 0; i < L.cols; i++) {
29
+ const p = ease(prog(t, 0.15 + i * 0.05, 1.1))
30
+ if (p <= 0) continue
31
+ const h = L.H * p
32
+ box(c, L.x(i), 0, 1, h, GUIDE)
33
+ box(c, L.x(i) + L.cw - 1, 0, 1, h, GUIDE)
34
+ }
35
+ }
36
+
37
+ function runningHead(c: C2, L: Layout, t: number, S: SceneSpan) {
38
+ const p = prog(t, 0.6, 0.6)
39
+ if (p <= 0) return
40
+ hline(c, L.m, L.head, L.W - 2 * L.m, ease(p), INK)
41
+ R(c, '孙宇晨 · 十二条规则', L.m, 36, 18, 700, INK, p)
42
+ const label = S.id === 'cover' ? 'N° 00 — 序' : `N° ${S.no} — ${S.title}`
43
+ R(c, label, L.por ? L.x(2) + 40 : L.x(4), 36, 18, 500, INK, prog(t, S.start + 0.1, 0.5))
44
+ if (p >= 1) T(c, `${fmt(t)} / ${fmt(DUR)}`, L.W - L.m, 36, 18, 500, G3, 'right')
45
+ }
46
+
47
+ function footer(c: C2, L: Layout, t: number) {
48
+ const p = prog(t, 0.9, 0.6)
49
+ if (p <= 0) return
50
+ const y = L.foot
51
+ hline(c, L.m, y - 14, L.W - 2 * L.m, ease(p), G2)
52
+ box(c, L.m, y - 15, (L.W - 2 * L.m) * Math.min(1, t / DUR), 3, INK)
53
+ R(c, `网格 ${L.cols} 栏 × ${Math.round(L.cw)} px · 栏距 ${L.g}`, L.m, y, 16, 500, G3, p)
54
+ const bar = Math.floor(t / BAR) + 1
55
+ R(c, `${BPM} BPM · 小节 ${String(bar).padStart(3, '0')}`, L.W - L.m, y, 16, 500, G3, p, 0, 'right')
56
+ }
57
+
58
+ function subtitles(c: C2, L: Layout, t: number) {
59
+ const s = L.sub
60
+ for (let i = 0; i < LINES.length; i++) {
61
+ const ln = LINES[i], nx = LINES[i + 1]
62
+ const exitT = nx ? Math.min(nx.start - 0.3, ln.end + 1.4) : ln.end + 0.8
63
+ if (t < ln.start - 0.05 || t > exitT + 0.4) continue
64
+ const S = SCENES.find(x => x.id === ln.scene)!
65
+ const k = S.lines.indexOf(ln)
66
+ const pout = prog(t, exitT, 0.35)
67
+ const lead = S.id === 'cover' ? `序 · ${k + 1}` : `N° ${S.no}.${k + 1}`
68
+ R(c, lead, s.x, s.y - 34, 18, 500, G3, prog(t, ln.start - 0.05, 0.35), pout)
69
+ const rows = wrap(c, ln.text, s.w, s.size, 700)
70
+ const total = ln.text.length
71
+ let acc = 0
72
+ rows.forEach((row, j) => {
73
+ const tin = ln.start + (acc / total) * ln.dur * 0.85
74
+ acc += row.length
75
+ R(c, row, s.x, s.y + j * s.lh, s.size, 700, INK, prog(t, tin - 0.05, 0.45), pout)
76
+ })
77
+ }
78
+ }
79
+
80
+ function drawScene(c: C2, L: Layout, S: SceneSpan, t: number, dx: number) {
81
+ const def = SCENE_DEFS[S.id]
82
+ c.save(); c.translate(dx, 0)
83
+ def.draw(ctx(c, t, L, S))
84
+ c.restore()
85
+ }
86
+
87
+ /** red target for scene index i at time t (null = off) */
88
+ function redOf(c: C2, L: Layout, i: number, t: number): Rect | null {
89
+ const S = SCENES[i]
90
+ return SCENE_DEFS[S.id].red(ctx(c, t, L, S))
91
+ }
92
+
93
+ export function drawFrame(c: C2, t: number, por: boolean) {
94
+ const L = lay(por)
95
+ t = Math.max(0, Math.min(DUR - 1e-3, t))
96
+ c.fillStyle = PAPER; c.fillRect(0, 0, L.W, L.H)
97
+ guides(c, L, t)
98
+ const i = sceneAt(t), S = SCENES[i]
99
+ const pan = i > 0 ? ease(prog(t, S.start, PAN)) : 1
100
+ if (pan < 1) {
101
+ const P = SCENES[i - 1]
102
+ drawScene(c, L, P, P.end - 1e-3, -L.W * pan)
103
+ drawScene(c, L, S, t, L.W * (1 - pan))
104
+ } else drawScene(c, L, S, t, 0)
105
+ if (!NO_HEAD.has(S.id) || t > 0) runningHead(c, L, t, S)
106
+ // the exception: one red rectangle, linear moves only
107
+ let red = redOf(c, L, i, t)
108
+ const mp = i > 0 ? prog(t, S.start, RED_MOVE) : 1
109
+ if (mp < 1) {
110
+ const prev = redOf(c, L, i - 1, SCENES[i - 1].end - 1e-3)
111
+ if (prev && red) red = { x: lerp(prev.x, red.x, mp), y: lerp(prev.y, red.y, mp), w: lerp(prev.w, red.w, mp), h: lerp(prev.h, red.h, mp) }
112
+ else if (prev && !red) red = prev
113
+ }
114
+ if (red) { c.fillStyle = '#E5231B'; c.fillRect(Math.round(red.x), Math.round(red.y), Math.round(red.w), Math.round(red.h)) }
115
+ const def = SCENE_DEFS[S.id]
116
+ if (def.over) { c.save(); c.translate(L.W * (1 - pan), 0); def.over(ctx(c, t, L, S)); c.restore() }
117
+ footer(c, L, t)
118
+ subtitles(c, L, t)
119
+ }
120
+
121
+ /** every sound-effect cue of the film, sorted */
122
+ export function allCues(): Cue[] {
123
+ const out: Cue[] = []
124
+ SCENES.forEach((S, i) => {
125
+ if (i > 0) out.push([S.start, 'slide', 0.8])
126
+ const d = SCENE_DEFS[S.id]
127
+ if (d.cues) out.push(...d.cues(S))
128
+ })
129
+ LINES.forEach(l => out.push([l.start - 0.05, 'tss', 0.35]))
130
+ return out.filter(c => c[0] >= 0 && c[0] < DUR).sort((a, b) => a[0] - b[0])
131
+ }
earth-online-sun/web/src/swiss/scenes.ts ADDED
@@ -0,0 +1,734 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 《规则 RULES》— thirteen typographic scenes on the modular grid.
2
+ // Every scene draws in absolute time. The red rectangle is NOT drawn here: each scene only
3
+ // reports where the red should be (`red`), the renderer moves it linearly between targets.
4
+ import { INK, G1, G2, G3, PAPER, SHEET, clamp, lerp, ease, prog, steps, hash, R, T, measure, wrap, box, wipe, hline, vline, frame, knife, note } from './kit'
5
+ import type { Layout, Rect } from './kit'
6
+ import type { SceneSpan } from './timeline'
7
+ import WORLD from '../film/world.json'
8
+
9
+ type C2 = CanvasRenderingContext2D
10
+ export interface SCtx { c: C2; t: number; L: Layout; S: SceneSpan; ls: number[]; le: number[] }
11
+ export type Cue = [number, string, number?]
12
+ export interface SceneDef {
13
+ draw(x: SCtx): void
14
+ red(x: SCtx): Rect | null
15
+ over?(x: SCtx): void
16
+ cues?(S: SceneSpan): Cue[]
17
+ }
18
+
19
+ // ---------- helpers ----------
20
+ const E = (x: SCtx, a: number, d = 0.5) => ease(prog(x.t, a, d))
21
+ const P = (x: SCtx, a: number, d = 0.5) => prog(x.t, a, d)
22
+ const F = (x: SCtx, k: number) => prog(x.t, x.ls[k], x.le[k] - x.ls[k])
23
+ /** absolute time at fraction f of line k */
24
+ const at = (x: SCtx, k: number, f = 0) => x.ls[k] + (x.le[k] - x.ls[k]) * f
25
+ const rr = (x: number, y: number, w: number, h: number): Rect => ({ x, y, w, h })
26
+ const mv = (a: Rect, b: Rect, p: number): Rect => (p <= 0 ? a : p >= 1 ? b : rr(lerp(a.x, b.x, p), lerp(a.y, b.y, p), lerp(a.w, b.w, p), lerp(a.h, b.h, p)))
27
+ const lt = (S: SceneSpan, k: number, f = 0) => S.lines[k].start + S.lines[k].dur * f
28
+
29
+ /** text that rises in at tin and is knife-cut at tk */
30
+ function KT(x: SCtx, s: string, X: number, Y: number, size: number, w: number, col: string, tin: number, tk = Infinity, align: CanvasTextAlign = 'left') {
31
+ const kp = prog(x.t, tk, 0.55)
32
+ if (kp >= 1 || x.t < tin) return
33
+ const tw = measure(x.c, s, size, w)
34
+ const x0 = align === 'left' ? X : align === 'right' ? X - tw : X - tw / 2
35
+ knife(x.c, rr(x0, Y, tw, size * 0.86), kp, () => R(x.c, s, X, Y, size, w, col, P(x, tin, 0.5), 0, align))
36
+ }
37
+ /** knife a whole group of marks living inside rect r */
38
+ function KG(x: SCtx, r: Rect, tk: number, draw: () => void) {
39
+ const kp = prog(x.t, tk, 0.55)
40
+ if (kp >= 1) return
41
+ knife(x.c, r, kp, draw)
42
+ }
43
+ /** panel swap: A leaves upward, B arrives from below, both clipped to r */
44
+ function swap(x: SCtx, r: Rect, ts: number, A: () => void, B: () => void) {
45
+ const p = E(x, ts, 0.7)
46
+ if (p <= 0) return A()
47
+ if (p >= 1) return B()
48
+ const c = x.c, h = r.h + 60
49
+ c.save(); c.beginPath(); c.rect(r.x - 60, r.y - 30, r.w + 120, r.h + 60); c.clip()
50
+ c.save(); c.translate(0, -p * h); A(); c.restore()
51
+ c.save(); c.translate(0, (1 - p) * h); B(); c.restore()
52
+ c.restore()
53
+ }
54
+ /** rule head: numeral climbs in four steps, title, english label, technical notes */
55
+ function head(x: SCtx, notes: [number, string][]) {
56
+ const { c, L, S, t } = x
57
+ R(c, S.no, L.num.x, L.num.y, L.num.size, 900, INK, steps(prog(t, S.start + 0.05, 0.5), 4))
58
+ R(c, S.title, L.title.x, L.title.y, L.title.size, 900, INK, P(x, S.start + 0.25))
59
+ R(c, S.en.toUpperCase(), L.en.x, L.en.y, 20, 500, G3, P(x, S.start + 0.45))
60
+ notes.forEach(([k, s], j) => note(c, s, L.notes.x, L.notes.y + j * 52, L.notes.w, P(x, (x.ls[k] ?? S.start) + 0.15, 0.6), 19))
61
+ }
62
+ const numLand = (S: SceneSpan) => S.start + 0.55
63
+
64
+ // =====================================================================
65
+ // COVER — 2016 → 2026 → twelve rules
66
+ // =====================================================================
67
+ function coverGeo(L: Layout) {
68
+ const por = L.por
69
+ const ty = por ? 440 : 420
70
+ const tx0 = L.m, tx1 = L.W - L.m
71
+ const cell = por ? L.cw : L.cw
72
+ const cells: Rect[] = []
73
+ for (let i = 0; i < 12; i++) {
74
+ if (por) cells.push(rr(L.x(i % 6), 860 + Math.floor(i / 6) * (cell + L.g), cell, cell))
75
+ else cells.push(rr(L.x(i), 640, cell, cell))
76
+ }
77
+ return { ty, tx0, tx1, cells }
78
+ }
79
+ const cover: SceneDef = {
80
+ draw(x) {
81
+ const { c, L, S } = x
82
+ const { ty, tx0, tx1, cells } = coverGeo(L)
83
+ const por = L.por
84
+ const kt = x.ls[3] - 0.15
85
+ // --- act 1: the decade ---
86
+ KG(x, rr(0, 100, L.W, por ? 1150 : 600), kt, () => {
87
+ R(c, '2016', L.m, 132, por ? 250 : 300, 900, INK, P(x, x.ls[0] - 0.2))
88
+ // the decade line + 11 ticks
89
+ hline(c, tx0, ty, tx1 - tx0, E(x, S.start + 0.6, 1.2), INK, 2)
90
+ for (let i = 0; i <= 10; i++) {
91
+ const tx = lerp(tx0, tx1, i / 10), p = P(x, S.start + 0.9 + i * 0.07, 0.3)
92
+ vline(c, tx, ty - 10, 10 * ease(p), p, INK, 1)
93
+ if (i % 5 === 0 || !por) R(c, String(2016 + i), tx, ty + 14, 16, 500, G3, p, 0, i === 10 ? 'right' : i === 0 ? 'left' : 'center')
94
+ }
95
+ // travelled decade (linear, follows the red)
96
+ wipe(c, tx0, ty - 4, tx1 - tx0, 6, F(x, 1), G2)
97
+ R(c, '《财富自由之路》', L.m, ty + (por ? 70 : 52), por ? 48 : 52, 700, INK, P(x, at(x, 0, 0.45)))
98
+ R(c, '孙宇晨 · 2016', L.m, ty + (por ? 140 : 124), 20, 500, G3, P(x, at(x, 0, 0.6)))
99
+ R(c, '十年 · 重新流传', L.m, ty + (por ? 176 : 160), 20, 500, G3, P(x, at(x, 1, 0.5)))
100
+ R(c, '2026', L.W - L.m, por ? 700 : 132, por ? 250 : 300, 900, INK, P(x, x.le[1] - 0.1), 0, 'right')
101
+ R(c, '新的答案', L.W - L.m, por ? 920 : ty + 52, 52, 700, INK, P(x, at(x, 2, 0.75)), 0, 'right')
102
+ R(c, '长访谈 · 2026', L.W - L.m, por ? 994 : ty + 124, 20, 500, G3, P(x, at(x, 2, 0.85)), 0, 'right')
103
+ })
104
+ // --- act 2: lockup ---
105
+ const lk = x.ls[3] + 0.35
106
+ if (x.t > lk) {
107
+ R(c, '十二条规则', L.m, por ? 520 : 250, por ? 150 : 200, 900, INK, P(x, lk, 0.6))
108
+ R(c, '孙宇晨 · 2026 长访谈整理', L.m, por ? 720 : 500, por ? 32 : 36, 500, INK, P(x, lk + 0.25, 0.6))
109
+ cells.forEach((r, i) => {
110
+ const p = E(x, lk + 0.3 + i * 0.06, 0.4)
111
+ if (p <= 0) return
112
+ hline(c, r.x, r.y, r.w, p, INK, 2)
113
+ R(c, String(i + 1).padStart(2, '0'), r.x, r.y + 12, 22, 700, INK, P(x, lk + 0.4 + i * 0.06, 0.4))
114
+ wipe(c, r.x, r.y + r.h - 1, r.w, 1, p, G2)
115
+ })
116
+ }
117
+ },
118
+ red(x) {
119
+ const { L, S } = x
120
+ const { ty, tx0, tx1, cells } = coverGeo(L)
121
+ if (x.t < S.start + 0.4) return null
122
+ const dx = lerp(tx0, tx1, F(x, 1))
123
+ const dot = rr(dx - 12 + (F(x, 1) === 0 ? 12 : F(x, 1) >= 1 ? -12 : 0), ty - 11, 24, 24)
124
+ const target = cells[0]
125
+ return mv(dot, target, P(x, x.ls[3] + 0.6, 0.9))
126
+ },
127
+ over(x) {
128
+ const { cells } = coverGeo(x.L)
129
+ if (x.t > x.ls[3] + 1.5) R(x.c, '01', cells[0].x, cells[0].y + 12, 22, 700, PAPER, 1)
130
+ },
131
+ cues(S) { return [[S.start + 0.4, 'tick'], [lt(S, 1, 0.98), 'clack'], [lt(S, 3) - 0.15, 'snick'], [lt(S, 3) + 0.35, 'stamp']] },
132
+ }
133
+
134
+ // =====================================================================
135
+ // R01 — 认知决定一切
136
+ // =====================================================================
137
+ const NAMES01 = ['腾讯', '比特币', '特斯拉', '英伟达']
138
+ const r01: SceneDef = {
139
+ draw(x) {
140
+ const { c, L } = x
141
+ const A = L.A
142
+ head(x, [[1, '清单 · 2016'], [2, '十年 · 4 / 4'], [3, '非运气']])
143
+ // claims on the right of the red block
144
+ KT(x, '疯话', A.x + 190, A.y + 12, 96, 900, G3, at(x, 2, 0.05), at(x, 2, 0.45))
145
+ KT(x, '答案', A.x + 190, A.y + 12, 96, 900, INK, at(x, 2, 0.62))
146
+ KT(x, '运气', A.x + A.w, A.y + 40, 48, 700, G3, x.ls[3] + 0.1, x.le[3] - 0.15, 'right')
147
+ if (x.t > x.le[3] - 0.15) R(c, '≠ 运气', A.x + A.w, A.y + 40, 48, 700, INK, P(x, x.le[3] + 0.2), 0, 'right')
148
+ // four rows, ten annual steps each
149
+ const y0 = A.y + 210, rh = 96, bx = A.x + 200, bw = A.w - 200
150
+ const grow = steps(F(x, 2), 10)
151
+ NAMES01.forEach((n, k) => {
152
+ const ti = at(x, 1, 0.42 + k * 0.14), y = y0 + k * rh
153
+ R(c, n, A.x, y, 40, 700, INK, P(x, ti))
154
+ hline(c, A.x, y + rh - 18, A.w, E(x, ti, 0.6), G2)
155
+ if (x.t > ti) box(c, bx, y + 6, Math.max(6, bw * grow), 30, INK)
156
+ })
157
+ // axis
158
+ const ay = y0 + 4 * rh + 6
159
+ hline(c, bx, ay, bw, E(x, x.ls[1], 0.8), INK)
160
+ for (let i = 0; i <= 10; i++) {
161
+ const p = P(x, x.ls[1] + 0.2 + i * 0.04, 0.3)
162
+ vline(c, bx + bw * i / 10, ay, 8, p)
163
+ if (i === 0 || i === 10 || i === 5) R(c, String(2016 + i), bx + bw * i / 10, ay + 16, 16, 500, G3, p, 0, i === 0 ? 'left' : i === 10 ? 'right' : 'center')
164
+ }
165
+ },
166
+ red(x) { return rr(x.L.A.x, x.L.A.y, 130, 130) },
167
+ over(x) { R(x.c, '认知', x.L.A.x + 22, x.L.A.y + 40, 64, 900, INK, P(x, x.ls[0] + 0.5)) },
168
+ cues(S) { return [[numLand(S), 'thunk'], [lt(S, 2, 0.45), 'snick'], [lt(S, 3) + S.lines[3].dur - 0.15, 'snick']] },
169
+ }
170
+
171
+ // =====================================================================
172
+ // R02 — 三件零成本的事
173
+ // =====================================================================
174
+ const KIT02 = [['孙学', '升级大脑的操作系统'], ['薄肌', '守住身体这张底牌'], ['黄毛', '认定对的事,就去执行']]
175
+ function cells02(L: Layout): Rect[] {
176
+ const A = L.A
177
+ if (!L.por) { const w = (A.w - 48) / 3; return [0, 1, 2].map(k => rr(A.x + k * (w + 24), A.y, w, A.h)) }
178
+ const h = (A.h - 48) / 3
179
+ return [0, 1, 2].map(k => rr(A.x, A.y + k * (h + 24), A.w, h))
180
+ }
181
+ const r02: SceneDef = {
182
+ draw(x) {
183
+ const { c, L } = x
184
+ head(x, [[1, '零成本 × 3'], [2, '大脑 · 身体 · 执行']])
185
+ cells02(L).forEach((r, k) => {
186
+ const ti = x.ls[k + 1] - 0.1
187
+ if (x.t < ti - 0.6) return
188
+ hline(c, r.x, r.y, r.w, E(x, ti - 0.6, 0.6), INK, 2)
189
+ if (!L.por) {
190
+ R(c, '0', r.x - 6, r.y + 30, 360, 900, G1, P(x, ti - 0.4, 0.6))
191
+ R(c, KIT02[k][0], r.x, r.y + 330, 80, 900, INK, P(x, ti))
192
+ wrap(c, KIT02[k][1], r.w - 10, 26, 500).forEach((ln, j) => R(c, ln, r.x, r.y + 440 + j * 40, 26, 500, INK, P(x, ti + 0.3 + j * 0.08)))
193
+ note(c, '成本 0', r.x, r.y + r.h - 50, r.w, P(x, ti + 0.5, 0.6))
194
+ } else {
195
+ R(c, '0', r.x + r.w, r.y + 10, 220, 900, G1, P(x, ti - 0.4, 0.6), 0, 'right')
196
+ R(c, KIT02[k][0], r.x, r.y + 28, 64, 900, INK, P(x, ti))
197
+ R(c, KIT02[k][1], r.x, r.y + 120, 28, 500, INK, P(x, ti + 0.3))
198
+ note(c, '成本 0', r.x, r.y + r.h - 50, 300, P(x, ti + 0.5, 0.6))
199
+ }
200
+ })
201
+ },
202
+ red(x) {
203
+ const cs = cells02(x.L)
204
+ const pos = (k: number) => rr(cs[k].x + cs[k].w - 44, cs[k].y + 16, 44, 44)
205
+ let r = pos(0)
206
+ for (let k = 1; k < 3; k++) r = mv(r, pos(k), P(x, x.ls[k + 1] - 0.2, 0.6))
207
+ return r
208
+ },
209
+ cues(S) { return [[numLand(S), 'thunk'], [lt(S, 1), 'clack'], [lt(S, 2), 'clack'], [lt(S, 3), 'clack']] },
210
+ }
211
+
212
+ // =====================================================================
213
+ // R03 — 交给 AI 托管
214
+ // =====================================================================
215
+ const r03: SceneDef = {
216
+ draw(x) {
217
+ const { c, L } = x
218
+ const A = L.A
219
+ head(x, [[1, '照片 · 睡眠 · 训练'], [2, '日报 · .md'], [3, '90 / 10'], [4, '三个模型 · 取中']])
220
+ swap(x, A, x.ls[3] - 0.35, () => {
221
+ // 24h strip
222
+ const sy = A.y + 40, hw = A.w / 24
223
+ hline(c, A.x, sy, A.w, E(x, x.ls[0] + 0.3, 0.9))
224
+ for (let h = 0; h <= 24; h++) {
225
+ const p = P(x, x.ls[0] + 0.4 + h * 0.025, 0.3)
226
+ vline(c, A.x + h * hw - (h === 24 ? 1 : 0), sy - (h % 6 ? 6 : 12), h % 6 ? 6 : 12, p)
227
+ if (h % 6 === 0) R(c, String(h).padStart(2, '0'), A.x + h * hw, sy - 40, 16, 500, G3, p, 0, h === 24 ? 'right' : h === 0 ? 'left' : 'center')
228
+ }
229
+ const by = sy + 18, bh = 56
230
+ const ph = (f: number) => P(x, at(x, 1, f), 0.6)
231
+ wipe(c, A.x, by, hw * 7, bh, ph(0.3), G2); wipe(c, A.x + 23 * hw, by, hw, bh, ph(0.3), G2)
232
+ for (const h of [8, 13, 19]) wipe(c, A.x + h * hw, by, hw * 0.5, bh, ph(0.05), INK, 'd')
233
+ wipe(c, A.x + 17 * hw, by, hw, bh, ph(0.55), INK, 'd')
234
+ R(c, '睡眠', A.x, by + bh + 14, 18, 500, G3, ph(0.32))
235
+ R(c, '三餐', A.x + 8 * hw, by + bh + 14, 18, 500, INK, ph(0.08))
236
+ R(c, '训练', A.x + 17 * hw, by + bh + 14, 18, 500, INK, ph(0.58))
237
+ // pipeline
238
+ const py = A.y + 270
239
+ const ins = ['照片', '睡眠', '视频']
240
+ const cx = A.x + A.w * 0.34, cw = 230, ch = 120
241
+ ins.forEach((s, k) => {
242
+ const y = py + k * 52 + 8
243
+ R(c, s, A.x, y, 26, 700, INK, ph(0.1 + k * 0.25))
244
+ hline(c, A.x + 70, y + 12, cx - A.x - 84, P(x, at(x, 1, 0.8), 0.5))
245
+ })
246
+ wipe(c, cx, py, cw, ch + 40, P(x, at(x, 1, 0.78), 0.4) ? E(x, at(x, 1, 0.78), 0.5) : 0, INK, 'r')
247
+ R(c, 'Claude', cx + 24, py + 52, 44, 700, PAPER, P(x, at(x, 1, 0.86)))
248
+ const dx = A.x + A.w - 300, dy = py - 30
249
+ hline(c, cx + cw, py + 80, dx - cx - cw, P(x, x.ls[2], 0.5))
250
+ if (x.t > x.ls[2]) {
251
+ const p = E(x, x.ls[2] + 0.2, 0.6)
252
+ wipe(c, dx, dy, 300, 380, p, SHEET, 'd'); if (p >= 1) frame(c, dx, dy, 300, 380, G2)
253
+ R(c, '日报', dx + 24, dy + 28, 40, 900, INK, P(x, x.ls[2] + 0.5))
254
+ R(c, '2026-10-10.md', dx + 24, dy + 84, 18, 500, G3, P(x, x.ls[2] + 0.6))
255
+ for (let j = 0; j < 8; j++) wipe(c, dx + 24, dy + 130 + j * 28, (j % 3 === 2 ? 0.55 : 0.9) * 252, 8, P(x, x.ls[2] + 0.7 + j * 0.15, 0.3), j === 0 ? INK : G2)
256
+ }
257
+ }, () => {
258
+ R(c, '90', A.x, A.y, 240, 900, INK, P(x, x.ls[3] - 0.1))
259
+ R(c, '10', A.x + A.w, A.y, 240, 900, INK, P(x, at(x, 3, 0.5)), 0, 'right')
260
+ const by = A.y + 220
261
+ wipe(c, A.x, by, A.w * 0.9 - 4, 40, E(x, x.ls[3], 0.8), INK)
262
+ R(c, 'AI', A.x, by + 56, 20, 500, G3, P(x, x.ls[3] + 0.3))
263
+ R(c, '自己 · 微调', A.x + A.w, by + 56, 20, 500, G3, P(x, at(x, 3, 0.6)), 0, 'right')
264
+ // three counsels
265
+ const ky = by + 150, kw = (A.w - 48) / 3, kh = 170
266
+ const sel = E(x, at(x, 4, 0.82), 0.5)
267
+ ;['上策', '中策', '下策'].forEach((s, k) => {
268
+ const kx = A.x + k * (kw + 24), ti = at(x, 4, 0.25 + k * 0.12)
269
+ if (x.t < ti) return
270
+ if (k === 1 && sel > 0) wipe(c, kx, ky, kw, kh, sel, INK, 'u')
271
+ const p = E(x, ti, 0.5)
272
+ hline(c, kx, ky, kw, p, INK, 2); hline(c, kx, ky + kh - 1, kw, p, k === 1 ? INK : G2)
273
+ R(c, `模型 ${'ABC'[k]}`, kx + 16, ky + 18, 18, 500, k === 1 && sel > 0.5 ? G2 : G3, P(x, ti))
274
+ R(c, s, kx + 16, ky + 70, 56, 900, k === 1 && sel > 0.5 ? PAPER : k === 1 ? INK : G3, P(x, ti + 0.1))
275
+ })
276
+ })
277
+ },
278
+ red(x) {
279
+ const A = x.L.A
280
+ const park = x.L.park
281
+ const seg = rr(A.x + A.w * 0.9, A.y + 220, A.w * 0.1, 40)
282
+ const ky = A.y + 220 + 150, kw = (A.w - 48) / 3
283
+ const under = rr(A.x + kw + 24, ky + 170 + 14, kw, 10)
284
+ let r = mv(park, seg, P(x, at(x, 3, 0.45), 0.7))
285
+ r = mv(r, under, P(x, at(x, 4, 0.82), 0.7))
286
+ return r
287
+ },
288
+ cues(S) { return [[numLand(S), 'thunk'], [lt(S, 3) - 0.35, 'slide'], [lt(S, 4, 0.82), 'stamp']] },
289
+ }
290
+
291
+ // =====================================================================
292
+ // R04 — 寿命是硬通货
293
+ // =====================================================================
294
+ function geo04(L: Layout) {
295
+ const A = L.A, cs = 38, gp = 5
296
+ const gx = A.x, gy = A.y + (L.por ? 250 : 262)
297
+ const tx = A.x + 10 * (cs + gp) + (L.por ? 40 : 70)
298
+ return { A, cs, gp, gx, gy, tx, tw: A.x + A.w - tx }
299
+ }
300
+ const r04: SceneDef = {
301
+ draw(x) {
302
+ const { c, L } = x
303
+ const { A, cs, gp, gx, gy, tx } = geo04(L)
304
+ head(x, [[1, '物质 富 · 身体 穷'], [2, '满分 100'], [3, '薄肌 = 50'], [4, '唯一不贬值']])
305
+ // rich / poor bars
306
+ const rows: [string, string, number, string][] = [['物质', '富人', 1, INK], ['身体', '穷人', 0.2, G3]]
307
+ rows.forEach(([a, b, w, col], k) => {
308
+ const ti = at(x, 1, 0.2 + k * 0.45), y = A.y + k * 96
309
+ R(c, a, A.x, y, 40, 700, INK, P(x, ti))
310
+ const bw = (A.w - 260) * w
311
+ wipe(c, A.x + 130, y + 6, bw, 30, E(x, ti + 0.15, 0.7), col)
312
+ R(c, b, A.x + 130 + bw + 16, y + 6, 26, 700, col, P(x, ti + 0.6))
313
+ })
314
+ // 10×10 longevity grid
315
+ const g0 = x.ls[2]
316
+ for (let i = 0; i < 100; i++) {
317
+ const r = Math.floor(i / 10), q = i % 10
318
+ const p = P(x, g0 + (r + q) * 0.025, 0.3)
319
+ if (p <= 0) continue
320
+ const cx = gx + q * (cs + gp), cy = gy + r * (cs + gp)
321
+ const cardio = r < 5 && q < 5
322
+ const ink = cardio && x.t > at(x, 2, 0.5) + (r * 5 + q) * 0.012
323
+ box(c, cx, cy, cs * ease(p), cs, ink ? INK : G1)
324
+ }
325
+ if (x.t > g0) {
326
+ R(c, '心肺 25', gx, gy + 10 * (cs + gp) + 10, 18, 500, INK, P(x, at(x, 2, 0.55)))
327
+ R(c, '肌肉 25', gx + 5 * (cs + gp), gy + 10 * (cs + gp) + 10, 18, 500, INK, P(x, at(x, 2, 0.8)))
328
+ }
329
+ const ta = rr(tx, gy, A.x + A.w - tx, 10 * (cs + gp))
330
+ const n1 = () => {
331
+ R(c, '100', tx, gy, 150, 900, INK, P(x, g0 + 0.2))
332
+ R(c, '寿命满分', tx, gy + 160, 26, 700, INK, P(x, g0 + 0.4))
333
+ R(c, '心肺 25 · 肌肉 25 · 其余 50', tx, gy + 204, 20, 500, G3, P(x, at(x, 2, 0.7)))
334
+ }
335
+ const n2 = () => {
336
+ R(c, '50', tx, gy, 150, 900, INK, P(x, x.ls[3]))
337
+ R(c, '练出薄肌 = 拿下一半', tx, gy + 160, 26, 700, INK, P(x, at(x, 3, 0.6)))
338
+ R(c, '肌肉 = 身体里的银行账户', tx, gy + 204, 20, 500, G3, P(x, at(x, 3, 0.2)))
339
+ }
340
+ const n3 = () => {
341
+ const items = ['算力', '内容', '服务', '商品']
342
+ items.forEach((s, k) => {
343
+ const y = gy + k * 62, kt = at(x, 4, 0.45 + k * 0.08)
344
+ KG(x, rr(tx, y, ta.w, 44), kt, () => {
345
+ R(c, s, tx, y, 36, 700, G3, P(x, x.ls[4] + k * 0.08))
346
+ R(c, '↓ 变便宜', tx + ta.w, y + 8, 20, 500, G3, P(x, x.ls[4] + 0.1 + k * 0.08), 0, 'right')
347
+ hline(c, tx, y + 52, ta.w, E(x, x.ls[4] + k * 0.08, 0.5), G2)
348
+ })
349
+ })
350
+ R(c, '寿命 ↑', tx, gy + 130, 96, 900, INK, P(x, at(x, 4, 0.8), 0.6))
351
+ }
352
+ swap(x, ta, x.ls[3] - 0.3, n1, () => swap(x, ta, x.ls[4] - 0.3, n2, n3))
353
+ },
354
+ red(x) {
355
+ const { cs, gp, gx, gy } = geo04(x.L)
356
+ const blk = rr(gx + 5 * (cs + gp), gy, 5 * (cs + gp) - gp, 5 * (cs + gp) - gp)
357
+ return mv(x.L.park, blk, P(x, at(x, 2, 0.75), 0.8))
358
+ },
359
+ cues(S) { return [[numLand(S), 'thunk'], [lt(S, 2, 0.75), 'glide'], [lt(S, 4, 0.45), 'snick'], [lt(S, 4, 0.8), 'stamp']] },
360
+ }
361
+
362
+ // =====================================================================
363
+ // R05 — 先开图再决策
364
+ // =====================================================================
365
+ function geo05(L: Layout) {
366
+ const A = L.A, cs = 44, gp = 6, pitch = cs + gp
367
+ return { A, cs, gp, pitch, gx: A.x, gy: A.y + 20, tx: L.por ? A.x : A.x + 10 * pitch + 60, ty: L.por ? A.y + 560 : A.y + 20 }
368
+ }
369
+ const pathCell = (i: number) => { const r = Math.floor(i / 10), q = i % 10; return [r % 2 ? 9 - q : q, r] }
370
+ function scout(x: SCtx) { return clamp((x.t - x.ls[1]) / (x.le[2] - x.ls[1])) * 99 }
371
+ const r05: SceneDef = {
372
+ draw(x) {
373
+ const { c, L } = x
374
+ const { A, cs, pitch, gx, gy, tx, ty } = geo05(L)
375
+ head(x, [[1, '斥候 → 造兵'], [2, '100 套 · 前 3 = 基准'], [3, '信息 ≥ 决定']])
376
+ const s = scout(x), seen = x.t > x.ls[1] ? Math.floor(s) + 1 : 0
377
+ for (let i = 0; i < 100; i++) {
378
+ const [q, r] = pathCell(i)
379
+ const p = P(x, x.ls[0] + 0.2 + hash(i) * 0.8, 0.3)
380
+ if (p <= 0) continue
381
+ const cx = gx + q * pitch, cy = gy + r * pitch
382
+ if (i < seen) { frame(c, cx, cy, cs, cs, G2); if (i < 3 && x.t > x.ls[2]) frame(c, cx - 3, cy - 3, cs + 6, cs + 6, INK, 2) }
383
+ else box(c, cx, cy, cs * ease(p), cs, G2)
384
+ }
385
+ if (x.t > x.ls[2]) R(c, '基准 · 不买', gx, gy + 10 * pitch + 12, 18, 500, INK, P(x, x.ls[2] + 0.3))
386
+ // counter
387
+ R(c, String(seen).padStart(3, '0'), tx, ty, 150, 900, INK, P(x, x.ls[1] - 0.2))
388
+ R(c, '已看 · 套', tx, ty + 150, 20, 500, G3, P(x, x.ls[1]))
389
+ hline(c, tx, ty + 190, L.por ? 400 : A.x + A.w - tx, E(x, x.ls[1], 0.8))
390
+ const dy = ty + (L.por ? 220 : 260)
391
+ KT(x, '决定', tx, dy, 72, 900, G3, x.ls[3] - 0.2, at(x, 3, 0.55))
392
+ KT(x, '无需决定', tx, dy, 72, 900, INK, at(x, 3, 0.75))
393
+ void A
394
+ },
395
+ red(x) {
396
+ const { pitch, gx, gy, cs } = geo05(x.L)
397
+ if (x.t < x.ls[1] - 0.6) return x.L.park
398
+ const s = scout(x), i = Math.floor(s), f = s - i
399
+ const [q0, r0] = pathCell(i), [q1, r1] = pathCell(Math.min(99, i + 1))
400
+ const tgt = rr(gx + lerp(q0, q1, f) * pitch, gy + lerp(r0, r1, f) * pitch, cs, cs)
401
+ return mv(x.L.park, tgt, P(x, x.ls[1] - 0.6, 0.6))
402
+ },
403
+ cues(S) { return [[numLand(S), 'thunk'], [lt(S, 3, 0.55), 'snick'], [lt(S, 3, 0.75), 'stamp']] },
404
+ }
405
+
406
+ // =====================================================================
407
+ // R06 — 全球寻找商机
408
+ // =====================================================================
409
+ const W6 = WORLD as { w: number; h: number; rows: string[] }
410
+ const BJ = [Math.round((116.4 + 180) / 360 * W6.w / 2), Math.round((90 - 39.9) / 180 * W6.h / 2)]
411
+ function geo06(L: Layout) {
412
+ const A = L.A, pitch = A.w / 96
413
+ return { A, pitch, mh: pitch * 48 }
414
+ }
415
+ const r06: SceneDef = {
416
+ draw(x) {
417
+ const { c, L } = x
418
+ const { A, pitch, mh } = geo06(L)
419
+ head(x, [[1, '楼下 = 1 个点'], [2, '同一订阅 · 不同价格'], [3, '4 亿 → 80 亿 + 20 亿']])
420
+ swap(x, A, x.ls[3] - 0.3, () => {
421
+ const d = pitch * 0.42
422
+ for (let j = 0; j < 48; j++) {
423
+ const row = W6.rows[j * 2]
424
+ for (let i = 0; i < 96; i++) {
425
+ if (row[i * 2] !== '1') continue
426
+ const dist = Math.hypot(i - BJ[0], j - BJ[1])
427
+ const p = P(x, x.ls[0] + 0.1 + dist * 0.018, 0.25)
428
+ if (p <= 0) continue
429
+ box(c, A.x + i * pitch + (pitch - d) / 2, A.y + j * pitch + (pitch - d) / 2, d, d, p < 1 ? G3 : G2)
430
+ }
431
+ }
432
+ const bx = A.x + BJ[0] * pitch, by = A.y + BJ[1] * pitch
433
+ KG(x, rr(bx + 24, by - 40, 200, 30), x.le[1] - 0.2, () => {
434
+ hline(c, bx + 18, by - 20, 30, P(x, x.ls[1], 0.3))
435
+ if (x.t > x.ls[1] + 0.1) box(c, bx + 48, by - 40, measure(c, '楼下 · 1 个点', 20, 700) + 12, 30, PAPER)
436
+ R(c, '楼下 · 1 个点', bx + 54, by - 34, 20, 700, INK, P(x, x.ls[1] + 0.1))
437
+ })
438
+ // same subscription, different prices
439
+ const cy = A.y + mh + 34, n = ['美国', '土耳其', '阿根廷', '尼日利亚']
440
+ const cw = (A.w - 3 * 60) / 4
441
+ n.forEach((s, k) => {
442
+ const cx = A.x + k * (cw + 60), ti = at(x, 2, 0.12 + k * 0.08)
443
+ hline(c, cx, cy, cw, E(x, ti, 0.5), INK, 2)
444
+ R(c, s, cx, cy + 14, L.por ? 26 : 30, 700, INK, P(x, ti))
445
+ R(c, 'AI 订阅 · ¥ ?', cx, cy + 60, 18, 500, G3, P(x, ti + 0.15))
446
+ if (k < 3) R(c, '≠', cx + cw + 30, cy + 12, 36, 700, INK, P(x, at(x, 2, 0.5) + k * 0.06), 0, 'center')
447
+ })
448
+ R(c, '差价 = 生意', A.x + A.w, cy + 110, L.por ? 40 : 34, 900, INK, P(x, at(x, 2, 0.8)), 0, 'right')
449
+ }, () => {
450
+ const cw = (A.w - 48) / 3, size = L.por ? 110 : 140
451
+ const items: [string, string][] = [['4亿', '年轻人'], ['80亿', '人'], ['20亿', '个 AI']]
452
+ items.forEach(([n, l], k) => {
453
+ const cx = A.x + k * (cw + 24), ti = at(x, 3, 0.05 + k * 0.28)
454
+ hline(c, cx, A.y + 120, cw, E(x, ti, 0.5), INK, 2)
455
+ R(c, ['服务者', '服务', '以及'][k], cx, A.y + 134, 18, 500, G3, P(x, ti))
456
+ R(c, n, cx, A.y + 190, size, 900, INK, P(x, ti + 0.1))
457
+ R(c, l, cx, A.y + 190 + size + 40, 30, 700, INK, P(x, ti + 0.2))
458
+ })
459
+ })
460
+ },
461
+ red(x) {
462
+ const { A, pitch } = geo06(x.L)
463
+ const bj = rr(A.x + BJ[0] * pitch - 1, A.y + BJ[1] * pitch - 1, pitch + 2, pitch + 2)
464
+ const size = x.L.por ? 110 : 140
465
+ const uw = measure(x.c, '4亿', size, 900)
466
+ const ul = rr(A.x, A.y + 190 + size * 0.86 + 12, uw, 12)
467
+ if (x.t < x.ls[0] + 0.1) return x.L.park
468
+ return mv(mv(x.L.park, bj, P(x, x.ls[0] + 0.1, 0.5)), ul, P(x, at(x, 3, 0.1), 0.7))
469
+ },
470
+ cues(S) { return [[numLand(S), 'thunk'], [lt(S, 1, 0.99) - 0.2, 'snick'], [lt(S, 3) - 0.3, 'slide']] },
471
+ }
472
+
473
+ // =====================================================================
474
+ // R07 — 货币的翻译
475
+ // =====================================================================
476
+ function geo07(L: Layout) {
477
+ const A = L.A, pitch = 32, cols = 16, rows = 10
478
+ const mx = L.por ? A.x : A.x + A.w - cols * pitch, my = L.por ? A.y + 260 : A.y
479
+ return { A, pitch, cols, rows, mx, my, dy: L.por ? my + rows * pitch + 100 : A.y + rows * pitch + 70 }
480
+ }
481
+ const BIT = [9, 4]
482
+ const r07: SceneDef = {
483
+ draw(x) {
484
+ const { c, L } = x
485
+ const { A, pitch, cols, rows, mx, my, dy } = geo07(L)
486
+ head(x, [[1, 'crypto'], [2, 'AI 语料'], [3, '未写入 = 未发生']])
487
+ KG(x, rr(A.x, A.y, A.w, A.h), x.ls[3] - 0.05, () => {
488
+ R(c, '货币', A.x, A.y, 200, 900, INK, P(x, x.ls[0] + 0.1))
489
+ if (!L.por) { hline(c, A.x + 10, A.y + 250, mx - A.x - 40, P(x, at(x, 0, 0.5), 0.6)); R(c, '→ 机器语言', A.x + 10, A.y + 266, 20, 500, G3, P(x, at(x, 0, 0.6))) }
490
+ else { R(c, '→ 机器语言', A.x + 440, A.y + 90, 28, 700, G3, P(x, at(x, 0, 0.6))) }
491
+ const fill = prog(x.t, at(x, 0, 0.4), x.le[1] - at(x, 0, 0.4))
492
+ for (let j = 0; j < rows; j++) for (let i = 0; i < cols; i++) {
493
+ const h = hash(j * 31 + i)
494
+ if (h > fill * 1.02 || (i === BIT[0] && j === BIT[1])) continue
495
+ T(c, h > 0.5 ? '1' : '0', mx + i * pitch + pitch / 2, my + j * pitch + 6, 20, 500, h < 0.12 ? INK : G3, 'center')
496
+ }
497
+ R(c, 'crypto =', mx, my + rows * pitch + 8, 20, 700, INK, P(x, x.ls[1]))
498
+ // AI corpus: grey document blocks
499
+ const n = L.por ? 4 : 7
500
+ for (let j = 0; j < n; j++) {
501
+ const yy = dy + j * 34
502
+ const w = (0.45 + hash(j + 7) * 0.55) * (L.por ? A.w : A.w)
503
+ wipe(c, A.x, yy, w, 14, prog(x.t, x.ls[2] + j * 0.12, 0.5), G2)
504
+ }
505
+ R(c, '语料', A.x, dy - 40, 20, 700, INK, P(x, x.ls[2]))
506
+ })
507
+ if (x.t > x.ls[3] + 0.4) R(c, '没有被写下的,等于没有发生。', A.x, L.por ? A.y + A.h - 60 : dy + 120, 28, 700, INK, P(x, at(x, 3, 0.6)))
508
+ },
509
+ red(x) {
510
+ const { pitch, mx, my } = geo07(x.L)
511
+ const bit = rr(mx + BIT[0] * pitch + 4, my + BIT[1] * pitch + 4, pitch - 8, pitch - 8)
512
+ return mv(x.L.park, bit, P(x, at(x, 0, 0.5), 0.8))
513
+ },
514
+ cues(S) { return [[numLand(S), 'thunk'], [lt(S, 3) - 0.05, 'snick']] },
515
+ }
516
+
517
+ // =====================================================================
518
+ // R08 — 只会向前更新
519
+ // =====================================================================
520
+ const ROWS08: [string, string, string, number][] = [['房地产', '▼', '被削弱', 1], ['AI', '▲', '被加强', 1], ['黄金', '—', '从不升级', 2], ['比特币', '▲', '每个版本都在升级', 2]]
521
+ function play08(x: SCtx) { return clamp((x.t - x.ls[0]) / (x.le[3] - x.ls[0])) }
522
+ const r08: SceneDef = {
523
+ draw(x) {
524
+ const { c, L } = x
525
+ const A = L.A
526
+ head(x, [[1, '房地产 ▼ · AI ▲'], [2, '黄金 v1.0 · 比特币 vN'], [3, '不可回滚']])
527
+ const ty = A.y + 40
528
+ hline(c, A.x, ty, A.w, E(x, x.ls[0], 0.9))
529
+ for (let i = 0; i <= 10; i++) vline(c, A.x + A.w * i / 10 - (i === 10 ? 1 : 0), ty - 8, 8, P(x, x.ls[0] + i * 0.04, 0.3))
530
+ R(c, 'v2016', A.x, ty - 40, 18, 700, INK, P(x, x.ls[0]))
531
+ R(c, 'v2026', A.x + A.w, ty - 40, 18, 700, INK, P(x, x.ls[0] + 0.2), 0, 'right')
532
+ wipe(c, A.x, ty + 6, A.w, 4, play08(x), G2)
533
+ const rh = 74, y0 = A.y + 110
534
+ ROWS08.forEach(([n, s, d, k], j) => {
535
+ const ti = at(x, k, 0.1 + (j % 2) * 0.4), y = y0 + j * rh
536
+ hline(c, A.x, y + rh - 14, A.w, E(x, ti, 0.6), G2)
537
+ R(c, n, A.x, y, 40, 900, INK, P(x, ti))
538
+ R(c, s, A.x + A.w * 0.36, y, 40, 900, s === '—' ? G3 : INK, P(x, ti + 0.15))
539
+ R(c, d, A.x + A.w * 0.46, y + 10, 26, 500, s === '—' ? G3 : INK, P(x, ti + 0.25))
540
+ })
541
+ const ey = y0 + 4 * rh + 26
542
+ KT(x, '← 回滚', A.x, ey + 20, 56, 900, G3, x.ls[3], at(x, 3, 0.32))
543
+ if (x.t > at(x, 3, 0.5)) {
544
+ const sz = lerp(160, 64, steps(prog(x.t, at(x, 3, 0.55), 1.4), 4))
545
+ R(c, 'EGO', A.x + A.w, ey, sz, 900, INK, P(x, at(x, 3, 0.5), 0.4), 0, 'right')
546
+ note(c, 'small ego', A.x + A.w - 200, ey + 190, 200, P(x, at(x, 3, 0.85)))
547
+ }
548
+ },
549
+ red(x) {
550
+ const A = x.L.A
551
+ return rr(A.x + (A.w - 6) * play08(x), A.y + 40 - 28, 6, 56)
552
+ },
553
+ cues(S) { return [[numLand(S), 'thunk'], [lt(S, 3, 0.32), 'snick'], [lt(S, 3, 0.55), 'tick'], [lt(S, 3, 0.55) + 0.35, 'tick'], [lt(S, 3, 0.55) + 0.7, 'tick'], [lt(S, 3, 0.55) + 1.05, 'tick']] },
554
+ }
555
+
556
+ // =====================================================================
557
+ // R09 — 只有信息量
558
+ // =====================================================================
559
+ function geo09(L: Layout) {
560
+ const A = L.A, cols = L.por ? 18 : 24, rows = L.por ? 9 : 8
561
+ const pitch = A.w / cols
562
+ return { A, cols, rows, pitch, gy: A.y + 230, n: cols * rows }
563
+ }
564
+ function fill09(x: SCtx) { return clamp((x.t - x.ls[1]) / (x.le[1] - x.ls[1] + 0.4)) }
565
+ const r09: SceneDef = {
566
+ draw(x) {
567
+ const { c, L } = x
568
+ const { A, cols, rows, pitch, gy, n } = geo09(L)
569
+ head(x, [[0, '智商 → 信息量'], [1, '人工智障 → 破解难题']])
570
+ KT(x, '智商', A.x, A.y, 160, 900, G3, x.ls[0] + 0.3, at(x, 0, 0.62))
571
+ KT(x, '信息量', A.x, A.y, 160, 900, INK, at(x, 0, 0.82))
572
+ const k = Math.floor(fill09(x) * n)
573
+ for (let i = 0; i < n; i++) {
574
+ const q = i % cols, r = Math.floor(i / cols)
575
+ const cx = A.x + q * pitch, cy = gy + r * pitch, s = pitch - 6
576
+ const p = P(x, x.ls[0] + 0.5 + (q + r) * 0.01, 0.3)
577
+ if (p <= 0) continue
578
+ if (i < k) box(c, cx, cy, s, s, INK)
579
+ else frame(c, cx, cy, s * ease(p), s, G2)
580
+ }
581
+ const sy = gy + rows * pitch + 30
582
+ hline(c, A.x, sy, A.w, E(x, x.ls[1], 0.6))
583
+ R(c, '人工智障', A.x, sy + 14, 22, 700, G3, P(x, x.ls[1]))
584
+ R(c, '破解难题', A.x + A.w, sy + 14, 22, 700, INK, P(x, at(x, 1, 0.75)), 0, 'right')
585
+ if (x.t > x.ls[1]) box(c, A.x + (A.w - 2) * fill09(x), sy - 14, 2, 28, INK)
586
+ },
587
+ red(x) {
588
+ const { A, cols, pitch, gy, n } = geo09(x.L)
589
+ if (x.t < x.ls[1] - 0.5) return x.L.park
590
+ const k = Math.min(n - 1, Math.floor(fill09(x) * n))
591
+ const tgt = rr(A.x + (k % cols) * pitch, gy + Math.floor(k / cols) * pitch, pitch - 6, pitch - 6)
592
+ return mv(x.L.park, tgt, P(x, x.ls[1] - 0.5, 0.5))
593
+ },
594
+ cues(S) { return [[numLand(S), 'thunk'], [lt(S, 0, 0.62), 'snick']] },
595
+ }
596
+
597
+ // =====================================================================
598
+ // R10 — 先自由后财富
599
+ // =====================================================================
600
+ const FIB: [number, number, number][] = [[9, 5, 1], [8, 5, 1], [8, 6, 2], [10, 5, 3], [8, 0, 5], [0, 0, 8]]
601
+ function geo10(L: Layout) {
602
+ const A = L.A
603
+ const u = L.por ? Math.min(A.w / 8, A.h / 13) : Math.min(A.w / 13, A.h / 8)
604
+ const sq = FIB.map(([a, b, s]) => (L.por ? rr(A.x + b * u, A.y + a * u, s * u, s * u) : rr(A.x + a * u, A.y + b * u, s * u, s * u)))
605
+ return { A, u, sq }
606
+ }
607
+ const r10: SceneDef = {
608
+ draw(x) {
609
+ const { c, L } = x
610
+ const { A, sq } = geo10(L)
611
+ head(x, [[1, '自由 ⇄ 钱'], [2, '钱 = 放大器']])
612
+ KG(x, A, x.ls[2] - 0.05, () => {
613
+ sq.forEach((r, i) => {
614
+ const ti = i === 0 ? x.ls[0] + 0.6 : at(x, 1, (i - 1) / 5)
615
+ const p = E(x, ti, 0.5)
616
+ if (p <= 0) return
617
+ if (i > 0 && i % 2 === 0) wipe(c, r.x, r.y, r.w, r.h, p, SHEET, 'd')
618
+ hline(c, r.x, r.y, r.w, p, INK, 2); vline(c, r.x, r.y, r.h, p, INK, 2)
619
+ hline(c, r.x, r.y + r.h - 2, r.w, p, INK, 2); vline(c, r.x + r.w - 2, r.y, r.h, p, INK, 2)
620
+ if (i > 0) {
621
+ const s = Math.max(16, r.w * 0.16)
622
+ R(c, i % 2 ? '财富' : '自由', r.x + 12, r.y + 12, s, 900, i % 2 ? INK : G3, P(x, ti + 0.2))
623
+ }
624
+ })
625
+ })
626
+ const ty = A.y
627
+ KT(x, '彩票', A.x, ty, 140, 900, INK, x.ls[2] + 0.3, at(x, 2, 0.28))
628
+ if (x.t > at(x, 2, 0.28)) R(c, '= 诅咒', A.x, ty + 170, 40, 700, G3, P(x, at(x, 2, 0.2)))
629
+ if (x.t > at(x, 2, 0.42)) {
630
+ R(c, '钱 = 放大器', A.x, ty + 280, 96, 900, INK, P(x, at(x, 2, 0.42)))
631
+ R(c, '认知 × 10', A.x, ty + 430, 56, 900, INK, P(x, at(x, 2, 0.66)))
632
+ R(c, '错误 × 10', A.x, ty + 520, 56, 900, G3, P(x, at(x, 2, 0.84)))
633
+ }
634
+ },
635
+ red(x) {
636
+ const { sq } = geo10(x.L)
637
+ if (x.t < x.ls[0] + 0.3) return x.L.park
638
+ const A = x.L.A
639
+ const dot = rr(A.x + A.w - 60, A.y + 280, 60, 60)
640
+ return mv(mv(x.L.park, sq[0], P(x, x.ls[0] + 0.3, 0.6)), dot, P(x, at(x, 2, 0.42), 0.8))
641
+ },
642
+ cues(S) { return [[numLand(S), 'thunk'], [lt(S, 2) - 0.05, 'snick'], [lt(S, 2, 0.28), 'snick'], [lt(S, 2, 0.42), 'stamp']] },
643
+ }
644
+
645
+ // =====================================================================
646
+ // R11 — 体验大于意义
647
+ // =====================================================================
648
+ function geo11(L: Layout) { const A = L.A; return { A, ly: A.y + (L.por ? 600 : 520) } }
649
+ const r11: SceneDef = {
650
+ draw(x) {
651
+ const { c, L } = x
652
+ const { A, ly } = geo11(L)
653
+ head(x, [[0, '体验 > 意义'], [1, '先上路']])
654
+ const s1 = L.por ? 220 : 240
655
+ R(c, '体验', A.x, A.y, s1, 900, INK, P(x, x.ls[0] + 0.3))
656
+ const w1 = measure(c, '体验', s1, 900)
657
+ if (!L.por) {
658
+ R(c, '>', A.x + w1 + 40, A.y + 40, 120, 300, INK, P(x, at(x, 0, 0.55)))
659
+ R(c, '意义', A.x + w1 + 140, A.y + 70, 96, 700, G2, P(x, at(x, 0, 0.7)))
660
+ } else {
661
+ R(c, '> 意义', A.x, A.y + 270, 110, 700, G2, P(x, at(x, 0, 0.6)))
662
+ }
663
+ const p = prog(x.t, x.ls[1], x.le[1] - x.ls[1] + 0.6)
664
+ hline(c, A.x, ly, A.w, p, INK, 2)
665
+ R(c, '出发', A.x, ly + 28, 28, 900, INK, P(x, x.ls[1]))
666
+ R(c, '为什么?', A.x + A.w, ly + 28, 28, 700, G3, P(x, x.le[1]), 0, 'right')
667
+ for (let i = 1; i < 12; i++) if (p > i / 12) vline(c, A.x + A.w * i / 12, ly - 6, 6, 1, INK)
668
+ },
669
+ red(x) {
670
+ const { A, ly } = geo11(x.L)
671
+ const p = prog(x.t, x.ls[1], x.le[1] - x.ls[1] + 0.6)
672
+ const head = rr(A.x + (A.w - 24) * p, ly - 11, 24, 24)
673
+ return mv(x.L.park, head, P(x, x.ls[0] + 0.8, 0.8))
674
+ },
675
+ cues(S) { return [[numLand(S), 'thunk'], [lt(S, 1), 'glide']] },
676
+ }
677
+
678
+ // =====================================================================
679
+ // R12 — 正确答案 + end card
680
+ // =====================================================================
681
+ function geo12(L: Layout) {
682
+ const A = L.A
683
+ const cols = L.por ? 9 : 12, rows = L.por ? 7 : 6, cs = L.por ? 76 : 60, pitch = cs + 24
684
+ return { A, cols, rows, cs, pitch, gx: A.x, gy: A.y + 10, RR: 2, RC: 7 }
685
+ }
686
+ const endAt = (x: SCtx) => x.le[2] + 1.6
687
+ function leave12(x: SCtx) { return prog(x.t, at(x, 2, 0.35), x.le[2] - at(x, 2, 0.35) + 0.3) }
688
+ const r12: SceneDef = {
689
+ draw(x) {
690
+ const { c, L, S } = x
691
+ const { A, cols, rows, cs, pitch, gx, gy, RR, RC } = geo12(L)
692
+ const kEnd = endAt(x)
693
+ KG(x, rr(0, 100, L.W, L.H - 260), kEnd, () => {
694
+ head(x, [[1, '所有人'], [2, 'on grid → off grid']])
695
+ const lv = leave12(x)
696
+ const shift = E(x, x.le[2] + 0.2, 0.7)
697
+ for (let r = 0; r < rows; r++) for (let q = 0; q < cols; q++) {
698
+ if (r === RR && q === RC) continue
699
+ const p = P(x, x.ls[0] + Math.hypot(r - RR, q - RC) * 0.05, 0.3)
700
+ if (p <= 0) continue
701
+ const dq = r === RR && q > RC ? shift : 0
702
+ box(c, gx + (q - dq) * pitch, gy + r * pitch, cs * ease(p), cs, G2)
703
+ }
704
+ const ry = gy + rows * pitch + 20
705
+ hline(c, A.x, ry, A.w, E(x, x.ls[1], 0.6))
706
+ R(c, lv > 0.5 ? 'off grid' : 'on grid', A.x, ry + 16, 22, 700, lv > 0.5 ? INK : G3, lv > 0.5 ? P(x, at(x, 2, 0.35) + (x.le[2] - at(x, 2, 0.35) + 0.3) / 2, 0.3) : P(x, x.ls[1]))
707
+ R(c, '成为答案之前,不知道自己是答案', A.x + A.w, ry + 16, 22, 500, G3, P(x, at(x, 2, 0.5)), 0, 'right')
708
+ })
709
+ // end card
710
+ const e0 = kEnd + 0.45
711
+ if (x.t > e0) {
712
+ const por = L.por
713
+ R(c, '十二条规则', L.m, por ? 360 : 200, por ? 150 : 200, 900, INK, P(x, e0, 0.6))
714
+ R(c, '孙宇晨 2026 长访谈 · 观点整理', L.m, por ? 560 : 440, por ? 34 : 40, 700, INK, P(x, e0 + 0.25))
715
+ const cr = ['版式 · 瑞士国际主义 · 12 栏网格', '配音 · 合成语音', `音乐 · 程序化合成 · 112 BPM`, '素材 · 访谈原文整理']
716
+ cr.forEach((s, i) => note(c, s, por ? L.m : L.x(i * 3), por ? 760 + i * 70 : 640, por ? L.W - 2 * L.m : L.span(3), P(x, e0 + 0.5 + i * 0.12, 0.6), 19))
717
+ R(c, '本片为访谈观点整理与解读,不构成任何投资建议。', L.m, por ? 1180 : 820, 22, 500, G3, P(x, e0 + 1.1))
718
+ void S
719
+ }
720
+ },
721
+ red(x) {
722
+ const { cs, pitch, gx, gy, RR, RC } = geo12(x.L)
723
+ const cell = rr(gx + RC * pitch, gy + RR * pitch, cs, cs)
724
+ const lv = leave12(x)
725
+ if (lv >= 1) return null
726
+ return rr(lerp(cell.x, x.L.W + 40, lv), cell.y, cs, cs)
727
+ },
728
+ cues(S) { const e = S.lines[2].start + S.lines[2].dur; return [[numLand(S), 'thunk'], [lt(S, 2, 0.35), 'glide'], [e + 1.6, 'snick'], [e + 2.05, 'stamp']] },
729
+ }
730
+
731
+ export const SCENE_DEFS: Record<string, SceneDef> = { cover, r01, r02, r03, r04, r05, r06, r07, r08, r09, r10, r11, r12 }
732
+ export const NO_HEAD = new Set(['cover'])
733
+ export { endAt }
734
+ void T; void G1
earth-online-sun/web/src/swiss/timeline.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 《规则 RULES》timeline — absolute times for every narration line, built from measured VO durations.
2
+ // Scene starts are quantised to the music's beat so every chapter snap lands on a downbeat.
3
+ import VO from './vo.json'
4
+
5
+ export const BPM = 112
6
+ export const BEAT = 60 / BPM
7
+ export const BAR = BEAT * 4
8
+
9
+ export interface Line { i: number; scene: string; text: string; dur: number; start: number; end: number }
10
+ export interface SceneSpan { id: string; no: string; title: string; en: string; start: number; end: number; lines: Line[]; idx: number }
11
+
12
+ const DEF: { id: string; no: string; title: string; en: string; pre: number; post: number; gap: number }[] = [
13
+ { id: 'cover', no: '00', title: '十二条规则', en: 'Twelve Rules · 2026', pre: 2.6, post: 1.0, gap: 0.55 },
14
+ { id: 'r01', no: '01', title: '认知决定一切', en: 'Cognition', pre: 0.7, post: 0.7, gap: 0.5 },
15
+ { id: 'r02', no: '02', title: '三件零成本的事', en: 'Zero-cost kit', pre: 0.6, post: 0.7, gap: 0.45 },
16
+ { id: 'r03', no: '03', title: '交给 AI 托管', en: 'Autopilot', pre: 0.6, post: 0.7, gap: 0.45 },
17
+ { id: 'r04', no: '04', title: '寿命是硬通货', en: 'Lifespan', pre: 0.6, post: 0.7, gap: 0.45 },
18
+ { id: 'r05', no: '05', title: '先开图再决策', en: 'Scout first', pre: 0.6, post: 0.8, gap: 0.5 },
19
+ { id: 'r06', no: '06', title: '全球寻找商机', en: 'Global', pre: 0.6, post: 0.7, gap: 0.45 },
20
+ { id: 'r07', no: '07', title: '货币的翻译', en: 'Translation', pre: 0.6, post: 1.8, gap: 0.5 },
21
+ { id: 'r08', no: '08', title: '只会向前更新', en: 'Forward only', pre: 0.6, post: 0.7, gap: 0.45 },
22
+ { id: 'r09', no: '09', title: '只有信息量', en: 'Information', pre: 0.6, post: 0.7, gap: 0.5 },
23
+ { id: 'r10', no: '10', title: '先自由后财富', en: 'Freedom first', pre: 0.6, post: 0.8, gap: 0.5 },
24
+ { id: 'r11', no: '11', title: '体验大于意义', en: 'Experience', pre: 0.6, post: 0.8, gap: 0.5 },
25
+ { id: 'r12', no: '12', title: '正确答案', en: 'The answer', pre: 0.6, post: 7.0, gap: 0.6 },
26
+ ]
27
+
28
+ const vo = VO as { i: number; scene: string; text: string; dur: number }[]
29
+ export const LINES: Line[] = []
30
+ export const SCENES: SceneSpan[] = []
31
+ let cur = 0
32
+ DEF.forEach((d, idx) => {
33
+ cur = Math.ceil(cur / BEAT - 1e-6) * BEAT // land on a beat
34
+ if (SCENES.length) SCENES[SCENES.length - 1].end = cur
35
+ const start = cur
36
+ cur += d.pre
37
+ const lines: Line[] = []
38
+ const own = vo.filter(v => v.scene === d.id)
39
+ own.forEach((v, k) => {
40
+ const ln: Line = { ...v, start: cur, end: cur + v.dur }
41
+ lines.push(ln); LINES.push(ln)
42
+ cur += v.dur + (k < own.length - 1 ? d.gap : 0)
43
+ })
44
+ cur += d.post
45
+ SCENES.push({ id: d.id, no: d.no, title: d.title, en: d.en, start, end: cur, lines, idx })
46
+ })
47
+ // last scene ends on a bar line
48
+ const endQ = Math.ceil(cur / BAR) * BAR
49
+ SCENES[SCENES.length - 1].end = endQ
50
+ export const DUR = endQ
51
+
52
+ export function sceneAt(t: number) {
53
+ for (let i = 0; i < SCENES.length; i++) if (t < SCENES[i].end) return i
54
+ return SCENES.length - 1
55
+ }