/ templates / animations.jsx
animations.jsx
  1  /**
  2   * animations.jsx — Timeline animation engine
  3   *
  4   * Stage + Sprite pattern, inspired by Remotion but lightweight.
  5   *
  6   * Exports (mounted on window.Animations):
  7   * - Stage: Animation container, provides time + controls
  8   * - Sprite: Time segment, visible within start/end, provides local progress
  9   * - useTime(): Read global time (seconds)
 10   * - useSprite(): Read local progress {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   * Usage:
 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   * Use useSprite() inside Sprite children to read the current segment progress.
 25   */
 26  
 27  (function() {
 28    const { createContext, useContext, useState, useEffect, useRef, useCallback } = React;
 29  
 30    const TimeContext = createContext({ time: 0, duration: 10, playing: false });
 31    const SpriteContext = createContext(null);
 32  
 33    const Easing = {
 34      linear: t => t,
 35      easeIn: t => t * t,
 36      easeOut: t => 1 - (1 - t) * (1 - t),
 37      easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
 38      // expoOut: Anthropic-level primary easing (cubic-bezier(0.16, 1, 0.3, 1))
 39      // Fast start + gentle brake, gives numeric elements physical weight
 40      expoOut: t => t === 1 ? 1 : 1 - Math.pow(2, -10 * t),
 41      // overshoot: Elastic toggle/button pop (cubic-bezier(0.34, 1.56, 0.64, 1))
 42      overshoot: t => {
 43        const c1 = 1.70158, c3 = c1 + 1;
 44        return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
 45      },
 46      spring: t => {
 47        const c = (2 * Math.PI) / 3;
 48        return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
 49      },
 50      anticipation: t => {
 51        if (t < 0.2) return -0.3 * (t / 0.2) * (t / 0.2);
 52        const adjusted = (t - 0.2) / 0.8;
 53        return -0.012 + 1.012 * adjusted * adjusted * (3 - 2 * adjusted);
 54      },
 55    };
 56  
 57    function interpolate(t, input, output, easing) {
 58      const [inStart, inEnd] = input;
 59      const [outStart, outEnd] = output;
 60  
 61      if (t <= inStart) return outStart;
 62      if (t >= inEnd) return outEnd;
 63  
 64      let progress = (t - inStart) / (inEnd - inStart);
 65      if (easing) {
 66        progress = easing(progress);
 67      }
 68  
 69      return outStart + (outEnd - outStart) * progress;
 70    }
 71  
 72    function useTime() {
 73      const ctx = useContext(TimeContext);
 74      return ctx.time;
 75    }
 76  
 77    function useSprite() {
 78      const sprite = useContext(SpriteContext);
 79      if (!sprite) {
 80        return { t: 0, elapsed: 0, duration: 0 };
 81      }
 82      return sprite;
 83    }
 84  
 85    const stageStyles = {
 86      wrapper: {
 87        position: 'fixed',
 88        inset: 0,
 89        background: '#000',
 90        display: 'flex',
 91        flexDirection: 'column',
 92        fontFamily: '-apple-system, sans-serif',
 93      },
 94      stageHolder: {
 95        flex: 1,
 96        position: 'relative',
 97        overflow: 'hidden',
 98      },
 99      canvas: {
100        position: 'absolute',
101        top: '50%',
102        left: '50%',
103        transformOrigin: 'center center',
104        background: '#111',
105        overflow: 'hidden',
106      },
107      controls: {
108        position: 'fixed',
109        bottom: 0,
110        left: 0,
111        right: 0,
112        background: 'rgba(0, 0, 0, 0.8)',
113        backdropFilter: 'blur(10px)',
114        padding: '12px 20px',
115        display: 'flex',
116        alignItems: 'center',
117        gap: 16,
118        color: '#fff',
119        fontSize: 12,
120        zIndex: 100,
121      },
122      button: {
123        background: 'none',
124        border: '1px solid rgba(255,255,255,0.3)',
125        color: '#fff',
126        padding: '6px 14px',
127        borderRadius: 4,
128        cursor: 'pointer',
129        fontSize: 12,
130      },
131      timeDisplay: {
132        fontFamily: 'ui-monospace, monospace',
133        fontVariantNumeric: 'tabular-nums',
134        minWidth: 90,
135      },
136      scrubber: {
137        flex: 1,
138        height: 4,
139        background: 'rgba(255,255,255,0.2)',
140        borderRadius: 2,
141        position: 'relative',
142        cursor: 'pointer',
143      },
144      scrubberFill: {
145        position: 'absolute',
146        top: 0,
147        left: 0,
148        height: '100%',
149        background: '#fff',
150        borderRadius: 2,
151        pointerEvents: 'none',
152      },
153      scrubberHandle: {
154        position: 'absolute',
155        top: '50%',
156        width: 12,
157        height: 12,
158        background: '#fff',
159        borderRadius: '50%',
160        transform: 'translate(-50%, -50%)',
161        pointerEvents: 'none',
162      },
163    };
164  
165    function Stage({ duration = 10, width = 1920, height = 1080, fps = 60, loop = true, children, bgColor = '#fff' }) {
166      const [time, setTime] = useState(0);
167      const [playing, setPlaying] = useState(true);
168      const [scale, setScale] = useState(1);
169      const rafRef = useRef(null);
170      const startTimeRef = useRef(performance.now());
171      const canvasRef = useRef(null);
172  
173      // Recording mode: render-video.js injects window.__recording = true before goto.
174      // When set, force loop=false so the export ends on the final frame instead of
175      // wrapping back to t=0 and capturing the start of the next cycle.
176      // (Browsers viewing manually still loop because __recording is undefined there.)
177      const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
178  
179      useEffect(() => {
180        function updateScale() {
181          const vw = window.innerWidth;
182          const vh = window.innerHeight - 56;
183          const s = Math.min(vw / width, vh / height);
184          setScale(s);
185        }
186        updateScale();
187        window.addEventListener('resize', updateScale);
188        return () => window.removeEventListener('resize', updateScale);
189      }, [width, height]);
190  
191      useEffect(() => {
192        if (!playing) return;
193        let cancelled = false;
194        let last = null;
195  
196        function tick(now) {
197          if (cancelled) return;
198          if (last === null) {
199            // First animation frame. Set last=now so delta starts at 0,
200            // AND announce readiness for video export.
201            // This pairing is critical: window.__ready must flip to true at
202            // the exact moment WebM captures frame 0 of the animation, so
203            // render-video.js's trim offset equals the pre-animation gap.
204            last = now;
205            if (typeof window !== 'undefined') window.__ready = true;
206          }
207          const delta = (now - last) / 1000;
208          last = now;
209          setTime(prev => {
210            const next = prev + delta;
211            if (next >= duration) {
212              // effectiveLoop honors window.__recording (forced non-loop during export).
213              // Stop just shy of duration so the final-frame state stays rendered
214              // (avoids exiting all Sprites that end exactly at `duration`).
215              return effectiveLoop ? 0 : duration - 0.001;
216            }
217            return next;
218          });
219          rafRef.current = requestAnimationFrame(tick);
220        }
221  
222        // Wait for fonts before starting the clock — makes frame 0 the
223        // real "finished-loading" frame users see, not a fallback-font flash.
224        const startAfterFonts = () => {
225          if (cancelled) return;
226          rafRef.current = requestAnimationFrame(tick);
227        };
228        if (typeof document !== 'undefined' && document.fonts && document.fonts.ready) {
229          document.fonts.ready.then(startAfterFonts);
230        } else {
231          startAfterFonts();
232        }
233  
234        return () => {
235          cancelled = true;
236          cancelAnimationFrame(rafRef.current);
237        };
238      }, [playing, duration, effectiveLoop]);
239  
240      const handleScrub = useCallback((e) => {
241        const rect = e.currentTarget.getBoundingClientRect();
242        const ratio = (e.clientX - rect.left) / rect.width;
243        setTime(Math.max(0, Math.min(duration, ratio * duration)));
244      }, [duration]);
245  
246      const handleSeek = useCallback((e) => {
247        handleScrub(e);
248        setPlaying(false);
249      }, [handleScrub]);
250  
251      const progress = time / duration;
252  
253      const ctx = {
254        time,
255        duration,
256        playing,
257        setPlaying,
258        setTime,
259      };
260  
261      const canvasStyle = {
262        ...stageStyles.canvas,
263        width,
264        height,
265        background: bgColor,
266        transform: `translate(-50%, -50%) scale(${scale})`,
267      };
268  
269      return (
270        <TimeContext.Provider value={ctx}>
271          <div style={stageStyles.wrapper}>
272            <div style={stageStyles.stageHolder}>
273              <div ref={canvasRef} style={canvasStyle}>
274                {children}
275              </div>
276            </div>
277  
278            <div style={stageStyles.controls}>
279              <button
280                style={stageStyles.button}
281                onClick={() => setPlaying(p => !p)}
282              >
283                {playing ? '⏸ Pause' : '▶ Play'}
284              </button>
285  
286              <button
287                style={stageStyles.button}
288                onClick={() => setTime(0)}
289              >
290                ⏮ Start
291              </button>
292  
293              <div style={stageStyles.timeDisplay}>
294                {time.toFixed(2)}s / {duration.toFixed(2)}s
295              </div>
296  
297              <div style={stageStyles.scrubber} onMouseDown={handleSeek}>
298                <div style={{ ...stageStyles.scrubberFill, width: `${progress * 100}%` }} />
299                <div style={{ ...stageStyles.scrubberHandle, left: `${progress * 100}%` }} />
300              </div>
301            </div>
302          </div>
303        </TimeContext.Provider>
304      );
305    }
306  
307    function Sprite({ start = 0, end, children, style }) {
308      const { time } = useContext(TimeContext);
309      const actualEnd = end == null ? Infinity : end;
310  
311      if (time < start || time >= actualEnd) {
312        return null;
313      }
314  
315      const duration = actualEnd - start;
316      const elapsed = time - start;
317      const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
318  
319      const spriteValue = { t, elapsed, duration, start, end: actualEnd };
320  
321      return (
322        <SpriteContext.Provider value={spriteValue}>
323          <div style={{ position: 'absolute', inset: 0, ...style }}>
324            {children}
325          </div>
326        </SpriteContext.Provider>
327      );
328    }
329  
330    if (typeof window !== 'undefined') {
331      window.Animations = {
332        Stage,
333        Sprite,
334        useTime,
335        useSprite,
336        Easing,
337        interpolate,
338      };
339    }
340  })();