animations.jsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. /**
  2. * animations.jsx — 时间轴动画引擎
  3. *
  4. * Stage + Sprite 模式,借鉴Remotion但轻量化。
  5. *
  6. * 导出(挂到 window.Animations):
  7. * - Stage: 整个动画容器,提供时间+控制
  8. * - Sprite: 时间片段,start/end内显示,提供本地进度
  9. * - useTime(): 读全局时间(秒)
  10. * - useSprite(): 读本地进度 {t: 0→1, elapsed: seconds, duration: seconds}
  11. * - Easing: {linear, easeIn, easeOut, easeInOut, spring, anticipation}
  12. * - interpolate(t, [input0, input1], [output0, output1], easing?)
  13. *
  14. * 用法:
  15. * <Stage duration={10}>
  16. * <Sprite start={0} end={3}>
  17. * <Title />
  18. * </Sprite>
  19. * <Sprite start={2} end={5}>
  20. * <Subtitle />
  21. * </Sprite>
  22. * </Stage>
  23. *
  24. * 在Sprite子组件里用 useSprite() 读当前片段进度。
  25. */
  26. (function() {
  27. const { createContext, useContext, useState, useEffect, useRef, useCallback } = React;
  28. const TimeContext = createContext({ time: 0, duration: 10, playing: false });
  29. const SpriteContext = createContext(null);
  30. const Easing = {
  31. linear: t => t,
  32. easeIn: t => t * t,
  33. easeOut: t => 1 - (1 - t) * (1 - t),
  34. easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
  35. // expoOut: Anthropic-level 主 easing (cubic-bezier(0.16, 1, 0.3, 1))
  36. // 迅速启动 + 缓慢刹车,给数字元素物理重量感
  37. expoOut: t => t === 1 ? 1 : 1 - Math.pow(2, -10 * t),
  38. // overshoot: 带弹性的 toggle/按钮弹出 (cubic-bezier(0.34, 1.56, 0.64, 1))
  39. overshoot: t => {
  40. const c1 = 1.70158, c3 = c1 + 1;
  41. return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
  42. },
  43. spring: t => {
  44. const c = (2 * Math.PI) / 3;
  45. return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
  46. },
  47. anticipation: t => {
  48. if (t < 0.2) return -0.3 * (t / 0.2) * (t / 0.2);
  49. const adjusted = (t - 0.2) / 0.8;
  50. return -0.012 + 1.012 * adjusted * adjusted * (3 - 2 * adjusted);
  51. },
  52. };
  53. function interpolate(t, input, output, easing) {
  54. const [inStart, inEnd] = input;
  55. const [outStart, outEnd] = output;
  56. if (t <= inStart) return outStart;
  57. if (t >= inEnd) return outEnd;
  58. let progress = (t - inStart) / (inEnd - inStart);
  59. if (easing) {
  60. progress = easing(progress);
  61. }
  62. return outStart + (outEnd - outStart) * progress;
  63. }
  64. function useTime() {
  65. const ctx = useContext(TimeContext);
  66. return ctx.time;
  67. }
  68. function useSprite() {
  69. const sprite = useContext(SpriteContext);
  70. if (!sprite) {
  71. return { t: 0, elapsed: 0, duration: 0 };
  72. }
  73. return sprite;
  74. }
  75. const stageStyles = {
  76. wrapper: {
  77. position: 'fixed',
  78. inset: 0,
  79. background: '#000',
  80. display: 'flex',
  81. flexDirection: 'column',
  82. fontFamily: '-apple-system, sans-serif',
  83. },
  84. stageHolder: {
  85. flex: 1,
  86. position: 'relative',
  87. overflow: 'hidden',
  88. },
  89. canvas: {
  90. position: 'absolute',
  91. top: '50%',
  92. left: '50%',
  93. transformOrigin: 'center center',
  94. background: '#111',
  95. overflow: 'hidden',
  96. },
  97. controls: {
  98. position: 'fixed',
  99. bottom: 0,
  100. left: 0,
  101. right: 0,
  102. background: 'rgba(0, 0, 0, 0.8)',
  103. backdropFilter: 'blur(10px)',
  104. padding: '12px 20px',
  105. display: 'flex',
  106. alignItems: 'center',
  107. gap: 16,
  108. color: '#fff',
  109. fontSize: 12,
  110. zIndex: 100,
  111. },
  112. button: {
  113. background: 'none',
  114. border: '1px solid rgba(255,255,255,0.3)',
  115. color: '#fff',
  116. padding: '6px 14px',
  117. borderRadius: 4,
  118. cursor: 'pointer',
  119. fontSize: 12,
  120. },
  121. timeDisplay: {
  122. fontFamily: 'ui-monospace, monospace',
  123. fontVariantNumeric: 'tabular-nums',
  124. minWidth: 90,
  125. },
  126. scrubber: {
  127. flex: 1,
  128. height: 4,
  129. background: 'rgba(255,255,255,0.2)',
  130. borderRadius: 2,
  131. position: 'relative',
  132. cursor: 'pointer',
  133. },
  134. scrubberFill: {
  135. position: 'absolute',
  136. top: 0,
  137. left: 0,
  138. height: '100%',
  139. background: '#fff',
  140. borderRadius: 2,
  141. pointerEvents: 'none',
  142. },
  143. scrubberHandle: {
  144. position: 'absolute',
  145. top: '50%',
  146. width: 12,
  147. height: 12,
  148. background: '#fff',
  149. borderRadius: '50%',
  150. transform: 'translate(-50%, -50%)',
  151. pointerEvents: 'none',
  152. },
  153. };
  154. function Stage({ duration = 10, width = 1920, height = 1080, fps = 60, loop = true, children, bgColor = '#fff' }) {
  155. const [time, setTime] = useState(0);
  156. const [playing, setPlaying] = useState(true);
  157. const [scale, setScale] = useState(1);
  158. const rafRef = useRef(null);
  159. const startTimeRef = useRef(performance.now());
  160. const canvasRef = useRef(null);
  161. // Recording mode: render-video.js injects window.__recording = true before goto.
  162. // When set, force loop=false so the export ends on the final frame instead of
  163. // wrapping back to t=0 and capturing the start of the next cycle.
  164. // (Browsers viewing manually still loop because __recording is undefined there.)
  165. const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
  166. useEffect(() => {
  167. function updateScale() {
  168. const vw = window.innerWidth;
  169. const vh = window.innerHeight - 56;
  170. const s = Math.min(vw / width, vh / height);
  171. setScale(s);
  172. }
  173. updateScale();
  174. window.addEventListener('resize', updateScale);
  175. return () => window.removeEventListener('resize', updateScale);
  176. }, [width, height]);
  177. useEffect(() => {
  178. // Seek-render mode (render-video-seek.js sets window.__seekRender): freeze the
  179. // self-driven clock and let the external renderer advance each frame via
  180. // window.__seek(t). No rAF self-drive here — every frame is a deterministic seek.
  181. if (typeof window !== 'undefined' && window.__seekRender) {
  182. window.__ready = true;
  183. window.__seek = (t) => setTime(Math.min(t, duration - 0.001));
  184. return;
  185. }
  186. if (!playing) return;
  187. let cancelled = false;
  188. let last = null;
  189. function tick(now) {
  190. if (cancelled) return;
  191. if (last === null) {
  192. // First animation frame. Set last=now so delta starts at 0,
  193. // AND announce readiness for video export.
  194. // This pairing is critical: window.__ready must flip to true at
  195. // the exact moment WebM captures frame 0 of the animation, so
  196. // render-video.js's trim offset equals the pre-animation gap.
  197. last = now;
  198. if (typeof window !== 'undefined') window.__ready = true;
  199. }
  200. const delta = (now - last) / 1000;
  201. last = now;
  202. setTime(prev => {
  203. const next = prev + delta;
  204. if (next >= duration) {
  205. // effectiveLoop honors window.__recording (forced non-loop during export).
  206. // Stop just shy of duration so the final-frame state stays rendered
  207. // (avoids exiting all Sprites that end exactly at `duration`).
  208. return effectiveLoop ? 0 : duration - 0.001;
  209. }
  210. return next;
  211. });
  212. rafRef.current = requestAnimationFrame(tick);
  213. }
  214. // Wait for fonts before starting the clock — makes frame 0 the
  215. // real "finished-loading" frame users see, not a fallback-font flash.
  216. const startAfterFonts = () => {
  217. if (cancelled) return;
  218. rafRef.current = requestAnimationFrame(tick);
  219. };
  220. if (typeof document !== 'undefined' && document.fonts && document.fonts.ready) {
  221. document.fonts.ready.then(startAfterFonts);
  222. } else {
  223. startAfterFonts();
  224. }
  225. return () => {
  226. cancelled = true;
  227. cancelAnimationFrame(rafRef.current);
  228. };
  229. }, [playing, duration, effectiveLoop]);
  230. const handleScrub = useCallback((e) => {
  231. const rect = e.currentTarget.getBoundingClientRect();
  232. const ratio = (e.clientX - rect.left) / rect.width;
  233. setTime(Math.max(0, Math.min(duration, ratio * duration)));
  234. }, [duration]);
  235. const handleSeek = useCallback((e) => {
  236. handleScrub(e);
  237. setPlaying(false);
  238. }, [handleScrub]);
  239. const progress = time / duration;
  240. const ctx = {
  241. time,
  242. duration,
  243. playing,
  244. setPlaying,
  245. setTime,
  246. };
  247. const canvasStyle = {
  248. ...stageStyles.canvas,
  249. width,
  250. height,
  251. background: bgColor,
  252. transform: `translate(-50%, -50%) scale(${scale})`,
  253. };
  254. return (
  255. <TimeContext.Provider value={ctx}>
  256. <div style={stageStyles.wrapper}>
  257. <div style={stageStyles.stageHolder}>
  258. <div ref={canvasRef} style={canvasStyle}>
  259. {children}
  260. </div>
  261. </div>
  262. <div style={stageStyles.controls}>
  263. <button
  264. style={stageStyles.button}
  265. onClick={() => setPlaying(p => !p)}
  266. >
  267. {playing ? '⏸ 暂停' : '▶ 播放'}
  268. </button>
  269. <button
  270. style={stageStyles.button}
  271. onClick={() => setTime(0)}
  272. >
  273. ⏮ 开始
  274. </button>
  275. <div style={stageStyles.timeDisplay}>
  276. {time.toFixed(2)}s / {duration.toFixed(2)}s
  277. </div>
  278. <div style={stageStyles.scrubber} onMouseDown={handleSeek}>
  279. <div style={{ ...stageStyles.scrubberFill, width: `${progress * 100}%` }} />
  280. <div style={{ ...stageStyles.scrubberHandle, left: `${progress * 100}%` }} />
  281. </div>
  282. </div>
  283. </div>
  284. </TimeContext.Provider>
  285. );
  286. }
  287. function Sprite({ start = 0, end, children, style }) {
  288. const { time } = useContext(TimeContext);
  289. const actualEnd = end == null ? Infinity : end;
  290. if (time < start || time >= actualEnd) {
  291. return null;
  292. }
  293. const duration = actualEnd - start;
  294. const elapsed = time - start;
  295. const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
  296. const spriteValue = { t, elapsed, duration, start, end: actualEnd };
  297. return (
  298. <SpriteContext.Provider value={spriteValue}>
  299. <div style={{ position: 'absolute', inset: 0, ...style }}>
  300. {children}
  301. </div>
  302. </SpriteContext.Provider>
  303. );
  304. }
  305. if (typeof window !== 'undefined') {
  306. window.Animations = {
  307. Stage,
  308. Sprite,
  309. useTime,
  310. useSprite,
  311. Easing,
  312. interpolate,
  313. };
  314. }
  315. })();