diff --git a/earth-online-sun/web/public/swiss/vo/10.mp3 b/earth-online-sun/web/public/swiss/vo/10.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..de0cad92136cc06b2c755ff0a611cee796ac0b28 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/10.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/11.mp3 b/earth-online-sun/web/public/swiss/vo/11.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..1005e72403a26f51c2715d164cbd3b724d284042 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/11.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/12.mp3 b/earth-online-sun/web/public/swiss/vo/12.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..4aec9da306b6f49eec95187a32f8b582c4aee5db Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/12.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/13.mp3 b/earth-online-sun/web/public/swiss/vo/13.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..f6c7da2835fef006f47fc29b0d3f991c5a6f21ac Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/13.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/14.mp3 b/earth-online-sun/web/public/swiss/vo/14.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..e94effa14568aa5975c5079a933f2abe2330f408 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/14.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/15.mp3 b/earth-online-sun/web/public/swiss/vo/15.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..a4f4d5907d56df8b0534d0de714067b1c562c88b Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/15.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/16.mp3 b/earth-online-sun/web/public/swiss/vo/16.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..9d269f660cadd2674a556d5f490ea9ab8f6f07e1 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/16.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/17.mp3 b/earth-online-sun/web/public/swiss/vo/17.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..cadd7da42295838dd501d154639d2034baf7966b Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/17.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/18.mp3 b/earth-online-sun/web/public/swiss/vo/18.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..ce431e9d1dc22e9b329f65ecf011d5d64351a718 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/18.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/19.mp3 b/earth-online-sun/web/public/swiss/vo/19.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..024df32403e6f8f41d3afd95d81ee55c1d048057 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/19.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/20.mp3 b/earth-online-sun/web/public/swiss/vo/20.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..57d9326b3e31975d051392d3b2439ff62355af6b Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/20.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/21.mp3 b/earth-online-sun/web/public/swiss/vo/21.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..2a613eeef5345f3ba97bbd964666225d1bb85225 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/21.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/22.mp3 b/earth-online-sun/web/public/swiss/vo/22.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..a691ff4939f4ed805b1ad8139e2859e0c9b16f55 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/22.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/23.mp3 b/earth-online-sun/web/public/swiss/vo/23.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..3208a59dbd7f25edfad88f595e086cb94d0a5571 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/23.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/24.mp3 b/earth-online-sun/web/public/swiss/vo/24.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..5d2d1cba26b208c6cebf87b0f2129f5a3c3fd0f9 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/24.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/25.mp3 b/earth-online-sun/web/public/swiss/vo/25.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..1fae05710ce49ca7d66ec7bbcd2219bdc92383c2 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/25.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/26.mp3 b/earth-online-sun/web/public/swiss/vo/26.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..89cb283d4015c209c9ac06dcaccde603e82cf771 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/26.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/27.mp3 b/earth-online-sun/web/public/swiss/vo/27.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..9c0cd584ca84632fabc8a4a2d265168f70d0d2ad Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/27.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/28.mp3 b/earth-online-sun/web/public/swiss/vo/28.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..fa2b79ded7ff438c4af42a57e12ebab5ffb04475 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/28.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/29.mp3 b/earth-online-sun/web/public/swiss/vo/29.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..a045d18e404c530fb866bbe1f8938f935d4b6703 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/29.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/30.mp3 b/earth-online-sun/web/public/swiss/vo/30.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..cf28c73b413d9c95df42efeade6baa8c31ccf670 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/30.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/31.mp3 b/earth-online-sun/web/public/swiss/vo/31.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..91b5f6592a7cb68afe613d628edbb8b3902860a6 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/31.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/32.mp3 b/earth-online-sun/web/public/swiss/vo/32.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..7634dd29c8c8a0af5ce410dae10971042dd21a6f Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/32.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/33.mp3 b/earth-online-sun/web/public/swiss/vo/33.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..9767191e976f3df57534f2e0ee5ea2244c71645c Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/33.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/34.mp3 b/earth-online-sun/web/public/swiss/vo/34.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..4a890ab43a2b094fd4d6ec049bc95f517cd2b30c Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/34.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/35.mp3 b/earth-online-sun/web/public/swiss/vo/35.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..3586ed420692c02651e37866931485bfbe8f16d2 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/35.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/36.mp3 b/earth-online-sun/web/public/swiss/vo/36.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..36846cc42c66da3408fccc201a5a25c970de09f1 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/36.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/37.mp3 b/earth-online-sun/web/public/swiss/vo/37.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..d113ad7daa43fbdf21b4b9ade67dce507b1b54a6 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/37.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/38.mp3 b/earth-online-sun/web/public/swiss/vo/38.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..d460b960b0de7f2da5db7083858e9360e87f3769 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/38.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/39.mp3 b/earth-online-sun/web/public/swiss/vo/39.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..b9e74b730552da8f141a66c909090831378b7dad Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/39.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/40.mp3 b/earth-online-sun/web/public/swiss/vo/40.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..b164a4c688daf7860a3e43b3b0ddf3f71e75b8ca Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/40.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/41.mp3 b/earth-online-sun/web/public/swiss/vo/41.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..17fb8176c7bd0486012b7d9736ea0035e004ba6c Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/41.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/42.mp3 b/earth-online-sun/web/public/swiss/vo/42.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..98e4274d6fced11a2a6b9d3fdf68a73e8f80b2e4 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/42.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/43.mp3 b/earth-online-sun/web/public/swiss/vo/43.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..8820a2c966f8c218458b0a41e54f7b02327c1151 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/43.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/44.mp3 b/earth-online-sun/web/public/swiss/vo/44.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..cf8b66ad7ba6e2ca0409241ffeefba2952757183 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/44.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/45.mp3 b/earth-online-sun/web/public/swiss/vo/45.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..8ddf925fe8f3f56a0ad859634cdcac07c0d80890 Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/45.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/46.mp3 b/earth-online-sun/web/public/swiss/vo/46.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..da1eb222507698b93d595667ce13dee48aec7f5f Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/46.mp3 differ diff --git a/earth-online-sun/web/public/swiss/vo/47.mp3 b/earth-online-sun/web/public/swiss/vo/47.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..18f5b0cc83636af1a265c42588f5addea4c89f3d Binary files /dev/null and b/earth-online-sun/web/public/swiss/vo/47.mp3 differ diff --git a/earth-online-sun/web/src/App.tsx b/earth-online-sun/web/src/App.tsx index c9bc3c28e66442cd1b3f30a4171ed4b332f0b399..ea585afd48cc37b453c77328f720ff89f84dbed0 100644 --- a/earth-online-sun/web/src/App.tsx +++ b/earth-online-sun/web/src/App.tsx @@ -5,6 +5,7 @@ import { buildSoundtrack } from './film/audio' import { exportMP4, canWebCodecs } from './film/export' import { coverCanvas, RATIO_PX, type Ratio } from './film/covers' import { PLATFORMS, len, type Platform } from './film/copy' +import SwissEdition from './swiss/SwissEdition' const BASE = import.meta.env.BASE_URL || './' const SIZE = { land: [384, 216], port: [216, 384] } as const @@ -258,7 +259,7 @@ function LivePhotos() { ) } -export default function App() { +function PixelEdition() { const [orient, setOrient] = useState('land') useEffect(() => { if (window.innerHeight > window.innerWidth) setOrient('port') }, []) const total = useMemo(() => fmt(DUR), []) @@ -285,3 +286,25 @@ export default function App() { ) } + +// ---------------- style switcher (both editions live side by side) ---------------- +type Edition = 'swiss' | 'pixel' +export default function App() { + const [ed, setEd] = useState(() => (typeof location !== 'undefined' && location.hash === '#pixel' ? 'pixel' : 'swiss')) + useEffect(() => { history.replaceState(null, '', `#${ed}`); window.scrollTo(0, 0) }, [ed]) + const dark = ed === 'pixel' + return ( + <> + + {ed === 'swiss' ? : } + + ) +} diff --git a/earth-online-sun/web/src/film/export.ts b/earth-online-sun/web/src/film/export.ts index 97b6ebf5d34f4cb6e751ea4c887c456be49b68d4..cf220b9f134b5d1975eb1ea98d8c0f78aab96a29 100644 --- a/earth-online-sun/web/src/film/export.ts +++ b/earth-online-sun/web/src/film/export.ts @@ -140,3 +140,101 @@ async function recordRealtime(o: ExportOpts): Promise<{ blob: Blob; ext: string const isMp4 = mime.startsWith('video/mp4') return { blob: new Blob(chunks, { type: isMp4 ? 'video/mp4' : 'video/webm' }), ext: isMp4 ? 'mp4' : 'webm' } } + +// --------------------------------------------------------------------------- +// Generic vector-canvas export (used by the Swiss edition). `draw(ctx, t)` paints one frame +// in logical units; the context is pre-scaled so the output is crisp at any size. +export interface CanvasExportOpts { + w: number; h: number // output pixels + lw: number; lh: number // logical size the draw function uses + fps: 24 | 30 | 60 + dur: number + audio: AudioBuffer + draw: (c: CanvasRenderingContext2D, t: number) => void + onProgress: (p: number, msg: string) => void + signal: { aborted: boolean } + limit?: number +} +export async function exportCanvasMP4(o: CanvasExportOpts): Promise<{ blob: Blob; ext: string }> { + const { w, h, lw, lh, fps, audio, onProgress, signal, draw } = o + const LEN = Math.min(o.dur, o.limit ?? o.dur) + const cv = document.createElement('canvas'); cv.width = w; cv.height = h + const c = cv.getContext('2d', { alpha: false })! + const paint = (t: number) => { c.setTransform(w / lw, 0, 0, h / lh, 0, 0); draw(c, t) } + const vcfg = await pickVideo(w, h, fps) + const acfg = await pickAudio() + if (!vcfg || !acfg) return recordCanvasRealtime(o, cv, paint, LEN) + const target = new ArrayBufferTarget() + const muxer = new Muxer({ + target, fastStart: 'in-memory', firstTimestampBehavior: 'offset', + video: { codec: 'avc', width: w, height: h, frameRate: fps }, + audio: { codec: acfg.kind, numberOfChannels: 2, sampleRate: 48000 }, + }) + let err: unknown = null + const venc = new VideoEncoder({ output: (ch, m) => muxer.addVideoChunk(ch, m), error: e => { err = e } }) + venc.configure({ ...vcfg, bitrate: Math.round(w * h * fps * 0.1) }) + const aenc = new AudioEncoder({ output: (ch, m) => muxer.addAudioChunk(ch, m), error: e => { err = e } }) + aenc.configure(acfg.cfg) + onProgress(0, '编码音频') + const SR = 48000, total = Math.min(audio.length, Math.ceil(LEN * SR)), CH = 4800 + const L = audio.getChannelData(0), R = audio.numberOfChannels > 1 ? audio.getChannelData(1) : L + for (let i = 0; i < total; i += CH) { + const n = Math.min(CH, total - i), data = new Float32Array(n * 2) + data.set(L.subarray(i, i + n), 0); data.set(R.subarray(i, i + n), n) + const ad = new AudioData({ format: 'f32-planar', sampleRate: SR, numberOfFrames: n, numberOfChannels: 2, timestamp: Math.round((i / SR) * 1e6), data }) + aenc.encode(ad); ad.close() + } + await aenc.flush() + const N = Math.round(LEN * fps), fd = Math.round(1e6 / fps), t0 = performance.now() + for (let i = 0; i < N; i++) { + if (signal.aborted) { try { venc.close(); aenc.close() } catch { /* */ } throw new Error('已取消') } + if (err) throw err + paint(i / fps) + const vf = new VideoFrame(cv, { timestamp: Math.round((i * 1e6) / fps), duration: fd }) + venc.encode(vf, { keyFrame: i % (fps * 2) === 0 }); vf.close() + while (venc.encodeQueueSize > 6) await new Promise(r => setTimeout(r, 1)) + if (i % 10 === 0) { + const el = (performance.now() - t0) / 1000, eta = (el / (i + 1)) * (N - i - 1) + onProgress((i + 1) / N, `渲染画面 ${i + 1}/${N} 帧 · 剩余约 ${Math.ceil(eta)} 秒`) + await new Promise(r => setTimeout(r, 0)) + } + } + onProgress(1, '封装 MP4') + await venc.flush() + if (err) throw err + muxer.finalize() + return { blob: new Blob([target.buffer], { type: 'video/mp4' }), ext: 'mp4' } +} +async function recordCanvasRealtime(o: CanvasExportOpts, cv: HTMLCanvasElement, paint: (t: number) => void, LEN: number): Promise<{ blob: Blob; ext: string }> { + const { fps, audio, onProgress, signal } = o + if (typeof MediaRecorder === 'undefined') throw new Error('此浏览器不支持本地导出,请使用最新版 Chrome / Edge / Safari') + const ac = new AudioContext({ sampleRate: 48000 }) + const dst = ac.createMediaStreamDestination() + const src = ac.createBufferSource(); src.buffer = audio; src.connect(dst) + const stream = new MediaStream([...cv.captureStream(fps).getVideoTracks(), ...dst.stream.getAudioTracks()]) + const types = ['video/mp4;codecs=avc1.640028,mp4a.40.2', 'video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm'] + const mime = types.find(t => MediaRecorder.isTypeSupported(t)) || '' + const rec = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 12_000_000 }) + const chunks: Blob[] = [] + rec.ondataavailable = e => e.data.size && chunks.push(e.data) + const done = new Promise(r => (rec.onstop = () => r())) + rec.start(1000) + const start = ac.currentTime + 0.1 + src.start(start) + await new Promise(resolve => { + const tick = () => { + if (signal.aborted) { resolve(); return } + const t = ac.currentTime - start + if (t >= LEN) { resolve(); return } + paint(Math.max(0, t)) + onProgress(Math.max(0, t) / LEN, `实时录制中(此浏览器不支持快速导出)${Math.floor(Math.max(0, t))}/${Math.floor(LEN)} 秒`) + requestAnimationFrame(tick) + } + tick() + }) + if (rec.state !== 'inactive') rec.stop() + await done; src.stop(); ac.close() + if (signal.aborted) throw new Error('已取消') + const isMp4 = mime.startsWith('video/mp4') + return { blob: new Blob(chunks, { type: isMp4 ? 'video/mp4' : 'video/webm' }), ext: isMp4 ? 'mp4' : 'webm' } +} diff --git a/earth-online-sun/web/src/swiss/SwissEdition.tsx b/earth-online-sun/web/src/swiss/SwissEdition.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7c7e0c579d61c5b4b0279e8c80ec095f41f9c677 --- /dev/null +++ b/earth-online-sun/web/src/swiss/SwissEdition.tsx @@ -0,0 +1,320 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Play, Pause, Download, Copy, Check, Maximize, Loader2, X } from 'lucide-react' +import { drawFrame, DUR, SCENES } from './render' +import { buildSwissSoundtrack } from './audio' +import { loadSwissFonts } from './fonts' +import { exportCanvasMP4, canWebCodecs } from '../film/export' +import { swissCoverCanvas, SRATIO_PX, type SRatio } from './covers' +import { SPLATFORMS, type SPlatform } from './copy' + +const BASE = import.meta.env.BASE_URL || './' +type Orient = 'land' | 'port' +const LOG = { land: [1920, 1080], port: [1080, 1920] } as const +const fmt = (t: number) => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, '0')}` +const len = (s: string) => [...s].length + +let trackP: Promise | null = null +const getTrack = (onP: (s: string, p: number) => void) => (trackP ??= buildSwissSoundtrack(onP).catch(e => { trackP = null; throw e })) + +const ink = 'text-[#111]', mute = 'text-[#8A877F]' +const btn = 'px-3 py-1.5 border border-[#111] text-sm transition-colors' +const on = 'bg-[#111] text-[#EDECE8]' + +function Label({ n, children }: { n: string; children: React.ReactNode }) { + return ( +
+ {n} +

{children}

+
+ ) +} + +// ---------------- player ---------------- +function Player({ orient, setOrient }: { orient: Orient; setOrient: (o: Orient) => void }) { + const [LW, LH] = LOG[orient] + const canvas = useRef(null) + const box = useRef(null) + const [playing, setPlaying] = useState(false) + const [time, setTime] = useState(0) + const [loading, setLoading] = useState(null) + const [fontsOk, setFontsOk] = useState(false) + const st = useRef({ ctx: null as AudioContext | null, buf: null as AudioBuffer | null, src: null as AudioBufferSourceNode | null, startAt: 0, offset: 0, playing: false }) + useEffect(() => { loadSwissFonts().then(() => setFontsOk(true)) }, []) + + const now = useCallback(() => { + const s = st.current + return s.playing && s.ctx ? Math.min(DUR, s.ctx.currentTime - s.startAt) : s.offset + }, []) + const stopSrc = () => { const s = st.current; if (s.src) { try { s.src.stop() } catch { /* */ } s.src.disconnect(); s.src = null } } + + useEffect(() => { + const cv = canvas.current! + const g = cv.getContext('2d', { alpha: false })! + let raf = 0, last = -1, lw = 0 + const loop = () => { + const dpr = Math.min(2, window.devicePixelRatio || 1) + const w = Math.max(320, Math.round(cv.clientWidth * dpr)) + if (w !== lw) { cv.width = w; cv.height = Math.round(w * LH / LW); lw = w; last = -1 } + const t = now() + if (st.current.playing && t >= DUR - 0.01) { stopSrc(); st.current.offset = 0; st.current.playing = false; setPlaying(false) } + if (t !== last) { + g.setTransform(cv.width / LW, 0, 0, cv.height / LH, 0, 0) + drawFrame(g, t, orient === 'port'); last = t; setTime(t) + } + raf = requestAnimationFrame(loop) + } + raf = requestAnimationFrame(loop) + return () => cancelAnimationFrame(raf) + }, [LW, LH, orient, now, fontsOk]) + + const startSrc = (offset: number) => { + const s = st.current + if (!s.ctx || !s.buf) return + stopSrc() + const src = s.ctx.createBufferSource(); src.buffer = s.buf; src.connect(s.ctx.destination) + src.start(0, offset); s.src = src; s.startAt = s.ctx.currentTime - offset + } + const toggle = async () => { + const s = st.current + if (s.playing) { s.offset = now(); stopSrc(); s.playing = false; setPlaying(false); return } + if (!s.ctx) s.ctx = new AudioContext({ sampleRate: 48000 }) + await s.ctx.resume() + if (!s.buf) { + setLoading('准备声音 0%') + try { s.buf = await getTrack((n, p) => setLoading(`准备${n} ${Math.round(p * 100)}%`)) } catch (e) { setLoading(null); alert('声音加载失败:' + (e as Error).message); return } + setLoading(null) + } + if (s.offset >= DUR - 0.05) s.offset = 0 + startSrc(s.offset); s.playing = true; setPlaying(true) + } + const seek = (t: number) => { + const s = st.current + s.offset = Math.max(0, Math.min(DUR, t)) + if (s.playing) startSrc(s.offset); else setTime(s.offset) + } + const full = () => { const el = box.current; if (!el) return; if (document.fullscreenElement) document.exitFullscreen(); else el.requestFullscreen?.() } + const cur = SCENES.findIndex(s => time < s.end) + const maxW = orient === 'land' ? 1100 : 440 + + return ( +
+
+ + {!playing && !loading && ( + + )} + {loading &&
{loading}
} +
+
+ + seek(+e.target.value)} className="flex-1 accent-[#E5231B]" /> + {fmt(time)} / {fmt(DUR)} + +
+
+
+ + +
+
+ {SCENES.map((s, i) => ( + + ))} +
+
+
+ ) +} + +// ---------------- export ---------------- +function Exporter({ orient }: { orient: Orient }) { + const [fps, setFps] = useState<24 | 30 | 60>(30) + const [o, setO] = useState(orient) + const [busy, setBusy] = useState(false) + const [msg, setMsg] = useState('') + const [p, setP] = useState(0) + const [out, setOut] = useState<{ url: string; name: string; size: number } | null>(null) + const sig = useRef({ aborted: false }) + useEffect(() => setO(orient), [orient]) + const go = async () => { + setBusy(true); setOut(null); setP(0); sig.current = { aborted: false } + try { + await loadSwissFonts() + setMsg('准备声音…') + const audio = await getTrack((n, q) => setMsg(`准备${n} ${Math.round(q * 100)}%`)) + const [w, h] = LOG[o] + const { blob, ext } = await exportCanvasMP4({ + w, h, lw: w, lh: h, fps, dur: DUR, audio, signal: sig.current, + draw: (c, t) => drawFrame(c, t, o === 'port'), + onProgress: (q, m) => { setP(q); setMsg(m) }, + }) + const name = `十二条规则_孙宇晨访谈_${o === 'land' ? '横屏1920x1080' : '竖屏1080x1920'}_${fps}fps.${ext}` + const url = URL.createObjectURL(blob) + setOut({ url, name, size: blob.size }) + const a = document.createElement('a'); a.href = url; a.download = name; a.click() + setMsg('完成') + } catch (e) { setMsg('导出中断:' + (e as Error).message) } + setBusy(false) + } + return ( +
+ +

在你的浏览器里本地渲染,矢量画面直接输出 1080p。

+
+
+ {(['land', 'port'] as Orient[]).map((k, i) => ( + + ))} +
+
+ {([24, 30, 60] as const).map((f, i) => ( + + ))} +
+
+
+ {!busy + ? + : } + {out && 再次下载({(out.size / 1048576).toFixed(1)} MB)} +
+ {(busy || msg) && ( +
+
+
{msg}
+
+ )} +

{canWebCodecs() ? '使用硬件编码快速导出。导出期间请保持本页在前台。' : '当前浏览器不支持快速导出,将以实时录制方式导出。推荐最新版 Chrome / Edge / Safari。'}

+
+ ) +} + +// ---------------- covers + copy ---------------- +function useCopy() { + const [k, setK] = useState('') + const copy = async (key: string, s: string) => { + try { await navigator.clipboard.writeText(s) } catch { + const ta = document.createElement('textarea'); ta.value = s; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove() + } + setK(key); setTimeout(() => setK(''), 1200) + } + return { k, copy } +} +function CopyBtn({ id, text, c }: { id: string; text: string; c: ReturnType }) { + return +} +function Cover({ p, ratio, label, ready }: { p: SPlatform; ratio: SRatio; label: string; ready: boolean }) { + const [url, setUrl] = useState('') + useEffect(() => { if (ready) setUrl(swissCoverCanvas(ratio, p.cover).toDataURL('image/jpeg', 0.94)) }, [p, ratio, ready]) + const [w, h] = SRATIO_PX[ratio] + return ( +
+ {url ? {`${p.name} :
} +
+ {label} · {w}×{h} + {url && 下载} +
+
+ ) +} +function PlatformCard({ p, ready }: { p: SPlatform; ready: boolean }) { + const c = useCopy() + const topics = p.topics.map(t => `#${t}`).join(' ') + const tl = len(p.title) + const all = `${p.title}\n\n${p.desc}\n\n${topics}` + return ( +
+
+

{p.name}

+ +
+
x.ratio === '3:4') ? 'grid-cols-[3fr_4fr]' : 'grid-cols-[4fr_5.33fr]'} items-start`}> + {p.covers.map(x => )} +
+
+
标题 p.titleMax ? 'text-[#E5231B]' : ''}>{tl}/{p.titleMax} 字
+

{p.title}

+
+
+
描述
+

{p.desc}

+
+
+
话题{p.topics.length}{p.topicMax ? `/${p.topicMax}` : ' 个(不限)'}
+

{topics}

+
+
+ ) +} + +// ---------------- live photos ---------------- +interface LiveItem { name: string; title: string; caption?: string; jpg: string; mp4?: string } +function LivePhotos() { + const [items, setItems] = useState(null) + useEffect(() => { fetch(`${BASE}swiss/live/manifest.json`).then(r => (r.ok ? r.json() : [])).then(setItems).catch(() => setItems([])) }, []) + return ( +
+ +
+

每组为同名 .jpg + .mov(3:4,1080×1440)。导入 iPhone 相册即为实况照片,可直接发小红书 / 抖音图文。

+ 下载全部(zip) +
+ {items === null ?
加载中…
: ( +
+ {items.map(it => ( +
+ {it.mp4 + ?
+ ))} +
+ )} +
+ ) +} + +export default function SwissEdition() { + const [orient, setOrient] = useState('land') + const [ready, setReady] = useState(false) + useEffect(() => { if (window.innerHeight > window.innerWidth) setOrient('port'); loadSwissFonts().then(() => setReady(true)) }, []) + return ( +
+
+
+
+ 孙宇晨 · 2026 长访谈N° 01—12 · {fmt(DUR)} +
+
+ 12 + +
+
+

十二条规则

+

瑞士国际主义版式 · 12 栏网格 · 112 BPM · 旁白 + 原创配乐

+
+
+
+ + +
+ +
+ +
{SPLATFORMS.map(p => )}
+
+ +
+

本片为访谈观点整理与解读,不构成任何投资建议。访谈:孙宇晨 × 邵艾伦(文字整理:律动BlockBeats)。

+

画面与音乐由程序实时生成;旁白为 AI 合成;字体 Noto Sans SC(SIL OFL 1.1);地图数据 Natural Earth(公有领域)。

+
+
+
+ ) +} diff --git a/earth-online-sun/web/src/swiss/audio.ts b/earth-online-sun/web/src/swiss/audio.ts new file mode 100644 index 0000000000000000000000000000000000000000..354e38c7f3454032cbefd158c0daaf9904261b4a --- /dev/null +++ b/earth-online-sun/web/src/swiss/audio.ts @@ -0,0 +1,182 @@ +// 《规则 RULES》soundtrack — a mechanical piano/marimba ostinato at 112 BPM, one layer per rule, +// print-room foley (cuts, stamps, slides) and narration, mixed offline into one stereo buffer. +import { SCENES, LINES, DUR, BEAT, BAR } from './timeline' +import { allCues } from './render' + +export const SR = 48000 +const hz = (m: number) => 440 * Math.pow(2, (m - 69) / 12) +// Am(add9) · Fmaj7 · Cmaj7 · G(add6) — one bar each +const CHORDS = [[57, 60, 64, 71], [53, 57, 60, 64], [48, 52, 55, 59], [55, 59, 62, 64]] +const ROOTS = [33, 29, 36, 31] +const ORDER = [0, 2, 1, 3, 2, 1, 3, 2] + +class Mix { + L: Float32Array; R: Float32Array; n: number + constructor(n: number) { this.n = n; this.L = new Float32Array(n); this.R = new Float32Array(n) } + add(t: number, d: Float32Array, g: number, pan = 0) { + const o = Math.round(t * SR), gl = g * Math.cos((pan + 1) * Math.PI / 4), gr = g * Math.sin((pan + 1) * Math.PI / 4) + 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 } + } +} +const buf = (sec: number) => new Float32Array(Math.round(sec * SR)) +let seed = 7 +const noise = () => { seed = (seed * 1664525 + 1013904223) | 0; return (seed >>> 0) / 2147483648 - 1 } + +// ---------- instruments (cached per pitch) ---------- +const cache = new Map() +function memo(key: string, f: () => Float32Array) { let b = cache.get(key); if (!b) { b = f(); cache.set(key, b) } return b } +const piano = (m: number) => memo('p' + m, () => { + const f = hz(m), d = buf(1.4), amp = [1, 0.45, 0.22, 0.1, 0.05] + for (let i = 0; i < d.length; i++) { + const t = i / SR, a = Math.min(1, t / 0.003) + let s = 0 + 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)) + d[i] = s * a * 0.32 + } + return d +}) +const marimba = (m: number) => memo('m' + m, () => { + const f = hz(m), d = buf(0.6) + 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 } + return d +}) +const bassN = (m: number) => memo('b' + m, () => { + const f = hz(m), d = buf(BEAT * 1.9) + 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 } + return d +}) +const padN = (ch: number[]) => memo('pad' + ch.join(), () => { + const d = buf(BAR + 0.6) + for (let i = 0; i < d.length; i++) { + const t = i / SR, env = Math.min(1, t / 0.7) * (t > BAR ? Math.max(0, 1 - (t - BAR) / 0.6) : 1) + let s = 0 + 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) } + d[i] = s * env * 0.045 + } + return d +}) +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 }) +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 }) +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 }) + +// ---------- foley ---------- +function foley(id: string): Float32Array { + return memo('fx' + id, () => { + let d: Float32Array + switch (id) { + 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 + 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 } + 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 } + 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 } + 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 + 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 + 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 + 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 } + default: d = buf(0.01) + } + return d + }) +} + +// ---------- score ---------- +function writeMusic(mx: Mix) { + const idx = (id: string) => SCENES.findIndex(s => s.id === id) + const S7 = SCENES[idx('r07')], S12 = SCENES[idx('r12')] + const silenceA = S7.lines[2].start, silenceB = S7.end + const dropA = S7.lines[2].start - BAR + const level = (t: number) => { // how many layers are active at time t (0 = piano only) + const i = SCENES.findIndex(s => t < s.end) + return i < 0 ? 0 : i + } + const start = 0.4 + const bars = Math.ceil(DUR / BAR) + for (let b = 0; b < bars; b++) { + const t0 = start + b * BAR + if (t0 >= DUR) break + const ci = b % 4, ch = CHORDS[ci] + for (let e = 0; e < 8; e++) { + const t = t0 + e * BEAT / 2 + if (t >= DUR - 0.2) continue + if (t >= silenceA && t < silenceB) continue + const lv = level(t) + const final = t >= S12.start + const m = ch[ORDER[e]] + 12 + // piano ostinato (always) + mx.add(t, piano(m), e % 2 ? 0.55 : 0.75, e % 2 ? 0.25 : -0.25) + if (final) continue + const drums = lv >= 1 && !(t >= dropA && t < silenceB) + if (drums && e % 2 === 0) mx.add(t, kick, e === 0 || e === 4 ? 0.85 : 0.0) + if (lv >= 2 && drums && e % 2 === 1) mx.add(t, hat, 0.9, 0.4) + if (lv >= 3 && e % 2 === 0) mx.add(t, bassN(ROOTS[ci] + (e === 6 ? 7 : 0)), e === 0 ? 0.85 : 0.6) + if (lv >= 4 && (e === 3 || e === 7)) mx.add(t + BEAT / 4, marimba(ch[(e + b) % 4] + 24), 0.45, 0.5) + if (lv >= 5 && drums && (e === 2 || e === 6)) mx.add(t, rim, 0.8, -0.4) + if (lv >= 6 && e === 0 && !(t >= dropA && t < silenceB)) mx.add(t, padN(ch), 1) + } + } + // final chord, let it ring + const fin = S12.lines[2].start + S12.lines[2].dur + 0.4 + const qf = Math.ceil((fin - start) / BEAT) * BEAT + start + for (const m of [45, 57, 60, 64, 71, 76]) mx.add(qf, piano(m), 0.55) +} + +// ---------- VO ---------- +const base = () => (import.meta as unknown as { env?: { BASE_URL?: string } }).env?.BASE_URL ?? './' +async function loadVO(onProgress?: (p: number) => void) { + let done = 0 + const ctx = new OfflineAudioContext(1, 1, SR) + return Promise.all(LINES.map(async l => { + const ab = await (await fetch(`${base()}swiss/vo/${String(l.i).padStart(2, '0')}.mp3`)).arrayBuffer() + const b = await ctx.decodeAudioData(ab) + onProgress?.(++done / LINES.length) + return b.getChannelData(0) + })) +} +const tick = () => new Promise(r => setTimeout(r, 0)) + +export async function buildSwissSoundtrack(onProgress?: (stage: string, p: number) => void): Promise { + const len = Math.ceil(DUR * SR) + onProgress?.('旁白', 0) + const vo = await loadVO(p => onProgress?.('旁白', p)) + onProgress?.('音乐', 0.3); await tick() + const music = new Mix(len), fx = new Mix(len) + writeMusic(music) + onProgress?.('音效', 0.6); await tick() + for (const [t, id, g] of allCues()) fx.add(t, foley(id), g ?? 1, 0) + const voB = new Float32Array(len) + 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] }) + onProgress?.('混音', 0.85); await tick() + // duck music ~-10 dB under narration + const BASE = 0.6, DUCK = 0.32 + const duck = new Float32Array(len).fill(1) + for (const l of LINES) { + 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) + for (let i = Math.max(0, a); i < Math.min(len, d); i++) { + const g = i < b ? 1 - (1 - DUCK) * (i - a) / (b - a) : i < c ? DUCK : DUCK + (1 - DUCK) * (i - c) / (d - c) + if (g < duck[i]) duck[i] = g + } + } + const L = new Float32Array(len), R = new Float32Array(len) + let hp = 0 + const aH = 1 - Math.exp(-2 * Math.PI * 90 / SR) + for (let i = 0; i < len; i++) { + hp += aH * (voB[i] - hp) + const v = (voB[i] - hp) * 1.05, m = BASE * duck[i] + L[i] = music.L[i] * m + fx.L[i] * 0.8 + v + R[i] = music.R[i] * m + fx.R[i] * 0.8 + v + } + // fade out last 1.2 s, then limiter at -1 dBFS + const fo = Math.round(1.2 * SR) + for (let i = len - fo; i < len; i++) { const g = (len - i) / fo; L[i] *= g; R[i] *= g } + const TH = 0.89, rel = Math.exp(-1 / (0.08 * SR)) + let env = 0 + for (let i = 0; i < len; i++) { + const pk = Math.max(Math.abs(L[i]), Math.abs(R[i])) + env = pk > env ? pk : env * rel + pk * (1 - rel) + const g = env > TH ? TH / env : 1 + L[i] *= g; R[i] *= g + } + const out = new AudioBuffer({ length: len, numberOfChannels: 2, sampleRate: SR }) + out.copyToChannel(L, 0); out.copyToChannel(R, 1) + onProgress?.('完成', 1) + return out +} diff --git a/earth-online-sun/web/src/swiss/copy.ts b/earth-online-sun/web/src/swiss/copy.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ccea8f0d824caed240d50b03776f7adf803805e --- /dev/null +++ b/earth-online-sun/web/src/swiss/copy.ts @@ -0,0 +1,74 @@ +// Swiss-edition copy per platform (titles / descriptions / topics within each platform's limits). +import type { SRatio, SCoverText } from './covers' + +export interface SPlatform { + id: string; name: string + titleMax: number; topicMax: number | null + covers: { ratio: SRatio; label: string }[] + title: string; desc: string; topics: string[] + cover: SCoverText +} + +const RULES = `01 认知决定一切 +02 先做三件零成本的事:孙学、薄肌、黄毛 +03 把日常交给 AI 托管:九成交给 AI,一成自己微调 +04 寿命是唯一的硬通货 +05 先开图,再决策:看一百套,前三套只做基准 +06 在全球寻找商机 +07 把货币翻译成机器的语言 +08 世界只会向前更新,版本不能回滚 +09 没有智商,只有信息量 +10 先自由,后财富 +11 体验大于意义 +12 成为正确答案之前,没有人知道自己是正确的` + +const FOOT = `访谈:孙宇晨 × 邵艾伦(文字整理:律动BlockBeats) +本片为访谈观点整理与解读,不构成任何投资建议。` + +export const SPLATFORMS: SPlatform[] = [ + { + id: 'xhs', name: '小红书', titleMax: 20, topicMax: 10, + covers: [{ ratio: '3:4', label: '竖版 3:4' }, { ratio: '4:3', label: '横版 4:3' }], + title: '孙宇晨的十二条人生规则', + desc: `2016 年他写下《财富自由之路》,2026 年的长访谈里,他交出了新的答案。 +用一张网格、一个红色方块,把它整理成十二条规则: + +${RULES} + +${FOOT}`, + topics: ['孙宇晨', '财富自由', '认知升级', '人生规则', '个人成长', 'AI', '访谈解读', '平面设计', '瑞士风格', '自我提升'], + cover: { kicker: 'N° 01—12', title: '十二条规则', sub: '认知 · 寿命 · 信息量', list: [['01', '认知决定一切'], ['04', '寿命是硬通货'], ['11', '体验大于意义']] }, + }, + { + id: 'douyin', name: '抖音', titleMax: 30, topicMax: 5, + covers: [{ ratio: '3:4', label: '竖版 3:4' }, { ratio: '4:3', label: '横版 4:3' }], + title: '孙宇晨2026长访谈:十二条规则,一条比一条清醒', + desc: `认知决定一切|寿命是唯一的硬通货|先开图再决策|版本不能回滚|体验大于意义。 +两分钟,十二条。 +本片为访谈观点整理与解读,不构成任何投资建议。`, + topics: ['孙宇晨', '财富自由', '认知', '人生建议', 'AI'], + cover: { kicker: 'N° 01—12', title: '十二条规则', sub: '一条比一条清醒', list: [['05', '先开图再决策'], ['08', '只会向前更新'], ['10', '先自由后财富']] }, + }, + { + id: 'bili', name: 'B站', titleMax: 80, topicMax: 10, + covers: [{ ratio: '4:3', label: '视频封面 4:3' }, { ratio: '16:9', label: '个人空间 16:9' }], + title: '【瑞士排版】孙宇晨2026长访谈整理:十二条规则——认知、寿命、开图、全球商机与small ego', + desc: `一部用瑞士国际主义版式做的观点整理:12 栏网格、一套字体、一个红色方块,112 BPM。 + +${RULES} + +画面与音乐均由程序原创生成;旁白为 AI 合成;字体 Noto Sans SC(SIL OFL 1.1)。 +${FOOT}`, + topics: ['孙宇晨', '访谈', '财富自由', '人工智能', '认知', '平面设计', '瑞士风格', '动态排版', '知识分享', '人生'], + cover: { kicker: 'N° 01—12', title: '十二条规则', sub: '孙宇晨 2026 长访谈 · 观点整理', list: [['01', '认知决定一切'], ['07', '货币的翻译'], ['12', '正确答案']] }, + }, + { + id: 'wx', name: '视频号', titleMax: 16, topicMax: null, + covers: [{ ratio: '3:4', label: '个人主页卡片 3:4' }, { ratio: '4:3', label: '分享卡片 4:3' }], + title: '孙宇晨的十二条规则', + desc: `认知决定一切,寿命是硬通货,先自由后财富,体验大于意义。 +本片为访谈观点整理与解读,不构成任何投资建议。`, + topics: ['孙宇晨', '财富自由', '认知升级', '人生规则', 'AI', '访谈', '个人成长', '终身学习', '平面设计'], + cover: { kicker: 'N° 01—12', title: '十二条规则', sub: '孙宇晨 2026 长访谈', list: [['01', '认知决定一切'], ['04', '寿命是硬通货'], ['10', '先自由后财富']] }, + }, +] diff --git a/earth-online-sun/web/src/swiss/covers.ts b/earth-online-sun/web/src/swiss/covers.ts new file mode 100644 index 0000000000000000000000000000000000000000..cff6381cb239dd6e0c050879a2d905bed7b45aa0 --- /dev/null +++ b/earth-online-sun/web/src/swiss/covers.ts @@ -0,0 +1,73 @@ +// Swiss-edition covers: grid, one giant numeral, one red rectangle, nothing else. +import { PAPER, INK, G2, G3, RED, T, measure, box, hline } from './kit' + +export type SRatio = '4:3' | '3:4' | '16:9' +export const SRATIO_PX: Record = { '4:3': [1440, 1080], '3:4': [1080, 1440], '16:9': [1920, 1080] } +export interface SCoverText { kicker: string; title: string; sub: string; list: [string, string][] } + +export function drawSwissCover(c: CanvasRenderingContext2D, r: SRatio, v: SCoverText) { + const [W, H] = SRATIO_PX[r] + const por = r === '3:4' + const cols = por ? 6 : 12, m = por ? 72 : r === '16:9' ? 96 : 72, g = 24 + const cw = (W - 2 * m - (cols - 1) * g) / cols + const X = (i: number) => m + i * (cw + g) + c.fillStyle = PAPER; c.fillRect(0, 0, W, H) + 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') } + // running head + T(c, '孙宇晨 · 2026 长访谈', m, 40, 22, 700, INK) + T(c, v.kicker, W - m, 40, 22, 500, INK, 'right') + hline(c, m, 88, W - 2 * m, 1, INK) + if (r === '16:9') { + T(c, '12', m - 14, 130, 640, 900, INK) + box(c, X(4) + 40, 150, 150, 150, RED) + const tx = X(6) + T(c, v.title, tx, 150, 128, 900, INK) + T(c, v.sub, tx, 320, 36, 500, INK) + v.list.forEach(([n, s], i) => { + const y = 470 + i * 112 + hline(c, tx, y, W - m - tx, 1, i === 0 ? INK : G2) + T(c, n, tx, y + 22, 22, 700, G3) + T(c, s, tx + 80, y + 18, 48, 900, INK) + }) + hline(c, m, H - 72, W - 2 * m, 1, G2) + T(c, '十二条规则 · 观点整理', m, H - 56, 18, 500, G3) + T(c, '不构成任何投资建议', W - m, H - 56, 18, 500, G3, 'right') + return + } + const big = por ? 600 : 520 + T(c, '12', m - 12, 128, big, 900, INK) + const rs = por ? 170 : 150 + box(c, W - m - rs, 128, rs, rs, RED) + const ty = 128 + big * 0.735 + (por ? 70 : 60) + let ts = por ? 130 : 120 + while (measure(c, v.title, ts, 900) > W - 2 * m && ts > 60) ts -= 4 + T(c, v.title, m, ty, ts, 900, INK) + T(c, v.sub, m, ty + ts + 34, por ? 38 : 34, 500, INK) + if (por) { + const ly = ty + ts + 140 + v.list.forEach(([n, s], i) => { + const y = ly + i * 112 + hline(c, m, y, W - 2 * m, 1, i === 0 ? INK : G2) + T(c, n, m, y + 24, 22, 700, G3) + T(c, s, m + 90, y + 20, 52, 900, INK) + }) + } else { + const ly = ty + ts + 120, w = (W - 2 * m - 2 * g) / 3 + v.list.forEach(([n, s], i) => { + const x = m + i * (w + g) + hline(c, x, ly, w, 1, INK, 2) + T(c, n, x, ly + 18, 20, 700, G3) + T(c, s, x, ly + 52, 40, 900, INK) + }) + } + hline(c, m, H - 72, W - 2 * m, 1, G2) + T(c, '十二条规则 · 观点整理', m, H - 56, 18, 500, G3) + T(c, '不构成任何投资建议', W - m, H - 56, 18, 500, G3, 'right') +} + +export function swissCoverCanvas(r: SRatio, v: SCoverText) { + const [W, H] = SRATIO_PX[r] + const cv = document.createElement('canvas'); cv.width = W; cv.height = H + drawSwissCover(cv.getContext('2d')!, r, v) + return cv +} diff --git a/earth-online-sun/web/src/swiss/fonts.ts b/earth-online-sun/web/src/swiss/fonts.ts new file mode 100644 index 0000000000000000000000000000000000000000..06d8717100c93e9076fe0fd6e9160e86b9d01cea --- /dev/null +++ b/earth-online-sun/web/src/swiss/fonts.ts @@ -0,0 +1,16 @@ +// Load the subset Noto Sans SC weights used by the Swiss edition (canvas text needs them ready before drawing). +const BASE = import.meta.env.BASE_URL || './' +let ready: Promise | null = null +export function loadSwissFonts(): Promise { + if (ready) return ready + ready = (async () => { + const ws = [400, 500, 700, 900] + await Promise.all(ws.map(async w => { + try { + const f = new FontFace('SwissSC', `url(${BASE}swiss/fonts/sc-${w}.woff2)`, { weight: String(w) }) + await f.load(); document.fonts.add(f) + } catch { /* fall back to system CJK */ } + })) + })() + return ready +} diff --git a/earth-online-sun/web/src/swiss/kit.ts b/earth-online-sun/web/src/swiss/kit.ts new file mode 100644 index 0000000000000000000000000000000000000000..a056d8cbbc2d8385ad59aa73e1c6d97e9ed91e20 --- /dev/null +++ b/earth-online-sun/web/src/swiss/kit.ts @@ -0,0 +1,158 @@ +// Swiss kit: palette, the two motion curves, the modular grid and typographic primitives. +export const PAPER = '#EDECE8', SHEET = '#F7F6F3', INK = '#111111', G1 = '#DCDAD4', G2 = '#BFBCB4', G3 = '#8A877F', RED = '#E5231B' +export const FONT = 'SwissSC' + +// ---------- curves ---------- +export const clamp = (v: number, a = 0, b = 1) => (v < a ? a : v > b ? b : v) +export const lerp = (a: number, b: number, t: number) => a + (b - a) * t +/** cubic-bezier(.7,0,.2,1) — the only easing in the film besides linear */ +export function ease(x: number) { + x = clamp(x) + if (x === 0 || x === 1) return x + const x1 = 0.7, y1 = 0, x2 = 0.2, y2 = 1 + const bx = (t: number) => 3 * (1 - t) * (1 - t) * t * x1 + 3 * (1 - t) * t * t * x2 + t * t * t + const by = (t: number) => 3 * (1 - t) * (1 - t) * t * y1 + 3 * (1 - t) * t * t * y2 + t * t * t + let lo = 0, hi = 1, t = x + for (let i = 0; i < 22; i++) { t = (lo + hi) / 2; if (bx(t) < x) lo = t; else hi = t } + return by(t) +} +export const prog = (t: number, a: number, d: number) => clamp((t - a) / d) +/** eased snap from 0→1 that *arrives* at time `arrive` */ +export const snap = (t: number, arrive: number, d = 0.45) => ease(prog(t, arrive - d, d)) +/** stepped climb: n eased steps (a numeral rising one module at a time) */ +export function steps(p: number, n: number) { + p = clamp(p) * n + const k = Math.floor(p) + return Math.min(1, (k + ease(p - k)) / n) +} +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 } + +// ---------- grid ---------- +export interface Rect { x: number; y: number; w: number; h: number } +export interface Layout { + W: number; H: number; por: boolean + m: number; g: number; cols: number; cw: number + x: (c: number) => number // left edge of column c (0-based) + span: (n: number) => number // width of n columns incl. gutters + head: number + num: { x: number; y: number; size: number } + title: { x: number; y: number; size: number; w: number } + en: { x: number; y: number } + notes: { x: number; y: number; w: number } + A: Rect + sub: { x: number; y: number; w: number; size: number; lh: number } + foot: number + park: Rect +} +export function layout(por: boolean): Layout { + const W = por ? 1080 : 1920, H = por ? 1920 : 1080 + const m = por ? 72 : 96, g = 24, cols = por ? 6 : 12 + const cw = (W - 2 * m - (cols - 1) * g) / cols + const x = (c: number) => m + c * (cw + g) + const span = (n: number) => n * cw + (n - 1) * g + if (!por) return { + W, H, por, m, g, cols, cw, x, span, head: 88, + num: { x: m, y: 132, size: 300 }, + title: { x: m, y: 468, size: 58, w: span(5) }, + en: { x: m, y: 548 }, + notes: { x: m, y: 604, w: span(4) }, + A: { x: x(5), y: 132, w: x(11) + cw - x(5), h: 696 }, + sub: { x: m, y: 878, w: span(10), size: 44, lh: 62 }, + foot: 1036, + park: { x: x(3) + 24, y: 136, w: 40, h: 40 }, + } + return { + W, H, por, m, g, cols, cw, x, span, head: 88, + num: { x: m, y: 132, size: 250 }, + title: { x: x(3), y: 138, size: 54, w: span(3) }, + en: { x: x(3), y: 214 }, + notes: { x: x(3), y: 268, w: span(3) }, + A: { x: m, y: 520, w: W - 2 * m, h: 816 }, + sub: { x: m, y: 1404, w: W - 2 * m, size: 50, lh: 70 }, + foot: 1856, + park: { x: x(2) + 40, y: 136, w: 36, h: 36 }, + } +} + +// ---------- type ---------- +type C2 = CanvasRenderingContext2D +export function font(c: C2, size: number, w = 700) { + c.font = `${w} ${size}px ${FONT}, "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif` + const ls = (c as unknown as { letterSpacing?: string }) + if ('letterSpacing' in c) ls.letterSpacing = size >= 90 ? `${(-0.025 * size).toFixed(1)}px` : size <= 22 ? '0.5px' : '0px' +} +/** ascent from the top of the line box to the baseline (CJK ideographic box ≈ 0.88em; digits/caps ≈ 0.735em) */ +const ASC = (s: string) => (/^[\d\s.,:%+\-×/$·>]+$/.test(s) ? 0.735 : 0.86) +export function measure(c: C2, s: string, size: number, w = 700) { font(c, size, w); return c.measureText(s).width } +/** draw text with its visual top at y (flush-left by default) */ +export function T(c: C2, s: string, x: number, y: number, size: number, w: number, col: string, align: CanvasTextAlign = 'left') { + font(c, size, w); c.fillStyle = col; c.textAlign = align; c.textBaseline = 'alphabetic' + c.fillText(s, x, y + size * ASC(s)) +} +/** clip-reveal: rises out of a clip box from below its baseline (pin), leaves upward (pout). */ +export function R(c: C2, s: string, x: number, y: number, size: number, w: number, col: string, pin: number, pout = 0, align: CanvasTextAlign = 'left') { + if (pin <= 0 || pout >= 1) return + const h = size * 1.14 + const dy = (1 - ease(pin)) * h - ease(pout) * h + font(c, size, w) + const tw = c.measureText(s).width + const x0 = align === 'left' ? x : align === 'right' ? x - tw : x - tw / 2 + c.save(); c.beginPath(); c.rect(x0 - size * 0.2, y - size * 0.1, tw + size * 0.4, h + size * 0.06); c.clip() + T(c, s, x, y + dy, size, w, col, align) + c.restore() +} +const NO_START = ',。、:;!?」》)…,.!?:;' +/** wrap Chinese/latin mixed text to maxW (keeps latin words together, no punctuation at line start) */ +export function wrap(c: C2, s: string, maxW: number, size: number, w = 700): string[] { + font(c, size, w) + const tokens = s.match(/[A-Za-z0-9.%$+\-]+|\s+|./gu) || [] + const out: string[] = [] + let line = '' + for (const tk of tokens) { + const nxt = line + tk + if (line && c.measureText(nxt).width > maxW && !NO_START.includes(tk[0])) { out.push(line.trimEnd()); line = tk.trimStart() } + else line = nxt + } + if (line) out.push(line) + return out +} + +// ---------- marks ---------- +export function box(c: C2, x: number, y: number, w: number, h: number, col: string) { + if (w <= 0 || h <= 0) return + c.fillStyle = col; c.fillRect(Math.round(x) - 0.5, Math.round(y) - 0.5, Math.round(w) + 1, Math.round(h) + 1) +} +/** rect growing along one axis: dir 'r' | 'l' | 'd' | 'u' */ +export function wipe(c: C2, x: number, y: number, w: number, h: number, p: number, col: string, dir: 'r' | 'l' | 'd' | 'u' = 'r') { + p = clamp(p); if (p <= 0) return + if (dir === 'r') box(c, x, y, w * p, h, col) + else if (dir === 'l') box(c, x + w * (1 - p), y, w * p, h, col) + else if (dir === 'd') box(c, x, y, w, h * p, col) + else box(c, x, y + h * (1 - p), w, h * p, col) +} +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) } +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) } +export function frame(c: C2, x: number, y: number, w: number, h: number, col: string, th = 1) { + 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) +} +/** knife cut: a thin line crosses the element along its midline, then the element collapses onto it */ +export function knife(c: C2, r: Rect, p: number, draw: () => void) { + if (p >= 1) return + const my = r.y + r.h / 2 + const cut = prog(p, 0, 0.42), col = ease(prog(p, 0.42, 0.4)), gone = prog(p, 0.82, 0.18) + c.save() + if (col > 0) { c.translate(0, my); c.scale(1, Math.max(0.001, 1 - col)); c.translate(0, -my) } + if (col < 1) draw() + c.restore() + if (p > 0) { + const x0 = r.x - 16, w = r.w + 32 + const a = gone > 0 ? x0 + w * ease(gone) : x0 + box(c, a, my - 1, x0 + w * cut - a, 2.5, INK) + } +} +/** a small technical note: 1px rule above, text below (caption row) */ +export function note(c: C2, s: string, x: number, y: number, w: number, p: number, size = 19, col = INK) { + if (p <= 0) return + hline(c, x, y, w, ease(p), col === INK ? INK : G2) + R(c, s, x, y + 8, size, 500, col, prog(p, 0.3, 0.7)) +} diff --git a/earth-online-sun/web/src/swiss/live.ts b/earth-online-sun/web/src/swiss/live.ts new file mode 100644 index 0000000000000000000000000000000000000000..909c4e9ca44a9f377744f21c0909c47e791fb6a8 --- /dev/null +++ b/earth-online-sun/web/src/swiss/live.ts @@ -0,0 +1,39 @@ +// Live-photo vignettes (3:4, 1080×1440): the portrait film set as a sheet on paper, with margin captions. +import { drawFrame } from './render' +import { SCENES } from './timeline' +import { PAPER, INK, G3, T, box, hline } from './kit' + +export const LIVE_W = 1080, LIVE_H = 1440, LIVE_SEC = 3, LIVE_FPS = 30 +export interface LiveDef { name: string; title: string; caption: string; scene: string; line: number; off: number } +export const SLIVE: LiveDef[] = [ + { name: '01-twelve-rules', title: '十二条规则', caption: '孙宇晨 2026 长访谈', scene: 'cover', line: 3, off: -0.2 }, + { name: '02-cognition', title: '认知决定一切', caption: '疯话 → 答案', scene: 'r01', line: 2, off: 0.6 }, + { name: '03-lifespan', title: '寿命是硬通货', caption: '心肺 25 · 肌肉 25', scene: 'r04', line: 2, off: 0.6 }, + { name: '04-scout', title: '先开图再决策', caption: '看一百套,前三套只做基准', scene: 'r05', line: 2, off: 0.5 }, + { name: '05-small-ego', title: '只会向前更新', caption: '版本不能回滚 · small ego', scene: 'r08', line: 3, off: 0.4 }, + { name: '06-off-grid', title: '正确答案', caption: '成为答案之前,没人知道', scene: 'r12', line: 2, off: 0.0 }, +] +export const liveStart = (d: LiveDef) => SCENES.find(s => s.id === d.scene)!.lines[d.line].start + d.off + +/** draw vignette d at local time t (0..LIVE_SEC) onto a 1080×1440 context */ +export function drawLive(c: CanvasRenderingContext2D, d: LiveDef, t: number) { + const S = SCENES.find(s => s.id === d.scene)! + c.setTransform(1, 0, 0, 1, 0, 0) + c.fillStyle = PAPER; c.fillRect(0, 0, LIVE_W, LIVE_H) + const k = 0.7, sw = 1080 * k, sh = 1920 * k, sx = (LIVE_W - sw) / 2, sy = 48 + c.save(); c.beginPath(); c.rect(sx, sy, sw, sh); c.clip() + c.setTransform(k, 0, 0, k, sx, sy) + drawFrame(c, liveStart(d) + t, true) + c.restore(); c.setTransform(1, 0, 0, 1, 0, 0) + // sheet edge + margin captions + 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) + c.save(); c.translate(sx - 30, sy); c.rotate(Math.PI / 2) + T(c, `N° ${S.no} — ${d.title}`, 0, -18, 18, 700, INK) + c.restore() + c.save(); c.translate(sx + sw + 30, sy + sh); c.rotate(-Math.PI / 2) + T(c, d.caption, 0, -18, 18, 500, G3) + c.restore() + hline(c, sx, LIVE_H - 40, sw, 1, INK) + T(c, '孙宇晨 · 十二条规则', sx, LIVE_H - 30, 14, 700, INK) + T(c, 'LIVE · 3"', sx + sw, LIVE_H - 30, 14, 500, G3, 'right') +} diff --git a/earth-online-sun/web/src/swiss/render.ts b/earth-online-sun/web/src/swiss/render.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd727a540792c3f48478d87d465d0b8f10fb5495 --- /dev/null +++ b/earth-online-sun/web/src/swiss/render.ts @@ -0,0 +1,131 @@ +// 《规则 RULES》renderer — one vector frame at time t, in logical units (1920×1080 or 1080×1920). +// The caller scales the context to the output size; nothing here depends on resolution. +import { PAPER, INK, G2, G3, layout, lerp, ease, prog, R, T, wrap, hline, box } from './kit' +import type { Layout, Rect } from './kit' +import { SCENES, LINES, DUR, BPM, BAR, sceneAt } from './timeline' +import type { SceneSpan } from './timeline' +import { SCENE_DEFS, NO_HEAD } from './scenes' +import type { SCtx, Cue } from './scenes' + +export { DUR, SCENES } +type C2 = CanvasRenderingContext2D +const GUIDE = '#E3E1DB' +const PAN = 0.7, RED_MOVE = 0.9 + +const L_CACHE: Record = {} +const lay = (por: boolean) => (L_CACHE[por ? 'p' : 'l'] ??= layout(por)) + +function ctx(c: C2, t: number, L: Layout, S: SceneSpan): SCtx { + return { c, t, L, S, ls: S.lines.map(l => l.start), le: S.lines.map(l => l.end) } +} +const fmt = (s: number) => { + s = Math.max(0, s) + const m = Math.floor(s / 60), ss = Math.floor(s % 60), cs = Math.floor((s * 100) % 100) + return `${String(m).padStart(2, '0')}:${String(ss).padStart(2, '0')}.${String(cs).padStart(2, '0')}` +} + +function guides(c: C2, L: Layout, t: number) { + for (let i = 0; i < L.cols; i++) { + const p = ease(prog(t, 0.15 + i * 0.05, 1.1)) + if (p <= 0) continue + const h = L.H * p + box(c, L.x(i), 0, 1, h, GUIDE) + box(c, L.x(i) + L.cw - 1, 0, 1, h, GUIDE) + } +} + +function runningHead(c: C2, L: Layout, t: number, S: SceneSpan) { + const p = prog(t, 0.6, 0.6) + if (p <= 0) return + hline(c, L.m, L.head, L.W - 2 * L.m, ease(p), INK) + R(c, '孙宇晨 · 十二条规则', L.m, 36, 18, 700, INK, p) + const label = S.id === 'cover' ? 'N° 00 — 序' : `N° ${S.no} — ${S.title}` + R(c, label, L.por ? L.x(2) + 40 : L.x(4), 36, 18, 500, INK, prog(t, S.start + 0.1, 0.5)) + if (p >= 1) T(c, `${fmt(t)} / ${fmt(DUR)}`, L.W - L.m, 36, 18, 500, G3, 'right') +} + +function footer(c: C2, L: Layout, t: number) { + const p = prog(t, 0.9, 0.6) + if (p <= 0) return + const y = L.foot + hline(c, L.m, y - 14, L.W - 2 * L.m, ease(p), G2) + box(c, L.m, y - 15, (L.W - 2 * L.m) * Math.min(1, t / DUR), 3, INK) + R(c, `网格 ${L.cols} 栏 × ${Math.round(L.cw)} px · 栏距 ${L.g}`, L.m, y, 16, 500, G3, p) + const bar = Math.floor(t / BAR) + 1 + R(c, `${BPM} BPM · 小节 ${String(bar).padStart(3, '0')}`, L.W - L.m, y, 16, 500, G3, p, 0, 'right') +} + +function subtitles(c: C2, L: Layout, t: number) { + const s = L.sub + for (let i = 0; i < LINES.length; i++) { + const ln = LINES[i], nx = LINES[i + 1] + const exitT = nx ? Math.min(nx.start - 0.3, ln.end + 1.4) : ln.end + 0.8 + if (t < ln.start - 0.05 || t > exitT + 0.4) continue + const S = SCENES.find(x => x.id === ln.scene)! + const k = S.lines.indexOf(ln) + const pout = prog(t, exitT, 0.35) + const lead = S.id === 'cover' ? `序 · ${k + 1}` : `N° ${S.no}.${k + 1}` + R(c, lead, s.x, s.y - 34, 18, 500, G3, prog(t, ln.start - 0.05, 0.35), pout) + const rows = wrap(c, ln.text, s.w, s.size, 700) + const total = ln.text.length + let acc = 0 + rows.forEach((row, j) => { + const tin = ln.start + (acc / total) * ln.dur * 0.85 + acc += row.length + R(c, row, s.x, s.y + j * s.lh, s.size, 700, INK, prog(t, tin - 0.05, 0.45), pout) + }) + } +} + +function drawScene(c: C2, L: Layout, S: SceneSpan, t: number, dx: number) { + const def = SCENE_DEFS[S.id] + c.save(); c.translate(dx, 0) + def.draw(ctx(c, t, L, S)) + c.restore() +} + +/** red target for scene index i at time t (null = off) */ +function redOf(c: C2, L: Layout, i: number, t: number): Rect | null { + const S = SCENES[i] + return SCENE_DEFS[S.id].red(ctx(c, t, L, S)) +} + +export function drawFrame(c: C2, t: number, por: boolean) { + const L = lay(por) + t = Math.max(0, Math.min(DUR - 1e-3, t)) + c.fillStyle = PAPER; c.fillRect(0, 0, L.W, L.H) + guides(c, L, t) + const i = sceneAt(t), S = SCENES[i] + const pan = i > 0 ? ease(prog(t, S.start, PAN)) : 1 + if (pan < 1) { + const P = SCENES[i - 1] + drawScene(c, L, P, P.end - 1e-3, -L.W * pan) + drawScene(c, L, S, t, L.W * (1 - pan)) + } else drawScene(c, L, S, t, 0) + if (!NO_HEAD.has(S.id) || t > 0) runningHead(c, L, t, S) + // the exception: one red rectangle, linear moves only + let red = redOf(c, L, i, t) + const mp = i > 0 ? prog(t, S.start, RED_MOVE) : 1 + if (mp < 1) { + const prev = redOf(c, L, i - 1, SCENES[i - 1].end - 1e-3) + 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) } + else if (prev && !red) red = prev + } + if (red) { c.fillStyle = '#E5231B'; c.fillRect(Math.round(red.x), Math.round(red.y), Math.round(red.w), Math.round(red.h)) } + const def = SCENE_DEFS[S.id] + if (def.over) { c.save(); c.translate(L.W * (1 - pan), 0); def.over(ctx(c, t, L, S)); c.restore() } + footer(c, L, t) + subtitles(c, L, t) +} + +/** every sound-effect cue of the film, sorted */ +export function allCues(): Cue[] { + const out: Cue[] = [] + SCENES.forEach((S, i) => { + if (i > 0) out.push([S.start, 'slide', 0.8]) + const d = SCENE_DEFS[S.id] + if (d.cues) out.push(...d.cues(S)) + }) + LINES.forEach(l => out.push([l.start - 0.05, 'tss', 0.35])) + return out.filter(c => c[0] >= 0 && c[0] < DUR).sort((a, b) => a[0] - b[0]) +} diff --git a/earth-online-sun/web/src/swiss/scenes.ts b/earth-online-sun/web/src/swiss/scenes.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b3a647d792f34549920089a70941bd7df1c7f45 --- /dev/null +++ b/earth-online-sun/web/src/swiss/scenes.ts @@ -0,0 +1,734 @@ +// 《规则 RULES》— thirteen typographic scenes on the modular grid. +// Every scene draws in absolute time. The red rectangle is NOT drawn here: each scene only +// reports where the red should be (`red`), the renderer moves it linearly between targets. +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' +import type { Layout, Rect } from './kit' +import type { SceneSpan } from './timeline' +import WORLD from '../film/world.json' + +type C2 = CanvasRenderingContext2D +export interface SCtx { c: C2; t: number; L: Layout; S: SceneSpan; ls: number[]; le: number[] } +export type Cue = [number, string, number?] +export interface SceneDef { + draw(x: SCtx): void + red(x: SCtx): Rect | null + over?(x: SCtx): void + cues?(S: SceneSpan): Cue[] +} + +// ---------- helpers ---------- +const E = (x: SCtx, a: number, d = 0.5) => ease(prog(x.t, a, d)) +const P = (x: SCtx, a: number, d = 0.5) => prog(x.t, a, d) +const F = (x: SCtx, k: number) => prog(x.t, x.ls[k], x.le[k] - x.ls[k]) +/** absolute time at fraction f of line k */ +const at = (x: SCtx, k: number, f = 0) => x.ls[k] + (x.le[k] - x.ls[k]) * f +const rr = (x: number, y: number, w: number, h: number): Rect => ({ x, y, w, h }) +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))) +const lt = (S: SceneSpan, k: number, f = 0) => S.lines[k].start + S.lines[k].dur * f + +/** text that rises in at tin and is knife-cut at tk */ +function KT(x: SCtx, s: string, X: number, Y: number, size: number, w: number, col: string, tin: number, tk = Infinity, align: CanvasTextAlign = 'left') { + const kp = prog(x.t, tk, 0.55) + if (kp >= 1 || x.t < tin) return + const tw = measure(x.c, s, size, w) + const x0 = align === 'left' ? X : align === 'right' ? X - tw : X - tw / 2 + 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)) +} +/** knife a whole group of marks living inside rect r */ +function KG(x: SCtx, r: Rect, tk: number, draw: () => void) { + const kp = prog(x.t, tk, 0.55) + if (kp >= 1) return + knife(x.c, r, kp, draw) +} +/** panel swap: A leaves upward, B arrives from below, both clipped to r */ +function swap(x: SCtx, r: Rect, ts: number, A: () => void, B: () => void) { + const p = E(x, ts, 0.7) + if (p <= 0) return A() + if (p >= 1) return B() + const c = x.c, h = r.h + 60 + c.save(); c.beginPath(); c.rect(r.x - 60, r.y - 30, r.w + 120, r.h + 60); c.clip() + c.save(); c.translate(0, -p * h); A(); c.restore() + c.save(); c.translate(0, (1 - p) * h); B(); c.restore() + c.restore() +} +/** rule head: numeral climbs in four steps, title, english label, technical notes */ +function head(x: SCtx, notes: [number, string][]) { + const { c, L, S, t } = x + 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)) + R(c, S.title, L.title.x, L.title.y, L.title.size, 900, INK, P(x, S.start + 0.25)) + R(c, S.en.toUpperCase(), L.en.x, L.en.y, 20, 500, G3, P(x, S.start + 0.45)) + 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)) +} +const numLand = (S: SceneSpan) => S.start + 0.55 + +// ===================================================================== +// COVER — 2016 → 2026 → twelve rules +// ===================================================================== +function coverGeo(L: Layout) { + const por = L.por + const ty = por ? 440 : 420 + const tx0 = L.m, tx1 = L.W - L.m + const cell = por ? L.cw : L.cw + const cells: Rect[] = [] + for (let i = 0; i < 12; i++) { + if (por) cells.push(rr(L.x(i % 6), 860 + Math.floor(i / 6) * (cell + L.g), cell, cell)) + else cells.push(rr(L.x(i), 640, cell, cell)) + } + return { ty, tx0, tx1, cells } +} +const cover: SceneDef = { + draw(x) { + const { c, L, S } = x + const { ty, tx0, tx1, cells } = coverGeo(L) + const por = L.por + const kt = x.ls[3] - 0.15 + // --- act 1: the decade --- + KG(x, rr(0, 100, L.W, por ? 1150 : 600), kt, () => { + R(c, '2016', L.m, 132, por ? 250 : 300, 900, INK, P(x, x.ls[0] - 0.2)) + // the decade line + 11 ticks + hline(c, tx0, ty, tx1 - tx0, E(x, S.start + 0.6, 1.2), INK, 2) + for (let i = 0; i <= 10; i++) { + const tx = lerp(tx0, tx1, i / 10), p = P(x, S.start + 0.9 + i * 0.07, 0.3) + vline(c, tx, ty - 10, 10 * ease(p), p, INK, 1) + 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') + } + // travelled decade (linear, follows the red) + wipe(c, tx0, ty - 4, tx1 - tx0, 6, F(x, 1), G2) + R(c, '《财富自由之路》', L.m, ty + (por ? 70 : 52), por ? 48 : 52, 700, INK, P(x, at(x, 0, 0.45))) + R(c, '孙宇晨 · 2016', L.m, ty + (por ? 140 : 124), 20, 500, G3, P(x, at(x, 0, 0.6))) + R(c, '十年 · 重新流传', L.m, ty + (por ? 176 : 160), 20, 500, G3, P(x, at(x, 1, 0.5))) + R(c, '2026', L.W - L.m, por ? 700 : 132, por ? 250 : 300, 900, INK, P(x, x.le[1] - 0.1), 0, 'right') + R(c, '新的答案', L.W - L.m, por ? 920 : ty + 52, 52, 700, INK, P(x, at(x, 2, 0.75)), 0, 'right') + R(c, '长访谈 · 2026', L.W - L.m, por ? 994 : ty + 124, 20, 500, G3, P(x, at(x, 2, 0.85)), 0, 'right') + }) + // --- act 2: lockup --- + const lk = x.ls[3] + 0.35 + if (x.t > lk) { + R(c, '十二条规则', L.m, por ? 520 : 250, por ? 150 : 200, 900, INK, P(x, lk, 0.6)) + R(c, '孙宇晨 · 2026 长访谈整理', L.m, por ? 720 : 500, por ? 32 : 36, 500, INK, P(x, lk + 0.25, 0.6)) + cells.forEach((r, i) => { + const p = E(x, lk + 0.3 + i * 0.06, 0.4) + if (p <= 0) return + hline(c, r.x, r.y, r.w, p, INK, 2) + 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)) + wipe(c, r.x, r.y + r.h - 1, r.w, 1, p, G2) + }) + } + }, + red(x) { + const { L, S } = x + const { ty, tx0, tx1, cells } = coverGeo(L) + if (x.t < S.start + 0.4) return null + const dx = lerp(tx0, tx1, F(x, 1)) + const dot = rr(dx - 12 + (F(x, 1) === 0 ? 12 : F(x, 1) >= 1 ? -12 : 0), ty - 11, 24, 24) + const target = cells[0] + return mv(dot, target, P(x, x.ls[3] + 0.6, 0.9)) + }, + over(x) { + const { cells } = coverGeo(x.L) + if (x.t > x.ls[3] + 1.5) R(x.c, '01', cells[0].x, cells[0].y + 12, 22, 700, PAPER, 1) + }, + 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']] }, +} + +// ===================================================================== +// R01 — 认知决定一切 +// ===================================================================== +const NAMES01 = ['腾讯', '比特币', '特斯拉', '英伟达'] +const r01: SceneDef = { + draw(x) { + const { c, L } = x + const A = L.A + head(x, [[1, '清单 · 2016'], [2, '十年 · 4 / 4'], [3, '非运气']]) + // claims on the right of the red block + KT(x, '疯话', A.x + 190, A.y + 12, 96, 900, G3, at(x, 2, 0.05), at(x, 2, 0.45)) + KT(x, '答案', A.x + 190, A.y + 12, 96, 900, INK, at(x, 2, 0.62)) + KT(x, '运气', A.x + A.w, A.y + 40, 48, 700, G3, x.ls[3] + 0.1, x.le[3] - 0.15, 'right') + 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') + // four rows, ten annual steps each + const y0 = A.y + 210, rh = 96, bx = A.x + 200, bw = A.w - 200 + const grow = steps(F(x, 2), 10) + NAMES01.forEach((n, k) => { + const ti = at(x, 1, 0.42 + k * 0.14), y = y0 + k * rh + R(c, n, A.x, y, 40, 700, INK, P(x, ti)) + hline(c, A.x, y + rh - 18, A.w, E(x, ti, 0.6), G2) + if (x.t > ti) box(c, bx, y + 6, Math.max(6, bw * grow), 30, INK) + }) + // axis + const ay = y0 + 4 * rh + 6 + hline(c, bx, ay, bw, E(x, x.ls[1], 0.8), INK) + for (let i = 0; i <= 10; i++) { + const p = P(x, x.ls[1] + 0.2 + i * 0.04, 0.3) + vline(c, bx + bw * i / 10, ay, 8, p) + 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') + } + }, + red(x) { return rr(x.L.A.x, x.L.A.y, 130, 130) }, + 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)) }, + cues(S) { return [[numLand(S), 'thunk'], [lt(S, 2, 0.45), 'snick'], [lt(S, 3) + S.lines[3].dur - 0.15, 'snick']] }, +} + +// ===================================================================== +// R02 — 三件零成本的事 +// ===================================================================== +const KIT02 = [['孙学', '升级大脑的操作系统'], ['薄肌', '守住身体这张底牌'], ['黄毛', '认定对的事,就去执行']] +function cells02(L: Layout): Rect[] { + const A = L.A + 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)) } + const h = (A.h - 48) / 3 + return [0, 1, 2].map(k => rr(A.x, A.y + k * (h + 24), A.w, h)) +} +const r02: SceneDef = { + draw(x) { + const { c, L } = x + head(x, [[1, '零成本 × 3'], [2, '大脑 · 身体 · 执行']]) + cells02(L).forEach((r, k) => { + const ti = x.ls[k + 1] - 0.1 + if (x.t < ti - 0.6) return + hline(c, r.x, r.y, r.w, E(x, ti - 0.6, 0.6), INK, 2) + if (!L.por) { + R(c, '0', r.x - 6, r.y + 30, 360, 900, G1, P(x, ti - 0.4, 0.6)) + R(c, KIT02[k][0], r.x, r.y + 330, 80, 900, INK, P(x, ti)) + 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))) + note(c, '成本 0', r.x, r.y + r.h - 50, r.w, P(x, ti + 0.5, 0.6)) + } else { + R(c, '0', r.x + r.w, r.y + 10, 220, 900, G1, P(x, ti - 0.4, 0.6), 0, 'right') + R(c, KIT02[k][0], r.x, r.y + 28, 64, 900, INK, P(x, ti)) + R(c, KIT02[k][1], r.x, r.y + 120, 28, 500, INK, P(x, ti + 0.3)) + note(c, '成本 0', r.x, r.y + r.h - 50, 300, P(x, ti + 0.5, 0.6)) + } + }) + }, + red(x) { + const cs = cells02(x.L) + const pos = (k: number) => rr(cs[k].x + cs[k].w - 44, cs[k].y + 16, 44, 44) + let r = pos(0) + for (let k = 1; k < 3; k++) r = mv(r, pos(k), P(x, x.ls[k + 1] - 0.2, 0.6)) + return r + }, + cues(S) { return [[numLand(S), 'thunk'], [lt(S, 1), 'clack'], [lt(S, 2), 'clack'], [lt(S, 3), 'clack']] }, +} + +// ===================================================================== +// R03 — 交给 AI 托管 +// ===================================================================== +const r03: SceneDef = { + draw(x) { + const { c, L } = x + const A = L.A + head(x, [[1, '照片 · 睡眠 · 训练'], [2, '日报 · .md'], [3, '90 / 10'], [4, '三个模型 · 取中']]) + swap(x, A, x.ls[3] - 0.35, () => { + // 24h strip + const sy = A.y + 40, hw = A.w / 24 + hline(c, A.x, sy, A.w, E(x, x.ls[0] + 0.3, 0.9)) + for (let h = 0; h <= 24; h++) { + const p = P(x, x.ls[0] + 0.4 + h * 0.025, 0.3) + vline(c, A.x + h * hw - (h === 24 ? 1 : 0), sy - (h % 6 ? 6 : 12), h % 6 ? 6 : 12, p) + 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') + } + const by = sy + 18, bh = 56 + const ph = (f: number) => P(x, at(x, 1, f), 0.6) + 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) + for (const h of [8, 13, 19]) wipe(c, A.x + h * hw, by, hw * 0.5, bh, ph(0.05), INK, 'd') + wipe(c, A.x + 17 * hw, by, hw, bh, ph(0.55), INK, 'd') + R(c, '睡眠', A.x, by + bh + 14, 18, 500, G3, ph(0.32)) + R(c, '三餐', A.x + 8 * hw, by + bh + 14, 18, 500, INK, ph(0.08)) + R(c, '训练', A.x + 17 * hw, by + bh + 14, 18, 500, INK, ph(0.58)) + // pipeline + const py = A.y + 270 + const ins = ['照片', '睡眠', '视频'] + const cx = A.x + A.w * 0.34, cw = 230, ch = 120 + ins.forEach((s, k) => { + const y = py + k * 52 + 8 + R(c, s, A.x, y, 26, 700, INK, ph(0.1 + k * 0.25)) + hline(c, A.x + 70, y + 12, cx - A.x - 84, P(x, at(x, 1, 0.8), 0.5)) + }) + 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') + R(c, 'Claude', cx + 24, py + 52, 44, 700, PAPER, P(x, at(x, 1, 0.86))) + const dx = A.x + A.w - 300, dy = py - 30 + hline(c, cx + cw, py + 80, dx - cx - cw, P(x, x.ls[2], 0.5)) + if (x.t > x.ls[2]) { + const p = E(x, x.ls[2] + 0.2, 0.6) + wipe(c, dx, dy, 300, 380, p, SHEET, 'd'); if (p >= 1) frame(c, dx, dy, 300, 380, G2) + R(c, '日报', dx + 24, dy + 28, 40, 900, INK, P(x, x.ls[2] + 0.5)) + R(c, '2026-10-10.md', dx + 24, dy + 84, 18, 500, G3, P(x, x.ls[2] + 0.6)) + 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) + } + }, () => { + R(c, '90', A.x, A.y, 240, 900, INK, P(x, x.ls[3] - 0.1)) + R(c, '10', A.x + A.w, A.y, 240, 900, INK, P(x, at(x, 3, 0.5)), 0, 'right') + const by = A.y + 220 + wipe(c, A.x, by, A.w * 0.9 - 4, 40, E(x, x.ls[3], 0.8), INK) + R(c, 'AI', A.x, by + 56, 20, 500, G3, P(x, x.ls[3] + 0.3)) + R(c, '自己 · 微调', A.x + A.w, by + 56, 20, 500, G3, P(x, at(x, 3, 0.6)), 0, 'right') + // three counsels + const ky = by + 150, kw = (A.w - 48) / 3, kh = 170 + const sel = E(x, at(x, 4, 0.82), 0.5) + ;['上策', '中策', '下策'].forEach((s, k) => { + const kx = A.x + k * (kw + 24), ti = at(x, 4, 0.25 + k * 0.12) + if (x.t < ti) return + if (k === 1 && sel > 0) wipe(c, kx, ky, kw, kh, sel, INK, 'u') + const p = E(x, ti, 0.5) + hline(c, kx, ky, kw, p, INK, 2); hline(c, kx, ky + kh - 1, kw, p, k === 1 ? INK : G2) + R(c, `模型 ${'ABC'[k]}`, kx + 16, ky + 18, 18, 500, k === 1 && sel > 0.5 ? G2 : G3, P(x, ti)) + R(c, s, kx + 16, ky + 70, 56, 900, k === 1 && sel > 0.5 ? PAPER : k === 1 ? INK : G3, P(x, ti + 0.1)) + }) + }) + }, + red(x) { + const A = x.L.A + const park = x.L.park + const seg = rr(A.x + A.w * 0.9, A.y + 220, A.w * 0.1, 40) + const ky = A.y + 220 + 150, kw = (A.w - 48) / 3 + const under = rr(A.x + kw + 24, ky + 170 + 14, kw, 10) + let r = mv(park, seg, P(x, at(x, 3, 0.45), 0.7)) + r = mv(r, under, P(x, at(x, 4, 0.82), 0.7)) + return r + }, + cues(S) { return [[numLand(S), 'thunk'], [lt(S, 3) - 0.35, 'slide'], [lt(S, 4, 0.82), 'stamp']] }, +} + +// ===================================================================== +// R04 — 寿命是硬通货 +// ===================================================================== +function geo04(L: Layout) { + const A = L.A, cs = 38, gp = 5 + const gx = A.x, gy = A.y + (L.por ? 250 : 262) + const tx = A.x + 10 * (cs + gp) + (L.por ? 40 : 70) + return { A, cs, gp, gx, gy, tx, tw: A.x + A.w - tx } +} +const r04: SceneDef = { + draw(x) { + const { c, L } = x + const { A, cs, gp, gx, gy, tx } = geo04(L) + head(x, [[1, '物质 富 · 身体 穷'], [2, '满分 100'], [3, '薄肌 = 50'], [4, '唯一不贬值']]) + // rich / poor bars + const rows: [string, string, number, string][] = [['物质', '富人', 1, INK], ['身体', '穷人', 0.2, G3]] + rows.forEach(([a, b, w, col], k) => { + const ti = at(x, 1, 0.2 + k * 0.45), y = A.y + k * 96 + R(c, a, A.x, y, 40, 700, INK, P(x, ti)) + const bw = (A.w - 260) * w + wipe(c, A.x + 130, y + 6, bw, 30, E(x, ti + 0.15, 0.7), col) + R(c, b, A.x + 130 + bw + 16, y + 6, 26, 700, col, P(x, ti + 0.6)) + }) + // 10×10 longevity grid + const g0 = x.ls[2] + for (let i = 0; i < 100; i++) { + const r = Math.floor(i / 10), q = i % 10 + const p = P(x, g0 + (r + q) * 0.025, 0.3) + if (p <= 0) continue + const cx = gx + q * (cs + gp), cy = gy + r * (cs + gp) + const cardio = r < 5 && q < 5 + const ink = cardio && x.t > at(x, 2, 0.5) + (r * 5 + q) * 0.012 + box(c, cx, cy, cs * ease(p), cs, ink ? INK : G1) + } + if (x.t > g0) { + R(c, '心肺 25', gx, gy + 10 * (cs + gp) + 10, 18, 500, INK, P(x, at(x, 2, 0.55))) + R(c, '肌肉 25', gx + 5 * (cs + gp), gy + 10 * (cs + gp) + 10, 18, 500, INK, P(x, at(x, 2, 0.8))) + } + const ta = rr(tx, gy, A.x + A.w - tx, 10 * (cs + gp)) + const n1 = () => { + R(c, '100', tx, gy, 150, 900, INK, P(x, g0 + 0.2)) + R(c, '寿命满分', tx, gy + 160, 26, 700, INK, P(x, g0 + 0.4)) + R(c, '心肺 25 · 肌肉 25 · 其余 50', tx, gy + 204, 20, 500, G3, P(x, at(x, 2, 0.7))) + } + const n2 = () => { + R(c, '50', tx, gy, 150, 900, INK, P(x, x.ls[3])) + R(c, '练出薄肌 = 拿下一半', tx, gy + 160, 26, 700, INK, P(x, at(x, 3, 0.6))) + R(c, '肌肉 = 身体里的银行账户', tx, gy + 204, 20, 500, G3, P(x, at(x, 3, 0.2))) + } + const n3 = () => { + const items = ['算力', '内容', '服务', '商品'] + items.forEach((s, k) => { + const y = gy + k * 62, kt = at(x, 4, 0.45 + k * 0.08) + KG(x, rr(tx, y, ta.w, 44), kt, () => { + R(c, s, tx, y, 36, 700, G3, P(x, x.ls[4] + k * 0.08)) + R(c, '↓ 变便宜', tx + ta.w, y + 8, 20, 500, G3, P(x, x.ls[4] + 0.1 + k * 0.08), 0, 'right') + hline(c, tx, y + 52, ta.w, E(x, x.ls[4] + k * 0.08, 0.5), G2) + }) + }) + R(c, '寿命 ↑', tx, gy + 130, 96, 900, INK, P(x, at(x, 4, 0.8), 0.6)) + } + swap(x, ta, x.ls[3] - 0.3, n1, () => swap(x, ta, x.ls[4] - 0.3, n2, n3)) + }, + red(x) { + const { cs, gp, gx, gy } = geo04(x.L) + const blk = rr(gx + 5 * (cs + gp), gy, 5 * (cs + gp) - gp, 5 * (cs + gp) - gp) + return mv(x.L.park, blk, P(x, at(x, 2, 0.75), 0.8)) + }, + cues(S) { return [[numLand(S), 'thunk'], [lt(S, 2, 0.75), 'glide'], [lt(S, 4, 0.45), 'snick'], [lt(S, 4, 0.8), 'stamp']] }, +} + +// ===================================================================== +// R05 — 先开图再决策 +// ===================================================================== +function geo05(L: Layout) { + const A = L.A, cs = 44, gp = 6, pitch = cs + gp + 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 } +} +const pathCell = (i: number) => { const r = Math.floor(i / 10), q = i % 10; return [r % 2 ? 9 - q : q, r] } +function scout(x: SCtx) { return clamp((x.t - x.ls[1]) / (x.le[2] - x.ls[1])) * 99 } +const r05: SceneDef = { + draw(x) { + const { c, L } = x + const { A, cs, pitch, gx, gy, tx, ty } = geo05(L) + head(x, [[1, '斥候 → 造兵'], [2, '100 套 · 前 3 = 基准'], [3, '信息 ≥ 决定']]) + const s = scout(x), seen = x.t > x.ls[1] ? Math.floor(s) + 1 : 0 + for (let i = 0; i < 100; i++) { + const [q, r] = pathCell(i) + const p = P(x, x.ls[0] + 0.2 + hash(i) * 0.8, 0.3) + if (p <= 0) continue + const cx = gx + q * pitch, cy = gy + r * pitch + 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) } + else box(c, cx, cy, cs * ease(p), cs, G2) + } + if (x.t > x.ls[2]) R(c, '基准 · 不买', gx, gy + 10 * pitch + 12, 18, 500, INK, P(x, x.ls[2] + 0.3)) + // counter + R(c, String(seen).padStart(3, '0'), tx, ty, 150, 900, INK, P(x, x.ls[1] - 0.2)) + R(c, '已看 · 套', tx, ty + 150, 20, 500, G3, P(x, x.ls[1])) + hline(c, tx, ty + 190, L.por ? 400 : A.x + A.w - tx, E(x, x.ls[1], 0.8)) + const dy = ty + (L.por ? 220 : 260) + KT(x, '决定', tx, dy, 72, 900, G3, x.ls[3] - 0.2, at(x, 3, 0.55)) + KT(x, '无需决定', tx, dy, 72, 900, INK, at(x, 3, 0.75)) + void A + }, + red(x) { + const { pitch, gx, gy, cs } = geo05(x.L) + if (x.t < x.ls[1] - 0.6) return x.L.park + const s = scout(x), i = Math.floor(s), f = s - i + const [q0, r0] = pathCell(i), [q1, r1] = pathCell(Math.min(99, i + 1)) + const tgt = rr(gx + lerp(q0, q1, f) * pitch, gy + lerp(r0, r1, f) * pitch, cs, cs) + return mv(x.L.park, tgt, P(x, x.ls[1] - 0.6, 0.6)) + }, + cues(S) { return [[numLand(S), 'thunk'], [lt(S, 3, 0.55), 'snick'], [lt(S, 3, 0.75), 'stamp']] }, +} + +// ===================================================================== +// R06 — 全球寻找商机 +// ===================================================================== +const W6 = WORLD as { w: number; h: number; rows: string[] } +const BJ = [Math.round((116.4 + 180) / 360 * W6.w / 2), Math.round((90 - 39.9) / 180 * W6.h / 2)] +function geo06(L: Layout) { + const A = L.A, pitch = A.w / 96 + return { A, pitch, mh: pitch * 48 } +} +const r06: SceneDef = { + draw(x) { + const { c, L } = x + const { A, pitch, mh } = geo06(L) + head(x, [[1, '楼下 = 1 个点'], [2, '同一订阅 · 不同价格'], [3, '4 亿 → 80 亿 + 20 亿']]) + swap(x, A, x.ls[3] - 0.3, () => { + const d = pitch * 0.42 + for (let j = 0; j < 48; j++) { + const row = W6.rows[j * 2] + for (let i = 0; i < 96; i++) { + if (row[i * 2] !== '1') continue + const dist = Math.hypot(i - BJ[0], j - BJ[1]) + const p = P(x, x.ls[0] + 0.1 + dist * 0.018, 0.25) + if (p <= 0) continue + box(c, A.x + i * pitch + (pitch - d) / 2, A.y + j * pitch + (pitch - d) / 2, d, d, p < 1 ? G3 : G2) + } + } + const bx = A.x + BJ[0] * pitch, by = A.y + BJ[1] * pitch + KG(x, rr(bx + 24, by - 40, 200, 30), x.le[1] - 0.2, () => { + hline(c, bx + 18, by - 20, 30, P(x, x.ls[1], 0.3)) + if (x.t > x.ls[1] + 0.1) box(c, bx + 48, by - 40, measure(c, '楼下 · 1 个点', 20, 700) + 12, 30, PAPER) + R(c, '楼下 · 1 个点', bx + 54, by - 34, 20, 700, INK, P(x, x.ls[1] + 0.1)) + }) + // same subscription, different prices + const cy = A.y + mh + 34, n = ['美国', '土耳其', '阿根廷', '尼日利亚'] + const cw = (A.w - 3 * 60) / 4 + n.forEach((s, k) => { + const cx = A.x + k * (cw + 60), ti = at(x, 2, 0.12 + k * 0.08) + hline(c, cx, cy, cw, E(x, ti, 0.5), INK, 2) + R(c, s, cx, cy + 14, L.por ? 26 : 30, 700, INK, P(x, ti)) + R(c, 'AI 订阅 · ¥ ?', cx, cy + 60, 18, 500, G3, P(x, ti + 0.15)) + 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') + }) + R(c, '差价 = 生意', A.x + A.w, cy + 110, L.por ? 40 : 34, 900, INK, P(x, at(x, 2, 0.8)), 0, 'right') + }, () => { + const cw = (A.w - 48) / 3, size = L.por ? 110 : 140 + const items: [string, string][] = [['4亿', '年轻人'], ['80亿', '人'], ['20亿', '个 AI']] + items.forEach(([n, l], k) => { + const cx = A.x + k * (cw + 24), ti = at(x, 3, 0.05 + k * 0.28) + hline(c, cx, A.y + 120, cw, E(x, ti, 0.5), INK, 2) + R(c, ['服务者', '服务', '以及'][k], cx, A.y + 134, 18, 500, G3, P(x, ti)) + R(c, n, cx, A.y + 190, size, 900, INK, P(x, ti + 0.1)) + R(c, l, cx, A.y + 190 + size + 40, 30, 700, INK, P(x, ti + 0.2)) + }) + }) + }, + red(x) { + const { A, pitch } = geo06(x.L) + const bj = rr(A.x + BJ[0] * pitch - 1, A.y + BJ[1] * pitch - 1, pitch + 2, pitch + 2) + const size = x.L.por ? 110 : 140 + const uw = measure(x.c, '4亿', size, 900) + const ul = rr(A.x, A.y + 190 + size * 0.86 + 12, uw, 12) + if (x.t < x.ls[0] + 0.1) return x.L.park + 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)) + }, + cues(S) { return [[numLand(S), 'thunk'], [lt(S, 1, 0.99) - 0.2, 'snick'], [lt(S, 3) - 0.3, 'slide']] }, +} + +// ===================================================================== +// R07 — 货币的翻译 +// ===================================================================== +function geo07(L: Layout) { + const A = L.A, pitch = 32, cols = 16, rows = 10 + const mx = L.por ? A.x : A.x + A.w - cols * pitch, my = L.por ? A.y + 260 : A.y + return { A, pitch, cols, rows, mx, my, dy: L.por ? my + rows * pitch + 100 : A.y + rows * pitch + 70 } +} +const BIT = [9, 4] +const r07: SceneDef = { + draw(x) { + const { c, L } = x + const { A, pitch, cols, rows, mx, my, dy } = geo07(L) + head(x, [[1, 'crypto'], [2, 'AI 语料'], [3, '未写入 = 未发生']]) + KG(x, rr(A.x, A.y, A.w, A.h), x.ls[3] - 0.05, () => { + R(c, '货币', A.x, A.y, 200, 900, INK, P(x, x.ls[0] + 0.1)) + 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))) } + else { R(c, '→ 机器语言', A.x + 440, A.y + 90, 28, 700, G3, P(x, at(x, 0, 0.6))) } + const fill = prog(x.t, at(x, 0, 0.4), x.le[1] - at(x, 0, 0.4)) + for (let j = 0; j < rows; j++) for (let i = 0; i < cols; i++) { + const h = hash(j * 31 + i) + if (h > fill * 1.02 || (i === BIT[0] && j === BIT[1])) continue + T(c, h > 0.5 ? '1' : '0', mx + i * pitch + pitch / 2, my + j * pitch + 6, 20, 500, h < 0.12 ? INK : G3, 'center') + } + R(c, 'crypto =', mx, my + rows * pitch + 8, 20, 700, INK, P(x, x.ls[1])) + // AI corpus: grey document blocks + const n = L.por ? 4 : 7 + for (let j = 0; j < n; j++) { + const yy = dy + j * 34 + const w = (0.45 + hash(j + 7) * 0.55) * (L.por ? A.w : A.w) + wipe(c, A.x, yy, w, 14, prog(x.t, x.ls[2] + j * 0.12, 0.5), G2) + } + R(c, '语料', A.x, dy - 40, 20, 700, INK, P(x, x.ls[2])) + }) + 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))) + }, + red(x) { + const { pitch, mx, my } = geo07(x.L) + const bit = rr(mx + BIT[0] * pitch + 4, my + BIT[1] * pitch + 4, pitch - 8, pitch - 8) + return mv(x.L.park, bit, P(x, at(x, 0, 0.5), 0.8)) + }, + cues(S) { return [[numLand(S), 'thunk'], [lt(S, 3) - 0.05, 'snick']] }, +} + +// ===================================================================== +// R08 — 只会向前更新 +// ===================================================================== +const ROWS08: [string, string, string, number][] = [['房地产', '▼', '被削弱', 1], ['AI', '▲', '被加强', 1], ['黄金', '—', '从不升级', 2], ['比特币', '▲', '每个版本都在升级', 2]] +function play08(x: SCtx) { return clamp((x.t - x.ls[0]) / (x.le[3] - x.ls[0])) } +const r08: SceneDef = { + draw(x) { + const { c, L } = x + const A = L.A + head(x, [[1, '房地产 ▼ · AI ▲'], [2, '黄金 v1.0 · 比特币 vN'], [3, '不可回滚']]) + const ty = A.y + 40 + hline(c, A.x, ty, A.w, E(x, x.ls[0], 0.9)) + 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)) + R(c, 'v2016', A.x, ty - 40, 18, 700, INK, P(x, x.ls[0])) + R(c, 'v2026', A.x + A.w, ty - 40, 18, 700, INK, P(x, x.ls[0] + 0.2), 0, 'right') + wipe(c, A.x, ty + 6, A.w, 4, play08(x), G2) + const rh = 74, y0 = A.y + 110 + ROWS08.forEach(([n, s, d, k], j) => { + const ti = at(x, k, 0.1 + (j % 2) * 0.4), y = y0 + j * rh + hline(c, A.x, y + rh - 14, A.w, E(x, ti, 0.6), G2) + R(c, n, A.x, y, 40, 900, INK, P(x, ti)) + R(c, s, A.x + A.w * 0.36, y, 40, 900, s === '—' ? G3 : INK, P(x, ti + 0.15)) + R(c, d, A.x + A.w * 0.46, y + 10, 26, 500, s === '—' ? G3 : INK, P(x, ti + 0.25)) + }) + const ey = y0 + 4 * rh + 26 + KT(x, '← 回滚', A.x, ey + 20, 56, 900, G3, x.ls[3], at(x, 3, 0.32)) + if (x.t > at(x, 3, 0.5)) { + const sz = lerp(160, 64, steps(prog(x.t, at(x, 3, 0.55), 1.4), 4)) + R(c, 'EGO', A.x + A.w, ey, sz, 900, INK, P(x, at(x, 3, 0.5), 0.4), 0, 'right') + note(c, 'small ego', A.x + A.w - 200, ey + 190, 200, P(x, at(x, 3, 0.85))) + } + }, + red(x) { + const A = x.L.A + return rr(A.x + (A.w - 6) * play08(x), A.y + 40 - 28, 6, 56) + }, + 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']] }, +} + +// ===================================================================== +// R09 — 只有信息量 +// ===================================================================== +function geo09(L: Layout) { + const A = L.A, cols = L.por ? 18 : 24, rows = L.por ? 9 : 8 + const pitch = A.w / cols + return { A, cols, rows, pitch, gy: A.y + 230, n: cols * rows } +} +function fill09(x: SCtx) { return clamp((x.t - x.ls[1]) / (x.le[1] - x.ls[1] + 0.4)) } +const r09: SceneDef = { + draw(x) { + const { c, L } = x + const { A, cols, rows, pitch, gy, n } = geo09(L) + head(x, [[0, '智商 → 信息量'], [1, '人工智障 → 破解难题']]) + KT(x, '智商', A.x, A.y, 160, 900, G3, x.ls[0] + 0.3, at(x, 0, 0.62)) + KT(x, '信息量', A.x, A.y, 160, 900, INK, at(x, 0, 0.82)) + const k = Math.floor(fill09(x) * n) + for (let i = 0; i < n; i++) { + const q = i % cols, r = Math.floor(i / cols) + const cx = A.x + q * pitch, cy = gy + r * pitch, s = pitch - 6 + const p = P(x, x.ls[0] + 0.5 + (q + r) * 0.01, 0.3) + if (p <= 0) continue + if (i < k) box(c, cx, cy, s, s, INK) + else frame(c, cx, cy, s * ease(p), s, G2) + } + const sy = gy + rows * pitch + 30 + hline(c, A.x, sy, A.w, E(x, x.ls[1], 0.6)) + R(c, '人工智障', A.x, sy + 14, 22, 700, G3, P(x, x.ls[1])) + R(c, '破解难题', A.x + A.w, sy + 14, 22, 700, INK, P(x, at(x, 1, 0.75)), 0, 'right') + if (x.t > x.ls[1]) box(c, A.x + (A.w - 2) * fill09(x), sy - 14, 2, 28, INK) + }, + red(x) { + const { A, cols, pitch, gy, n } = geo09(x.L) + if (x.t < x.ls[1] - 0.5) return x.L.park + const k = Math.min(n - 1, Math.floor(fill09(x) * n)) + const tgt = rr(A.x + (k % cols) * pitch, gy + Math.floor(k / cols) * pitch, pitch - 6, pitch - 6) + return mv(x.L.park, tgt, P(x, x.ls[1] - 0.5, 0.5)) + }, + cues(S) { return [[numLand(S), 'thunk'], [lt(S, 0, 0.62), 'snick']] }, +} + +// ===================================================================== +// R10 — 先自由后财富 +// ===================================================================== +const FIB: [number, number, number][] = [[9, 5, 1], [8, 5, 1], [8, 6, 2], [10, 5, 3], [8, 0, 5], [0, 0, 8]] +function geo10(L: Layout) { + const A = L.A + const u = L.por ? Math.min(A.w / 8, A.h / 13) : Math.min(A.w / 13, A.h / 8) + 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))) + return { A, u, sq } +} +const r10: SceneDef = { + draw(x) { + const { c, L } = x + const { A, sq } = geo10(L) + head(x, [[1, '自由 ⇄ 钱'], [2, '钱 = 放大器']]) + KG(x, A, x.ls[2] - 0.05, () => { + sq.forEach((r, i) => { + const ti = i === 0 ? x.ls[0] + 0.6 : at(x, 1, (i - 1) / 5) + const p = E(x, ti, 0.5) + if (p <= 0) return + if (i > 0 && i % 2 === 0) wipe(c, r.x, r.y, r.w, r.h, p, SHEET, 'd') + hline(c, r.x, r.y, r.w, p, INK, 2); vline(c, r.x, r.y, r.h, p, INK, 2) + 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) + if (i > 0) { + const s = Math.max(16, r.w * 0.16) + R(c, i % 2 ? '财富' : '自由', r.x + 12, r.y + 12, s, 900, i % 2 ? INK : G3, P(x, ti + 0.2)) + } + }) + }) + const ty = A.y + KT(x, '彩票', A.x, ty, 140, 900, INK, x.ls[2] + 0.3, at(x, 2, 0.28)) + if (x.t > at(x, 2, 0.28)) R(c, '= 诅咒', A.x, ty + 170, 40, 700, G3, P(x, at(x, 2, 0.2))) + if (x.t > at(x, 2, 0.42)) { + R(c, '钱 = 放大器', A.x, ty + 280, 96, 900, INK, P(x, at(x, 2, 0.42))) + R(c, '认知 × 10', A.x, ty + 430, 56, 900, INK, P(x, at(x, 2, 0.66))) + R(c, '错误 × 10', A.x, ty + 520, 56, 900, G3, P(x, at(x, 2, 0.84))) + } + }, + red(x) { + const { sq } = geo10(x.L) + if (x.t < x.ls[0] + 0.3) return x.L.park + const A = x.L.A + const dot = rr(A.x + A.w - 60, A.y + 280, 60, 60) + 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)) + }, + cues(S) { return [[numLand(S), 'thunk'], [lt(S, 2) - 0.05, 'snick'], [lt(S, 2, 0.28), 'snick'], [lt(S, 2, 0.42), 'stamp']] }, +} + +// ===================================================================== +// R11 — 体验大于意义 +// ===================================================================== +function geo11(L: Layout) { const A = L.A; return { A, ly: A.y + (L.por ? 600 : 520) } } +const r11: SceneDef = { + draw(x) { + const { c, L } = x + const { A, ly } = geo11(L) + head(x, [[0, '体验 > 意义'], [1, '先上路']]) + const s1 = L.por ? 220 : 240 + R(c, '体验', A.x, A.y, s1, 900, INK, P(x, x.ls[0] + 0.3)) + const w1 = measure(c, '体验', s1, 900) + if (!L.por) { + R(c, '>', A.x + w1 + 40, A.y + 40, 120, 300, INK, P(x, at(x, 0, 0.55))) + R(c, '意义', A.x + w1 + 140, A.y + 70, 96, 700, G2, P(x, at(x, 0, 0.7))) + } else { + R(c, '> 意义', A.x, A.y + 270, 110, 700, G2, P(x, at(x, 0, 0.6))) + } + const p = prog(x.t, x.ls[1], x.le[1] - x.ls[1] + 0.6) + hline(c, A.x, ly, A.w, p, INK, 2) + R(c, '出发', A.x, ly + 28, 28, 900, INK, P(x, x.ls[1])) + R(c, '为什么?', A.x + A.w, ly + 28, 28, 700, G3, P(x, x.le[1]), 0, 'right') + for (let i = 1; i < 12; i++) if (p > i / 12) vline(c, A.x + A.w * i / 12, ly - 6, 6, 1, INK) + }, + red(x) { + const { A, ly } = geo11(x.L) + const p = prog(x.t, x.ls[1], x.le[1] - x.ls[1] + 0.6) + const head = rr(A.x + (A.w - 24) * p, ly - 11, 24, 24) + return mv(x.L.park, head, P(x, x.ls[0] + 0.8, 0.8)) + }, + cues(S) { return [[numLand(S), 'thunk'], [lt(S, 1), 'glide']] }, +} + +// ===================================================================== +// R12 — 正确答案 + end card +// ===================================================================== +function geo12(L: Layout) { + const A = L.A + const cols = L.por ? 9 : 12, rows = L.por ? 7 : 6, cs = L.por ? 76 : 60, pitch = cs + 24 + return { A, cols, rows, cs, pitch, gx: A.x, gy: A.y + 10, RR: 2, RC: 7 } +} +const endAt = (x: SCtx) => x.le[2] + 1.6 +function leave12(x: SCtx) { return prog(x.t, at(x, 2, 0.35), x.le[2] - at(x, 2, 0.35) + 0.3) } +const r12: SceneDef = { + draw(x) { + const { c, L, S } = x + const { A, cols, rows, cs, pitch, gx, gy, RR, RC } = geo12(L) + const kEnd = endAt(x) + KG(x, rr(0, 100, L.W, L.H - 260), kEnd, () => { + head(x, [[1, '所有人'], [2, 'on grid → off grid']]) + const lv = leave12(x) + const shift = E(x, x.le[2] + 0.2, 0.7) + for (let r = 0; r < rows; r++) for (let q = 0; q < cols; q++) { + if (r === RR && q === RC) continue + const p = P(x, x.ls[0] + Math.hypot(r - RR, q - RC) * 0.05, 0.3) + if (p <= 0) continue + const dq = r === RR && q > RC ? shift : 0 + box(c, gx + (q - dq) * pitch, gy + r * pitch, cs * ease(p), cs, G2) + } + const ry = gy + rows * pitch + 20 + hline(c, A.x, ry, A.w, E(x, x.ls[1], 0.6)) + 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])) + R(c, '成为答案之前,不知道自己是答案', A.x + A.w, ry + 16, 22, 500, G3, P(x, at(x, 2, 0.5)), 0, 'right') + }) + // end card + const e0 = kEnd + 0.45 + if (x.t > e0) { + const por = L.por + R(c, '十二条规则', L.m, por ? 360 : 200, por ? 150 : 200, 900, INK, P(x, e0, 0.6)) + R(c, '孙宇晨 2026 长访谈 · 观点整理', L.m, por ? 560 : 440, por ? 34 : 40, 700, INK, P(x, e0 + 0.25)) + const cr = ['版式 · 瑞士国际主义 · 12 栏网格', '配音 · 合成语音', `音乐 · 程序化合成 · 112 BPM`, '素材 · 访谈原文整理'] + 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)) + R(c, '本片为访谈观点整理与解读,不构成任何投资建议。', L.m, por ? 1180 : 820, 22, 500, G3, P(x, e0 + 1.1)) + void S + } + }, + red(x) { + const { cs, pitch, gx, gy, RR, RC } = geo12(x.L) + const cell = rr(gx + RC * pitch, gy + RR * pitch, cs, cs) + const lv = leave12(x) + if (lv >= 1) return null + return rr(lerp(cell.x, x.L.W + 40, lv), cell.y, cs, cs) + }, + 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']] }, +} + +export const SCENE_DEFS: Record = { cover, r01, r02, r03, r04, r05, r06, r07, r08, r09, r10, r11, r12 } +export const NO_HEAD = new Set(['cover']) +export { endAt } +void T; void G1 diff --git a/earth-online-sun/web/src/swiss/timeline.ts b/earth-online-sun/web/src/swiss/timeline.ts new file mode 100644 index 0000000000000000000000000000000000000000..29b0beed89fc31edc94a64d4abdd15d905fb69cd --- /dev/null +++ b/earth-online-sun/web/src/swiss/timeline.ts @@ -0,0 +1,55 @@ +// 《规则 RULES》timeline — absolute times for every narration line, built from measured VO durations. +// Scene starts are quantised to the music's beat so every chapter snap lands on a downbeat. +import VO from './vo.json' + +export const BPM = 112 +export const BEAT = 60 / BPM +export const BAR = BEAT * 4 + +export interface Line { i: number; scene: string; text: string; dur: number; start: number; end: number } +export interface SceneSpan { id: string; no: string; title: string; en: string; start: number; end: number; lines: Line[]; idx: number } + +const DEF: { id: string; no: string; title: string; en: string; pre: number; post: number; gap: number }[] = [ + { id: 'cover', no: '00', title: '十二条规则', en: 'Twelve Rules · 2026', pre: 2.6, post: 1.0, gap: 0.55 }, + { id: 'r01', no: '01', title: '认知决定一切', en: 'Cognition', pre: 0.7, post: 0.7, gap: 0.5 }, + { id: 'r02', no: '02', title: '三件零成本的事', en: 'Zero-cost kit', pre: 0.6, post: 0.7, gap: 0.45 }, + { id: 'r03', no: '03', title: '交给 AI 托管', en: 'Autopilot', pre: 0.6, post: 0.7, gap: 0.45 }, + { id: 'r04', no: '04', title: '寿命是硬通货', en: 'Lifespan', pre: 0.6, post: 0.7, gap: 0.45 }, + { id: 'r05', no: '05', title: '先开图再决策', en: 'Scout first', pre: 0.6, post: 0.8, gap: 0.5 }, + { id: 'r06', no: '06', title: '全球寻找商机', en: 'Global', pre: 0.6, post: 0.7, gap: 0.45 }, + { id: 'r07', no: '07', title: '货币的翻译', en: 'Translation', pre: 0.6, post: 1.8, gap: 0.5 }, + { id: 'r08', no: '08', title: '只会向前更新', en: 'Forward only', pre: 0.6, post: 0.7, gap: 0.45 }, + { id: 'r09', no: '09', title: '只有信息量', en: 'Information', pre: 0.6, post: 0.7, gap: 0.5 }, + { id: 'r10', no: '10', title: '先自由后财富', en: 'Freedom first', pre: 0.6, post: 0.8, gap: 0.5 }, + { id: 'r11', no: '11', title: '体验大于意义', en: 'Experience', pre: 0.6, post: 0.8, gap: 0.5 }, + { id: 'r12', no: '12', title: '正确答案', en: 'The answer', pre: 0.6, post: 7.0, gap: 0.6 }, +] + +const vo = VO as { i: number; scene: string; text: string; dur: number }[] +export const LINES: Line[] = [] +export const SCENES: SceneSpan[] = [] +let cur = 0 +DEF.forEach((d, idx) => { + cur = Math.ceil(cur / BEAT - 1e-6) * BEAT // land on a beat + if (SCENES.length) SCENES[SCENES.length - 1].end = cur + const start = cur + cur += d.pre + const lines: Line[] = [] + const own = vo.filter(v => v.scene === d.id) + own.forEach((v, k) => { + const ln: Line = { ...v, start: cur, end: cur + v.dur } + lines.push(ln); LINES.push(ln) + cur += v.dur + (k < own.length - 1 ? d.gap : 0) + }) + cur += d.post + SCENES.push({ id: d.id, no: d.no, title: d.title, en: d.en, start, end: cur, lines, idx }) +}) +// last scene ends on a bar line +const endQ = Math.ceil(cur / BAR) * BAR +SCENES[SCENES.length - 1].end = endQ +export const DUR = endQ + +export function sceneAt(t: number) { + for (let i = 0; i < SCENES.length; i++) if (t < SCENES[i].end) return i + return SCENES.length - 1 +} diff --git a/earth-online-sun/web/src/swiss/vo.json b/earth-online-sun/web/src/swiss/vo.json new file mode 100644 index 0000000000000000000000000000000000000000..cbbd4cd2b933f4be6febeb4ad6a09ed036e4c7a0 --- /dev/null +++ b/earth-online-sun/web/src/swiss/vo.json @@ -0,0 +1,290 @@ +[ + { + "i": 0, + "scene": "cover", + "text": "2016年,孙宇晨写下《财富自由之路》。", + "dur": 2.94 + }, + { + "i": 1, + "scene": "cover", + "text": "十年后,这本书在年轻人中重新流传。", + "dur": 2.73 + }, + { + "i": 2, + "scene": "cover", + "text": "2026年,他在一次长访谈里,交出了新的答案。", + "dur": 3.38 + }, + { + "i": 3, + "scene": "cover", + "text": "我们把它整理成十二条规则。", + "dur": 1.76 + }, + { + "i": 4, + "scene": "r01", + "text": "第一条:认知,决定一切。", + "dur": 1.77 + }, + { + "i": 5, + "scene": "r01", + "text": "2016年,他的清单是:腾讯、比特币、特斯拉、英伟达。", + "dur": 4.38 + }, + { + "i": 6, + "scene": "r01", + "text": "当年听起来像疯话。十年后,每一项都是答案。", + "dur": 3.58 + }, + { + "i": 7, + "scene": "r01", + "text": "他说,这不是运气。", + "dur": 1.33 + }, + { + "i": 8, + "scene": "r02", + "text": "第二条:先做三件零成本的事。", + "dur": 2.01 + }, + { + "i": 9, + "scene": "r02", + "text": "孙学,升级大脑的操作系统。", + "dur": 2.1 + }, + { + "i": 10, + "scene": "r02", + "text": "薄肌,守住身体这张底牌。", + "dur": 1.83 + }, + { + "i": 11, + "scene": "r02", + "text": "黄毛,认定对的事,就去执行。", + "dur": 2.17 + }, + { + "i": 12, + "scene": "r03", + "text": "第三条:把日常交给AI托管。", + "dur": 2.3 + }, + { + "i": 13, + "scene": "r03", + "text": "三餐拍照,戒指记录睡眠,训练录成视频,统统交给Claude。", + "dur": 4.62 + }, + { + "i": 14, + "scene": "r03", + "text": "它每天写一份日报,存成md文档。", + "dur": 2.6 + }, + { + "i": 15, + "scene": "r03", + "text": "九成交给AI,一成自己微调。", + "dur": 2.31 + }, + { + "i": 16, + "scene": "r03", + "text": "拿不准时,同时问三个模型,上中下三策,取其中。", + "dur": 3.9 + }, + { + "i": 17, + "scene": "r04", + "text": "第四条:寿命,是唯一的硬通货。", + "dur": 2.43 + }, + { + "i": 18, + "scene": "r04", + "text": "AI告诉他:你物质上是富人,身体上是穷人。", + "dur": 3.26 + }, + { + "i": 19, + "scene": "r04", + "text": "寿命满分一百。心肺二十五分,肌肉二十五分。", + "dur": 3.89 + }, + { + "i": 20, + "scene": "r04", + "text": "肌肉是身体里的银行账户。练出薄肌,就拿下一半。", + "dur": 4.06 + }, + { + "i": 21, + "scene": "r04", + "text": "AI时代,一切都会变便宜,除了寿命。", + "dur": 2.69 + }, + { + "i": 22, + "scene": "r05", + "text": "第五条:先开图,再决策。", + "dur": 2.02 + }, + { + "i": 23, + "scene": "r05", + "text": "像打即时战略:先派斥候探图,再造兵。", + "dur": 3.04 + }, + { + "i": 24, + "scene": "r05", + "text": "买房之前,先看一百套。前三套不买,只用来建立基准。", + "dur": 4.58 + }, + { + "i": 25, + "scene": "r05", + "text": "信息足够多,就不需要做决定。", + "dur": 2.03 + }, + { + "i": 26, + "scene": "r06", + "text": "第六条:在全球寻找商机。", + "dur": 1.87 + }, + { + "i": 27, + "scene": "r06", + "text": "别只盯着楼下老奶奶的那点钱。", + "dur": 2.0 + }, + { + "i": 28, + "scene": "r06", + "text": "同一份AI订阅,各国价格不同,这本身就是生意。", + "dur": 3.72 + }, + { + "i": 29, + "scene": "r06", + "text": "四亿年轻人,服务八十亿人,和未来的二十亿个AI。", + "dur": 3.63 + }, + { + "i": 30, + "scene": "r07", + "text": "第七条:把货币,翻译成机器的语言。", + "dur": 2.56 + }, + { + "i": 31, + "scene": "r07", + "text": "这是他对crypto的定义。", + "dur": 1.49 + }, + { + "i": 32, + "scene": "r07", + "text": "因为没有成为AI语料的东西,", + "dur": 1.99 + }, + { + "i": 33, + "scene": "r07", + "text": "就等于从来没有发生过。", + "dur": 1.49 + }, + { + "i": 34, + "scene": "r08", + "text": "第八条:世界只会向前更新。", + "dur": 2.06 + }, + { + "i": 35, + "scene": "r08", + "text": "房地产被削弱,AI被加强。", + "dur": 2.16 + }, + { + "i": 36, + "scene": "r08", + "text": "黄金从不升级,比特币每个版本都在升级。", + "dur": 3.23 + }, + { + "i": 37, + "scene": "r08", + "text": "版本不能回滚。越成功,越要保持small ego。", + "dur": 4.0 + }, + { + "i": 38, + "scene": "r09", + "text": "第九条:没有智商,只有信息量。", + "dur": 2.12 + }, + { + "i": 39, + "scene": "r09", + "text": "大模型从人工智障到破解难题,靠的就是灌进更多信息。", + "dur": 4.22 + }, + { + "i": 40, + "scene": "r10", + "text": "第十条:先自由,后财富。", + "dur": 2.0 + }, + { + "i": 41, + "scene": "r10", + "text": "自由让你更容易赚钱,钱又让你更自由。", + "dur": 2.92 + }, + { + "i": 42, + "scene": "r10", + "text": "所以中彩票是诅咒。钱只是放大器,放大认知,也放大错误。", + "dur": 5.15 + }, + { + "i": 43, + "scene": "r11", + "text": "第十一条:体验大于意义。", + "dur": 1.8 + }, + { + "i": 44, + "scene": "r11", + "text": "先上路,比想清楚为什么上路更重要。", + "dur": 2.62 + }, + { + "i": 45, + "scene": "r12", + "text": "第十二条。", + "dur": 0.58 + }, + { + "i": 46, + "scene": "r12", + "text": "所有成为正确答案的人,", + "dur": 1.37 + }, + { + "i": 47, + "scene": "r12", + "text": "在成为正确答案之前,都不知道自己是正确的。", + "dur": 3.1 + } +] \ No newline at end of file