| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332 |
- /**
- * animations.jsx — 时间轴动画引擎
- *
- * Stage + Sprite 模式,借鉴Remotion但轻量化。
- *
- * 导出(挂到 window.Animations):
- * - Stage: 整个动画容器,提供时间+控制
- * - Sprite: 时间片段,start/end内显示,提供本地进度
- * - useTime(): 读全局时间(秒)
- * - useSprite(): 读本地进度 {t: 0→1, elapsed: seconds, duration: seconds}
- * - Easing: {linear, easeIn, easeOut, easeInOut, spring, anticipation}
- * - interpolate(t, [input0, input1], [output0, output1], easing?)
- *
- * 用法:
- * <Stage duration={10}>
- * <Sprite start={0} end={3}>
- * <Title />
- * </Sprite>
- * <Sprite start={2} end={5}>
- * <Subtitle />
- * </Sprite>
- * </Stage>
- *
- * 在Sprite子组件里用 useSprite() 读当前片段进度。
- */
- (function() {
- const { createContext, useContext, useState, useEffect, useRef, useCallback } = React;
- const TimeContext = createContext({ time: 0, duration: 10, playing: false });
- const SpriteContext = createContext(null);
- const Easing = {
- linear: t => t,
- easeIn: t => t * t,
- easeOut: t => 1 - (1 - t) * (1 - t),
- easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
- spring: t => {
- const c = (2 * Math.PI) / 3;
- return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
- },
- anticipation: t => {
- if (t < 0.2) return -0.3 * (t / 0.2) * (t / 0.2);
- const adjusted = (t - 0.2) / 0.8;
- return -0.012 + 1.012 * adjusted * adjusted * (3 - 2 * adjusted);
- },
- };
- function interpolate(t, input, output, easing) {
- const [inStart, inEnd] = input;
- const [outStart, outEnd] = output;
- if (t <= inStart) return outStart;
- if (t >= inEnd) return outEnd;
- let progress = (t - inStart) / (inEnd - inStart);
- if (easing) {
- progress = easing(progress);
- }
- return outStart + (outEnd - outStart) * progress;
- }
- function useTime() {
- const ctx = useContext(TimeContext);
- return ctx.time;
- }
- function useSprite() {
- const sprite = useContext(SpriteContext);
- if (!sprite) {
- return { t: 0, elapsed: 0, duration: 0 };
- }
- return sprite;
- }
- const stageStyles = {
- wrapper: {
- position: 'fixed',
- inset: 0,
- background: '#000',
- display: 'flex',
- flexDirection: 'column',
- fontFamily: '-apple-system, sans-serif',
- },
- stageHolder: {
- flex: 1,
- position: 'relative',
- overflow: 'hidden',
- },
- canvas: {
- position: 'absolute',
- top: '50%',
- left: '50%',
- transformOrigin: 'center center',
- background: '#111',
- overflow: 'hidden',
- },
- controls: {
- position: 'fixed',
- bottom: 0,
- left: 0,
- right: 0,
- background: 'rgba(0, 0, 0, 0.8)',
- backdropFilter: 'blur(10px)',
- padding: '12px 20px',
- display: 'flex',
- alignItems: 'center',
- gap: 16,
- color: '#fff',
- fontSize: 12,
- zIndex: 100,
- },
- button: {
- background: 'none',
- border: '1px solid rgba(255,255,255,0.3)',
- color: '#fff',
- padding: '6px 14px',
- borderRadius: 4,
- cursor: 'pointer',
- fontSize: 12,
- },
- timeDisplay: {
- fontFamily: 'ui-monospace, monospace',
- fontVariantNumeric: 'tabular-nums',
- minWidth: 90,
- },
- scrubber: {
- flex: 1,
- height: 4,
- background: 'rgba(255,255,255,0.2)',
- borderRadius: 2,
- position: 'relative',
- cursor: 'pointer',
- },
- scrubberFill: {
- position: 'absolute',
- top: 0,
- left: 0,
- height: '100%',
- background: '#fff',
- borderRadius: 2,
- pointerEvents: 'none',
- },
- scrubberHandle: {
- position: 'absolute',
- top: '50%',
- width: 12,
- height: 12,
- background: '#fff',
- borderRadius: '50%',
- transform: 'translate(-50%, -50%)',
- pointerEvents: 'none',
- },
- };
- function Stage({ duration = 10, width = 1920, height = 1080, fps = 60, loop = true, children, bgColor = '#fff' }) {
- const [time, setTime] = useState(0);
- const [playing, setPlaying] = useState(true);
- const [scale, setScale] = useState(1);
- const rafRef = useRef(null);
- const startTimeRef = useRef(performance.now());
- const canvasRef = useRef(null);
- // Recording mode: render-video.js injects window.__recording = true before goto.
- // When set, force loop=false so the export ends on the final frame instead of
- // wrapping back to t=0 and capturing the start of the next cycle.
- // (Browsers viewing manually still loop because __recording is undefined there.)
- const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
- useEffect(() => {
- function updateScale() {
- const vw = window.innerWidth;
- const vh = window.innerHeight - 56;
- const s = Math.min(vw / width, vh / height);
- setScale(s);
- }
- updateScale();
- window.addEventListener('resize', updateScale);
- return () => window.removeEventListener('resize', updateScale);
- }, [width, height]);
- useEffect(() => {
- if (!playing) return;
- let cancelled = false;
- let last = null;
- function tick(now) {
- if (cancelled) return;
- if (last === null) {
- // First animation frame. Set last=now so delta starts at 0,
- // AND announce readiness for video export.
- // This pairing is critical: window.__ready must flip to true at
- // the exact moment WebM captures frame 0 of the animation, so
- // render-video.js's trim offset equals the pre-animation gap.
- last = now;
- if (typeof window !== 'undefined') window.__ready = true;
- }
- const delta = (now - last) / 1000;
- last = now;
- setTime(prev => {
- const next = prev + delta;
- if (next >= duration) {
- // effectiveLoop honors window.__recording (forced non-loop during export).
- // Stop just shy of duration so the final-frame state stays rendered
- // (avoids exiting all Sprites that end exactly at `duration`).
- return effectiveLoop ? 0 : duration - 0.001;
- }
- return next;
- });
- rafRef.current = requestAnimationFrame(tick);
- }
- // Wait for fonts before starting the clock — makes frame 0 the
- // real "finished-loading" frame users see, not a fallback-font flash.
- const startAfterFonts = () => {
- if (cancelled) return;
- rafRef.current = requestAnimationFrame(tick);
- };
- if (typeof document !== 'undefined' && document.fonts && document.fonts.ready) {
- document.fonts.ready.then(startAfterFonts);
- } else {
- startAfterFonts();
- }
- return () => {
- cancelled = true;
- cancelAnimationFrame(rafRef.current);
- };
- }, [playing, duration, effectiveLoop]);
- const handleScrub = useCallback((e) => {
- const rect = e.currentTarget.getBoundingClientRect();
- const ratio = (e.clientX - rect.left) / rect.width;
- setTime(Math.max(0, Math.min(duration, ratio * duration)));
- }, [duration]);
- const handleSeek = useCallback((e) => {
- handleScrub(e);
- setPlaying(false);
- }, [handleScrub]);
- const progress = time / duration;
- const ctx = {
- time,
- duration,
- playing,
- setPlaying,
- setTime,
- };
- const canvasStyle = {
- ...stageStyles.canvas,
- width,
- height,
- background: bgColor,
- transform: `translate(-50%, -50%) scale(${scale})`,
- };
- return (
- <TimeContext.Provider value={ctx}>
- <div style={stageStyles.wrapper}>
- <div style={stageStyles.stageHolder}>
- <div ref={canvasRef} style={canvasStyle}>
- {children}
- </div>
- </div>
- <div style={stageStyles.controls}>
- <button
- style={stageStyles.button}
- onClick={() => setPlaying(p => !p)}
- >
- {playing ? '⏸ 暂停' : '▶ 播放'}
- </button>
- <button
- style={stageStyles.button}
- onClick={() => setTime(0)}
- >
- ⏮ 开始
- </button>
- <div style={stageStyles.timeDisplay}>
- {time.toFixed(2)}s / {duration.toFixed(2)}s
- </div>
- <div style={stageStyles.scrubber} onMouseDown={handleSeek}>
- <div style={{ ...stageStyles.scrubberFill, width: `${progress * 100}%` }} />
- <div style={{ ...stageStyles.scrubberHandle, left: `${progress * 100}%` }} />
- </div>
- </div>
- </div>
- </TimeContext.Provider>
- );
- }
- function Sprite({ start = 0, end, children, style }) {
- const { time } = useContext(TimeContext);
- const actualEnd = end == null ? Infinity : end;
- if (time < start || time >= actualEnd) {
- return null;
- }
- const duration = actualEnd - start;
- const elapsed = time - start;
- const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
- const spriteValue = { t, elapsed, duration, start, end: actualEnd };
- return (
- <SpriteContext.Provider value={spriteValue}>
- <div style={{ position: 'absolute', inset: 0, ...style }}>
- {children}
- </div>
- </SpriteContext.Provider>
- );
- }
- if (typeof window !== 'undefined') {
- window.Animations = {
- Stage,
- Sprite,
- useTime,
- useSprite,
- Easing,
- interpolate,
- };
- }
- })();
|