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