narration_stage.jsx 18 KB

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