narration_stage.jsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. /**
  2. * narration_stage.jsx · 解说驱动 Stage
  3. *
  4. * ╔══════════════════════════════════════════════════════════════════╗
  5. * ║ 🛑 用这套工具之前必读:references/voiceover-pipeline.md ║
  6. * ║ ║
  7. * ║ 铁律 #1: 整片是一个连续的运动叙事,不是一组独立场景 ║
  8. * ║ You are not making 7 slides. You are directing 1 movie. ║
  9. * ║ ║
  10. * ║ 铁律 #2: 选定 hero element 跨 scene 持续存在,不要每段一个新布局║
  11. * ║ ║
  12. * ║ 铁律 #3: scene 之间禁止硬切(opacity 1→0/0→1) ║
  13. * ║ 要 morph,不要 cut ║
  14. * ║ ║
  15. * ║ 失败模式 #1(本 skill v1 实战踩坑): ║
  16. * ║ 每个 Scene 各自独立 layout + cue 用 fade-up + scene 切换║
  17. * ║ 整页 opacity 切换 = 带配音的 PowerPoint = 质感归零 ║
  18. * ║ ║
  19. * ║ 正确做法:把 hero 直接放在 <NarrationStage> 子级(不进 Scene) ║
  20. * ║ 用 useNarration() 在 hero 里读 time/scene/cue 状态 ║
  21. * ║ hero 自己根据当前时间决定形态 → 跨 scene 连续运动 ║
  22. * ╚══════════════════════════════════════════════════════════════════╝
  23. *
  24. * 用法(inline 进 HTML 的 <script type="text/babel">):
  25. * const { NarrationStage, Scene, Cue, useNarration } = NarrationStageLib;
  26. *
  27. * const App = () => (
  28. * <NarrationStage timeline={TIMELINE} audioSrc="voiceover.mp3"
  29. * width={1920} height={1080}>
  30. * <Scene id="intro">
  31. * <h1>什么是 token</h1>
  32. * <Cue id="question">
  33. * {(triggered) => triggered && <p>↑ 这是问题</p>}
  34. * </Cue>
  35. * </Scene>
  36. * <Scene id="token-2">
  37. * <Cue id="split">
  38. * {(triggered, progress) => (
  39. * <div style={{opacity: triggered ? 1 : 0.3}}>...</div>
  40. * )}
  41. * </Cue>
  42. * </Scene>
  43. * </NarrationStage>
  44. * );
  45. *
  46. * 时间源(自动二选一):
  47. * - 录视频模式(window.__recording === true):走 window.__time(外部 driver 推帧)
  48. * - 实播模式:走 <audio> 的 currentTime(用户点播放时和音频严格同步)
  49. *
  50. * 与 render-video.js 兼容:
  51. * - tick 第一帧设 window.__ready = true
  52. * - 录视频时检测 window.__recording 强制不播 audio、用 window.__time
  53. * - 暴露 window.__totalDuration 给 driver 算总帧数
  54. *
  55. * 依赖:React 18 + ReactDOM 18 + Babel standalone(同 animations.jsx)
  56. */
  57. const NarrationStageLib = (() => {
  58. const NarrationContext = React.createContext({
  59. time: 0,
  60. scene: null,
  61. sceneTime: 0,
  62. isCueTriggered: () => false,
  63. cueProgress: () => 0,
  64. });
  65. /**
  66. * 主组件:吃 timeline + audio,提供 context
  67. *
  68. * Props:
  69. * timeline timeline.json 对象(必需)
  70. * audioSrc voiceover.mp3 路径(必需)
  71. * width/height Stage 尺寸,默认 1920x1080
  72. * background 默认 '#0e0e0e'
  73. * controls 是否显示底部播放条,默认 true
  74. * children 动画内容(用 <Scene>/<Cue> 组织)
  75. */
  76. function NarrationStage({
  77. timeline,
  78. audioSrc,
  79. width = 1920,
  80. height = 1080,
  81. background = '#0e0e0e',
  82. controls = true,
  83. children,
  84. }) {
  85. const audioRef = React.useRef(null);
  86. const [time, setTime] = React.useState(0);
  87. const [playing, setPlaying] = React.useState(false);
  88. const recording = typeof window !== 'undefined' && window.__recording === true;
  89. // 暴露给 render-video.js
  90. React.useEffect(() => {
  91. if (typeof window === 'undefined') return;
  92. window.__totalDuration = timeline.totalDuration;
  93. window.__ready = true;
  94. }, [timeline.totalDuration]);
  95. // 时间 tick
  96. React.useEffect(() => {
  97. let raf;
  98. if (recording) {
  99. // 录视频模式:rAF wall-clock 自驱动从 0 开始
  100. // 兼容 render-video.js(它依赖动画自然推进 + window.__seek 复位)
  101. let startedAt = null;
  102. const tick = (now) => {
  103. if (startedAt === null) startedAt = now;
  104. setTime(Math.min((now - startedAt) / 1000, timeline.totalDuration));
  105. raf = requestAnimationFrame(tick);
  106. };
  107. raf = requestAnimationFrame(tick);
  108. // 暴露 __seek 给 render-video.js 在 ready 后调 __seek(0) 复位
  109. if (typeof window !== 'undefined') {
  110. window.__seek = (t) => {
  111. startedAt = performance.now() - t * 1000;
  112. setTime(t);
  113. };
  114. }
  115. } else {
  116. // 实播模式:跟随 audio.currentTime
  117. const tick = () => {
  118. if (audioRef.current && !audioRef.current.paused) {
  119. setTime(audioRef.current.currentTime);
  120. }
  121. raf = requestAnimationFrame(tick);
  122. };
  123. tick();
  124. }
  125. return () => cancelAnimationFrame(raf);
  126. }, [recording, timeline.totalDuration]);
  127. // 当前 scene
  128. const currentScene = React.useMemo(() => {
  129. if (!timeline.scenes) return null;
  130. // 找到 start <= time < end 的段。最后一段保留到 end
  131. for (let i = 0; i < timeline.scenes.length; i++) {
  132. const s = timeline.scenes[i];
  133. const next = timeline.scenes[i + 1];
  134. if (time >= s.start && (!next || time < next.start)) return s;
  135. }
  136. return timeline.scenes[0];
  137. }, [time, timeline.scenes]);
  138. const sceneTime = currentScene ? Math.max(0, time - currentScene.start) : 0;
  139. // 找 cue 状态(按 absoluteTime 比较,跨 scene 也能查)
  140. const allCues = React.useMemo(() => {
  141. const map = {};
  142. for (const s of timeline.scenes || []) {
  143. for (const c of s.cues || []) {
  144. map[c.id] = c;
  145. }
  146. }
  147. return map;
  148. }, [timeline.scenes]);
  149. const isCueTriggered = React.useCallback(
  150. (cueId) => {
  151. const c = allCues[cueId];
  152. if (!c) return false;
  153. return time >= c.absoluteTime;
  154. },
  155. [allCues, time],
  156. );
  157. /** 触发后多少秒 0→1,>1 后保持 1。用于 cue 后做渐入动画 */
  158. const cueProgress = React.useCallback(
  159. (cueId, ramp = 0.5) => {
  160. const c = allCues[cueId];
  161. if (!c) return 0;
  162. const dt = time - c.absoluteTime;
  163. if (dt <= 0) return 0;
  164. if (dt >= ramp) return 1;
  165. return dt / ramp;
  166. },
  167. [allCues, time],
  168. );
  169. const ctx = { time, scene: currentScene, sceneTime, isCueTriggered, cueProgress, timeline };
  170. // play/pause/seek 控制
  171. const handlePlayPause = () => {
  172. if (!audioRef.current) return;
  173. if (audioRef.current.paused) {
  174. audioRef.current.play();
  175. setPlaying(true);
  176. } else {
  177. audioRef.current.pause();
  178. setPlaying(false);
  179. }
  180. };
  181. const handleSeek = (e) => {
  182. if (!audioRef.current) return;
  183. const t = parseFloat(e.target.value);
  184. audioRef.current.currentTime = t;
  185. setTime(t);
  186. };
  187. const handleAudioEnded = () => setPlaying(false);
  188. return (
  189. <NarrationContext.Provider value={ctx}>
  190. <div
  191. style={{
  192. position: 'relative',
  193. width,
  194. height,
  195. background,
  196. overflow: 'hidden',
  197. color: '#fff',
  198. fontFamily: '-apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif',
  199. }}
  200. >
  201. {children}
  202. </div>
  203. {!recording && (
  204. <audio
  205. ref={audioRef}
  206. src={audioSrc}
  207. preload="auto"
  208. onEnded={handleAudioEnded}
  209. />
  210. )}
  211. {!recording && controls && (
  212. <div
  213. style={{
  214. display: 'flex',
  215. alignItems: 'center',
  216. gap: 12,
  217. padding: '12px 16px',
  218. background: '#1a1a1a',
  219. color: '#ddd',
  220. fontFamily: 'monospace',
  221. fontSize: 13,
  222. width,
  223. boxSizing: 'border-box',
  224. }}
  225. >
  226. <button
  227. onClick={handlePlayPause}
  228. style={{
  229. padding: '6px 14px',
  230. background: '#fff',
  231. color: '#000',
  232. border: 0,
  233. borderRadius: 4,
  234. cursor: 'pointer',
  235. fontWeight: 600,
  236. }}
  237. >
  238. {playing ? '❚❚ Pause' : '▶ Play'}
  239. </button>
  240. <input
  241. type="range"
  242. min={0}
  243. max={timeline.totalDuration}
  244. step={0.01}
  245. value={time}
  246. onChange={handleSeek}
  247. style={{ flex: 1 }}
  248. />
  249. <span style={{ minWidth: 110, textAlign: 'right' }}>
  250. {time.toFixed(2)} / {timeline.totalDuration.toFixed(2)}s
  251. </span>
  252. <span
  253. style={{
  254. padding: '4px 10px',
  255. background: '#2a2a2a',
  256. borderRadius: 4,
  257. minWidth: 100,
  258. textAlign: 'center',
  259. }}
  260. >
  261. {currentScene ? currentScene.id : '—'}
  262. </span>
  263. </div>
  264. )}
  265. </NarrationContext.Provider>
  266. );
  267. }
  268. /**
  269. * Scene 包裹器:只在指定 scene id 激活时渲染 children
  270. *
  271. * Props:
  272. * id scene id(对应 timeline.scenes[].id)
  273. * children 渲染内容;可以是 ReactNode 或 (sceneTime, sceneInfo) => ReactNode
  274. * keepMounted 默认 false。设 true 则一直挂载只切换 visibility(动画连贯需要时用)
  275. */
  276. function Scene({ id, children, keepMounted = false }) {
  277. const { scene, sceneTime } = React.useContext(NarrationContext);
  278. const isActive = scene && scene.id === id;
  279. if (!isActive && !keepMounted) return null;
  280. const content = typeof children === 'function' ? children(sceneTime, scene) : children;
  281. return (
  282. <div
  283. style={{
  284. position: 'absolute',
  285. inset: 0,
  286. opacity: isActive ? 1 : 0,
  287. pointerEvents: isActive ? 'auto' : 'none',
  288. transition: keepMounted ? 'opacity 0.2s' : undefined,
  289. }}
  290. >
  291. {content}
  292. </div>
  293. );
  294. }
  295. /**
  296. * Cue 包裹器:监听 cue 触发状态
  297. *
  298. * Props:
  299. * id cue id(对应 timeline.scenes[].cues[].id)
  300. * ramp cue 触发后 progress 0→1 的 ramp 时长(秒),默认 0.5
  301. * children 必须是函数:(triggered: bool, progress: 0-1) => ReactNode
  302. */
  303. function Cue({ id, ramp = 0.5, children }) {
  304. const { isCueTriggered, cueProgress } = React.useContext(NarrationContext);
  305. const triggered = isCueTriggered(id);
  306. const progress = cueProgress(id, ramp);
  307. return children(triggered, progress);
  308. }
  309. /** Hook:在自定义组件里直接拿 narration 状态 */
  310. function useNarration() {
  311. return React.useContext(NarrationContext);
  312. }
  313. /**
  314. * splitChunkToLines · 把一段文字按标点切成 ≤maxLen 字的短行
  315. *
  316. * 用于字幕显示——B 站标准是单行 ≤12 字便于阅读。本函数:
  317. * 1. 先按强标点(。!?\n)切句,绝不跨句号截断
  318. * 2. 每句 ≤ maxLen 直接用,否则按弱标点(,、;:)切片合并
  319. * 3. 中英混合:英文/数字按 0.5 字算视觉宽度
  320. * 4. 兜底硬切(罕见:单个标点段超 maxLen)
  321. *
  322. * @param text 原文
  323. * @param maxLen 单行最大视觉长度,默认 13(≈12 字 + 一个标点)
  324. * @returns 切好的字幕行数组
  325. */
  326. function visualLen(s) {
  327. let n = 0;
  328. for (const ch of s) n += /[a-zA-Z0-9 .,'":;\-]/.test(ch) ? 0.5 : 1;
  329. return n;
  330. }
  331. function splitChunkToLines(text, maxLen = 13) {
  332. const lines = [];
  333. const sentences = [];
  334. let buf = '';
  335. for (const ch of text) {
  336. buf += ch;
  337. if ('。!?\n'.includes(ch)) { if (buf.trim()) sentences.push(buf.trim()); buf = ''; }
  338. }
  339. if (buf.trim()) sentences.push(buf.trim());
  340. for (const sent of sentences) {
  341. if (visualLen(sent) <= maxLen) { lines.push(sent); continue; }
  342. const parts = [];
  343. let pbuf = '';
  344. for (const ch of sent) {
  345. pbuf += ch;
  346. if (',、;:'.includes(ch)) { parts.push(pbuf); pbuf = ''; }
  347. }
  348. if (pbuf) parts.push(pbuf);
  349. let merged = '';
  350. for (const p of parts) {
  351. if (visualLen(merged) + visualLen(p) <= maxLen) merged += p;
  352. else { if (merged) lines.push(merged); merged = p; }
  353. }
  354. if (merged) {
  355. if (visualLen(merged) <= maxLen) lines.push(merged);
  356. else {
  357. let hbuf = '';
  358. for (const ch of merged) { hbuf += ch; if (visualLen(hbuf) >= maxLen) { lines.push(hbuf); hbuf = ''; } }
  359. if (hbuf) lines.push(hbuf);
  360. }
  361. }
  362. }
  363. return lines.filter(l => l.trim());
  364. }
  365. /**
  366. * Subtitles · B 站风格字幕组件(白光晕深墨字,无背景,按 chunks 时间显示)
  367. *
  368. * 自动从当前 scene.chunks 取活动 chunk,按 splitChunkToLines 切成短行,
  369. * 按字数比例分配 chunk 时间窗给每行显示。
  370. *
  371. * 必需:timeline.scenes[].chunks[](narrate-pipeline.mjs 已默认输出)
  372. *
  373. * Props(可覆盖默认样式):
  374. * bottom 距底部像素,默认 90(不贴边)
  375. * fontSize 字号,默认 32
  376. * color 字色,默认深墨 #1a1a1a(适合浅纸白底)
  377. * haloColor 光晕色,默认 rgba(245,241,232,0.9)(适合 #f5f1e8 底)
  378. * maxLen 单行最大视觉长度,默认 13
  379. *
  380. * 深底场景:把 color 改成 '#fff',haloColor 改成 'rgba(0,0,0,0.85)' 即可。
  381. */
  382. function Subtitles({ bottom = 90, fontSize = 32, color = '#1a1a1a', haloColor = 'rgba(245,241,232,0.9)', maxLen = 13 } = {}) {
  383. const { time, scene } = React.useContext(NarrationContext);
  384. if (!scene || !scene.chunks) return null;
  385. const active = scene.chunks.find(c => time >= c.absoluteStart && time < c.absoluteEnd);
  386. if (!active) return null;
  387. const lines = splitChunkToLines(active.text, maxLen);
  388. if (lines.length === 0) return null;
  389. const totalLen = lines.reduce((s, l) => s + visualLen(l), 0);
  390. const chunkDur = active.absoluteEnd - active.absoluteStart;
  391. let acc = active.absoluteStart;
  392. let activeLine = lines[lines.length - 1];
  393. let lineStart = active.absoluteStart;
  394. for (const line of lines) {
  395. const dur = (visualLen(line) / totalLen) * chunkDur;
  396. if (time < acc + dur) { activeLine = line; lineStart = acc; break; }
  397. acc += dur;
  398. }
  399. const lineProg = Math.min(1, (time - lineStart) / 0.15);
  400. return React.createElement('div', {
  401. style: { position: 'absolute', left: 0, right: 0, bottom, display: 'flex', justifyContent: 'center', pointerEvents: 'none', zIndex: 50 },
  402. }, React.createElement('div', {
  403. key: lineStart,
  404. style: {
  405. fontFamily: '"PingFang SC", "Noto Sans SC", -apple-system, sans-serif',
  406. fontSize, fontWeight: 600, color,
  407. letterSpacing: '0.04em', lineHeight: 1.2, textAlign: 'center',
  408. textShadow: `0 0 6px ${haloColor}, 0 0 12px ${haloColor}, 0 1px 2px rgba(255,255,255,0.5)`,
  409. opacity: lineProg, transform: `translateY(${(1 - lineProg) * 4}px)`,
  410. },
  411. }, activeLine));
  412. }
  413. /**
  414. * useSceneFade · scene 内辅助元素的软淡入淡出 helper
  415. *
  416. * 铁律第二条要求 scene 之间禁止硬切——但 scene 内辅助元素(数据卡、引用块)
  417. * 一旦 cue 触发后默认会一直亮到 scene 结束。如果不淡出,离开本段进入下段时
  418. * 这些元素会突兀地存在或瞬间消失。本 hook 提供 [入场淡入 → hold → 出场淡出] 的统一软切换。
  419. *
  420. * 用法(把 op 乘进辅助元素的 opacity):
  421. * const op = useSceneFade('md-side', 0.6, 0.8); // 进 0.6s, 出 0.8s
  422. * <Cue id="agents-md">{(t, p) => (
  423. * <div style={{ opacity: op * p }}>...</div>
  424. * )}</Cue>
  425. *
  426. * 这样数据卡片在 md-side 段开始 0.6s 内淡入,在段结束前 0.8s 开始淡出,
  427. * 与下一段的辅助元素淡入形成 overlap,画面不出现硬切。
  428. *
  429. * @param sceneId scene id
  430. * @param fadeIn 入场淡入秒数(默认 0.5)
  431. * @param fadeOut 出场淡出秒数(默认 0.5)
  432. * @returns 0-1 之间的不透明度倍率
  433. */
  434. function useSceneFade(sceneId, fadeIn = 0.5, fadeOut = 0.5) {
  435. const { time, timeline } = React.useContext(NarrationContext);
  436. if (!timeline) return 0;
  437. const s = timeline.scenes.find(x => x.id === sceneId);
  438. if (!s) return 0;
  439. const inT = (time - s.start) / fadeIn;
  440. const outT = (s.end - time) / fadeOut;
  441. const v = Math.min(1, Math.min(inT, outT));
  442. return Math.max(0, v);
  443. }
  444. return { NarrationStage, Scene, Cue, useNarration, useSceneFade, Subtitles, splitChunkToLines };
  445. })();
  446. if (typeof window !== 'undefined') {
  447. Object.assign(window, { NarrationStageLib });
  448. }