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 })();