c3-motion-design.html 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>Huashu-Design · Motion Design</title>
  6. <script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
  7. <script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
  8. <script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
  9. <link rel="preconnect" href="https://fonts.googleapis.com">
  10. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  11. <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@400;500;600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
  12. <style>
  13. * { box-sizing: border-box; margin: 0; padding: 0; }
  14. html, body { width: 100%; height: 100%; overflow: hidden; }
  15. body { background: #0c0c0c; font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif; color: #1a1a1a; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
  16. </style>
  17. </head>
  18. <body>
  19. <div id="root"></div>
  20. <!-- animations.jsx inlined -->
  21. <script type="text/babel">
  22. (function() {
  23. const { createContext, useContext, useState, useEffect, useRef } = React;
  24. const TimeContext = createContext({ time: 0, duration: 10, playing: false });
  25. const SpriteContext = createContext(null);
  26. const Easing = {
  27. linear: t => t,
  28. easeIn: t => t * t,
  29. easeOut: t => 1 - (1 - t) * (1 - t),
  30. easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
  31. spring: t => {
  32. const c = (2 * Math.PI) / 3;
  33. return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
  34. },
  35. };
  36. function interpolate(t, input, output, easing) {
  37. const [a, b] = input, [x, y] = output;
  38. if (t <= a) return x; if (t >= b) return y;
  39. let p = (t - a) / (b - a); if (easing) p = easing(p);
  40. return x + (y - x) * p;
  41. }
  42. function useTime() { return useContext(TimeContext).time; }
  43. function useSprite() { return useContext(SpriteContext) || { t: 0, elapsed: 0, duration: 0 }; }
  44. function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
  45. const [time, setTime] = useState(0);
  46. const [playing, setPlaying] = useState(true);
  47. const [scale, setScale] = useState(1);
  48. const rafRef = useRef(null);
  49. const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
  50. useEffect(() => {
  51. const update = () => {
  52. const s = Math.min(window.innerWidth / width, (window.innerHeight - 56) / height);
  53. setScale(s);
  54. };
  55. update(); window.addEventListener('resize', update);
  56. return () => window.removeEventListener('resize', update);
  57. }, [width, height]);
  58. useEffect(() => {
  59. if (!playing) return;
  60. let cancelled = false, last = null;
  61. function tick(now) {
  62. if (cancelled) return;
  63. if (last === null) { last = now; if (typeof window !== 'undefined') window.__ready = true; }
  64. const delta = (now - last) / 1000; last = now;
  65. setTime(prev => {
  66. const next = prev + delta;
  67. if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
  68. return next;
  69. });
  70. rafRef.current = requestAnimationFrame(tick);
  71. }
  72. const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
  73. if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
  74. return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
  75. }, [playing, duration, effectiveLoop]);
  76. const progress = time / duration;
  77. const ctx = { time, duration, playing, setPlaying, setTime };
  78. return (
  79. <TimeContext.Provider value={ctx}>
  80. <div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
  81. <div style={{flex:1, position:'relative', overflow:'hidden'}}>
  82. <div style={{position:'absolute', top:'50%', left:'50%', transformOrigin:'center center', width, height, background: bgColor, overflow:'hidden', transform:`translate(-50%, -50%) scale(${scale})`}}>
  83. {children}
  84. </div>
  85. </div>
  86. <div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
  87. <button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
  88. <button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
  89. <div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
  90. <div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
  91. <div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
  92. </div>
  93. </div>
  94. </div>
  95. </TimeContext.Provider>
  96. );
  97. }
  98. function Sprite({ start = 0, end, children, style }) {
  99. const { time } = useContext(TimeContext);
  100. const actualEnd = end == null ? Infinity : end;
  101. if (time < start || time >= actualEnd) return null;
  102. const duration = actualEnd - start;
  103. const elapsed = time - start;
  104. const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
  105. return (
  106. <SpriteContext.Provider value={{ t, elapsed, duration, start, end: actualEnd }}>
  107. <div style={{position:'absolute', inset:0, ...style}}>{children}</div>
  108. </SpriteContext.Provider>
  109. );
  110. }
  111. window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
  112. })();
  113. </script>
  114. <!-- Demo scene -->
  115. <script type="text/babel">
  116. const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
  117. const CREAM = '#FAF6EF';
  118. const INK = '#1a1a1a';
  119. const TERRA = '#C04A1A';
  120. const OLIVE = '#6a6b4e';
  121. const DEEP_BLUE = '#2a3552';
  122. const ASH = '#6b6b6b';
  123. const LINE = '#d9d2c5';
  124. const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
  125. const sans = "'Inter', -apple-system, sans-serif";
  126. const mono = "'JetBrains Mono', ui-monospace, monospace";
  127. // ── Scene 1: Title (0 – 3s) ────────────────────────────
  128. function Scene1_Title() {
  129. const { elapsed } = useSprite();
  130. const titleY = interpolate(elapsed, [0, 1.2], [60, 0], Easing.easeOut);
  131. const titleOp = interpolate(elapsed, [0, 0.8], [0, 1]);
  132. const subOp = interpolate(elapsed, [0.6, 1.4], [0, 1]);
  133. const lineW = interpolate(elapsed, [0.9, 1.6], [0, 520]);
  134. const apiOp = interpolate(elapsed, [1.4, 2.2], [0, 1]);
  135. const fadeOut = interpolate(elapsed, [2.6, 3.0], [1, 0]);
  136. return (
  137. <div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
  138. display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
  139. <div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
  140. color: TERRA, marginBottom: 24, opacity: titleOp}}>
  141. 动画引擎 · Stage + Sprite
  142. </div>
  143. <div style={{fontFamily: serif, fontSize: 148, fontWeight: 500, color: INK,
  144. lineHeight: 1, letterSpacing:'-0.02em', opacity: titleOp,
  145. transform: `translateY(${titleY}px)`}}>
  146. <span style={{fontStyle:'italic', color: TERRA}}>Motion</span> Design
  147. </div>
  148. <div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
  149. <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24, color: ASH,
  150. marginTop: 28, opacity: subOp}}>
  151. 时间驱动 · 可编排 · 60fps 导出
  152. </div>
  153. <div style={{fontFamily: mono, fontSize: 14, color: ASH,
  154. marginTop: 40, opacity: apiOp, letterSpacing:'0.1em'}}>
  155. &lt;Stage&gt; · &lt;Sprite&gt; · useTime() · useSprite() · interpolate() · Easing
  156. </div>
  157. </div>
  158. );
  159. }
  160. // ── Scene 2: Easing functions comparison (3 – 8s) ────────
  161. function Scene2_Easing() {
  162. const { elapsed } = useSprite();
  163. const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
  164. const fadeOut = interpolate(elapsed, [4.6, 5.0], [1, 0]);
  165. // Lane sweep cycle every 2s
  166. const cycle = (elapsed % 2.2) / 2.0;
  167. const sweepT = Math.min(1, Math.max(0, cycle));
  168. const curves = [
  169. { name: 'linear', label: 'linear', fn: Easing.linear, color: ASH },
  170. { name: 'easeOut', label: 'easeOut', fn: Easing.easeOut, color: OLIVE },
  171. { name: 'spring', label: 'spring', fn: Easing.spring, color: TERRA },
  172. { name: 'easeInOut', label: 'easeInOut', fn: Easing.easeInOut, color: DEEP_BLUE },
  173. ];
  174. const trackLeft = 320;
  175. const trackRight = 1480;
  176. const trackLen = trackRight - trackLeft;
  177. return (
  178. <div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
  179. padding: '80px 100px', display:'flex', flexDirection:'column'}}>
  180. <div style={{opacity: titleOp, marginBottom: 50,
  181. display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
  182. <div>
  183. <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
  184. letterSpacing:'0.3em', marginBottom: 6}}>场景 1 · EASING</div>
  185. <div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
  186. letterSpacing:'-0.01em'}}>
  187. 四种缓动曲线同跑
  188. </div>
  189. </div>
  190. <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
  191. textAlign:'right'}}>
  192. 同样的 2 秒,<br/>
  193. 走出四种不同的「节奏感」
  194. </div>
  195. </div>
  196. <div style={{flex: 1, position:'relative'}}>
  197. {curves.map((c, i) => {
  198. const y = 80 + i * 140;
  199. const t = c.fn(sweepT);
  200. const x = trackLeft + trackLen * t;
  201. // Draw the curve as a mini sparkline right of track
  202. const sparkW = 160, sparkH = 50;
  203. const sparkPts = Array.from({length: 30}, (_, k) => {
  204. const tx = k / 29;
  205. const ty = 1 - c.fn(tx);
  206. return `${tx * sparkW},${ty * sparkH}`;
  207. }).join(' ');
  208. return (
  209. <div key={i}>
  210. {/* Label (left) */}
  211. <div style={{position:'absolute', left: 0, top: y - 22, width: 280,
  212. fontFamily: mono, fontSize: 14, color: INK, letterSpacing:'0.05em'}}>
  213. <span style={{color: c.color, marginRight: 12}}>●</span>
  214. Easing.<span style={{color: c.color}}>{c.label}</span>
  215. </div>
  216. {/* Track */}
  217. <div style={{position:'absolute', left: trackLeft, top: y,
  218. width: trackLen, height: 2, background: LINE}} />
  219. {/* Dot */}
  220. <div style={{position:'absolute', left: x - 14, top: y - 14,
  221. width: 28, height: 28, borderRadius: '50%',
  222. background: c.color,
  223. boxShadow: `0 4px 12px ${c.color}55`}} />
  224. {/* Sparkline */}
  225. <svg style={{position:'absolute', left: trackRight + 60, top: y - sparkH/2 - 5,
  226. width: sparkW, height: sparkH}}>
  227. <polyline points={sparkPts} stroke={c.color} strokeWidth="1.5" fill="none" />
  228. <circle cx={sweepT * sparkW} cy={(1 - c.fn(sweepT)) * sparkH}
  229. r="3.5" fill={c.color} />
  230. </svg>
  231. </div>
  232. );
  233. })}
  234. </div>
  235. {/* Timeline at bottom */}
  236. <div style={{marginTop: 10, position:'relative', height: 30}}>
  237. <div style={{fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.15em'}}>
  238. t = <span style={{color: INK}}>{sweepT.toFixed(2)}</span> &nbsp; · &nbsp; 周期 2.0s
  239. </div>
  240. </div>
  241. </div>
  242. );
  243. }
  244. // ── Scene 3: interpolate() function demo (8 – 14s) ───────
  245. function Scene3_Interpolate() {
  246. const { elapsed } = useSprite();
  247. const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
  248. const fadeOut = interpolate(elapsed, [5.6, 6.0], [1, 0]);
  249. // Animated t value: 0→1 cycle over ~3s
  250. const cycle = (elapsed % 3.2) / 3.0;
  251. const t = Math.min(1, Math.max(0, cycle));
  252. // Three mapped outputs from same t:
  253. const opacity = interpolate(t, [0, 1], [0, 1]);
  254. const scale = interpolate(t, [0, 1], [0.4, 1.2], Easing.spring);
  255. const rotation = interpolate(t, [0, 1], [-30, 30], Easing.easeInOut);
  256. const translateX = interpolate(t, [0, 0.5, 1], [-80, 40, 0]);
  257. return (
  258. <div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
  259. padding: '80px 100px', display:'flex', flexDirection:'column'}}>
  260. <div style={{opacity: titleOp, marginBottom: 40,
  261. display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
  262. <div>
  263. <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
  264. letterSpacing:'0.3em', marginBottom: 6}}>场景 2 · INTERPOLATE</div>
  265. <div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
  266. letterSpacing:'-0.01em'}}>
  267. 一个 <span style={{fontStyle:'italic', color: TERRA}}>t</span>,四种变化
  268. </div>
  269. </div>
  270. <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
  271. textAlign:'right'}}>
  272. 用同一条时间轴,<br/>
  273. 映射出透明度、尺寸、旋转、位移
  274. </div>
  275. </div>
  276. {/* t value progress bar */}
  277. <div style={{background:'#fff', border: `1px solid ${LINE}`,
  278. padding: '20px 32px', marginBottom: 30}}>
  279. <div style={{display:'flex', justifyContent:'space-between',
  280. alignItems:'baseline', marginBottom: 14}}>
  281. <div style={{fontFamily: mono, fontSize: 13, color: INK}}>
  282. <span style={{color: ASH}}>const</span> t = <span style={{color: TERRA}}>{t.toFixed(3)}</span>
  283. </div>
  284. <div style={{fontFamily: mono, fontSize: 11, color: ASH, letterSpacing:'0.15em'}}>
  285. 时间 → 0 到 1
  286. </div>
  287. </div>
  288. <div style={{height: 4, background: LINE, position:'relative'}}>
  289. <div style={{position:'absolute', top:0, left:0, height:'100%',
  290. width: `${t * 100}%`, background: TERRA}} />
  291. </div>
  292. </div>
  293. {/* Four demos */}
  294. <div style={{flex: 1, display:'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 24}}>
  295. {[
  296. { name: 'opacity', code: 'interpolate(t, [0,1], [0,1])', val: opacity, render:
  297. <div style={{width: 120, height: 120, background: TERRA, opacity}} /> },
  298. { name: 'scale + spring', code: 'interpolate(t, [0,1], [0.4,1.2], spring)', val: scale, render:
  299. <div style={{width: 120, height: 120, background: OLIVE,
  300. transform: `scale(${scale})`}} /> },
  301. { name: 'rotate', code: 'interpolate(t, [0,1], [-30,30], easeInOut)', val: rotation, render:
  302. <div style={{width: 120, height: 120, background: DEEP_BLUE,
  303. transform: `rotate(${rotation}deg)`}} /> },
  304. { name: 'translateX (3 stops)', code: 'interpolate(t, [0,.5,1], [-80,40,0])', val: translateX, render:
  305. <div style={{width: 120, height: 120, background: INK,
  306. transform: `translateX(${translateX}px)`}} /> },
  307. ].map((d, i) => (
  308. <div key={i} style={{background:'#fff', border:`1px solid ${LINE}`,
  309. padding: '18px 18px 14px', display:'flex', flexDirection:'column'}}>
  310. <div style={{fontFamily: mono, fontSize: 9, color: TERRA,
  311. letterSpacing:'0.2em', marginBottom: 6}}>0{i+1}</div>
  312. <div style={{fontFamily: serif, fontSize: 18, fontWeight: 500, color: INK,
  313. marginBottom: 6}}>{d.name}</div>
  314. <div style={{fontFamily: mono, fontSize: 10, color: ASH,
  315. marginBottom: 20, minHeight: 32, wordBreak:'break-all', lineHeight: 1.5}}>
  316. {d.code}
  317. </div>
  318. <div style={{flex: 1, display:'flex', alignItems:'center',
  319. justifyContent:'center', overflow:'hidden'}}>
  320. {d.render}
  321. </div>
  322. <div style={{fontFamily: mono, fontSize: 10, color: ASH,
  323. marginTop: 12, textAlign:'right'}}>
  324. = <span style={{color: TERRA}}>{d.val.toFixed(2)}</span>
  325. </div>
  326. </div>
  327. ))}
  328. </div>
  329. </div>
  330. );
  331. }
  332. // ── Scene 4: Sprite sequencing on timeline (14 – 20s) ───
  333. function Scene4_Sprite() {
  334. const { elapsed } = useSprite();
  335. const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
  336. const fadeOut = interpolate(elapsed, [5.6, 6.0], [1, 0]);
  337. // Timeline plays 6s
  338. const localTime = Math.min(elapsed, 5.6);
  339. const sprites = [
  340. { name: 'Title', start: 0.0, end: 2.5, color: TERRA, y: 0, label: '标题' },
  341. { name: 'Image', start: 0.8, end: 3.5, color: OLIVE, y: 1, label: '图像淡入' },
  342. { name: 'Text', start: 1.8, end: 4.5, color: DEEP_BLUE, y: 2, label: '正文' },
  343. { name: 'Outro', start: 4.0, end: 5.5, color: '#8b4a2b', y: 3, label: '结尾' },
  344. ];
  345. const timelineLeft = 100;
  346. const timelineRight = 1820;
  347. const timelineW = timelineRight - timelineLeft;
  348. const totalDur = 5.6;
  349. const cursorX = timelineLeft + (localTime / totalDur) * timelineW;
  350. return (
  351. <div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
  352. padding: '80px 100px 60px', display:'flex', flexDirection:'column'}}>
  353. <div style={{opacity: titleOp, marginBottom: 30,
  354. display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
  355. <div>
  356. <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
  357. letterSpacing:'0.3em', marginBottom: 6}}>场景 3 · SPRITE 编排</div>
  358. <div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
  359. letterSpacing:'-0.01em'}}>
  360. 时间片段 · <span style={{fontStyle:'italic'}}>同台起舞</span>
  361. </div>
  362. </div>
  363. <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
  364. textAlign:'right'}}>
  365. 每个 &lt;Sprite start=... end=...&gt;<br/>
  366. 在自己的时间窗口出场、退场
  367. </div>
  368. </div>
  369. {/* Live visualization area */}
  370. <div style={{background:'#fff', border:`1px solid ${LINE}`, flex: 1,
  371. position:'relative', overflow:'hidden', marginBottom: 30}}>
  372. {sprites.map((s, i) => {
  373. const active = localTime >= s.start && localTime < s.end;
  374. if (!active) return null;
  375. const localT = (localTime - s.start) / (s.end - s.start);
  376. const op = interpolate(localT, [0, 0.15, 0.85, 1], [0, 1, 1, 0]);
  377. const ty = interpolate(localT, [0, 0.2], [30, 0], Easing.easeOut);
  378. if (s.name === 'Title') {
  379. return (
  380. <div key={i} style={{position:'absolute', top: 60, left: 80, right: 80,
  381. opacity: op, transform: `translateY(${ty}px)`}}>
  382. <div style={{fontFamily: mono, fontSize: 10, color: s.color,
  383. letterSpacing:'0.3em', marginBottom: 10}}>CHAPTER 01</div>
  384. <div style={{fontFamily: serif, fontSize: 64, fontWeight: 500,
  385. color: INK, lineHeight: 1.05, letterSpacing:'-0.01em'}}>
  386. 如何让动画 <span style={{fontStyle:'italic', color: s.color}}>好看</span>
  387. </div>
  388. </div>
  389. );
  390. }
  391. if (s.name === 'Image') {
  392. return (
  393. <div key={i} style={{position:'absolute', top: 60, right: 80,
  394. width: 380, height: 240, opacity: op,
  395. transform: `translateY(${ty}px)`,
  396. background: `linear-gradient(135deg, ${s.color}, ${s.color}88 50%, ${s.color}33)`,
  397. overflow: 'hidden'}}>
  398. <div style={{position:'absolute', inset: 0,
  399. background: `radial-gradient(circle at 30% 30%, ${s.color}aa, transparent 50%)`}} />
  400. <div style={{position:'absolute', bottom: 14, left: 16,
  401. fontFamily: mono, fontSize: 9, color: '#fff',
  402. letterSpacing:'0.2em', opacity: 0.8}}>
  403. IMAGE · FADE-IN
  404. </div>
  405. </div>
  406. );
  407. }
  408. if (s.name === 'Text') {
  409. return (
  410. <div key={i} style={{position:'absolute', bottom: 80, left: 80, right: 80,
  411. opacity: op, transform: `translateY(${ty}px)`}}>
  412. <div style={{fontFamily: mono, fontSize: 10, color: s.color,
  413. letterSpacing:'0.3em', marginBottom: 10}}>BODY</div>
  414. <div style={{fontFamily: serif, fontSize: 20, color: INK,
  415. lineHeight: 1.55, maxWidth: 720}}>
  416. 好的 motion 不是每个元素都在抢戏——是<span style={{fontStyle:'italic'}}>一个
  417. </span>进、<span style={{fontStyle:'italic'}}>一个</span>退、留足呼吸,
  418. 最后合奏收尾。
  419. </div>
  420. </div>
  421. );
  422. }
  423. if (s.name === 'Outro') {
  424. return (
  425. <div key={i} style={{position:'absolute', inset: 0,
  426. opacity: op, display:'flex', alignItems:'center',
  427. justifyContent:'center', flexDirection:'column'}}>
  428. <div style={{fontFamily: serif, fontSize: 88, fontWeight: 500,
  429. color: s.color, fontStyle:'italic', letterSpacing:'-0.01em'}}>
  430. — fin —
  431. </div>
  432. <div style={{fontFamily: mono, fontSize: 11, color: ASH,
  433. letterSpacing:'0.3em', marginTop: 14}}>
  434. 4 SPRITES · 5.5 SECONDS · 1 STAGE
  435. </div>
  436. </div>
  437. );
  438. }
  439. })}
  440. </div>
  441. {/* Timeline viz (showing sprite spans) */}
  442. <div style={{position:'relative', height: 110}}>
  443. {/* Labels */}
  444. {sprites.map((s, i) => (
  445. <div key={i} style={{position:'absolute', left: 0, top: i * 22,
  446. fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.05em'}}>
  447. <span style={{color: s.color, marginRight: 8}}>●</span>{s.label}
  448. </div>
  449. ))}
  450. {/* Tracks */}
  451. {sprites.map((s, i) => {
  452. const x0 = timelineLeft + (s.start / totalDur) * timelineW;
  453. const x1 = timelineLeft + (s.end / totalDur) * timelineW;
  454. const active = localTime >= s.start && localTime < s.end;
  455. return (
  456. <div key={i} style={{position:'absolute',
  457. left: x0, top: i * 22 - 2, width: x1 - x0, height: 16,
  458. background: active ? s.color : `${s.color}55`,
  459. borderLeft: `2px solid ${s.color}`}} />
  460. );
  461. })}
  462. {/* Playhead cursor */}
  463. <div style={{position:'absolute', left: cursorX - 1, top: -6,
  464. width: 2, height: 110, background: INK, zIndex: 5}} />
  465. <div style={{position:'absolute', left: cursorX - 20, top: -20,
  466. fontFamily: mono, fontSize: 10, color: INK,
  467. letterSpacing:'0.1em'}}>
  468. {localTime.toFixed(2)}s
  469. </div>
  470. </div>
  471. </div>
  472. );
  473. }
  474. // ── Scene 5: Outro (20 – 22s) ─────────────────────────
  475. function Scene5_Outro() {
  476. const { elapsed } = useSprite();
  477. const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1], Easing.easeOut);
  478. const lineW = interpolate(elapsed, [0.5, 1.3], [0, 620]);
  479. return (
  480. <div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeIn,
  481. display:'flex', alignItems:'center', justifyContent:'center',
  482. flexDirection:'column'}}>
  483. <div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
  484. color: TERRA, marginBottom: 20}}>
  485. 导出 · MP4 / GIF / 60FPS / BGM
  486. </div>
  487. <div style={{fontFamily: serif, fontSize: 108, fontWeight: 500,
  488. color: INK, lineHeight: 1, letterSpacing:'-0.015em'}}>
  489. 从 <span style={{fontStyle:'italic', color: TERRA}}>时间</span>,到成片
  490. </div>
  491. <div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
  492. <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22, color: ASH,
  493. marginTop: 26, maxWidth: 800, textAlign:'center', lineHeight: 1.55}}>
  494. render-video.js · convert-formats.sh · add-music.sh<br/>
  495. 一条命令跑完,产出社交媒体可直接用的素材
  496. </div>
  497. </div>
  498. );
  499. }
  500. // ── Watermark ──────────────────────────────────────────
  501. function Watermark() {
  502. return (
  503. <div style={{position:'absolute', bottom: 24, right: 32,
  504. fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
  505. fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
  506. Created by Huashu-Design
  507. </div>
  508. );
  509. }
  510. function App() {
  511. return (
  512. <Stage duration={22} width={1920} height={1080} bgColor={CREAM}>
  513. <Sprite start={0} end={3}><Scene1_Title /></Sprite>
  514. <Sprite start={3} end={8}><Scene2_Easing /></Sprite>
  515. <Sprite start={8} end={14}><Scene3_Interpolate /></Sprite>
  516. <Sprite start={14} end={20}><Scene4_Sprite /></Sprite>
  517. <Sprite start={20} end={22}><Scene5_Outro /></Sprite>
  518. <Watermark />
  519. </Stage>
  520. );
  521. }
  522. ReactDOM.createRoot(document.getElementById('root')).render(<App />);
  523. </script>
  524. </body>
  525. </html>