md-html-demo.html 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>md还是html,这是个蠢问题 · 解说 demo (v3 · 字幕+持续运动+修溢出)</title>
  7. <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
  8. <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
  9. <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
  10. <link rel="preconnect" href="https://fonts.googleapis.com">
  11. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  12. <link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:wght@300;400;600;700;800&family=Noto+Serif+SC:wght@400;600;700;900&family=JetBrains+Mono:wght@400;500;700&family=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet">
  13. <style>
  14. body { margin: 0; background: #0a0a0a; min-height: 100vh; display: flex; align-items: center; justify-content: center; flex-direction: column; padding: 20px; box-sizing: border-box; font-family: -apple-system, sans-serif; }
  15. #root { box-shadow: 0 30px 80px rgba(0,0,0,0.6); border-radius: 4px; overflow: hidden; }
  16. * { box-sizing: border-box; }
  17. </style>
  18. </head>
  19. <body>
  20. <div id="root"></div>
  21. <script type="text/babel">
  22. // ── timeline.json (inline · 精简版,每段含 chunks 用于字幕) ───
  23. const TIMELINE = {"title":"md还是html,这是个蠢问题","totalDuration":198.168,"voiceover":"voiceover.mp3","scenes":[
  24. {"id":"opening","start":0,"end":22.32,"duration":22.32,"chunks":[
  25. {"text":"前两天,","absoluteStart":0,"absoluteEnd":0.984},
  26. {"text":"Claude Code 团队的 Thariq 发了篇爆文。标题就一句话,HTML 是新的 markdown。","absoluteStart":0.984,"absoluteEnd":8.5},
  27. {"text":"他说他几乎不再写 md 文件了,全让 AI 给他生成 HTML。500 万阅读,X 上立马吵翻了。","absoluteStart":8.5,"absoluteEnd":14.952},
  28. {"text":"一派是 md 党,觉得 md 才是 AI 时代的源代码。另一派觉得 HTML 才是终极答案。","absoluteStart":14.952,"absoluteEnd":22.32}
  29. ],"cues":[{"id":"thariq","absoluteTime":0.984},{"id":"two-camps","absoluteTime":14.952}]},
  30. {"id":"md-side","start":22.82,"end":56.516,"duration":33.696,"chunks":[
  31. {"text":"md 党的证据其实挺硬的。","absoluteStart":22.82,"absoluteEnd":26.5},
  32. {"text":"OpenAI 去年发的 AGENTS.md,60000 多个项目用,","absoluteStart":26.5,"absoluteEnd":31.5},
  33. {"text":"AWS、Anthropic、Google、微软、OpenAI,AI 半壁江山一起捐进 Linux Foundation。","absoluteStart":31.5,"absoluteEnd":38.5},
  34. {"text":"Karpathy 的 llm-wiki,单一个 CLAUDE.md 文件,5 万 star。","absoluteStart":38.5,"absoluteEnd":45.14},
  35. {"text":"Cloudflare 实测,同一篇博客 HTML 一万六千 token,转成 md 只要三千。省 80%。","absoluteStart":45.14,"absoluteEnd":54.764},
  36. {"text":"GitHub 官方说:文档不再是描述代码,文档就是代码。","absoluteStart":54.764,"absoluteEnd":56.516}
  37. ],"cues":[{"id":"agents-md","absoluteTime":27.5},{"id":"token-saving","absoluteTime":45.14},{"id":"doc-is-code","absoluteTime":54.764}]},
  38. {"id":"html-side","start":57.016,"end":100.168,"duration":43.152,"chunks":[
  39. {"text":"但 html 党也没说错。Thariq 的论据我都同意。","absoluteStart":57.016,"absoluteEnd":62.92},
  40. {"text":"第一是空间信息。diff、调用图、架构图本来就有空间维度,html 能左右对照。","absoluteStart":62.92,"absoluteEnd":74.632},
  41. {"text":"第二是动态体验。按钮颜色、easing 曲线,文字描述再多没用,html 能让你直接看见。","absoluteStart":74.632,"absoluteEnd":85.864},
  42. {"text":"第三是结构化阅读。可折叠章节、tab 代码块、边栏术语表。","absoluteStart":85.864,"absoluteEnd":93},
  43. {"text":"Anthropic 的 Live Artifacts,HTML 已升级为可交互、能拉实时数据的 dashboard。","absoluteStart":93,"absoluteEnd":100.168}
  44. ],"cues":[{"id":"spatial","absoluteTime":62.92},{"id":"dynamic","absoluteTime":74.632},{"id":"structured","absoluteTime":85.864}]},
  45. {"id":"the-real-question","start":100.668,"end":117.588,"duration":16.92,"chunks":[
  46. {"text":"我看完想说,","absoluteStart":100.668,"absoluteEnd":101.748},
  47. {"text":"这俩根本是在争一个蠢问题。","absoluteStart":101.748,"absoluteEnd":106},
  48. {"text":"两边都赢了。但赢的是不同的问题。","absoluteStart":106,"absoluteEnd":109.044},
  49. {"text":"md 党回答:我们用什么写。","absoluteStart":109.044,"absoluteEnd":112.62},
  50. {"text":"html 党回答:我们给人什么看。","absoluteStart":112.62,"absoluteEnd":115.5},
  51. {"text":"两个不同问题,怎么会有谁取代谁。","absoluteStart":115.5,"absoluteEnd":117.588}
  52. ],"cues":[{"id":"reveal","absoluteTime":101.748},{"id":"question-md","absoluteTime":109.044},{"id":"question-html","absoluteTime":112.62}]},
  53. {"id":"the-split","start":118.088,"end":158.744,"duration":40.656,"chunks":[
  54. {"text":"我觉得真问题是这个。","absoluteStart":118.088,"absoluteEnd":121},
  55. {"text":"md 和 html 不是替代,是分工关系。","absoluteStart":121,"absoluteEnd":126.5},
  56. {"text":"以前你写 md 自己也看 md,要折中,所以 md 胜出。","absoluteStart":126.5,"absoluteEnd":131},
  57. {"text":"AI 出现后,生产成本被 AI 吸收。","absoluteStart":131,"absoluteEnd":135},
  58. {"text":"原来要折中的需求,被拆成了两端的极端最优。","absoluteStart":135,"absoluteEnd":140},
  59. {"text":"生产端要轻、要快、要 token efficient——那就是 md。","absoluteStart":140,"absoluteEnd":148.28},
  60. {"text":"消费端要丰富、要可视化、要好分享——那就是 html。","absoluteStart":148.28,"absoluteEnd":153.464},
  61. {"text":"两端各自登顶,中间那个折中位置,没人需要了。","absoluteStart":153.464,"absoluteEnd":158.744}
  62. ],"cues":[{"id":"split","absoluteTime":122.84},{"id":"ai-changes","absoluteTime":131},{"id":"md-side-win","absoluteTime":148.28},{"id":"html-side-win","absoluteTime":153.464}]},
  63. {"id":"activity-proof","start":159.244,"end":184.084,"duration":24.84,"chunks":[
  64. {"text":"最干净的活样本是 Thariq 自己。","absoluteStart":159.244,"absoluteEnd":162.5},
  65. {"text":"3 月份他发《Skills 指南》,强调核心还是 markdown。","absoluteStart":162.5,"absoluteEnd":167},
  66. {"text":"5 月份他发《HTML is the new markdown》。","absoluteStart":167,"absoluteEnd":169.372},
  67. {"text":"同一个人,两端各自登顶,互不打架。","absoluteStart":169.372,"absoluteEnd":174},
  68. {"text":"Karpathy 和 Lex Fridman 那对组合也一样。","absoluteStart":174,"absoluteEnd":177},
  69. {"text":"内核是 markdown wiki,外壳是动态 HTML——是加了一层消费层。","absoluteStart":177,"absoluteEnd":184.084}
  70. ],"cues":[{"id":"thariq-march","absoluteTime":164.236},{"id":"same-person","absoluteTime":169.372},{"id":"karpathy-lex","absoluteTime":176.764}]},
  71. {"id":"closing","start":184.584,"end":197.88,"duration":13.296,"chunks":[
  72. {"text":"所以下次你想吵这个的时候,","absoluteStart":184.584,"absoluteEnd":186.672},
  73. {"text":"先问自己一句——你面对的是「写」,还是「看」?","absoluteStart":186.672,"absoluteEnd":192},
  74. {"text":"写,用 md。","absoluteStart":192,"absoluteEnd":193.704},
  75. {"text":"看,用 html。","absoluteStart":193.704,"absoluteEnd":195.5},
  76. {"text":"工具替你处理切换,立场可以放下了。","absoluteStart":195.5,"absoluteEnd":197.88}
  77. ],"cues":[{"id":"final","absoluteTime":186.672},{"id":"md-final","absoluteTime":192},{"id":"html-final","absoluteTime":193.704}]}
  78. ]};
  79. // ── narration_stage.jsx (inline) ─────────────────────────────
  80. const NarrationStageLib = (() => {
  81. const NarrationContext = React.createContext({});
  82. function NarrationStage({ timeline, audioSrc, width = 1920, height = 1080, background = '#0e0e0e', controls = true, children }) {
  83. const audioRef = React.useRef(null);
  84. const [time, setTime] = React.useState(0);
  85. const [playing, setPlaying] = React.useState(false);
  86. const recording = typeof window !== 'undefined' && window.__recording === true;
  87. React.useEffect(() => { if (typeof window !== 'undefined') { window.__totalDuration = timeline.totalDuration; window.__ready = true; } }, [timeline.totalDuration]);
  88. React.useEffect(() => {
  89. let raf;
  90. if (recording) {
  91. let startedAt = null;
  92. const tick = (now) => {
  93. if (startedAt === null) startedAt = now;
  94. setTime(Math.min((now - startedAt) / 1000, timeline.totalDuration));
  95. raf = requestAnimationFrame(tick);
  96. };
  97. raf = requestAnimationFrame(tick);
  98. if (typeof window !== 'undefined') window.__seek = (t) => { startedAt = performance.now() - t * 1000; setTime(t); };
  99. } else {
  100. const tick = () => {
  101. if (audioRef.current && !audioRef.current.paused) setTime(audioRef.current.currentTime);
  102. raf = requestAnimationFrame(tick);
  103. };
  104. tick();
  105. }
  106. return () => cancelAnimationFrame(raf);
  107. }, [recording, timeline.totalDuration]);
  108. const currentScene = React.useMemo(() => {
  109. if (!timeline.scenes) return null;
  110. for (let i = 0; i < timeline.scenes.length; i++) {
  111. const s = timeline.scenes[i]; const next = timeline.scenes[i + 1];
  112. if (time >= s.start && (!next || time < next.start)) return s;
  113. }
  114. return timeline.scenes[0];
  115. }, [time, timeline.scenes]);
  116. const sceneTime = currentScene ? Math.max(0, time - currentScene.start) : 0;
  117. const allCues = React.useMemo(() => { const m = {}; for (const s of timeline.scenes || []) for (const c of s.cues || []) m[c.id] = c; return m; }, [timeline.scenes]);
  118. const isCueTriggered = React.useCallback(id => { const c = allCues[id]; return c ? time >= c.absoluteTime : false; }, [allCues, time]);
  119. const cueProgress = React.useCallback((id, ramp = 0.6) => { const c = allCues[id]; if (!c) return 0; const dt = time - c.absoluteTime; if (dt <= 0) return 0; if (dt >= ramp) return 1; return dt / ramp; }, [allCues, time]);
  120. const ctx = { time, scene: currentScene, sceneTime, isCueTriggered, cueProgress, timeline };
  121. return (
  122. <NarrationContext.Provider value={ctx}>
  123. <div style={{ position: 'relative', width, height, background, overflow: 'hidden', color: '#1a1a1a' }}>{children}</div>
  124. {!recording && <audio ref={audioRef} src={audioSrc} preload="auto" onEnded={() => setPlaying(false)} />}
  125. {!recording && controls && (
  126. <div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', background: '#1a1a1a', color: '#ddd', fontFamily: 'monospace', fontSize: 13, width, boxSizing: 'border-box' }}>
  127. <button onClick={() => { if (audioRef.current.paused) { audioRef.current.play(); setPlaying(true); } else { audioRef.current.pause(); setPlaying(false); } }} style={{ padding: '6px 14px', background: '#fff', color: '#000', border: 0, borderRadius: 4, cursor: 'pointer', fontWeight: 600 }}>{playing ? '❚❚ Pause' : '▶ Play'}</button>
  128. <input type="range" min={0} max={timeline.totalDuration} step={0.01} value={time} onChange={e => { const t = parseFloat(e.target.value); audioRef.current.currentTime = t; setTime(t); }} style={{ flex: 1 }} />
  129. <span style={{ minWidth: 130, textAlign: 'right' }}>{time.toFixed(2)} / {timeline.totalDuration.toFixed(2)}s</span>
  130. <span style={{ padding: '4px 10px', background: '#2a2a2a', borderRadius: 4, minWidth: 130, textAlign: 'center' }}>{currentScene ? currentScene.id : '—'}</span>
  131. </div>
  132. )}
  133. </NarrationContext.Provider>
  134. );
  135. }
  136. function useNarration() { return React.useContext(NarrationContext); }
  137. function useSceneFade(sceneId, fadeIn = 0.5, fadeOut = 0.5) {
  138. const { time, timeline } = React.useContext(NarrationContext);
  139. if (!timeline) return 0;
  140. const s = timeline.scenes.find(x => x.id === sceneId);
  141. if (!s) return 0;
  142. const inT = (time - s.start) / fadeIn;
  143. const outT = (s.end - time) / fadeOut;
  144. return Math.max(0, Math.min(1, Math.min(inT, outT)));
  145. }
  146. function Cue({ id, ramp = 0.6, children }) {
  147. const { isCueTriggered, cueProgress } = React.useContext(NarrationContext);
  148. return children(isCueTriggered(id), cueProgress(id, ramp));
  149. }
  150. return { NarrationStage, Cue, useNarration, useSceneFade };
  151. })();
  152. const { NarrationStage, Cue, useNarration, useSceneFade } = NarrationStageLib;
  153. // ── 设计 token ────────────────────────────────────────────
  154. const C = {
  155. paper: '#f5f1e8', paperDeep: '#ebe5d4',
  156. ink: '#1a1a1a', inkSoft: '#3a3a3a', inkMute: '#888',
  157. md: '#1B4965', html: '#C04A1A', green: '#7BC47F',
  158. };
  159. const F = {
  160. display: '"Source Serif 4", "Noto Serif SC", Georgia, serif',
  161. body: '"Noto Sans SC", "Noto Serif SC", "Source Serif 4", sans-serif',
  162. mono: '"JetBrains Mono", Menlo, monospace',
  163. };
  164. // ── easing & interpolate ──────────────────────────────────
  165. const expoOut = t => t === 1 ? 1 : 1 - Math.pow(2, -10 * t);
  166. const lerp = (a, b, t) => a + (b - a) * t;
  167. const lerpC = (from, to, t) => ({
  168. x: lerp(from.x, to.x, t), y: lerp(from.y, to.y, t),
  169. scale: lerp(from.scale, to.scale, t),
  170. opacity: lerp(from.opacity ?? 1, to.opacity ?? 1, t),
  171. });
  172. // ── HERO 状态表(v3:缩小 scale 避免溢出,y 留给字幕区) ──
  173. // 字幕条占 y=88-100 区域,所以 hero y ≤ 70%
  174. const HERO_KEYS = {
  175. opening: { md: { x: 50, y: 28, scale: 1.0, opacity: 1 }, html: { x: 50, y: 55, scale: 1.0, opacity: 1 } },
  176. 'md-side': { md: { x: 72, y: 48, scale: 1.4, opacity: 1 }, html: { x: 92, y: 12, scale: 0.3, opacity: 0.5 } },
  177. 'html-side': { md: { x: 8, y: 12, scale: 0.3, opacity: 0.5 }, html: { x: 28, y: 48, scale: 1.4, opacity: 1 } },
  178. 'the-real-question': { md: { x: 30, y: 30, scale: 0.85, opacity: 1 }, html: { x: 70, y: 30, scale: 0.85, opacity: 1 } },
  179. 'the-split': { md: { x: 22, y: 60, scale: 1.15, opacity: 1 }, html: { x: 78, y: 60, scale: 1.15, opacity: 1 } },
  180. 'activity-proof': { md: { x: 18, y: 18, scale: 0.5, opacity: 1 }, html: { x: 82, y: 18, scale: 0.5, opacity: 1 } },
  181. closing: { md: { x: 28, y: 50, scale: 1.3, opacity: 1 }, html: { x: 72, y: 50, scale: 1.3, opacity: 1 } },
  182. };
  183. const SCENE_ORDER = ['opening', 'md-side', 'html-side', 'the-real-question', 'the-split', 'activity-proof', 'closing'];
  184. // ── HeroAnchor: 跨 scene hero + 持续微动(消除 3s 静止)──
  185. const HeroAnchor = () => {
  186. const { time, scene } = useNarration();
  187. if (!scene) return null;
  188. const idx = SCENE_ORDER.indexOf(scene.id);
  189. const prevId = idx > 0 ? SCENE_ORDER[idx - 1] : scene.id;
  190. const fromPos = HERO_KEYS[prevId];
  191. const toPos = HERO_KEYS[scene.id];
  192. const transitionDur = Math.min(2.0, scene.duration * 0.45);
  193. const t = expoOut(Math.min(1, Math.max(0, (time - scene.start) / transitionDur)));
  194. const md = lerpC(fromPos.md, toPos.md, t);
  195. const html = lerpC(fromPos.html, toPos.html, t);
  196. // ── 持续微动:scale 呼吸 + figure-8 飘移(确保任意 3s 都有变化)──
  197. const breath = 1 + Math.sin(time * 0.7) * 0.018;
  198. const driftXm = Math.cos(time * 0.32) * 0.6;
  199. const driftYm = Math.sin(time * 0.41) * 0.5;
  200. const driftXh = Math.sin(time * 0.28) * 0.6;
  201. const driftYh = Math.cos(time * 0.37) * 0.5;
  202. const baseSize = 240; // 缩小 from 360
  203. const renderHero = (label, pos, color, dx, dy) => {
  204. const px = (pos.x + dx) * 19.2;
  205. const py = (pos.y + dy) * 10.8;
  206. return (
  207. <div key={label} style={{
  208. position: 'absolute', left: px, top: py,
  209. transform: `translate(-50%, -50%) scale(${pos.scale * breath})`,
  210. opacity: pos.opacity,
  211. fontSize: baseSize, fontFamily: F.display, fontWeight: 800,
  212. color, lineHeight: 1, letterSpacing: '-0.02em',
  213. willChange: 'transform, opacity', pointerEvents: 'none',
  214. }}>{label}</div>
  215. );
  216. };
  217. return (
  218. <div style={{ position: 'absolute', inset: 0, perspective: '2400px' }}>
  219. <div style={{ position: 'absolute', inset: 0, transformStyle: 'preserve-3d', transform: 'rotateX(2deg) rotateY(-1deg)' }}>
  220. {renderHero('md', md, C.md, driftXm, driftYm)}
  221. {renderHero('html', html, C.html, driftXh, driftYh)}
  222. </div>
  223. </div>
  224. );
  225. };
  226. // ── BackgroundDrift ────────────────────────────────────────
  227. const BackgroundDrift = () => {
  228. const { time } = useNarration();
  229. const dx = Math.sin(time * 0.08) * 16;
  230. const dy = Math.cos(time * 0.06) * 12;
  231. return (
  232. <div style={{
  233. position: 'absolute', inset: -40,
  234. background: `radial-gradient(ellipse 1400px 800px at ${50 + dx/4}% ${50 + dy/4}%, ${C.paperDeep} 0%, ${C.paper} 60%, ${C.paper} 100%)`,
  235. pointerEvents: 'none',
  236. }} />
  237. );
  238. };
  239. // ── Subtitles: B 站风字幕(白字 + 黑描边,无背景,每行 ≤12 字不截断句子)──
  240. // 把每个 chunk 按标点切成短行,按字数比例分配 chunk 时间窗显示
  241. // 切分算法:先按强标点(。!?\n)切句,每句再按弱标点(,、;:)合并到 maxLen
  242. // 中英混合:英文字母按 0.5 字算(视觉宽度近似)
  243. function visualLen(s) {
  244. let n = 0;
  245. for (const ch of s) n += /[a-zA-Z0-9 .,'":;\-]/.test(ch) ? 0.5 : 1;
  246. return n;
  247. }
  248. function splitChunkToLines(text, maxLen = 13) {
  249. const lines = [];
  250. // 1. 按强标点切句(保留标点)
  251. const sentences = [];
  252. let buf = '';
  253. for (const ch of text) {
  254. buf += ch;
  255. if ('。!?\n'.includes(ch)) {
  256. if (buf.trim()) sentences.push(buf.trim());
  257. buf = '';
  258. }
  259. }
  260. if (buf.trim()) sentences.push(buf.trim());
  261. // 2. 每句按弱标点切并合并到 maxLen 以内(不跨句号边界)
  262. for (const sent of sentences) {
  263. if (visualLen(sent) <= maxLen) { lines.push(sent); continue; }
  264. // 按弱标点切(保留标点跟前段)
  265. const parts = [];
  266. let pbuf = '';
  267. for (const ch of sent) {
  268. pbuf += ch;
  269. if (',、;:'.includes(ch)) { parts.push(pbuf); pbuf = ''; }
  270. }
  271. if (pbuf) parts.push(pbuf);
  272. // 合并到 maxLen
  273. let merged = '';
  274. for (const p of parts) {
  275. if (visualLen(merged) + visualLen(p) <= maxLen) merged += p;
  276. else { if (merged) lines.push(merged); merged = p; }
  277. }
  278. if (merged) {
  279. if (visualLen(merged) <= maxLen) lines.push(merged);
  280. else {
  281. // 兜底硬切(罕见:单个标点段超 maxLen)
  282. let hbuf = '';
  283. for (const ch of merged) {
  284. hbuf += ch;
  285. if (visualLen(hbuf) >= maxLen) { lines.push(hbuf); hbuf = ''; }
  286. }
  287. if (hbuf) lines.push(hbuf);
  288. }
  289. }
  290. }
  291. return lines.filter(l => l.trim());
  292. }
  293. const Subtitles = () => {
  294. const { time, scene } = useNarration();
  295. if (!scene || !scene.chunks) return null;
  296. const active = scene.chunks.find(c => time >= c.absoluteStart && time < c.absoluteEnd);
  297. if (!active) return null;
  298. const lines = splitChunkToLines(active.text);
  299. if (lines.length === 0) return null;
  300. // 按字数比例把 chunk 时长分配给每行
  301. const totalLen = lines.reduce((s, l) => s + visualLen(l), 0);
  302. const chunkDur = active.absoluteEnd - active.absoluteStart;
  303. let acc = active.absoluteStart;
  304. let activeLine = lines[lines.length - 1];
  305. let lineStart = active.absoluteStart;
  306. for (const line of lines) {
  307. const dur = (visualLen(line) / totalLen) * chunkDur;
  308. if (time < acc + dur) { activeLine = line; lineStart = acc; break; }
  309. acc += dur;
  310. }
  311. // 行内淡入 0.15s
  312. const lineProg = Math.min(1, (time - lineStart) / 0.15);
  313. return (
  314. <div style={{
  315. position: 'absolute', left: 0, right: 0, bottom: 90,
  316. display: 'flex', justifyContent: 'center', pointerEvents: 'none', zIndex: 50,
  317. }}>
  318. <div key={lineStart} style={{
  319. fontFamily: '"PingFang SC", "Noto Sans SC", -apple-system, sans-serif',
  320. fontSize: 32, fontWeight: 600, color: C.ink,
  321. letterSpacing: '0.04em', lineHeight: 1.2, textAlign: 'center',
  322. // 浅纸白背景上:深墨字 + 极细白色光晕,让字在底上跳出来又不重
  323. textShadow: '0 0 6px rgba(245,241,232,0.9), 0 0 12px rgba(245,241,232,0.7), 0 1px 2px rgba(255,255,255,0.5)',
  324. opacity: lineProg, transform: `translateY(${(1 - lineProg) * 4}px)`,
  325. }}>
  326. {activeLine}
  327. </div>
  328. </div>
  329. );
  330. };
  331. // ── 段标签 ─────────────────────────────────────────────
  332. const SceneLabel = ({ sceneId, text }) => {
  333. const op = useSceneFade(sceneId, 0.4, 0.4);
  334. return (
  335. <div style={{
  336. position: 'absolute', top: 56, left: 80, fontFamily: F.mono, fontSize: 14,
  337. color: C.inkMute, letterSpacing: '0.22em', textTransform: 'uppercase', opacity: op,
  338. }}>{text}</div>
  339. );
  340. };
  341. // ── 各 scene 辅助元素 ──────────────────────────────────
  342. const OpeningAux = () => {
  343. const op = useSceneFade('opening', 0.6, 1.0);
  344. return (
  345. <>
  346. <Cue id="thariq">{(t, p) => (
  347. <div style={{ position: 'absolute', top: 110, left: 100, opacity: op * p, transform: `translateY(${(1-p)*20}px)`, maxWidth: 700 }}>
  348. <div style={{ fontFamily: F.mono, fontSize: 14, color: C.inkMute, marginBottom: 10, letterSpacing: '0.12em' }}>2026.05.07 · @THARIQ · CLAUDE CODE</div>
  349. <div style={{ fontSize: 56, fontFamily: F.display, fontWeight: 700, lineHeight: 1.05, color: C.ink, fontStyle: 'italic' }}>
  350. HTML is the new<br/>markdown.
  351. </div>
  352. </div>
  353. )}</Cue>
  354. <Cue id="two-camps">{(t, p) => t && (
  355. <div style={{ position: 'absolute', top: 110, right: 100, opacity: op * p, transform: `translateY(${(1-p)*16}px)`, fontFamily: F.mono, fontSize: 18, color: C.inkSoft, textAlign: 'right' }}>
  356. <div style={{ fontSize: 38, fontWeight: 700, color: C.ink, letterSpacing: '-0.02em' }}>5,000,000</div>
  357. <div style={{ fontSize: 13, color: C.inkMute, letterSpacing: '0.18em', marginTop: 4 }}>阅读 · &lt; 24H</div>
  358. </div>
  359. )}</Cue>
  360. </>
  361. );
  362. };
  363. const MdSideAux = () => {
  364. const op = useSceneFade('md-side', 0.6, 0.8);
  365. return (
  366. <>
  367. <Cue id="agents-md">{(t, p) => (
  368. <div style={{ position: 'absolute', left: 80, top: 200, opacity: op * p, transform: `translateY(${(1-p)*16}px)` }}>
  369. <div style={{ fontFamily: F.mono, fontSize: 13, color: C.inkMute, marginBottom: 6, letterSpacing: '0.12em' }}>AGENTS.md · OpenAI 2025</div>
  370. <div style={{ fontSize: 76, fontFamily: F.display, fontWeight: 700, color: C.ink, lineHeight: 0.95 }}>60,000<span style={{ color: C.html }}>+</span></div>
  371. <div style={{ fontSize: 18, color: C.inkSoft, marginTop: 4, fontFamily: F.body }}>开源项目采用</div>
  372. <div style={{ marginTop: 14, fontFamily: F.mono, fontSize: 12, color: C.inkMute, letterSpacing: '0.1em' }}>AWS · ANTHROPIC · GOOGLE · MICROSOFT · OPENAI</div>
  373. </div>
  374. )}</Cue>
  375. <Cue id="agents-md">{(t, p) => (
  376. <div style={{ position: 'absolute', left: 80, top: 460, opacity: op * Math.max(0, p - 0.25) * 1.33, transform: `translateY(${(1-p)*16}px)` }}>
  377. <div style={{ fontFamily: F.mono, fontSize: 13, color: C.inkMute, marginBottom: 4, letterSpacing: '0.12em' }}>karpathy/llm-wiki · CLAUDE.md</div>
  378. <div style={{ fontSize: 64, fontFamily: F.display, fontWeight: 700, color: C.ink, lineHeight: 0.95 }}>50,000<span style={{ color: C.html }}>★</span></div>
  379. </div>
  380. )}</Cue>
  381. <Cue id="token-saving">{(t, p) => t && (
  382. <div style={{ position: 'absolute', left: 80, top: 640, opacity: op * p, transform: `translateY(${(1-p)*14}px)`, padding: '28px 36px', background: C.ink, color: C.paper, minWidth: 540, fontFamily: F.mono }}>
  383. <div style={{ fontSize: 11, color: '#999', letterSpacing: '0.2em', marginBottom: 14 }}>CLOUDFLARE 实测 · 同一篇博客</div>
  384. <div style={{ display: 'flex', alignItems: 'baseline', gap: 20, marginBottom: 14 }}>
  385. <div>
  386. <div style={{ fontSize: 11, color: '#777', marginBottom: 2 }}>HTML</div>
  387. <div style={{ fontSize: 50, fontWeight: 700, color: C.html, lineHeight: 1 }}>16,180</div>
  388. </div>
  389. <div style={{ fontSize: 32, color: '#555' }}>→</div>
  390. <div>
  391. <div style={{ fontSize: 11, color: '#777', marginBottom: 2 }}>md</div>
  392. <div style={{ fontSize: 50, fontWeight: 700, color: C.green, lineHeight: 1 }}>3,150</div>
  393. </div>
  394. </div>
  395. <div style={{ fontSize: 70, fontFamily: F.display, fontWeight: 700, color: C.html, lineHeight: 0.95, fontStyle: 'italic' }}>−80% token</div>
  396. </div>
  397. )}</Cue>
  398. </>
  399. );
  400. };
  401. const HtmlSideAux = () => {
  402. const op = useSceneFade('html-side', 0.6, 0.8);
  403. const items = [
  404. { cue: 'spatial', label: '空间信息', desc: 'diff · 调用图 · 架构图', md: '一行字', html: '左右对照', topPx: 220 },
  405. { cue: 'dynamic', label: '动态体验', desc: '按钮 · easing · 动效', md: '文字描述', html: '直接看见', topPx: 410 },
  406. { cue: 'structured', label: '结构化阅读', desc: '可折叠 · tab · 边栏', md: '线性堆字', html: '真的会读', topPx: 600 },
  407. ];
  408. return (
  409. <>
  410. {items.map((it, i) => (
  411. <Cue key={it.cue} id={it.cue}>{(t, p) => (
  412. <div style={{ position: 'absolute', right: 80, top: it.topPx, opacity: op * p, transform: `translateX(${(1-p)*40}px)`, display: 'flex', alignItems: 'baseline', gap: 22, justifyContent: 'flex-end' }}>
  413. <div style={{ fontFamily: F.mono, fontSize: 16, color: C.html, fontWeight: 700, letterSpacing: '0.18em' }}>0{i+1}</div>
  414. <div style={{ textAlign: 'right' }}>
  415. <div style={{ fontSize: 32, fontFamily: F.display, fontWeight: 600, color: C.ink }}>{it.label}</div>
  416. <div style={{ fontSize: 16, color: C.inkMute, fontFamily: F.mono, marginTop: 3 }}>{it.desc}</div>
  417. <div style={{ marginTop: 10, display: 'flex', alignItems: 'baseline', gap: 12, justifyContent: 'flex-end', fontFamily: F.body }}>
  418. <span style={{ fontSize: 19, color: C.inkMute, textDecoration: 'line-through' }}>md: {it.md}</span>
  419. <span style={{ fontSize: 16, color: C.html }}>→</span>
  420. <span style={{ fontSize: 19, color: C.html, fontWeight: 600 }}>html: {it.html}</span>
  421. </div>
  422. </div>
  423. </div>
  424. )}</Cue>
  425. ))}
  426. </>
  427. );
  428. };
  429. const RealQuestionAux = () => {
  430. const op = useSceneFade('the-real-question', 0.4, 0.4);
  431. return (
  432. <>
  433. <Cue id="reveal">{(t, p) => (
  434. <div style={{ position: 'absolute', top: 480, left: 0, right: 0, textAlign: 'center', opacity: op * p }}>
  435. <div style={{ fontSize: 26, fontFamily: F.body, color: C.inkMute, marginBottom: 14, fontWeight: 300 }}>这俩根本是在争一个</div>
  436. <div style={{ fontSize: 170, fontFamily: F.display, fontWeight: 800, color: C.html, lineHeight: 0.95, letterSpacing: '0.05em', fontStyle: 'italic' }}>蠢问题</div>
  437. </div>
  438. )}</Cue>
  439. <Cue id="question-md">{(t, p) => (
  440. <div style={{ position: 'absolute', top: 770, left: 200, opacity: op * p, transform: `translateX(${(1-p)*-20}px)`, fontFamily: F.body, fontSize: 32, color: C.ink, textAlign: 'right', maxWidth: 360 }}>
  441. <div style={{ fontFamily: F.mono, fontSize: 13, color: C.md, letterSpacing: '0.18em', marginBottom: 8 }}>MD 党在回答</div>
  442. 我们用什么<span style={{ color: C.md, fontStyle: 'italic', fontWeight: 700 }}>写</span>?
  443. </div>
  444. )}</Cue>
  445. <div style={{ position: 'absolute', top: 800, left: 0, right: 0, fontSize: 48, color: C.inkMute, textAlign: 'center', fontFamily: F.mono, opacity: op * 0.6 }}>≠</div>
  446. <Cue id="question-html">{(t, p) => (
  447. <div style={{ position: 'absolute', top: 770, right: 200, opacity: op * p, transform: `translateX(${(1-p)*20}px)`, fontFamily: F.body, fontSize: 32, color: C.ink, maxWidth: 360 }}>
  448. <div style={{ fontFamily: F.mono, fontSize: 13, color: C.html, letterSpacing: '0.18em', marginBottom: 8 }}>HTML 党在回答</div>
  449. 我们给人什么<span style={{ color: C.html, fontStyle: 'italic', fontWeight: 700 }}>看</span>?
  450. </div>
  451. )}</Cue>
  452. </>
  453. );
  454. };
  455. const SplitAux = () => {
  456. const op = useSceneFade('the-split', 0.4, 0.6);
  457. return (
  458. <>
  459. <Cue id="split">{(t, p) => (
  460. <div style={{ position: 'absolute', top: 110, left: 0, right: 0, textAlign: 'center', opacity: op * p, transform: `translateY(${(1-p)*15}px)` }}>
  461. <div style={{ fontSize: 22, color: C.inkMute, fontFamily: F.body, marginBottom: 6 }}>md 和 html 不是替代,是</div>
  462. <div style={{ fontSize: 110, fontFamily: F.display, fontWeight: 800, color: C.ink, letterSpacing: '0.04em', lineHeight: 1 }}>
  463. 分工<span style={{ color: C.html }}>关系</span>
  464. </div>
  465. </div>
  466. )}</Cue>
  467. <Cue id="ai-changes">{(t, p) => t && (
  468. <div style={{ position: 'absolute', top: 320, left: 0, right: 0, textAlign: 'center', opacity: op * p, fontFamily: F.body, fontSize: 20, color: C.inkSoft, lineHeight: 1.7, maxWidth: 1100, margin: '0 auto' }}>
  469. <div style={{ maxWidth: 980, margin: '0 auto' }}>
  470. 以前你写 md 自己也看 md,所以折中。<br/>
  471. AI 出现后,生产成本被 AI 吸收,原来要折中的需求<strong>被拆成了两端的极端最优。</strong>
  472. </div>
  473. </div>
  474. )}</Cue>
  475. {/* 生产端 / 消费端标签放 hero 上方,避免被遮挡 */}
  476. <Cue id="md-side-win">{(t, p) => (
  477. <div style={{ position: 'absolute', top: 470, left: '22%', transform: `translateX(-50%) translateY(${(1-p)*30}px)`, opacity: op * p, textAlign: 'center' }}>
  478. <div style={{ fontFamily: F.mono, fontSize: 13, color: C.md, letterSpacing: '0.22em', marginBottom: 6 }}>生产端</div>
  479. <div style={{ fontSize: 19, color: C.inkSoft, fontFamily: F.body }}>轻 · 快 · token-efficient</div>
  480. </div>
  481. )}</Cue>
  482. <Cue id="html-side-win">{(t, p) => (
  483. <div style={{ position: 'absolute', top: 470, left: '78%', transform: `translateX(-50%) translateY(${(1-p)*30}px)`, opacity: op * p, textAlign: 'center' }}>
  484. <div style={{ fontFamily: F.mono, fontSize: 13, color: C.html, letterSpacing: '0.22em', marginBottom: 6 }}>消费端</div>
  485. <div style={{ fontSize: 19, color: C.inkSoft, fontFamily: F.body }}>丰富 · 可视化 · 好分享</div>
  486. </div>
  487. )}</Cue>
  488. </>
  489. );
  490. };
  491. const ProofAux = () => {
  492. const op = useSceneFade('activity-proof', 0.4, 0.5);
  493. return (
  494. <>
  495. <div style={{ position: 'absolute', top: 320, left: 0, right: 0, textAlign: 'center', opacity: op, fontSize: 28, fontFamily: F.body, color: C.ink }}>
  496. 最干净的活样本是 <span style={{ color: C.html, fontFamily: F.mono, fontWeight: 700 }}>@thariq</span>
  497. </div>
  498. <Cue id="thariq-march">{(t, p) => (
  499. <div style={{ position: 'absolute', top: 410, left: '50%', transform: `translateX(-50%) translateY(${(1-p)*16}px)`, opacity: op * p, display: 'flex', alignItems: 'center', gap: 22 }}>
  500. <div style={{ fontFamily: F.mono, fontSize: 19, color: C.md, fontWeight: 700, minWidth: 90, textAlign: 'right' }}>2026.03</div>
  501. <div style={{ width: 12, height: 12, borderRadius: 6, background: C.md }} />
  502. <div style={{ fontSize: 23, fontFamily: F.body, color: C.ink, minWidth: 380 }}>《Skills 指南》—— <span style={{ color: C.md }}>核心还是 markdown</span></div>
  503. </div>
  504. )}</Cue>
  505. <Cue id="same-person">{(t, p) => (
  506. <div style={{ position: 'absolute', top: 480, left: '50%', transform: `translateX(-50%) translateY(${(1-p)*16}px)`, opacity: op * p, display: 'flex', alignItems: 'center', gap: 22 }}>
  507. <div style={{ fontFamily: F.mono, fontSize: 19, color: C.html, fontWeight: 700, minWidth: 90, textAlign: 'right' }}>2026.05</div>
  508. <div style={{ width: 12, height: 12, borderRadius: 6, background: C.html }} />
  509. <div style={{ fontSize: 23, fontFamily: F.body, color: C.ink, minWidth: 380 }}>《HTML is the new markdown》</div>
  510. </div>
  511. )}</Cue>
  512. <Cue id="same-person">{(t, p) => t && (
  513. <div style={{ position: 'absolute', top: 580, left: 0, right: 0, textAlign: 'center', opacity: op * p, fontFamily: F.display, fontSize: 28, color: C.ink, fontStyle: 'italic' }}>
  514. 同一个人 · 两端各自登顶 · 互不打架
  515. </div>
  516. )}</Cue>
  517. <Cue id="karpathy-lex">{(t, p) => t && (
  518. <div style={{ position: 'absolute', top: 700, left: '50%', transform: `translateX(-50%) translateY(${(1-p)*14}px)`, opacity: op * p, padding: '18px 28px', background: C.ink, color: C.paper, display: 'flex', alignItems: 'center', gap: 30 }}>
  519. <div style={{ fontFamily: F.mono, fontSize: 12, color: '#999', letterSpacing: '0.2em' }}>KARPATHY × LEX</div>
  520. <div style={{ display: 'flex', gap: 20, alignItems: 'center', fontFamily: F.body }}>
  521. <div>
  522. <div style={{ fontSize: 10, color: '#999', fontFamily: F.mono, marginBottom: 2, letterSpacing: '0.12em' }}>内核</div>
  523. <div style={{ fontSize: 19, color: C.md, fontWeight: 600 }}>markdown wiki</div>
  524. </div>
  525. <div style={{ fontSize: 19, color: '#666' }}>+</div>
  526. <div>
  527. <div style={{ fontSize: 10, color: '#999', fontFamily: F.mono, marginBottom: 2, letterSpacing: '0.12em' }}>外壳</div>
  528. <div style={{ fontSize: 19, color: C.html, fontWeight: 600 }}>动态 HTML</div>
  529. </div>
  530. </div>
  531. </div>
  532. )}</Cue>
  533. </>
  534. );
  535. };
  536. const ClosingAux = () => {
  537. const op = useSceneFade('closing', 0.3, 0.6);
  538. return (
  539. <>
  540. <Cue id="final">{(t, p) => (
  541. <div style={{ position: 'absolute', top: 110, left: 0, right: 0, textAlign: 'center', opacity: op * p, transform: `translateY(${(1-p)*12}px)` }}>
  542. <div style={{ fontSize: 22, color: C.inkMute, fontFamily: F.body, marginBottom: 12 }}>下次想吵的时候,先问自己 ——</div>
  543. <div style={{ fontSize: 68, fontFamily: F.display, fontWeight: 700, color: C.ink, lineHeight: 1.15 }}>
  544. 你面对的是「<span style={{ color: C.md }}>写</span>」,
  545. 还是「<span style={{ color: C.html }}>看</span>」?
  546. </div>
  547. </div>
  548. )}</Cue>
  549. <Cue id="md-final">{(t, p) => (
  550. <div style={{ position: 'absolute', top: 740, left: '28%', transform: `translateX(-50%) translateY(${(1-p)*16}px)`, opacity: op * p, textAlign: 'center' }}>
  551. <div style={{ fontSize: 42, fontFamily: F.display, fontWeight: 600, color: C.md, letterSpacing: '0.04em' }}>写</div>
  552. <div style={{ fontSize: 22, color: C.inkMute, fontFamily: F.mono, marginTop: 4 }}>↓</div>
  553. </div>
  554. )}</Cue>
  555. <Cue id="html-final">{(t, p) => (
  556. <div style={{ position: 'absolute', top: 740, left: '72%', transform: `translateX(-50%) translateY(${(1-p)*16}px)`, opacity: op * p, textAlign: 'center' }}>
  557. <div style={{ fontSize: 42, fontFamily: F.display, fontWeight: 600, color: C.html, letterSpacing: '0.04em' }}>看</div>
  558. <div style={{ fontSize: 22, color: C.inkMute, fontFamily: F.mono, marginTop: 4 }}>↓</div>
  559. </div>
  560. )}</Cue>
  561. </>
  562. );
  563. };
  564. // ── 主 App ─────────────────────────────────────────
  565. const App = () => (
  566. <NarrationStage timeline={TIMELINE} audioSrc="_narration/voiceover.mp3" width={1920} height={1080} background={C.paper}>
  567. <BackgroundDrift />
  568. <HeroAnchor />
  569. <SceneLabel sceneId="opening" text="2026.05.07 · X" />
  570. <SceneLabel sceneId="md-side" text="MD 党的证据" />
  571. <SceneLabel sceneId="html-side" text="HTML 党的证据" />
  572. <SceneLabel sceneId="the-real-question" text="真问题" />
  573. <SceneLabel sceneId="the-split" text="MD 生产 · HTML 消费" />
  574. <SceneLabel sceneId="activity-proof" text="活样本" />
  575. <SceneLabel sceneId="closing" text="结语" />
  576. <OpeningAux />
  577. <MdSideAux />
  578. <HtmlSideAux />
  579. <RealQuestionAux />
  580. <SplitAux />
  581. <ProofAux />
  582. <ClosingAux />
  583. {/* 字幕条放最上层(z-index 自然在 DOM 顺序最后),盖住下方内容 */}
  584. <Subtitles />
  585. <div style={{ position: 'absolute', bottom: 24, right: 36, fontSize: 11, color: 'rgba(26,26,26,0.35)', letterSpacing: '0.2em', fontFamily: F.mono, pointerEvents: 'none' }}>
  586. Created by Huashu-Design
  587. </div>
  588. </NarrationStage>
  589. );
  590. ReactDOM.createRoot(document.getElementById('root')).render(<App />);
  591. </script>
  592. <script>
  593. // ── Fit 1920 stage to viewport ─────────────────────────────────
  594. // ≥1920(含录制用的 1920×1080)保持 1:1 原尺寸不缩放;
  595. // 窄于 1920 才缩小,减 40 是 body 左右各 20px 的 padding
  596. (function () {
  597. var root = document.getElementById('root');
  598. function rescale() {
  599. var cw = document.documentElement.clientWidth;
  600. root.style.zoom = cw >= 1920 ? 1 : (cw - 40) / 1920;
  601. }
  602. rescale();
  603. window.addEventListener('resize', rescale);
  604. })();
  605. </script>
  606. </body>
  607. </html>