202 lines
11 KiB
HTML
202 lines
11 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>什么是 token · narration demo</title>
|
||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||
<style>
|
||
body { margin: 0; background: #0a0a0a; font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif; min-height: 100vh; display: flex; align-items: center; justify-content: center; flex-direction: column; }
|
||
#root { box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
|
||
.scene-padding { padding: 120px; height: 100%; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="root"></div>
|
||
|
||
<script type="text/babel">
|
||
// ── timeline.json (inline) ─────────────────────────────────
|
||
const TIMELINE = {
|
||
"title": "什么是 token",
|
||
"voice": null,
|
||
"speed": 1,
|
||
"gap": 0.4,
|
||
"totalDuration": 23.808,
|
||
"scenes": [
|
||
{"id":"intro","start":0,"end":4.368,"duration":4.368,"audio":"audio/intro.mp3","text":"你有没有想过,当我们和 AI 对话的时候,AI 到底是怎么理解我们的话的呢。","cues":[{"id":"question","offset":1.08,"absoluteTime":1.08}]},
|
||
{"id":"token-1","start":4.768,"end":7.576,"duration":2.808,"audio":"audio/token-1.mp3","text":"答案是它根本不理解汉字,它只认识 token。","cues":[{"id":"reveal","offset":1.632,"absoluteTime":6.4}]},
|
||
{"id":"token-2","start":7.976,"end":16.808,"duration":8.832,"audio":"audio/token-2.mp3","text":"你可以把 token 理解成 AI 的最小信息单位。\n比如「人工智能」这四个字,在 AI 眼里可能是两个 token:人工,智能。","cues":[{"id":"split","offset":5.4,"absoluteTime":13.376}]},
|
||
{"id":"ending","start":17.208,"end":23.664,"duration":6.456,"audio":"audio/ending.mp3","text":"所以下次看到「百万 token 上下文」这种说法,你就知道,它说的是 AI 一次能记住多少个这样的小块。","cues":[{"id":"context","offset":2.376,"absoluteTime":19.584}]}
|
||
],
|
||
"voiceover": "voiceover.mp3"
|
||
};
|
||
|
||
// ── narration_stage.jsx (inline) ───────────────────────────
|
||
const NarrationStageLib = (() => {
|
||
const NarrationContext = React.createContext({ time: 0, scene: null, sceneTime: 0, isCueTriggered: () => false, cueProgress: () => 0 });
|
||
|
||
function NarrationStage({ timeline, audioSrc, width = 1920, height = 1080, background = '#0e0e0e', controls = true, children }) {
|
||
const audioRef = React.useRef(null);
|
||
const [time, setTime] = React.useState(0);
|
||
const [playing, setPlaying] = React.useState(false);
|
||
const recording = typeof window !== 'undefined' && window.__recording === true;
|
||
|
||
React.useEffect(() => {
|
||
if (typeof window === 'undefined') return;
|
||
window.__totalDuration = timeline.totalDuration;
|
||
window.__ready = true;
|
||
}, [timeline.totalDuration]);
|
||
|
||
React.useEffect(() => {
|
||
let raf;
|
||
const tick = () => {
|
||
if (recording) {
|
||
if (typeof window.__time === 'number') setTime(window.__time);
|
||
} else if (audioRef.current && !audioRef.current.paused) {
|
||
setTime(audioRef.current.currentTime);
|
||
}
|
||
raf = requestAnimationFrame(tick);
|
||
};
|
||
tick();
|
||
return () => cancelAnimationFrame(raf);
|
||
}, [recording]);
|
||
|
||
const currentScene = React.useMemo(() => {
|
||
if (!timeline.scenes) return null;
|
||
for (let i = 0; i < timeline.scenes.length; i++) {
|
||
const s = timeline.scenes[i];
|
||
const next = timeline.scenes[i + 1];
|
||
if (time >= s.start && (!next || time < next.start)) return s;
|
||
}
|
||
return timeline.scenes[0];
|
||
}, [time, timeline.scenes]);
|
||
|
||
const sceneTime = currentScene ? Math.max(0, time - currentScene.start) : 0;
|
||
|
||
const allCues = React.useMemo(() => {
|
||
const map = {};
|
||
for (const s of timeline.scenes || []) for (const c of s.cues || []) map[c.id] = c;
|
||
return map;
|
||
}, [timeline.scenes]);
|
||
|
||
const isCueTriggered = React.useCallback((cueId) => { const c = allCues[cueId]; return c ? time >= c.absoluteTime : false; }, [allCues, time]);
|
||
const cueProgress = React.useCallback((cueId, ramp = 0.5) => { const c = allCues[cueId]; if (!c) return 0; const dt = time - c.absoluteTime; if (dt <= 0) return 0; if (dt >= ramp) return 1; return dt / ramp; }, [allCues, time]);
|
||
|
||
const ctx = { time, scene: currentScene, sceneTime, isCueTriggered, cueProgress };
|
||
|
||
const handlePlayPause = () => { if (!audioRef.current) return; if (audioRef.current.paused) { audioRef.current.play(); setPlaying(true); } else { audioRef.current.pause(); setPlaying(false); } };
|
||
const handleSeek = (e) => { if (!audioRef.current) return; const t = parseFloat(e.target.value); audioRef.current.currentTime = t; setTime(t); };
|
||
|
||
return (
|
||
<NarrationContext.Provider value={ctx}>
|
||
<div style={{ position: 'relative', width, height, background, overflow: 'hidden', color: '#fff', fontFamily: '-apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif' }}>
|
||
{children}
|
||
</div>
|
||
{!recording && <audio ref={audioRef} src={audioSrc} preload="auto" onEnded={() => setPlaying(false)} />}
|
||
{!recording && controls && (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', background: '#1a1a1a', color: '#ddd', fontFamily: 'monospace', fontSize: 13, width, boxSizing: 'border-box' }}>
|
||
<button onClick={handlePlayPause} style={{ padding: '6px 14px', background: '#fff', color: '#000', border: 0, borderRadius: 4, cursor: 'pointer', fontWeight: 600 }}>
|
||
{playing ? '❚❚ Pause' : '▶ Play'}
|
||
</button>
|
||
<input type="range" min={0} max={timeline.totalDuration} step={0.01} value={time} onChange={handleSeek} style={{ flex: 1 }} />
|
||
<span style={{ minWidth: 110, textAlign: 'right' }}>{time.toFixed(2)} / {timeline.totalDuration.toFixed(2)}s</span>
|
||
<span style={{ padding: '4px 10px', background: '#2a2a2a', borderRadius: 4, minWidth: 100, textAlign: 'center' }}>{currentScene ? currentScene.id : '—'}</span>
|
||
</div>
|
||
)}
|
||
</NarrationContext.Provider>
|
||
);
|
||
}
|
||
|
||
function Scene({ id, children, keepMounted = false }) {
|
||
const { scene, sceneTime } = React.useContext(NarrationContext);
|
||
const isActive = scene && scene.id === id;
|
||
if (!isActive && !keepMounted) return null;
|
||
const content = typeof children === 'function' ? children(sceneTime, scene) : children;
|
||
return <div style={{ position: 'absolute', inset: 0, opacity: isActive ? 1 : 0, pointerEvents: isActive ? 'auto' : 'none', transition: keepMounted ? 'opacity 0.2s' : undefined }}>{content}</div>;
|
||
}
|
||
|
||
function Cue({ id, ramp = 0.5, children }) {
|
||
const { isCueTriggered, cueProgress } = React.useContext(NarrationContext);
|
||
return children(isCueTriggered(id), cueProgress(id, ramp));
|
||
}
|
||
|
||
return { NarrationStage, Scene, Cue };
|
||
})();
|
||
const { NarrationStage, Scene, Cue } = NarrationStageLib;
|
||
|
||
// ── 视觉内容 ─────────────────────────────────────────────
|
||
const App = () => (
|
||
<NarrationStage timeline={TIMELINE} audioSrc="_narration_token/voiceover.mp3" width={1920} height={1080} background="#0a0a0a">
|
||
{/* Scene 1: 大问号引入 */}
|
||
<Scene id="intro">
|
||
<div className="scene-padding" style={{ alignItems: 'center', justifyContent: 'center' }}>
|
||
<Cue id="question">{(triggered, p) => (
|
||
<div style={{ fontSize: 320, color: triggered ? '#ffd54a' : '#3a3a3a', fontWeight: 200, transition: 'color 0.4s', transform: `scale(${0.8 + p * 0.2})`, lineHeight: 1 }}>?</div>
|
||
)}</Cue>
|
||
<div style={{ fontSize: 56, color: '#aaa', marginTop: 60, letterSpacing: '0.05em', fontWeight: 300 }}>AI 是怎么理解我们的话的</div>
|
||
</div>
|
||
</Scene>
|
||
|
||
{/* Scene 2: reveal 关键词 */}
|
||
<Scene id="token-1">
|
||
<div className="scene-padding" style={{ alignItems: 'center', justifyContent: 'center' }}>
|
||
<div style={{ fontSize: 64, color: '#888', marginBottom: 80, fontWeight: 300 }}>它不认识汉字</div>
|
||
<Cue id="reveal">{(triggered, p) => (
|
||
<div style={{
|
||
fontSize: 280, fontWeight: 700, color: '#ffd54a', letterSpacing: '0.05em',
|
||
opacity: p, transform: `translateY(${(1 - p) * 40}px)`,
|
||
fontFamily: 'monospace', textShadow: triggered ? '0 0 40px rgba(255, 213, 74, 0.4)' : 'none'
|
||
}}>
|
||
token
|
||
</div>
|
||
)}</Cue>
|
||
</div>
|
||
</Scene>
|
||
|
||
{/* Scene 3: 拆字演示 */}
|
||
<Scene id="token-2">
|
||
<div className="scene-padding" style={{ alignItems: 'center', justifyContent: 'center' }}>
|
||
<div style={{ fontSize: 48, color: '#aaa', marginBottom: 100, fontWeight: 300 }}>token = AI 的最小信息单位</div>
|
||
<Cue id="split">{(triggered, p) => (
|
||
<div style={{ display: 'flex', gap: triggered ? 80 : 8, transition: 'gap 0.6s cubic-bezier(0.16, 1, 0.3, 1)' }}>
|
||
<div style={{ fontSize: 200, fontWeight: 600, color: triggered ? '#ffd54a' : '#fff', padding: triggered ? '40px 60px' : '40px 20px', border: triggered ? '4px solid #ffd54a' : '4px solid transparent', borderRadius: 24, transition: 'all 0.6s cubic-bezier(0.16, 1, 0.3, 1)', background: triggered ? 'rgba(255, 213, 74, 0.05)' : 'transparent' }}>
|
||
人工
|
||
</div>
|
||
<div style={{ fontSize: 200, fontWeight: 600, color: triggered ? '#ffd54a' : '#fff', padding: triggered ? '40px 60px' : '40px 20px', border: triggered ? '4px solid #ffd54a' : '4px solid transparent', borderRadius: 24, transition: 'all 0.6s cubic-bezier(0.16, 1, 0.3, 1)', background: triggered ? 'rgba(255, 213, 74, 0.05)' : 'transparent' }}>
|
||
智能
|
||
</div>
|
||
</div>
|
||
)}</Cue>
|
||
<div style={{ fontSize: 36, color: '#666', marginTop: 60, opacity: 0.6 }}>「人工智能」= 2 个 token</div>
|
||
</div>
|
||
</Scene>
|
||
|
||
{/* Scene 4: 总结 */}
|
||
<Scene id="ending">
|
||
<div className="scene-padding" style={{ alignItems: 'center', justifyContent: 'center' }}>
|
||
<Cue id="context">{(triggered, p) => (
|
||
<>
|
||
<div style={{ fontSize: 96, fontWeight: 700, letterSpacing: '0.02em', marginBottom: 40, color: '#fff', opacity: triggered ? 1 : 0.3, transition: 'opacity 0.5s' }}>
|
||
<span style={{ color: '#ffd54a' }}>1,000,000</span> token
|
||
</div>
|
||
<div style={{ fontSize: 48, color: '#888', fontWeight: 300, opacity: p }}>
|
||
≈ AI 一次能记住的<span style={{ color: '#fff', fontWeight: 500 }}>「小块」数量</span>
|
||
</div>
|
||
</>
|
||
)}</Cue>
|
||
</div>
|
||
</Scene>
|
||
|
||
{/* 全局水印 */}
|
||
<div style={{ position: 'absolute', bottom: 24, right: 32, fontSize: 11, color: 'rgba(255,255,255,0.35)', letterSpacing: '0.15em', fontFamily: 'monospace', pointerEvents: 'none', zIndex: 100 }}>
|
||
Created by Huashu-Design
|
||
</div>
|
||
</NarrationStage>
|
||
);
|
||
|
||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||
</script>
|
||
</body>
|
||
</html>
|