mirror of
https://github.com/aculix/huashu-design.git
synced 2026-10-27 07:15:54 +00:00
launch huashu-design v1.0: 9 demos + marketing-first README
- 新增 9 个能力 demo(workflow + capability),每个 MP4 + GIF + 60fps - README 重写:营销 hook 开头 · 功能优先结构 · 自然嵌入 GIF - 修复 render-video.js Google Fonts networkidle 超时 - 修复 c1-ios-prototype 刘海/Home Indicator 背景色 - 补充 references/editable-pptx.md 和 scripts/export_deck_stage_pdf.mjs Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f3692aac10
commit
b127f8baa3
24 files changed
+8206
-281
No files matched your search
@@ -0,0 +1,721 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Huashu-Design · iOS App Prototype</title>
|
||||
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<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,300;1,6..72,400&family=Noto+Serif+SC:wght@400;500;600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; }
|
||||
body { background: #0c0c0c; font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif; color: #1a1a1a; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- animations.jsx inlined -->
|
||||
<script type="text/babel">
|
||||
(function() {
|
||||
const { createContext, useContext, useState, useEffect, useRef } = 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;
|
||||
},
|
||||
};
|
||||
function interpolate(t, input, output, easing) {
|
||||
const [a, b] = input, [x, y] = output;
|
||||
if (t <= a) return x; if (t >= b) return y;
|
||||
let p = (t - a) / (b - a); if (easing) p = easing(p);
|
||||
return x + (y - x) * p;
|
||||
}
|
||||
function useTime() { return useContext(TimeContext).time; }
|
||||
function useSprite() { return useContext(SpriteContext) || { t: 0, elapsed: 0, duration: 0 }; }
|
||||
function Stage({ duration = 10, width = 1920, height = 1080, 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 effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const s = Math.min(window.innerWidth / width, (window.innerHeight - 56) / height);
|
||||
setScale(s);
|
||||
};
|
||||
update(); window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, [width, height]);
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
let cancelled = false, last = null;
|
||||
function tick(now) {
|
||||
if (cancelled) return;
|
||||
if (last === null) { 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) return effectiveLoop ? 0 : duration - 0.001;
|
||||
return next;
|
||||
});
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
|
||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
|
||||
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
|
||||
}, [playing, duration, effectiveLoop]);
|
||||
const progress = time / duration;
|
||||
const ctx = { time, duration, playing, setPlaying, setTime };
|
||||
return (
|
||||
<TimeContext.Provider value={ctx}>
|
||||
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
|
||||
<div style={{position:'absolute', top:'50%', left:'50%', transformOrigin:'center center', width, height, background: bgColor, overflow:'hidden', transform:`translate(-50%, -50%) scale(${scale})`}}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<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}}>
|
||||
<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>
|
||||
<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>
|
||||
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
|
||||
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
|
||||
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
|
||||
</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));
|
||||
return (
|
||||
<SpriteContext.Provider value={{ t, elapsed, duration, start, end: actualEnd }}>
|
||||
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
|
||||
</SpriteContext.Provider>
|
||||
);
|
||||
}
|
||||
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Demo scene -->
|
||||
<script type="text/babel">
|
||||
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
|
||||
|
||||
// ── Design tokens ─────────────────────────────────────────
|
||||
const CREAM = '#FAF6EF';
|
||||
const PAPER = '#FDFBF5';
|
||||
const INK = '#1a1a1a';
|
||||
const TERRA = '#C04A1A';
|
||||
const OLIVE = '#6a6b4e';
|
||||
const ASH = '#6b6b6b';
|
||||
const LINE = '#e5ddcd';
|
||||
const LINE2 = '#d9d2c5';
|
||||
|
||||
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
|
||||
const sans = "'Inter', -apple-system, sans-serif";
|
||||
const mono = "'JetBrains Mono', ui-monospace, monospace";
|
||||
|
||||
// ── Art image: CSS-rendered "oil painting" hero ──────────
|
||||
function ArtBlock({ mood = 'warm', height = 200 }) {
|
||||
// Three curated palettes for variety (no Unsplash dep, stable offline)
|
||||
const palettes = {
|
||||
warm: ['#8b4a2b', '#c67b4a', '#e3a876', '#f2d4a7'], // Turner sunset
|
||||
quiet: ['#3d4a3a', '#6a8066', '#a8b89c', '#e0d8b8'], // Corot pastoral
|
||||
study: ['#2a3552', '#5e6b8a', '#8b98b5', '#d4c9a5'], // Vermeer indoor
|
||||
};
|
||||
const p = palettes[mood];
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', height, position: 'relative', overflow: 'hidden',
|
||||
background: `linear-gradient(135deg, ${p[0]} 0%, ${p[1]} 35%, ${p[2]} 70%, ${p[3]} 100%)`,
|
||||
}}>
|
||||
{/* Impressionist brush texture */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: `
|
||||
radial-gradient(ellipse 80px 30px at 30% 40%, ${p[3]}44, transparent 70%),
|
||||
radial-gradient(ellipse 60px 20px at 70% 60%, ${p[0]}33, transparent 70%),
|
||||
radial-gradient(ellipse 100px 40px at 50% 80%, ${p[2]}44, transparent 70%),
|
||||
radial-gradient(ellipse 50px 25px at 20% 70%, ${p[1]}55, transparent 70%)
|
||||
`,
|
||||
filter: 'blur(1px)',
|
||||
}} />
|
||||
{/* Subtle scratch noise */}
|
||||
<svg width="100%" height="100%" style={{position:'absolute', inset:0, opacity: 0.18}}>
|
||||
<filter id="paint-noise">
|
||||
<feTurbulence baseFrequency="0.9" numOctaves="2" />
|
||||
<feColorMatrix values="0 0 0 0 0.3 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 1 0" />
|
||||
</filter>
|
||||
<rect width="100%" height="100%" filter="url(#paint-noise)" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── iOS Frame (simplified from ios_frame.jsx, positioned for demo) ──
|
||||
function IosFrame({ children, time = '9:41', scale = 1, style = {} }) {
|
||||
const W = 420, H = 900;
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
padding: 13,
|
||||
background: '#0a0a0a',
|
||||
borderRadius: 62,
|
||||
boxShadow: '0 0 0 2px #2a2a2a, 0 30px 80px rgba(0,0,0,0.35), 0 10px 30px rgba(0,0,0,0.2)',
|
||||
position: 'relative',
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'center center',
|
||||
...style,
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'relative', width: W, height: H,
|
||||
borderRadius: 50, overflow: 'hidden', background: PAPER,
|
||||
}}>
|
||||
{/* Status bar */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, right: 0, height: 54,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '0 34px', fontSize: 17, fontWeight: 600,
|
||||
fontFamily: '-apple-system, "SF Pro Text", sans-serif',
|
||||
color: '#000', zIndex: 20, pointerEvents: 'none',
|
||||
}}>
|
||||
<span>{time}</span>
|
||||
<div style={{display:'flex', alignItems:'center', gap: 6}}>
|
||||
<div style={{display:'flex', alignItems:'flex-end', gap: 2, height: 12}}>
|
||||
{[4, 6, 9, 11].map((h, i) => <div key={i} style={{width:3, height:h, background:'#000', borderRadius:1}} />)}
|
||||
</div>
|
||||
<svg width="16" height="12" viewBox="0 0 16 12">
|
||||
<path d="M8 11.5a1 1 0 100-2 1 1 0 000 2z" fill="#000" />
|
||||
<path d="M3 7.5a7 7 0 0110 0" stroke="#000" strokeWidth="1.3" fill="none" strokeLinecap="round" />
|
||||
<path d="M1 4.5a11 11 0 0114 0" stroke="#000" strokeWidth="1.3" fill="none" strokeLinecap="round" opacity="0.7" />
|
||||
</svg>
|
||||
<div style={{width:26, height:12, border:'1.5px solid #000', borderRadius:3, padding:1, position:'relative'}}>
|
||||
<div style={{width:'85%', height:'100%', background:'#000', borderRadius:1}} />
|
||||
<div style={{position:'absolute', top:3, right:-3, width:2, height:6, background:'#000', borderRadius:'0 1px 1px 0'}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Dynamic island */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 12, left: '50%',
|
||||
transform: 'translateX(-50%)', width: 124, height: 36,
|
||||
background: '#000', borderRadius: 999, zIndex: 30,
|
||||
}} />
|
||||
{/* Content */}
|
||||
<div style={{position:'absolute', top: 54, left: 0, right: 0, bottom: 34, overflow: 'hidden'}}>
|
||||
{children}
|
||||
</div>
|
||||
{/* Home indicator */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 10, left: '50%',
|
||||
transform: 'translateX(-50%)', width: 140, height: 5,
|
||||
background: 'rgba(0,0,0,0.28)', borderRadius: 999, zIndex: 10,
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Screen: Today ────────────────────────────────────────
|
||||
function TodayScreen({ animateT = 1 }) {
|
||||
const headerOp = interpolate(animateT, [0, 0.25], [0, 1]);
|
||||
const headerY = interpolate(animateT, [0, 0.35], [20, 0], Easing.easeOut);
|
||||
const heroOp = interpolate(animateT, [0.15, 0.5], [0, 1]);
|
||||
const heroY = interpolate(animateT, [0.15, 0.5], [30, 0], Easing.easeOut);
|
||||
const memoriesOp = interpolate(animateT, [0.4, 0.8], [0, 1]);
|
||||
|
||||
return (
|
||||
<div style={{padding: '24px 22px 0', height: '100%', display:'flex', flexDirection:'column', background: PAPER}}>
|
||||
{/* Header */}
|
||||
<div style={{opacity: headerOp, transform: `translateY(${headerY}px)`, marginBottom: 18}}>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.25em', marginBottom: 4}}>
|
||||
周二 · 4月20日
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 34, fontWeight: 500, color: INK, lineHeight: 1, letterSpacing:'-0.01em'}}>
|
||||
今日
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero card */}
|
||||
<div style={{opacity: heroOp, transform: `translateY(${heroY}px)`, border: `1px solid ${LINE}`, background:'#fff', marginBottom: 14}}>
|
||||
<ArtBlock mood="warm" height={180} />
|
||||
<div style={{padding: '14px 16px 16px'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 9, color: TERRA, letterSpacing:'0.2em', marginBottom: 6}}>
|
||||
继续阅读 · 剩 12 分钟
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 20, fontWeight: 500, color: INK, lineHeight: 1.2, marginBottom: 4, letterSpacing:'-0.005em'}}>
|
||||
《<span style={{fontStyle:'italic'}}>沉思录</span>》
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13, color: ASH}}>
|
||||
马可·奥勒留 · 第四卷
|
||||
</div>
|
||||
{/* AI insight */}
|
||||
<div style={{marginTop: 14, paddingTop: 12, borderTop: `1px solid ${LINE}`,
|
||||
display:'flex', gap: 10, alignItems: 'flex-start'}}>
|
||||
<div style={{width: 6, height: 6, borderRadius:'50%', background: TERRA, marginTop: 6, flexShrink: 0}} />
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 9, color: TERRA, letterSpacing:'0.2em', marginBottom: 2}}>
|
||||
流明 · 已关联
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 13, color: INK, lineHeight: 1.4}}>
|
||||
呼应你 3 周前读的《<span style={{fontStyle:'italic'}}>塞涅卡书简·28</span>》——同在谈<span style={{fontStyle:'italic'}}>内心堡垒</span>。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Memory bubbles list */}
|
||||
<div style={{opacity: memoriesOp, flex: 1}}>
|
||||
<div style={{fontFamily: mono, fontSize: 9, color: ASH, letterSpacing:'0.25em', marginBottom: 10}}>
|
||||
来自你的记忆
|
||||
</div>
|
||||
{[
|
||||
{ title: '"Amor fati"——一个说法', sub: '尼采 · 2 个月前', dot: OLIVE },
|
||||
{ title: '论「注意力即爱」', sub: '薇依 · 5 个月前', dot: TERRA },
|
||||
].map((m, i) => (
|
||||
<div key={i} style={{padding: '11px 0', borderTop: i === 0 ? `1px solid ${LINE}` : 'none', borderBottom: `1px solid ${LINE}`, display:'flex', alignItems:'center', gap: 12}}>
|
||||
<div style={{width: 8, height: 8, borderRadius: '50%', background: m.dot}} />
|
||||
<div style={{flex: 1}}>
|
||||
<div style={{fontFamily: serif, fontSize: 14, color: INK, lineHeight: 1.3}}>{m.title}</div>
|
||||
<div style={{fontFamily: mono, fontSize: 9, color: ASH, marginTop: 2, letterSpacing:'0.1em'}}>{m.sub}</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 18, color: ASH, fontStyle:'italic'}}>→</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Screen: Memory (graph view) ───────────────────────────
|
||||
function MemoryScreen({ animateT = 1 }) {
|
||||
const headerOp = interpolate(animateT, [0, 0.3], [0, 1]);
|
||||
const graphOp = interpolate(animateT, [0.15, 0.6], [0, 1]);
|
||||
const listOp = interpolate(animateT, [0.5, 0.9], [0, 1]);
|
||||
|
||||
// Nodes for graph
|
||||
const nodes = [
|
||||
{ x: 210, y: 100, r: 22, label: '斯多葛', emph: true },
|
||||
{ x: 110, y: 180, r: 14, label: '伦理' },
|
||||
{ x: 310, y: 170, r: 16, label: '美德', emph: true },
|
||||
{ x: 90, y: 260, r: 10, label: '' },
|
||||
{ x: 200, y: 240, r: 12, label: '' },
|
||||
{ x: 320, y: 270, r: 18, label: '自我' },
|
||||
{ x: 150, y: 330, r: 11, label: '' },
|
||||
{ x: 280, y: 340, r: 13, label: '心流' },
|
||||
];
|
||||
const edges = [
|
||||
[0, 1], [0, 2], [0, 4], [1, 3], [2, 5], [4, 5], [4, 6], [5, 7], [6, 7], [1, 4],
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{padding: '24px 22px 0', height: '100%', background: PAPER, display:'flex', flexDirection:'column'}}>
|
||||
<div style={{opacity: headerOp, marginBottom: 14}}>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.25em', marginBottom: 4}}>
|
||||
287 条 · 4 个聚类
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 34, fontWeight: 500, color: INK, lineHeight: 1, letterSpacing:'-0.01em'}}>
|
||||
记忆
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Graph visualization */}
|
||||
<div style={{
|
||||
opacity: graphOp, border:`1px solid ${LINE}`, background:'#fff',
|
||||
height: 400, position:'relative', overflow:'hidden', marginBottom: 14,
|
||||
}}>
|
||||
<svg viewBox="0 0 420 400" width="100%" height="100%" style={{display:'block'}}>
|
||||
{/* edges */}
|
||||
{edges.map(([a, b], i) => {
|
||||
const na = nodes[a], nb = nodes[b];
|
||||
return <line key={i} x1={na.x} y1={na.y} x2={nb.x} y2={nb.y}
|
||||
stroke="#c8beb0" strokeWidth={0.8} opacity={0.7} />;
|
||||
})}
|
||||
{/* nodes */}
|
||||
{nodes.map((n, i) => {
|
||||
const appear = interpolate(animateT, [0.2 + i * 0.04, 0.4 + i * 0.04], [0, 1], Easing.easeOut);
|
||||
return (
|
||||
<g key={i} opacity={appear}>
|
||||
<circle cx={n.x} cy={n.y} r={n.r}
|
||||
fill={n.emph ? TERRA : '#ede5d3'}
|
||||
stroke={n.emph ? TERRA : '#b8ac94'}
|
||||
strokeWidth={1} />
|
||||
{n.label && (
|
||||
<text x={n.x} y={n.y + n.r + 14} textAnchor="middle"
|
||||
fontFamily={serif} fontStyle="italic" fontSize={11}
|
||||
fill={n.emph ? TERRA : '#666'}>
|
||||
{n.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
{/* corner label */}
|
||||
<div style={{position:'absolute', top: 12, left: 14,
|
||||
fontFamily: mono, fontSize: 9, color: ASH, letterSpacing:'0.2em'}}>
|
||||
· 图谱视图
|
||||
</div>
|
||||
<div style={{position:'absolute', bottom: 12, right: 14,
|
||||
fontFamily: mono, fontSize: 9, color: TERRA, letterSpacing:'0.2em'}}>
|
||||
斯多葛派 · 47 条
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top clusters */}
|
||||
<div style={{opacity: listOp}}>
|
||||
<div style={{fontFamily: mono, fontSize: 9, color: ASH, letterSpacing:'0.25em', marginBottom: 8}}>
|
||||
主要聚类
|
||||
</div>
|
||||
{[
|
||||
{ name: '斯多葛', count: 47, swatch: TERRA },
|
||||
{ name: '注意力', count: 32, swatch: OLIVE },
|
||||
].map((c, i) => (
|
||||
<div key={i} style={{padding: '9px 0', borderTop: i === 0 ? `1px solid ${LINE}` : 'none', borderBottom: `1px solid ${LINE}`, display:'flex', alignItems:'center', gap: 10}}>
|
||||
<div style={{width: 14, height: 14, background: c.swatch, borderRadius: 2}} />
|
||||
<div style={{flex: 1, fontFamily: serif, fontSize: 14, color: INK}}>{c.name}</div>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH}}>{c.count}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Screen: Chat ─────────────────────────────────────────
|
||||
function ChatScreen({ animateT = 1 }) {
|
||||
const headerOp = interpolate(animateT, [0, 0.2], [0, 1]);
|
||||
const msg1Op = interpolate(animateT, [0.15, 0.35], [0, 1]);
|
||||
const msg2Op = interpolate(animateT, [0.4, 0.65], [0, 1]);
|
||||
const ctxCardOp = interpolate(animateT, [0.55, 0.75], [0, 1]);
|
||||
const msg3Op = interpolate(animateT, [0.7, 0.92], [0, 1]);
|
||||
const inputOp = interpolate(animateT, [0.5, 0.8], [0, 1]);
|
||||
|
||||
// Typewriter for AI reply (msg2)
|
||||
const aiText = '两处呼应——马可谈的「内心堡垒」和塞涅卡第 28 封信中的独处。';
|
||||
const charCount = Math.floor(interpolate(animateT, [0.4, 0.7], [0, aiText.length]));
|
||||
const typed = aiText.slice(0, charCount);
|
||||
|
||||
return (
|
||||
<div style={{padding: '24px 22px 0', height: '100%', background: PAPER, display:'flex', flexDirection:'column'}}>
|
||||
<div style={{opacity: headerOp, marginBottom: 16}}>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.25em', marginBottom: 4}}>
|
||||
流明 · 已关联
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 30, fontWeight: 500, color: INK, lineHeight: 1, letterSpacing:'-0.01em'}}>
|
||||
问你的记忆
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{flex: 1, display:'flex', flexDirection:'column', gap: 12}}>
|
||||
{/* User msg */}
|
||||
<div style={{opacity: msg1Op, alignSelf:'flex-end', maxWidth: '85%',
|
||||
background:'#f0e8d5', padding: '10px 14px', borderRadius: '18px 18px 4px 18px'}}>
|
||||
<div style={{fontFamily: serif, fontSize: 14, color: INK, lineHeight: 1.4}}>
|
||||
我最近关于<span style={{fontStyle:'italic'}}>「独处」</span>在想什么?
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI reply (typewriter) */}
|
||||
<div style={{opacity: msg2Op, alignSelf:'flex-start', maxWidth: '90%',
|
||||
paddingLeft: 14, borderLeft: `2px solid ${TERRA}`}}>
|
||||
<div style={{fontFamily: mono, fontSize: 9, color: TERRA, letterSpacing:'0.2em', marginBottom: 4}}>
|
||||
流明
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 14, color: INK, lineHeight: 1.45, minHeight: 60}}>
|
||||
{typed}
|
||||
{charCount < aiText.length && charCount > 0 && (
|
||||
<span style={{color: TERRA, marginLeft: 2}}>|</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context card */}
|
||||
<div style={{opacity: ctxCardOp, alignSelf:'flex-start', maxWidth: '88%',
|
||||
background:'#fff', border: `1px solid ${LINE}`, padding: '10px 12px',
|
||||
marginLeft: 14, display:'flex', gap: 10, alignItems:'center'}}>
|
||||
<div style={{width: 40, height: 40, flexShrink: 0, overflow:'hidden'}}>
|
||||
<ArtBlock mood="study" height={40} />
|
||||
</div>
|
||||
<div style={{flex: 1}}>
|
||||
<div style={{fontFamily: serif, fontSize: 12, fontWeight: 500, color: INK, lineHeight: 1.2}}>
|
||||
塞涅卡 · 第 28 封信
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 8, color: ASH, marginTop: 2, letterSpacing:'0.15em'}}>
|
||||
3 周前阅读 · 4 分钟
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 16, color: ASH, fontStyle:'italic'}}>↗</div>
|
||||
</div>
|
||||
|
||||
{/* User follow-up */}
|
||||
<div style={{opacity: msg3Op, alignSelf:'flex-end', maxWidth: '70%',
|
||||
background:'#f0e8d5', padding: '10px 14px', borderRadius: '18px 18px 4px 18px'}}>
|
||||
<div style={{fontFamily: serif, fontSize: 14, color: INK}}>
|
||||
给我看原文段落。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
<div style={{opacity: inputOp, padding: '10px 0 16px',
|
||||
borderTop: `1px solid ${LINE}`, marginTop: 12,
|
||||
display:'flex', alignItems:'center', gap: 10}}>
|
||||
<div style={{flex: 1, fontFamily: serif, fontStyle:'italic',
|
||||
fontSize: 13, color: ASH}}>
|
||||
从你的阅读里问我任何事…
|
||||
</div>
|
||||
<div style={{width: 28, height: 28, background: TERRA, borderRadius: '50%',
|
||||
display:'flex', alignItems:'center', justifyContent:'center',
|
||||
color:'#fff', fontFamily: sans, fontSize: 16}}>↑</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tab bar ───────────────────────────────────────────────
|
||||
function TabBar({ active = 'today', tapping = null }) {
|
||||
const tabs = [
|
||||
{ id: 'today', label: '今日' },
|
||||
{ id: 'memory', label: '记忆' },
|
||||
{ id: 'chat', label: '对话' },
|
||||
];
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0,
|
||||
height: 72, background: 'rgba(253,251,245,0.95)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
borderTop: `1px solid ${LINE}`,
|
||||
display: 'flex', alignItems: 'center',
|
||||
fontFamily: serif,
|
||||
}}>
|
||||
{tabs.map((t) => {
|
||||
const isActive = active === t.id;
|
||||
const isTapping = tapping === t.id;
|
||||
return (
|
||||
<div key={t.id} style={{
|
||||
flex: 1, textAlign:'center', position:'relative',
|
||||
padding: '12px 0 18px',
|
||||
}}>
|
||||
{/* Ripple */}
|
||||
{isTapping !== null && isTapping > 0 && isTapping < 1 && (
|
||||
<div style={{
|
||||
position:'absolute', top:'50%', left:'50%',
|
||||
transform: `translate(-50%, -50%) scale(${1 + isTapping * 2})`,
|
||||
width: 44, height: 44, borderRadius:'50%',
|
||||
background: TERRA, opacity: 0.25 * (1 - isTapping),
|
||||
pointerEvents:'none',
|
||||
}} />
|
||||
)}
|
||||
<div style={{
|
||||
fontSize: 15, fontWeight: isActive ? 600 : 400,
|
||||
fontStyle: isActive ? 'normal' : 'italic',
|
||||
color: isActive ? TERRA : ASH,
|
||||
letterSpacing: '0.02em',
|
||||
}}>
|
||||
{t.label}
|
||||
</div>
|
||||
{isActive && (
|
||||
<div style={{
|
||||
position:'absolute', bottom: 8, left:'50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 18, height: 2, background: TERRA,
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene composition ─────────────────────────────────────
|
||||
// Timeline:
|
||||
// 0.0 – 1.8 iPhone fade+bounce in
|
||||
// 1.8 – 7.5 Today screen (fills in)
|
||||
// 7.5 – 8.5 Tap on Memory tab (ripple)
|
||||
// 8.5 – 13.5 Memory screen
|
||||
// 13.5 – 14.5 Tap on Chat tab
|
||||
// 14.5 – 19.5 Chat screen
|
||||
// 19.5 – 21.5 Pan: phone shrinks + capability labels appear
|
||||
// 21.5 – 24.0 Hold final frame with labels
|
||||
function App() {
|
||||
return (
|
||||
<Stage duration={24} width={1920} height={1080} bgColor={CREAM}>
|
||||
<MainComposition />
|
||||
</Stage>
|
||||
);
|
||||
}
|
||||
|
||||
function MainComposition() {
|
||||
const time = useTime();
|
||||
|
||||
// Phone entrance
|
||||
const entranceT = Math.min(1, Math.max(0, time / 1.8));
|
||||
const phoneOp = interpolate(entranceT, [0, 0.5], [0, 1]);
|
||||
const phoneScale = interpolate(entranceT, [0, 1], [0.82, 0.88], Easing.spring);
|
||||
|
||||
// Pan-out in final scene (19.5 – 21.5)
|
||||
const panT = Math.min(1, Math.max(0, (time - 19.5) / 2));
|
||||
const finalScale = interpolate(panT, [0, 1], [0.88, 0.68], Easing.easeInOut);
|
||||
const finalX = interpolate(panT, [0, 1], [0, -200], Easing.easeInOut);
|
||||
|
||||
const currentScale = panT > 0 ? finalScale : phoneScale;
|
||||
const currentX = panT > 0 ? finalX : 0;
|
||||
|
||||
// Screen determination
|
||||
let activeScreen = 'today';
|
||||
let tapping = null; // { id: 'memory', t: 0..1 }
|
||||
let screenAnimateT = 1;
|
||||
let transitionProgress = 0;
|
||||
|
||||
if (time < 7.5) {
|
||||
activeScreen = 'today';
|
||||
screenAnimateT = Math.min(1, Math.max(0, (time - 1.8) / 2.5));
|
||||
} else if (time < 8.5) {
|
||||
activeScreen = 'today';
|
||||
tapping = { id: 'memory', t: (time - 7.5) / 1.0 };
|
||||
transitionProgress = (time - 8.0) / 0.5; // slide starts at 8.0
|
||||
} else if (time < 13.5) {
|
||||
activeScreen = 'memory';
|
||||
screenAnimateT = Math.min(1, Math.max(0, (time - 8.5) / 2.5));
|
||||
} else if (time < 14.5) {
|
||||
activeScreen = 'memory';
|
||||
tapping = { id: 'chat', t: (time - 13.5) / 1.0 };
|
||||
transitionProgress = (time - 14.0) / 0.5;
|
||||
} else if (time < 19.5) {
|
||||
activeScreen = 'chat';
|
||||
screenAnimateT = Math.min(1, Math.max(0, (time - 14.5) / 2.5));
|
||||
} else {
|
||||
activeScreen = 'chat';
|
||||
screenAnimateT = 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background: CREAM}}>
|
||||
{/* Phone */}
|
||||
<div style={{
|
||||
position: 'absolute', top: '50%', left: '50%',
|
||||
transform: `translate(calc(-50% + ${currentX}px), -50%) scale(${currentScale})`,
|
||||
opacity: phoneOp, transformOrigin: 'center center',
|
||||
}}>
|
||||
<IosFrame>
|
||||
<div style={{position:'relative', width:'100%', height:'100%'}}>
|
||||
{activeScreen === 'today' && <TodayScreen animateT={screenAnimateT} />}
|
||||
{activeScreen === 'memory' && <MemoryScreen animateT={screenAnimateT} />}
|
||||
{activeScreen === 'chat' && <ChatScreen animateT={screenAnimateT} />}
|
||||
<TabBar active={activeScreen} tapping={tapping ? tapping.t : null} />
|
||||
</div>
|
||||
</IosFrame>
|
||||
</div>
|
||||
|
||||
{/* Capability labels (appear during pan-out 19.5+) */}
|
||||
{panT > 0.05 && <CapabilityLabels t={panT} />}
|
||||
|
||||
{/* Masthead (tucked corner, always visible from ~2s) */}
|
||||
{time > 2 && time < 19.5 && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 60, left: 80,
|
||||
opacity: Math.min(1, (time - 2) / 0.6),
|
||||
maxWidth: 420,
|
||||
}}>
|
||||
<div style={{fontFamily: mono, fontSize: 12, color: TERRA, letterSpacing:'0.3em', marginBottom: 10}}>
|
||||
iOS APP 原型
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 70, fontWeight: 500, color: INK, lineHeight: 1.05, letterSpacing:'-0.015em'}}>
|
||||
真机。<br/>
|
||||
<span style={{fontStyle:'italic', color: TERRA}}>真</span>交互。
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 20, color: ASH, marginTop: 22, lineHeight: 1.55}}>
|
||||
iPhone 15 Pro 机身 · 灵动岛 · 状态驱动多屏<br/>
|
||||
AI 密度信息 · CSS 艺术 · Playwright 点击测试
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Screen label (bottom) */}
|
||||
{time > 2 && time < 19.5 && (
|
||||
<ScreenLabel active={activeScreen} time={time} />
|
||||
)}
|
||||
|
||||
{/* Watermark */}
|
||||
<div style={{position:'absolute', bottom: 24, right: 32,
|
||||
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
|
||||
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
|
||||
Created by Huashu-Design
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenLabel({ active, time }) {
|
||||
const label = { today: '屏幕 1 · 今日', memory: '屏幕 2 · 记忆', chat: '屏幕 3 · 对话' }[active];
|
||||
const idx = { today: 1, memory: 2, chat: 3 }[active];
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 80, left: 80,
|
||||
fontFamily: mono, fontSize: 11, color: ASH, letterSpacing:'0.25em',
|
||||
opacity: 0.9,
|
||||
}}>
|
||||
<span style={{color: TERRA, marginRight: 12}}>0{idx}</span>
|
||||
<span>{label.toUpperCase()}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityLabels({ t }) {
|
||||
const labels = [
|
||||
{ text: '真图 · Wikimedia / Met / Unsplash', y: 220, delay: 0.0 },
|
||||
{ text: 'Inline React · 双击就开', y: 380, delay: 0.15 },
|
||||
{ text: 'AppPhone · 状态驱动多屏切换', y: 540, delay: 0.30 },
|
||||
{ text: '信息密度型 · 每屏 ≥ 3 处差异化', y: 700, delay: 0.45 },
|
||||
{ text: 'Playwright · 交付前点击测试', y: 860, delay: 0.60 },
|
||||
];
|
||||
return (
|
||||
<>
|
||||
{labels.map((l, i) => {
|
||||
const localT = Math.max(0, Math.min(1, (t - l.delay) / 0.35));
|
||||
const op = localT;
|
||||
const x = interpolate(localT, [0, 1], [1400, 1280], Easing.easeOut);
|
||||
return (
|
||||
<div key={i} style={{
|
||||
position: 'absolute', left: x, top: l.y,
|
||||
opacity: op, display:'flex', alignItems:'center', gap: 14,
|
||||
}}>
|
||||
<div style={{width: 60, height: 1, background: TERRA}} />
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: TERRA, letterSpacing:'0.25em', marginBottom: 3}}>
|
||||
0{i + 1}
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 20, color: INK, lineHeight: 1.25, letterSpacing:'-0.005em'}}>
|
||||
{l.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,978 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Huashu-Design · Slides → PPTX</title>
|
||||
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<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,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; }
|
||||
body {
|
||||
background: #0c0c0c;
|
||||
font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
|
||||
color: #1a1a1a;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- animations.jsx inlined -->
|
||||
<script type="text/babel">
|
||||
(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;
|
||||
},
|
||||
};
|
||||
|
||||
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() { return useContext(TimeContext).time; }
|
||||
function useSprite() {
|
||||
const sprite = useContext(SpriteContext);
|
||||
return sprite || { t: 0, elapsed: 0, duration: 0 };
|
||||
}
|
||||
|
||||
function Stage({ duration = 10, width = 1920, height = 1080, 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 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) {
|
||||
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) return effectiveLoop ? 0 : duration - 0.001;
|
||||
return next;
|
||||
});
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
|
||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
|
||||
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
|
||||
}, [playing, duration, effectiveLoop]);
|
||||
|
||||
const progress = time / duration;
|
||||
const ctx = { time, duration, playing, setPlaying, setTime };
|
||||
|
||||
const canvasStyle = {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transformOrigin: 'center center',
|
||||
width,
|
||||
height,
|
||||
background: bgColor,
|
||||
overflow: 'hidden',
|
||||
transform: `translate(-50%, -50%) scale(${scale})`,
|
||||
};
|
||||
|
||||
return (
|
||||
<TimeContext.Provider value={ctx}>
|
||||
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
|
||||
<div style={canvasStyle}>{children}</div>
|
||||
</div>
|
||||
<div className="no-record" style={{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 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>
|
||||
<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>
|
||||
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
|
||||
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
|
||||
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Demo scene -->
|
||||
<script type="text/babel">
|
||||
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
|
||||
|
||||
// ── Design tokens ─────────────────────────────────────────
|
||||
const CREAM = '#FAF6EF';
|
||||
const INK = '#1a1a1a';
|
||||
const TERRA = '#C04A1A';
|
||||
const ASH = '#6b6b6b';
|
||||
const LINE = '#d9d2c5';
|
||||
const OLIVE = '#6a6b4e';
|
||||
const DEEP_BLUE = '#2a3552';
|
||||
|
||||
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
|
||||
const sans = "'Inter', -apple-system, sans-serif";
|
||||
const mono = "'JetBrains Mono', ui-monospace, monospace";
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Scene 1 (0 – 3s) · 开题
|
||||
// ══════════════════════════════════════════════════════════
|
||||
function Scene1_Title() {
|
||||
const { elapsed } = useSprite();
|
||||
const tagOp = interpolate(elapsed, [0, 0.6], [0, 1]);
|
||||
const mainOp = interpolate(elapsed, [0.4, 1.2], [0, 1]);
|
||||
const mainY = interpolate(elapsed, [0.4, 1.2], [40, 0], Easing.easeOut);
|
||||
const terraOp = interpolate(elapsed, [1.1, 1.8], [0, 1]);
|
||||
const lineW = interpolate(elapsed, [1.6, 2.2], [0, 640]);
|
||||
const subOp = interpolate(elapsed, [1.9, 2.5], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [2.7, 3.0], [1, 0]);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
|
||||
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
|
||||
<div style={{position:'absolute', top: 72, left: 88,
|
||||
fontFamily: mono, fontSize: 12, letterSpacing:'0.3em',
|
||||
color: ASH, opacity: tagOp}}>
|
||||
<span style={{color: TERRA}}>●</span> 幻灯片能力 · HTML + PPTX
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 130, fontWeight: 500,
|
||||
color: INK, lineHeight: 1.0, letterSpacing:'-0.015em',
|
||||
opacity: mainOp, transform: `translateY(${mainY}px)`,
|
||||
textAlign: 'center'}}>
|
||||
<span style={{fontStyle:'italic'}}>播放</span>用 HTML,<br/>
|
||||
<span style={{fontStyle:'italic', color: TERRA, opacity: terraOp}}>编辑</span>用 PPTX
|
||||
</div>
|
||||
<div style={{height: 1, background: INK, width: lineW, marginTop: 40}} />
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24,
|
||||
color: ASH, marginTop: 24, opacity: subOp, letterSpacing:'0.02em'}}>
|
||||
一个源文件,两种交付形态
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Scene 2 (3 – 9s) · HTML Deck 翻页
|
||||
// ══════════════════════════════════════════════════════════
|
||||
function Scene2_DeckFlip() {
|
||||
const { elapsed } = useSprite();
|
||||
const frameOp = interpolate(elapsed, [0, 0.6], [0, 1]);
|
||||
const frameScale = interpolate(elapsed, [0, 0.8], [0.94, 1], Easing.easeOut);
|
||||
|
||||
// Three pages, each ~1.5s. Stagger timings inside deck.
|
||||
// Page 1: 0.6 – 2.2 | Page 2: 2.2 – 3.8 | Page 3: 3.8 – 5.6
|
||||
const pageIndex = elapsed < 2.2 ? 0 : elapsed < 3.8 ? 1 : 2;
|
||||
const pageNum = pageIndex + 1;
|
||||
|
||||
const fadeOut = interpolate(elapsed, [5.6, 6.0], [1, 0]);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
|
||||
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
|
||||
<div style={{position:'absolute', top: 48, left: 88,
|
||||
fontFamily: mono, fontSize: 11, letterSpacing:'0.3em', color: ASH}}>
|
||||
<span style={{color: TERRA}}>●</span> SCENE 02 · HTML DECK
|
||||
</div>
|
||||
<div style={{position:'absolute', top: 48, right: 88,
|
||||
fontFamily: serif, fontStyle:'italic', fontSize: 20, color: ASH}}>
|
||||
浏览器里直接演讲
|
||||
</div>
|
||||
|
||||
<div style={{opacity: frameOp, transform: `scale(${frameScale})`,
|
||||
transformOrigin:'center center'}}>
|
||||
<BrowserFrame url="file:///Users/huashu/decks/annual-2026/deck.html">
|
||||
<DeckSlide pageIndex={pageIndex} localElapsed={elapsed} />
|
||||
{/* Footer inside deck */}
|
||||
<div style={{position:'absolute', bottom: 18, left: 28, right: 28,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'center',
|
||||
zIndex: 5}}>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
|
||||
letterSpacing:'0.15em'}}>
|
||||
{String(pageNum).padStart(2,'0')} / 12
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
|
||||
letterSpacing:'0.2em'}}>
|
||||
HUASHU · DESIGN
|
||||
</div>
|
||||
</div>
|
||||
{/* TERRA progress bar */}
|
||||
<div style={{position:'absolute', bottom: 0, left: 0, right: 0,
|
||||
height: 3, background: '#eee', zIndex: 5}}>
|
||||
<div style={{height:'100%', width: `${(pageNum/12)*100}%`,
|
||||
background: TERRA}} />
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
</div>
|
||||
|
||||
<div style={{marginTop: 28, fontFamily: mono, fontSize: 11, color: ASH,
|
||||
letterSpacing:'0.25em'}}>
|
||||
<span style={{color: pageIndex === 0 ? TERRA : LINE}}>●</span>
|
||||
<span style={{margin:'0 10px', color: pageIndex === 1 ? TERRA : LINE}}>●</span>
|
||||
<span style={{color: pageIndex === 2 ? TERRA : LINE}}>●</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Browser chrome container (chrome style, 1600×900 deck 16:9)
|
||||
function BrowserFrame({ url, children }) {
|
||||
const W = 1400, H = 788; // 16:9 ratio
|
||||
return (
|
||||
<div style={{
|
||||
display:'inline-block',
|
||||
background:'#e8e4dc',
|
||||
borderRadius: 12,
|
||||
boxShadow:'0 30px 70px rgba(0,0,0,0.18), 0 10px 24px rgba(0,0,0,0.12)',
|
||||
padding: 0,
|
||||
overflow:'hidden',
|
||||
border:`1px solid ${LINE}`,
|
||||
}}>
|
||||
{/* Title bar */}
|
||||
<div style={{height: 42, display:'flex', alignItems:'center',
|
||||
background:'#e8e4dc', padding:'0 16px', gap: 8,
|
||||
borderBottom:`1px solid ${LINE}`}}>
|
||||
<div style={{width:12, height:12, borderRadius:'50%', background:'#ff5f57'}} />
|
||||
<div style={{width:12, height:12, borderRadius:'50%', background:'#febc2e'}} />
|
||||
<div style={{width:12, height:12, borderRadius:'50%', background:'#28c840'}} />
|
||||
<div style={{flex: 1, height: 26, background:'#faf6ef', border:`1px solid ${LINE}`,
|
||||
borderRadius: 6, marginLeft: 16, padding:'0 14px',
|
||||
display:'flex', alignItems:'center', gap: 8,
|
||||
fontFamily: mono, fontSize: 11, color: ASH, letterSpacing:'0.02em',
|
||||
overflow:'hidden', whiteSpace:'nowrap'}}>
|
||||
<svg width="10" height="12" viewBox="0 0 10 12" style={{flexShrink: 0}}>
|
||||
<path d="M2 5 V3.5 a3 3 0 016 0 V5" stroke={OLIVE} strokeWidth="1.2" fill="none"/>
|
||||
<rect x="1" y="5" width="8" height="6" fill={OLIVE} opacity="0.85"/>
|
||||
</svg>
|
||||
<span style={{color: INK, opacity: 0.7}}>{url}</span>
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
|
||||
letterSpacing:'0.15em'}}>DECK MODE</div>
|
||||
</div>
|
||||
{/* Deck area */}
|
||||
<div style={{width: W, height: H, background:'#fff', position:'relative',
|
||||
overflow:'hidden'}}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Three deck pages
|
||||
function DeckSlide({ pageIndex, localElapsed }) {
|
||||
// Slide-in entrance each time pageIndex changes
|
||||
const pageStart = pageIndex === 0 ? 0.6 : pageIndex === 1 ? 2.2 : 3.8;
|
||||
const sinceStart = localElapsed - pageStart;
|
||||
const slideX = interpolate(sinceStart, [0, 0.5], [140, 0], Easing.easeOut);
|
||||
const fadeIn = interpolate(sinceStart, [0, 0.4], [0, 1]);
|
||||
|
||||
return (
|
||||
<div key={pageIndex} style={{position:'absolute', inset:0,
|
||||
opacity: fadeIn, transform: `translateX(${slideX}px)`}}>
|
||||
{pageIndex === 0 && <CoverPage />}
|
||||
{pageIndex === 1 && <DataPage />}
|
||||
{pageIndex === 2 && <QuotePage />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CoverPage() {
|
||||
return (
|
||||
<div style={{padding: '80px 80px 60px', height:'100%', background:'#fff',
|
||||
display:'flex', flexDirection:'column'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.3em',
|
||||
color: TERRA, marginBottom: 14}}>
|
||||
VOL.01 · ANNUAL REPORT
|
||||
</div>
|
||||
<div style={{flex: 1, display:'flex', flexDirection:'column',
|
||||
justifyContent:'center'}}>
|
||||
<div style={{fontFamily: serif, fontSize: 132, fontWeight: 500,
|
||||
color: INK, lineHeight: 1.02, letterSpacing:'-0.02em'}}>
|
||||
2026<br/>
|
||||
<span style={{fontStyle:'italic'}}>设计年度</span>报告
|
||||
</div>
|
||||
<div style={{height: 1, background: INK, width: 380, marginTop: 36,
|
||||
marginBottom: 28}} />
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22,
|
||||
color: ASH, letterSpacing:'0.02em'}}>
|
||||
The shape of digital craft, from typography to motion.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataPage() {
|
||||
const numbers = [
|
||||
{ big: '428', label: '项目交付', unit: 'projects' },
|
||||
{ big: '92%', label: '客户续约', unit: 'retention' },
|
||||
{ big: '3.1x', label: '交付提速', unit: 'vs 2025' },
|
||||
];
|
||||
const bars = [
|
||||
{ h: 0.45, label: 'Q1' },
|
||||
{ h: 0.62, label: 'Q2' },
|
||||
{ h: 0.78, label: 'Q3' },
|
||||
{ h: 1.00, label: 'Q4', hi: true },
|
||||
];
|
||||
return (
|
||||
<div style={{padding: '60px 80px 56px', height:'100%', background:'#fff',
|
||||
display:'flex', flexDirection:'column'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.3em',
|
||||
color: TERRA, marginBottom: 10}}>SECTION 02 · NUMBERS</div>
|
||||
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
|
||||
letterSpacing:'-0.015em', marginBottom: 36}}>
|
||||
今年的三个关键数字
|
||||
</div>
|
||||
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap: 48,
|
||||
marginBottom: 40}}>
|
||||
{numbers.map((n, i) => (
|
||||
<div key={i}>
|
||||
<div style={{fontFamily: serif, fontSize: 112, fontWeight: 400,
|
||||
color: i === 2 ? TERRA : INK, lineHeight: 1, letterSpacing:'-0.02em'}}>
|
||||
{n.big}
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22,
|
||||
color: INK, marginTop: 10}}>
|
||||
{n.label}
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
|
||||
letterSpacing:'0.2em', marginTop: 4}}>
|
||||
{n.unit}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{flex: 1, display:'flex', alignItems:'flex-end', gap: 20,
|
||||
paddingLeft: 4, borderTop:`1px solid ${LINE}`, paddingTop: 24}}>
|
||||
{bars.map((b, i) => (
|
||||
<div key={i} style={{flex: 1, display:'flex', flexDirection:'column',
|
||||
alignItems:'center'}}>
|
||||
<div style={{width:'78%', height: `${b.h * 180}px`,
|
||||
background: b.hi ? TERRA : INK, marginBottom: 10}} />
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
|
||||
letterSpacing:'0.2em'}}>{b.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotePage() {
|
||||
return (
|
||||
<div style={{padding: '80px', height:'100%', background:'#faf6ef',
|
||||
display:'flex', flexDirection:'column', justifyContent:'center',
|
||||
alignItems:'center', position:'relative'}}>
|
||||
<div style={{position:'absolute', top: 64, left: 80,
|
||||
fontFamily: mono, fontSize: 11, letterSpacing:'0.3em', color: TERRA}}>
|
||||
EPIGRAPH · III
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 104,
|
||||
fontWeight: 400, color: INK, lineHeight: 1.15, letterSpacing:'-0.015em',
|
||||
textAlign:'center', maxWidth: 1100}}>
|
||||
"Less,<br/>but <span style={{color: TERRA}}>better</span>."
|
||||
</div>
|
||||
<div style={{height: 1, background: INK, width: 140, marginTop: 44,
|
||||
marginBottom: 20}} />
|
||||
<div style={{fontFamily: serif, fontSize: 22, color: ASH,
|
||||
letterSpacing:'0.08em'}}>
|
||||
— Dieter Rams
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Scene 3 (9 – 15s) · 导出流水线
|
||||
// ══════════════════════════════════════════════════════════
|
||||
function Scene3_Pipeline() {
|
||||
const { elapsed } = useSprite();
|
||||
const titleOp = interpolate(elapsed, [0, 0.6], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [5.6, 6.0], [1, 0]);
|
||||
|
||||
const nodes = [
|
||||
{ title: 'HTML Deck', sub: 'source of truth', icon: 'code', delay: 0.4 },
|
||||
{ title: 'html2pptx.js', sub: 'read computedStyle', icon: 'scan', delay: 1.1, hi: true },
|
||||
{ title: 'pptxgenjs', sub: 'assemble objects', icon: 'compose', delay: 1.8 },
|
||||
{ title: 'deck.pptx', sub: 'editable output', icon: 'doc', delay: 2.5 },
|
||||
];
|
||||
|
||||
const cmdOp = interpolate(elapsed, [3.8, 4.4], [0, 1]);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
|
||||
padding: '72px 96px 56px', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline', opacity: titleOp, marginBottom: 12}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.3em',
|
||||
color: TERRA, marginBottom: 10}}>
|
||||
<span>●</span> SCENE 03 · EXPORT PIPELINE
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 64, fontWeight: 500,
|
||||
color: INK, letterSpacing:'0.04em'}}>
|
||||
导出流水线
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 20,
|
||||
color: ASH, textAlign:'right', maxWidth: 380, lineHeight: 1.5}}>
|
||||
把 DOM 翻译成<br/>
|
||||
PowerPoint 对象图
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{height: 1, background: INK, width: '100%', opacity: titleOp,
|
||||
marginTop: 28, marginBottom: 48}} />
|
||||
|
||||
{/* Pipeline nodes */}
|
||||
<div style={{display:'flex', alignItems:'stretch', gap: 0, flex: 1,
|
||||
position:'relative'}}>
|
||||
{nodes.map((n, i) => {
|
||||
const op = interpolate(elapsed, [n.delay, n.delay + 0.5], [0, 1]);
|
||||
const ty = interpolate(elapsed, [n.delay, n.delay + 0.5], [28, 0], Easing.easeOut);
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
<div style={{flex: 1, opacity: op, transform: `translateY(${ty}px)`,
|
||||
background: n.hi ? TERRA : '#fff',
|
||||
border: `1px solid ${n.hi ? TERRA : LINE}`,
|
||||
padding:'28px 24px', display:'flex', flexDirection:'column',
|
||||
color: n.hi ? '#fff' : INK}}>
|
||||
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.25em',
|
||||
opacity: n.hi ? 0.85 : 0.5, marginBottom: 18}}>
|
||||
STEP {String(i+1).padStart(2, '0')}
|
||||
</div>
|
||||
<NodeIcon kind={n.icon} hi={n.hi} />
|
||||
<div style={{fontFamily: mono, fontSize: 20, fontWeight: 500,
|
||||
marginTop: 20, letterSpacing:'0.01em'}}>
|
||||
{n.title}
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
|
||||
opacity: n.hi ? 0.85 : 0.6, marginTop: 6}}>
|
||||
{n.sub}
|
||||
</div>
|
||||
</div>
|
||||
{i < nodes.length - 1 && (
|
||||
<ArrowBetween elapsed={elapsed} startTime={n.delay + 0.4} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Data flow caption */}
|
||||
<div style={{marginTop: 36, display:'flex', alignItems:'center', gap: 24,
|
||||
opacity: interpolate(elapsed, [3.2, 3.8], [0, 1])}}>
|
||||
<div style={{fontFamily: mono, fontSize: 13, color: ASH,
|
||||
letterSpacing:'0.05em', flex: 1}}>
|
||||
<span style={{color: OLIVE}}>DOM node</span> <span style={{color: TERRA}}>→</span>{' '}
|
||||
<span style={{color: INK}}>{'{ type, text, font, color, x, y }'}</span>
|
||||
<span style={{color: ASH, margin:'0 14px'}}>·</span>
|
||||
<span style={{color: TERRA}}>→</span> <span style={{color: INK}}>slide.addText(...) / slide.addShape(...)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Command subtitle */}
|
||||
<div style={{marginTop: 22, opacity: cmdOp,
|
||||
background:'#1a1a1a', padding:'16px 24px',
|
||||
borderLeft: `3px solid ${TERRA}`,
|
||||
display:'flex', alignItems:'center', gap: 16}}>
|
||||
<div style={{fontFamily: mono, fontSize: 12, color: TERRA,
|
||||
letterSpacing:'0.2em'}}>$</div>
|
||||
<div style={{fontFamily: mono, fontSize: 15, color: '#f5f0e6',
|
||||
letterSpacing:'0.02em'}}>
|
||||
node export_deck_pptx.mjs deck.html <span style={{color: '#8ca577'}}>--mode editable</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeIcon({ kind, hi }) {
|
||||
const fg = hi ? '#fff' : INK;
|
||||
const bg = hi ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.04)';
|
||||
if (kind === 'code') {
|
||||
return (
|
||||
<div style={{width: 72, height: 72, background: bg,
|
||||
display:'flex', alignItems:'center', justifyContent:'center'}}>
|
||||
<svg width="34" height="34" viewBox="0 0 34 34" fill="none">
|
||||
<path d="M12 10 L5 17 L12 24" stroke={fg} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M22 10 L29 17 L22 24" stroke={fg} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M19 7 L15 27" stroke={fg} strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === 'scan') {
|
||||
return (
|
||||
<div style={{width: 72, height: 72, background: bg,
|
||||
display:'flex', alignItems:'center', justifyContent:'center'}}>
|
||||
<svg width="38" height="38" viewBox="0 0 38 38" fill="none">
|
||||
<rect x="6" y="6" width="26" height="26" stroke={fg} strokeWidth="2"/>
|
||||
<line x1="6" y1="15" x2="32" y2="15" stroke={fg} strokeWidth="1.5"/>
|
||||
<line x1="6" y1="23" x2="32" y2="23" stroke={fg} strokeWidth="1.5"/>
|
||||
<line x1="15" y1="6" x2="15" y2="32" stroke={fg} strokeWidth="1.5"/>
|
||||
<line x1="23" y1="6" x2="23" y2="32" stroke={fg} strokeWidth="1.5"/>
|
||||
<circle cx="19" cy="19" r="3" fill={fg}/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === 'compose') {
|
||||
return (
|
||||
<div style={{width: 72, height: 72, background: bg,
|
||||
display:'flex', alignItems:'center', justifyContent:'center'}}>
|
||||
<svg width="38" height="38" viewBox="0 0 38 38" fill="none">
|
||||
<rect x="4" y="4" width="16" height="12" stroke={fg} strokeWidth="2"/>
|
||||
<rect x="22" y="4" width="12" height="12" stroke={fg} strokeWidth="2"/>
|
||||
<rect x="4" y="20" width="12" height="14" stroke={fg} strokeWidth="2"/>
|
||||
<rect x="18" y="20" width="16" height="14" stroke={fg} strokeWidth="2"/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// doc
|
||||
return (
|
||||
<div style={{width: 72, height: 72, background: bg,
|
||||
display:'flex', alignItems:'center', justifyContent:'center'}}>
|
||||
<svg width="34" height="38" viewBox="0 0 34 38" fill="none">
|
||||
<path d="M6 4 H22 L28 10 V34 H6 Z" stroke={fg} strokeWidth="2" strokeLinejoin="round"/>
|
||||
<path d="M22 4 V10 H28" stroke={fg} strokeWidth="2" strokeLinejoin="round"/>
|
||||
<line x1="11" y1="17" x2="23" y2="17" stroke={fg} strokeWidth="1.5"/>
|
||||
<line x1="11" y1="22" x2="23" y2="22" stroke={fg} strokeWidth="1.5"/>
|
||||
<line x1="11" y1="27" x2="19" y2="27" stroke={fg} strokeWidth="1.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrowBetween({ elapsed, startTime }) {
|
||||
const reveal = interpolate(elapsed, [startTime, startTime + 0.3], [0, 1]);
|
||||
return (
|
||||
<div style={{width: 48, display:'flex', alignItems:'center',
|
||||
justifyContent:'center', position:'relative'}}>
|
||||
<svg width="48" height="24" viewBox="0 0 48 24" style={{opacity: reveal}}>
|
||||
<line x1="0" y1="12" x2={34 * reveal + 8} y2="12" stroke={TERRA} strokeWidth="1.5"/>
|
||||
{reveal > 0.6 && (
|
||||
<path d="M38 6 L44 12 L38 18" stroke={TERRA} strokeWidth="1.5" fill="none"
|
||||
strokeLinecap="round" strokeLinejoin="round"/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Scene 4 (15 – 20s) · 产物:可编辑文本框
|
||||
// ══════════════════════════════════════════════════════════
|
||||
function Scene4_PPTEdit() {
|
||||
const { elapsed } = useSprite();
|
||||
const fadeIn = interpolate(elapsed, [0, 0.5], [0, 1]);
|
||||
const pptScale = interpolate(elapsed, [0, 0.8], [0.94, 1], Easing.easeOut);
|
||||
|
||||
// Selection bounding box appears at 0.8s, handles animate in staggered
|
||||
const selectOp = interpolate(elapsed, [0.9, 1.3], [0, 1]);
|
||||
|
||||
// Format panel slides in from right at 1.5s
|
||||
const panelX = interpolate(elapsed, [1.6, 2.4], [80, 0], Easing.easeOut);
|
||||
const panelOp = interpolate(elapsed, [1.6, 2.4], [0, 1]);
|
||||
|
||||
// Caption fades in 2.4s
|
||||
const captionOp = interpolate(elapsed, [2.4, 3.0], [0, 1]);
|
||||
|
||||
// Checkboxes tick in sequentially
|
||||
const chk1 = elapsed > 3.2 ? 1 : 0;
|
||||
const chk2 = elapsed > 3.7 ? 1 : 0;
|
||||
const chk3 = elapsed > 4.2 ? 1 : 0;
|
||||
|
||||
const fadeOut = interpolate(elapsed, [4.6, 5.0], [1, 0]);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background: CREAM,
|
||||
opacity: fadeIn * fadeOut,
|
||||
display:'flex', flexDirection:'column', alignItems:'center',
|
||||
padding:'60px 60px 40px'}}>
|
||||
<div style={{width:'100%', display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline', marginBottom: 20}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.3em',
|
||||
color: TERRA, marginBottom: 8}}>
|
||||
<span>●</span> SCENE 04 · THE ARTIFACT
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 52, fontWeight: 500, color: INK,
|
||||
letterSpacing:'-0.01em'}}>
|
||||
产物:可编辑文本框
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
|
||||
textAlign:'right', maxWidth: 340, lineHeight: 1.5}}>
|
||||
在 PowerPoint 里<br/>
|
||||
像素级复现,字还是字
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{position:'relative', transform: `scale(${pptScale})`,
|
||||
transformOrigin:'center center'}}>
|
||||
<PPTMockup selectOp={selectOp} />
|
||||
|
||||
{/* Format panel */}
|
||||
<div style={{position:'absolute', top: 94, right: -296,
|
||||
width: 272, background:'#f5f2ed', border:`1px solid ${LINE}`,
|
||||
boxShadow:'0 12px 30px rgba(0,0,0,0.08)',
|
||||
transform: `translateX(${panelX}px)`, opacity: panelOp,
|
||||
padding: 0}}>
|
||||
<FormatPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{marginTop: 28, display:'flex', alignItems:'center', gap: 48,
|
||||
opacity: captionOp}}>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24,
|
||||
color: TERRA, letterSpacing:'0.01em'}}>
|
||||
原生 PowerPoint 文本框 · 不是图片
|
||||
</div>
|
||||
<div style={{display:'flex', gap: 28, fontFamily: mono, fontSize: 13}}>
|
||||
<CheckRow label="文字可编辑" on={chk1} />
|
||||
<CheckRow label="字体保留" on={chk2} />
|
||||
<CheckRow label="位置/颜色精确" on={chk3} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckRow({ label, on }) {
|
||||
return (
|
||||
<div style={{display:'flex', alignItems:'center', gap: 8}}>
|
||||
<div style={{width: 18, height: 18, border:`1.5px solid ${on ? TERRA : LINE}`,
|
||||
background: on ? TERRA : 'transparent',
|
||||
display:'flex', alignItems:'center', justifyContent:'center',
|
||||
transition:'none'}}>
|
||||
{on ? (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||
<path d="M2 6 L5 9 L10 3" stroke="#fff" strokeWidth="2" fill="none"
|
||||
strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
) : null}
|
||||
</div>
|
||||
<span style={{color: on ? INK : ASH}}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PPTMockup({ selectOp }) {
|
||||
const W = 1100, H = 620;
|
||||
return (
|
||||
<div style={{width: W, height: H, background:'#f4f1ec',
|
||||
border:`1px solid ${LINE}`, boxShadow:'0 22px 50px rgba(0,0,0,0.14)',
|
||||
display:'flex', flexDirection:'column'}}>
|
||||
{/* PPT ribbon (title bar + tabs) */}
|
||||
<div style={{height: 32, background:'#dcd7cd', display:'flex',
|
||||
alignItems:'center', padding:'0 14px', gap: 8,
|
||||
borderBottom:`1px solid ${LINE}`}}>
|
||||
<div style={{width:10, height:10, borderRadius:'50%', background:'#ff5f57'}} />
|
||||
<div style={{width:10, height:10, borderRadius:'50%', background:'#febc2e'}} />
|
||||
<div style={{width:10, height:10, borderRadius:'50%', background:'#28c840'}} />
|
||||
<div style={{flex: 1, textAlign:'center', fontFamily: sans, fontSize: 11,
|
||||
color: ASH, letterSpacing:'0.02em'}}>
|
||||
deck.pptx — PowerPoint
|
||||
</div>
|
||||
</div>
|
||||
<div style={{height: 34, background:'#ebe7de', display:'flex',
|
||||
alignItems:'center', padding:'0 18px', gap: 22,
|
||||
fontFamily: sans, fontSize: 11, color: INK,
|
||||
borderBottom:`1px solid ${LINE}`}}>
|
||||
<span style={{color: TERRA, fontWeight: 600,
|
||||
borderBottom: `2px solid ${TERRA}`, paddingBottom: 6,
|
||||
marginBottom: -7}}>Home</span>
|
||||
<span style={{opacity: 0.55}}>Insert</span>
|
||||
<span style={{opacity: 0.55}}>Design</span>
|
||||
<span style={{opacity: 0.55}}>Transitions</span>
|
||||
<span style={{opacity: 0.55}}>Animations</span>
|
||||
<span style={{opacity: 0.55}}>Slide Show</span>
|
||||
<span style={{opacity: 0.55}}>Review</span>
|
||||
<span style={{opacity: 0.55}}>View</span>
|
||||
</div>
|
||||
|
||||
{/* Body: slide panel (left) + slide canvas (main) */}
|
||||
<div style={{flex: 1, display:'flex'}}>
|
||||
{/* Thumbnails */}
|
||||
<div style={{width: 160, background:'#eae5db',
|
||||
borderRight:`1px solid ${LINE}`, padding:'12px 12px',
|
||||
display:'flex', flexDirection:'column', gap: 8}}>
|
||||
{[0,1,2,3].map(i => (
|
||||
<div key={i} style={{
|
||||
background:'#fff',
|
||||
border: i === 2 ? `2px solid ${TERRA}` : `1px solid ${LINE}`,
|
||||
aspectRatio:'16/9', position:'relative',
|
||||
padding: 8, display:'flex', alignItems:'center',
|
||||
justifyContent:'center'}}>
|
||||
<div style={{position:'absolute', top: 4, left: 4,
|
||||
fontFamily: mono, fontSize: 8, color: ASH}}>{i+1}</div>
|
||||
{i === 2 && (
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 12,
|
||||
color: INK}}>"Less..."</div>
|
||||
)}
|
||||
{i !== 2 && (
|
||||
<div style={{width:'70%', height: 3, background: LINE}} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Slide canvas */}
|
||||
<div style={{flex: 1, background:'#e8e4dc', display:'flex',
|
||||
alignItems:'center', justifyContent:'center', padding: 32,
|
||||
position:'relative'}}>
|
||||
<div style={{width: 720, height: 405, background:'#faf6ef',
|
||||
boxShadow:'0 8px 24px rgba(0,0,0,0.1)',
|
||||
border:`1px solid ${LINE}`, position:'relative'}}>
|
||||
{/* The editable text box */}
|
||||
<div style={{position:'absolute', top:'50%', left:'50%',
|
||||
transform:'translate(-50%, -50%)', textAlign:'center',
|
||||
padding:'18px 40px'}}>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic',
|
||||
fontSize: 72, color: INK, lineHeight: 1.1,
|
||||
letterSpacing:'-0.01em'}}>
|
||||
"Less, but <span style={{color: TERRA}}>better</span>."
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 14, color: ASH,
|
||||
marginTop: 14, letterSpacing:'0.1em'}}>
|
||||
— Dieter Rams
|
||||
</div>
|
||||
</div>
|
||||
{/* Selection bounding box + 8 handles */}
|
||||
{selectOp > 0 && <SelectionBox opacity={selectOp} />}
|
||||
|
||||
{/* slide number */}
|
||||
<div style={{position:'absolute', bottom: 10, right: 14,
|
||||
fontFamily: sans, fontSize: 9, color: ASH}}>3</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionBox({ opacity }) {
|
||||
// Box centered around the textbox (~ 520×160)
|
||||
const BW = 560, BH = 170;
|
||||
const color = '#4a9eff';
|
||||
const handles = [
|
||||
{ x: 0, y: 0 }, { x: 0.5, y: 0 }, { x: 1, y: 0 },
|
||||
{ x: 0, y: 0.5 }, { x: 1, y: 0.5 },
|
||||
{ x: 0, y: 1 }, { x: 0.5, y: 1 }, { x: 1, y: 1 },
|
||||
];
|
||||
return (
|
||||
<div style={{position:'absolute', top:'50%', left:'50%',
|
||||
width: BW, height: BH, transform:'translate(-50%, -50%)',
|
||||
border: `1.5px solid ${color}`, opacity,
|
||||
boxShadow:`0 0 0 1px rgba(255,255,255,0.6)`, pointerEvents:'none'}}>
|
||||
{handles.map((h, i) => (
|
||||
<div key={i} style={{position:'absolute',
|
||||
left: `${h.x * 100}%`, top: `${h.y * 100}%`,
|
||||
transform:'translate(-50%, -50%)',
|
||||
width: 10, height: 10, background:'#fff',
|
||||
border: `1.5px solid ${color}`, borderRadius: 2}} />
|
||||
))}
|
||||
{/* Rotate handle */}
|
||||
<div style={{position:'absolute', left:'50%', top: -26,
|
||||
transform:'translateX(-50%)',
|
||||
width: 10, height: 10, background:'#fff',
|
||||
border: `1.5px solid ${color}`, borderRadius: '50%'}} />
|
||||
<div style={{position:'absolute', left:'50%', top: -17,
|
||||
transform:'translateX(-50%)', width: 1, height: 9, background: color}} />
|
||||
{/* Label: Text Box */}
|
||||
<div style={{position:'absolute', top: -26, left: 0,
|
||||
fontFamily: mono, fontSize: 10, color: color,
|
||||
background:'rgba(255,255,255,0.9)', padding:'2px 6px',
|
||||
letterSpacing:'0.1em'}}>
|
||||
TEXT BOX · shape #1
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormatPanel() {
|
||||
return (
|
||||
<div>
|
||||
<div style={{padding:'12px 16px', borderBottom:`1px solid ${LINE}`,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'center',
|
||||
background:'#ebe7de'}}>
|
||||
<div style={{fontFamily: sans, fontSize: 12, color: INK,
|
||||
fontWeight: 600}}>Format Text</div>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH}}>✕</div>
|
||||
</div>
|
||||
<div style={{padding:'16px'}}>
|
||||
<div style={{fontFamily: sans, fontSize: 10, color: ASH,
|
||||
letterSpacing:'0.15em', marginBottom: 8}}>FONT</div>
|
||||
<div style={{background:'#fff', border:`1px solid ${LINE}`,
|
||||
padding:'8px 10px', marginBottom: 14,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'center',
|
||||
fontFamily: sans, fontSize: 12, color: INK}}>
|
||||
<span style={{fontFamily: serif, fontStyle:'italic'}}>Newsreader</span>
|
||||
<span style={{color: ASH, fontSize: 10}}>▾</span>
|
||||
</div>
|
||||
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap: 10,
|
||||
marginBottom: 14}}>
|
||||
<div>
|
||||
<div style={{fontFamily: sans, fontSize: 10, color: ASH,
|
||||
letterSpacing:'0.15em', marginBottom: 6}}>SIZE</div>
|
||||
<div style={{background:'#fff', border:`1px solid ${LINE}`,
|
||||
padding:'6px 10px', fontFamily: sans, fontSize: 12}}>72 pt</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{fontFamily: sans, fontSize: 10, color: ASH,
|
||||
letterSpacing:'0.15em', marginBottom: 6}}>WEIGHT</div>
|
||||
<div style={{background:'#fff', border:`1px solid ${LINE}`,
|
||||
padding:'6px 10px', fontFamily: sans, fontSize: 12}}>400 · italic</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: sans, fontSize: 10, color: ASH,
|
||||
letterSpacing:'0.15em', marginBottom: 6}}>COLOR</div>
|
||||
<div style={{display:'flex', gap: 8, marginBottom: 14}}>
|
||||
<div style={{width: 28, height: 28, background: INK, border:`2px solid ${INK}`}} />
|
||||
<div style={{width: 28, height: 28, background: TERRA,
|
||||
outline:`2px solid ${TERRA}`, outlineOffset: 1}} />
|
||||
<div style={{width: 28, height: 28, background: OLIVE}} />
|
||||
<div style={{width: 28, height: 28, background: DEEP_BLUE}} />
|
||||
<div style={{width: 28, height: 28, background:'#fff', border:`1px solid ${LINE}`}} />
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
|
||||
letterSpacing:'0.1em', lineHeight: 1.6,
|
||||
paddingTop: 10, borderTop:`1px solid ${LINE}`}}>
|
||||
x: 2.4in · y: 2.1in<br/>
|
||||
w: 5.8in · h: 1.7in
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// Scene 5 (20 – 24s) · 收尾
|
||||
// ══════════════════════════════════════════════════════════
|
||||
function Scene5_Final() {
|
||||
const { elapsed } = useSprite();
|
||||
const tagOp = interpolate(elapsed, [0, 0.5], [0, 1]);
|
||||
const mainY = interpolate(elapsed, [0.2, 1.2], [50, 0], Easing.easeOut);
|
||||
const mainOp = interpolate(elapsed, [0.2, 1.0], [0, 1]);
|
||||
const lineW = interpolate(elapsed, [1.1, 1.8], [0, 540]);
|
||||
const subOp = interpolate(elapsed, [1.5, 2.2], [0, 1]);
|
||||
const monoOp = interpolate(elapsed, [2.2, 2.8], [0, 1]);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background: CREAM,
|
||||
display:'flex', alignItems:'center', justifyContent:'center',
|
||||
flexDirection:'column'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
|
||||
color: TERRA, marginBottom: 32, opacity: tagOp}}>
|
||||
ONE SOURCE · TWO STATES
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 220, fontWeight: 500,
|
||||
color: INK, lineHeight: 0.98, letterSpacing:'-0.03em',
|
||||
opacity: mainOp, transform: `translateY(${mainY}px)`}}>
|
||||
一<span style={{color: ASH, fontStyle:'italic'}}>源</span>
|
||||
<span style={{color: TERRA, margin:'0 28px'}}>·</span>
|
||||
双<span style={{color: ASH, fontStyle:'italic'}}>态</span>
|
||||
</div>
|
||||
<div style={{height: 1, background: INK, width: lineW, marginTop: 46}} />
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 28,
|
||||
color: ASH, marginTop: 28, opacity: subOp, letterSpacing:'0.02em'}}>
|
||||
浏览器里演讲 · PowerPoint 里二次编辑
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 18, color: INK, marginTop: 34,
|
||||
opacity: monoOp, letterSpacing:'0.1em',
|
||||
padding:'12px 28px', background:'#fff', border:`1px solid ${LINE}`}}>
|
||||
<span style={{color: OLIVE}}>deck.html</span>
|
||||
<span style={{color: TERRA, margin:'0 14px'}}>⇌</span>
|
||||
<span style={{color: DEEP_BLUE}}>deck.pptx</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Watermark ─────────────────────────────────────────────
|
||||
function Watermark() {
|
||||
return (
|
||||
<div style={{position:'absolute', bottom: 24, right: 32,
|
||||
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
|
||||
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
|
||||
Created by Huashu-Design
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main composition ──────────────────────────────────────
|
||||
function App() {
|
||||
return (
|
||||
<Stage duration={24} width={1920} height={1080} bgColor={CREAM}>
|
||||
<Sprite start={0} end={3}><Scene1_Title /></Sprite>
|
||||
<Sprite start={3} end={9}><Scene2_DeckFlip /></Sprite>
|
||||
<Sprite start={9} end={15}><Scene3_Pipeline /></Sprite>
|
||||
<Sprite start={15} end={20}><Scene4_PPTEdit /></Sprite>
|
||||
<Sprite start={20} end={24}><Scene5_Final /></Sprite>
|
||||
<Watermark />
|
||||
</Stage>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,556 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Huashu-Design · Motion Design</title>
|
||||
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<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">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; }
|
||||
body { background: #0c0c0c; font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif; color: #1a1a1a; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- animations.jsx inlined -->
|
||||
<script type="text/babel">
|
||||
(function() {
|
||||
const { createContext, useContext, useState, useEffect, useRef } = 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;
|
||||
},
|
||||
};
|
||||
function interpolate(t, input, output, easing) {
|
||||
const [a, b] = input, [x, y] = output;
|
||||
if (t <= a) return x; if (t >= b) return y;
|
||||
let p = (t - a) / (b - a); if (easing) p = easing(p);
|
||||
return x + (y - x) * p;
|
||||
}
|
||||
function useTime() { return useContext(TimeContext).time; }
|
||||
function useSprite() { return useContext(SpriteContext) || { t: 0, elapsed: 0, duration: 0 }; }
|
||||
function Stage({ duration = 10, width = 1920, height = 1080, 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 effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const s = Math.min(window.innerWidth / width, (window.innerHeight - 56) / height);
|
||||
setScale(s);
|
||||
};
|
||||
update(); window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, [width, height]);
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
let cancelled = false, last = null;
|
||||
function tick(now) {
|
||||
if (cancelled) return;
|
||||
if (last === null) { 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) return effectiveLoop ? 0 : duration - 0.001;
|
||||
return next;
|
||||
});
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
|
||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
|
||||
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
|
||||
}, [playing, duration, effectiveLoop]);
|
||||
const progress = time / duration;
|
||||
const ctx = { time, duration, playing, setPlaying, setTime };
|
||||
return (
|
||||
<TimeContext.Provider value={ctx}>
|
||||
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
|
||||
<div style={{position:'absolute', top:'50%', left:'50%', transformOrigin:'center center', width, height, background: bgColor, overflow:'hidden', transform:`translate(-50%, -50%) scale(${scale})`}}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<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}}>
|
||||
<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>
|
||||
<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>
|
||||
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
|
||||
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
|
||||
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
|
||||
</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));
|
||||
return (
|
||||
<SpriteContext.Provider value={{ t, elapsed, duration, start, end: actualEnd }}>
|
||||
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
|
||||
</SpriteContext.Provider>
|
||||
);
|
||||
}
|
||||
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Demo scene -->
|
||||
<script type="text/babel">
|
||||
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
|
||||
|
||||
const CREAM = '#FAF6EF';
|
||||
const INK = '#1a1a1a';
|
||||
const TERRA = '#C04A1A';
|
||||
const OLIVE = '#6a6b4e';
|
||||
const DEEP_BLUE = '#2a3552';
|
||||
const ASH = '#6b6b6b';
|
||||
const LINE = '#d9d2c5';
|
||||
|
||||
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
|
||||
const sans = "'Inter', -apple-system, sans-serif";
|
||||
const mono = "'JetBrains Mono', ui-monospace, monospace";
|
||||
|
||||
// ── Scene 1: Title (0 – 3s) ────────────────────────────
|
||||
function Scene1_Title() {
|
||||
const { elapsed } = useSprite();
|
||||
const titleY = interpolate(elapsed, [0, 1.2], [60, 0], Easing.easeOut);
|
||||
const titleOp = interpolate(elapsed, [0, 0.8], [0, 1]);
|
||||
const subOp = interpolate(elapsed, [0.6, 1.4], [0, 1]);
|
||||
const lineW = interpolate(elapsed, [0.9, 1.6], [0, 520]);
|
||||
const apiOp = interpolate(elapsed, [1.4, 2.2], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [2.6, 3.0], [1, 0]);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
|
||||
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
|
||||
color: TERRA, marginBottom: 24, opacity: titleOp}}>
|
||||
动画引擎 · Stage + Sprite
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 148, fontWeight: 500, color: INK,
|
||||
lineHeight: 1, letterSpacing:'-0.02em', opacity: titleOp,
|
||||
transform: `translateY(${titleY}px)`}}>
|
||||
<span style={{fontStyle:'italic', color: TERRA}}>Motion</span> Design
|
||||
</div>
|
||||
<div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24, color: ASH,
|
||||
marginTop: 28, opacity: subOp}}>
|
||||
时间驱动 · 可编排 · 60fps 导出
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 14, color: ASH,
|
||||
marginTop: 40, opacity: apiOp, letterSpacing:'0.1em'}}>
|
||||
<Stage> · <Sprite> · useTime() · useSprite() · interpolate() · Easing
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 2: Easing functions comparison (3 – 8s) ────────
|
||||
function Scene2_Easing() {
|
||||
const { elapsed } = useSprite();
|
||||
const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [4.6, 5.0], [1, 0]);
|
||||
|
||||
// Lane sweep cycle every 2s
|
||||
const cycle = (elapsed % 2.2) / 2.0;
|
||||
const sweepT = Math.min(1, Math.max(0, cycle));
|
||||
|
||||
const curves = [
|
||||
{ name: 'linear', label: 'linear', fn: Easing.linear, color: ASH },
|
||||
{ name: 'easeOut', label: 'easeOut', fn: Easing.easeOut, color: OLIVE },
|
||||
{ name: 'spring', label: 'spring', fn: Easing.spring, color: TERRA },
|
||||
{ name: 'easeInOut', label: 'easeInOut', fn: Easing.easeInOut, color: DEEP_BLUE },
|
||||
];
|
||||
|
||||
const trackLeft = 320;
|
||||
const trackRight = 1480;
|
||||
const trackLen = trackRight - trackLeft;
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
|
||||
padding: '80px 100px', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{opacity: titleOp, marginBottom: 50,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
letterSpacing:'0.3em', marginBottom: 6}}>场景 1 · EASING</div>
|
||||
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
|
||||
letterSpacing:'-0.01em'}}>
|
||||
四种缓动曲线同跑
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
|
||||
textAlign:'right'}}>
|
||||
同样的 2 秒,<br/>
|
||||
走出四种不同的「节奏感」
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{flex: 1, position:'relative'}}>
|
||||
{curves.map((c, i) => {
|
||||
const y = 80 + i * 140;
|
||||
const t = c.fn(sweepT);
|
||||
const x = trackLeft + trackLen * t;
|
||||
// Draw the curve as a mini sparkline right of track
|
||||
const sparkW = 160, sparkH = 50;
|
||||
const sparkPts = Array.from({length: 30}, (_, k) => {
|
||||
const tx = k / 29;
|
||||
const ty = 1 - c.fn(tx);
|
||||
return `${tx * sparkW},${ty * sparkH}`;
|
||||
}).join(' ');
|
||||
return (
|
||||
<div key={i}>
|
||||
{/* Label (left) */}
|
||||
<div style={{position:'absolute', left: 0, top: y - 22, width: 280,
|
||||
fontFamily: mono, fontSize: 14, color: INK, letterSpacing:'0.05em'}}>
|
||||
<span style={{color: c.color, marginRight: 12}}>●</span>
|
||||
Easing.<span style={{color: c.color}}>{c.label}</span>
|
||||
</div>
|
||||
{/* Track */}
|
||||
<div style={{position:'absolute', left: trackLeft, top: y,
|
||||
width: trackLen, height: 2, background: LINE}} />
|
||||
{/* Dot */}
|
||||
<div style={{position:'absolute', left: x - 14, top: y - 14,
|
||||
width: 28, height: 28, borderRadius: '50%',
|
||||
background: c.color,
|
||||
boxShadow: `0 4px 12px ${c.color}55`}} />
|
||||
{/* Sparkline */}
|
||||
<svg style={{position:'absolute', left: trackRight + 60, top: y - sparkH/2 - 5,
|
||||
width: sparkW, height: sparkH}}>
|
||||
<polyline points={sparkPts} stroke={c.color} strokeWidth="1.5" fill="none" />
|
||||
<circle cx={sweepT * sparkW} cy={(1 - c.fn(sweepT)) * sparkH}
|
||||
r="3.5" fill={c.color} />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Timeline at bottom */}
|
||||
<div style={{marginTop: 10, position:'relative', height: 30}}>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.15em'}}>
|
||||
t = <span style={{color: INK}}>{sweepT.toFixed(2)}</span> · 周期 2.0s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 3: interpolate() function demo (8 – 14s) ───────
|
||||
function Scene3_Interpolate() {
|
||||
const { elapsed } = useSprite();
|
||||
const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [5.6, 6.0], [1, 0]);
|
||||
|
||||
// Animated t value: 0→1 cycle over ~3s
|
||||
const cycle = (elapsed % 3.2) / 3.0;
|
||||
const t = Math.min(1, Math.max(0, cycle));
|
||||
|
||||
// Three mapped outputs from same t:
|
||||
const opacity = interpolate(t, [0, 1], [0, 1]);
|
||||
const scale = interpolate(t, [0, 1], [0.4, 1.2], Easing.spring);
|
||||
const rotation = interpolate(t, [0, 1], [-30, 30], Easing.easeInOut);
|
||||
const translateX = interpolate(t, [0, 0.5, 1], [-80, 40, 0]);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
|
||||
padding: '80px 100px', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{opacity: titleOp, marginBottom: 40,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
letterSpacing:'0.3em', marginBottom: 6}}>场景 2 · INTERPOLATE</div>
|
||||
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
|
||||
letterSpacing:'-0.01em'}}>
|
||||
一个 <span style={{fontStyle:'italic', color: TERRA}}>t</span>,四种变化
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
|
||||
textAlign:'right'}}>
|
||||
用同一条时间轴,<br/>
|
||||
映射出透明度、尺寸、旋转、位移
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* t value progress bar */}
|
||||
<div style={{background:'#fff', border: `1px solid ${LINE}`,
|
||||
padding: '20px 32px', marginBottom: 30}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline', marginBottom: 14}}>
|
||||
<div style={{fontFamily: mono, fontSize: 13, color: INK}}>
|
||||
<span style={{color: ASH}}>const</span> t = <span style={{color: TERRA}}>{t.toFixed(3)}</span>
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: ASH, letterSpacing:'0.15em'}}>
|
||||
时间 → 0 到 1
|
||||
</div>
|
||||
</div>
|
||||
<div style={{height: 4, background: LINE, position:'relative'}}>
|
||||
<div style={{position:'absolute', top:0, left:0, height:'100%',
|
||||
width: `${t * 100}%`, background: TERRA}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Four demos */}
|
||||
<div style={{flex: 1, display:'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 24}}>
|
||||
{[
|
||||
{ name: 'opacity', code: 'interpolate(t, [0,1], [0,1])', val: opacity, render:
|
||||
<div style={{width: 120, height: 120, background: TERRA, opacity}} /> },
|
||||
{ name: 'scale + spring', code: 'interpolate(t, [0,1], [0.4,1.2], spring)', val: scale, render:
|
||||
<div style={{width: 120, height: 120, background: OLIVE,
|
||||
transform: `scale(${scale})`}} /> },
|
||||
{ name: 'rotate', code: 'interpolate(t, [0,1], [-30,30], easeInOut)', val: rotation, render:
|
||||
<div style={{width: 120, height: 120, background: DEEP_BLUE,
|
||||
transform: `rotate(${rotation}deg)`}} /> },
|
||||
{ name: 'translateX (3 stops)', code: 'interpolate(t, [0,.5,1], [-80,40,0])', val: translateX, render:
|
||||
<div style={{width: 120, height: 120, background: INK,
|
||||
transform: `translateX(${translateX}px)`}} /> },
|
||||
].map((d, i) => (
|
||||
<div key={i} style={{background:'#fff', border:`1px solid ${LINE}`,
|
||||
padding: '18px 18px 14px', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 9, color: TERRA,
|
||||
letterSpacing:'0.2em', marginBottom: 6}}>0{i+1}</div>
|
||||
<div style={{fontFamily: serif, fontSize: 18, fontWeight: 500, color: INK,
|
||||
marginBottom: 6}}>{d.name}</div>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
|
||||
marginBottom: 20, minHeight: 32, wordBreak:'break-all', lineHeight: 1.5}}>
|
||||
{d.code}
|
||||
</div>
|
||||
<div style={{flex: 1, display:'flex', alignItems:'center',
|
||||
justifyContent:'center', overflow:'hidden'}}>
|
||||
{d.render}
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
|
||||
marginTop: 12, textAlign:'right'}}>
|
||||
= <span style={{color: TERRA}}>{d.val.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 4: Sprite sequencing on timeline (14 – 20s) ───
|
||||
function Scene4_Sprite() {
|
||||
const { elapsed } = useSprite();
|
||||
const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [5.6, 6.0], [1, 0]);
|
||||
|
||||
// Timeline plays 6s
|
||||
const localTime = Math.min(elapsed, 5.6);
|
||||
|
||||
const sprites = [
|
||||
{ name: 'Title', start: 0.0, end: 2.5, color: TERRA, y: 0, label: '标题' },
|
||||
{ name: 'Image', start: 0.8, end: 3.5, color: OLIVE, y: 1, label: '图像淡入' },
|
||||
{ name: 'Text', start: 1.8, end: 4.5, color: DEEP_BLUE, y: 2, label: '正文' },
|
||||
{ name: 'Outro', start: 4.0, end: 5.5, color: '#8b4a2b', y: 3, label: '结尾' },
|
||||
];
|
||||
|
||||
const timelineLeft = 100;
|
||||
const timelineRight = 1820;
|
||||
const timelineW = timelineRight - timelineLeft;
|
||||
const totalDur = 5.6;
|
||||
const cursorX = timelineLeft + (localTime / totalDur) * timelineW;
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
|
||||
padding: '80px 100px 60px', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{opacity: titleOp, marginBottom: 30,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
letterSpacing:'0.3em', marginBottom: 6}}>场景 3 · SPRITE 编排</div>
|
||||
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
|
||||
letterSpacing:'-0.01em'}}>
|
||||
时间片段 · <span style={{fontStyle:'italic'}}>同台起舞</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
|
||||
textAlign:'right'}}>
|
||||
每个 <Sprite start=... end=...><br/>
|
||||
在自己的时间窗口出场、退场
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live visualization area */}
|
||||
<div style={{background:'#fff', border:`1px solid ${LINE}`, flex: 1,
|
||||
position:'relative', overflow:'hidden', marginBottom: 30}}>
|
||||
{sprites.map((s, i) => {
|
||||
const active = localTime >= s.start && localTime < s.end;
|
||||
if (!active) return null;
|
||||
const localT = (localTime - s.start) / (s.end - s.start);
|
||||
const op = interpolate(localT, [0, 0.15, 0.85, 1], [0, 1, 1, 0]);
|
||||
const ty = interpolate(localT, [0, 0.2], [30, 0], Easing.easeOut);
|
||||
|
||||
if (s.name === 'Title') {
|
||||
return (
|
||||
<div key={i} style={{position:'absolute', top: 60, left: 80, right: 80,
|
||||
opacity: op, transform: `translateY(${ty}px)`}}>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: s.color,
|
||||
letterSpacing:'0.3em', marginBottom: 10}}>CHAPTER 01</div>
|
||||
<div style={{fontFamily: serif, fontSize: 64, fontWeight: 500,
|
||||
color: INK, lineHeight: 1.05, letterSpacing:'-0.01em'}}>
|
||||
如何让动画 <span style={{fontStyle:'italic', color: s.color}}>好看</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (s.name === 'Image') {
|
||||
return (
|
||||
<div key={i} style={{position:'absolute', top: 60, right: 80,
|
||||
width: 380, height: 240, opacity: op,
|
||||
transform: `translateY(${ty}px)`,
|
||||
background: `linear-gradient(135deg, ${s.color}, ${s.color}88 50%, ${s.color}33)`,
|
||||
overflow: 'hidden'}}>
|
||||
<div style={{position:'absolute', inset: 0,
|
||||
background: `radial-gradient(circle at 30% 30%, ${s.color}aa, transparent 50%)`}} />
|
||||
<div style={{position:'absolute', bottom: 14, left: 16,
|
||||
fontFamily: mono, fontSize: 9, color: '#fff',
|
||||
letterSpacing:'0.2em', opacity: 0.8}}>
|
||||
IMAGE · FADE-IN
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (s.name === 'Text') {
|
||||
return (
|
||||
<div key={i} style={{position:'absolute', bottom: 80, left: 80, right: 80,
|
||||
opacity: op, transform: `translateY(${ty}px)`}}>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: s.color,
|
||||
letterSpacing:'0.3em', marginBottom: 10}}>BODY</div>
|
||||
<div style={{fontFamily: serif, fontSize: 20, color: INK,
|
||||
lineHeight: 1.55, maxWidth: 720}}>
|
||||
好的 motion 不是每个元素都在抢戏——是<span style={{fontStyle:'italic'}}>一个
|
||||
</span>进、<span style={{fontStyle:'italic'}}>一个</span>退、留足呼吸,
|
||||
最后合奏收尾。
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (s.name === 'Outro') {
|
||||
return (
|
||||
<div key={i} style={{position:'absolute', inset: 0,
|
||||
opacity: op, display:'flex', alignItems:'center',
|
||||
justifyContent:'center', flexDirection:'column'}}>
|
||||
<div style={{fontFamily: serif, fontSize: 88, fontWeight: 500,
|
||||
color: s.color, fontStyle:'italic', letterSpacing:'-0.01em'}}>
|
||||
— fin —
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
|
||||
letterSpacing:'0.3em', marginTop: 14}}>
|
||||
4 SPRITES · 5.5 SECONDS · 1 STAGE
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Timeline viz (showing sprite spans) */}
|
||||
<div style={{position:'relative', height: 110}}>
|
||||
{/* Labels */}
|
||||
{sprites.map((s, i) => (
|
||||
<div key={i} style={{position:'absolute', left: 0, top: i * 22,
|
||||
fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.05em'}}>
|
||||
<span style={{color: s.color, marginRight: 8}}>●</span>{s.label}
|
||||
</div>
|
||||
))}
|
||||
{/* Tracks */}
|
||||
{sprites.map((s, i) => {
|
||||
const x0 = timelineLeft + (s.start / totalDur) * timelineW;
|
||||
const x1 = timelineLeft + (s.end / totalDur) * timelineW;
|
||||
const active = localTime >= s.start && localTime < s.end;
|
||||
return (
|
||||
<div key={i} style={{position:'absolute',
|
||||
left: x0, top: i * 22 - 2, width: x1 - x0, height: 16,
|
||||
background: active ? s.color : `${s.color}55`,
|
||||
borderLeft: `2px solid ${s.color}`}} />
|
||||
);
|
||||
})}
|
||||
{/* Playhead cursor */}
|
||||
<div style={{position:'absolute', left: cursorX - 1, top: -6,
|
||||
width: 2, height: 110, background: INK, zIndex: 5}} />
|
||||
<div style={{position:'absolute', left: cursorX - 20, top: -20,
|
||||
fontFamily: mono, fontSize: 10, color: INK,
|
||||
letterSpacing:'0.1em'}}>
|
||||
{localTime.toFixed(2)}s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 5: Outro (20 – 22s) ─────────────────────────
|
||||
function Scene5_Outro() {
|
||||
const { elapsed } = useSprite();
|
||||
const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1], Easing.easeOut);
|
||||
const lineW = interpolate(elapsed, [0.5, 1.3], [0, 620]);
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeIn,
|
||||
display:'flex', alignItems:'center', justifyContent:'center',
|
||||
flexDirection:'column'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
|
||||
color: TERRA, marginBottom: 20}}>
|
||||
导出 · MP4 / GIF / 60FPS / BGM
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 108, fontWeight: 500,
|
||||
color: INK, lineHeight: 1, letterSpacing:'-0.015em'}}>
|
||||
从 <span style={{fontStyle:'italic', color: TERRA}}>时间</span>,到成片
|
||||
</div>
|
||||
<div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22, color: ASH,
|
||||
marginTop: 26, maxWidth: 800, textAlign:'center', lineHeight: 1.55}}>
|
||||
render-video.js · convert-formats.sh · add-music.sh<br/>
|
||||
一条命令跑完,产出社交媒体可直接用的素材
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Watermark ──────────────────────────────────────────
|
||||
function Watermark() {
|
||||
return (
|
||||
<div style={{position:'absolute', bottom: 24, right: 32,
|
||||
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
|
||||
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
|
||||
Created by Huashu-Design
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Stage duration={22} width={1920} height={1080} bgColor={CREAM}>
|
||||
<Sprite start={0} end={3}><Scene1_Title /></Sprite>
|
||||
<Sprite start={3} end={8}><Scene2_Easing /></Sprite>
|
||||
<Sprite start={8} end={14}><Scene3_Interpolate /></Sprite>
|
||||
<Sprite start={14} end={20}><Scene4_Sprite /></Sprite>
|
||||
<Sprite start={20} end={22}><Scene5_Outro /></Sprite>
|
||||
<Watermark />
|
||||
</Stage>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,762 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Huashu-Design · Tweaks 实时变体</title>
|
||||
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<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,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; }
|
||||
body {
|
||||
background: #0c0c0c;
|
||||
font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
|
||||
color: #1a1a1a;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- animations.jsx inlined -->
|
||||
<script type="text/babel">
|
||||
(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;
|
||||
},
|
||||
};
|
||||
|
||||
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() { return useContext(TimeContext).time; }
|
||||
function useSprite() {
|
||||
const sprite = useContext(SpriteContext);
|
||||
return sprite || { t: 0, elapsed: 0, duration: 0 };
|
||||
}
|
||||
|
||||
function Stage({ duration = 10, width = 1920, height = 1080, 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 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) {
|
||||
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) return effectiveLoop ? 0 : duration - 0.001;
|
||||
return next;
|
||||
});
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
|
||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
|
||||
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
|
||||
}, [playing, duration, effectiveLoop]);
|
||||
|
||||
const progress = time / duration;
|
||||
const ctx = { time, duration, playing, setPlaying, setTime };
|
||||
|
||||
const canvasStyle = {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transformOrigin: 'center center',
|
||||
width,
|
||||
height,
|
||||
background: bgColor,
|
||||
overflow: 'hidden',
|
||||
transform: `translate(-50%, -50%) scale(${scale})`,
|
||||
};
|
||||
|
||||
return (
|
||||
<TimeContext.Provider value={ctx}>
|
||||
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
|
||||
<div style={canvasStyle}>{children}</div>
|
||||
</div>
|
||||
<div className="no-record" style={{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 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>
|
||||
<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>
|
||||
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
|
||||
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
|
||||
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Demo scene -->
|
||||
<script type="text/babel">
|
||||
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
|
||||
|
||||
// ── Design tokens ─────────────────────────────────────────
|
||||
const CREAM = '#FAF6EF';
|
||||
const INK = '#1a1a1a';
|
||||
const TERRA = '#C04A1A';
|
||||
const ASH = '#6b6b6b';
|
||||
const LINE = '#d9d2c5';
|
||||
const OLIVE = '#6a6b4e';
|
||||
const DEEP_BLUE = '#2a3552';
|
||||
|
||||
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
|
||||
const sansCN = "'Noto Sans SC', -apple-system, sans-serif";
|
||||
const playfair = "'Playfair Display', Georgia, serif";
|
||||
const sans = "'Inter', -apple-system, sans-serif";
|
||||
const mono = "'JetBrains Mono', ui-monospace, monospace";
|
||||
|
||||
// Palettes — the actual Tweak presets
|
||||
const PALETTES = {
|
||||
warm: { bg: CREAM, accent: TERRA, text: INK, sub: ASH, line: LINE, name: '暖米 + 赤陶', enName: 'WARM · TERRA' },
|
||||
olive: { bg: '#f2efdf', accent: OLIVE, text: '#2a2a1e', sub: '#7a7a5e', line: '#d4d1b8', name: '墨绿 + 鹅黄', enName: 'OLIVE · CITRON' },
|
||||
deep: { bg: '#f4efe6', accent: DEEP_BLUE, text: INK, sub: '#5a6478', line: '#c9c3b3', name: '深蓝 + 沙金', enName: 'DEEP · SAND' },
|
||||
};
|
||||
|
||||
const FONTS = {
|
||||
serif: { ui: serif, display: serif, name: 'Newsreader(衬线)' },
|
||||
sans: { ui: sansCN, display: sansCN, name: '思源黑体' },
|
||||
play: { ui: playfair, display: playfair, name: 'Playfair Display' },
|
||||
};
|
||||
|
||||
// ── Scene 1: Title (0 – 3s) ────────────────────────────────
|
||||
function Scene1_Title() {
|
||||
const { elapsed } = useSprite();
|
||||
const labelOp = interpolate(elapsed, [0.2, 0.8], [0, 1]);
|
||||
const mainY = interpolate(elapsed, [0.3, 1.3], [40, 0], Easing.easeOut);
|
||||
const mainOp = interpolate(elapsed, [0.3, 1.1], [0, 1]);
|
||||
const lineW = interpolate(elapsed, [1.1, 1.9], [0, 460]);
|
||||
const subOp = interpolate(elapsed, [1.4, 2.1], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [2.6, 3.0], [1, 0], Easing.easeIn);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
|
||||
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 14, letterSpacing:'0.35em',
|
||||
color: TERRA, marginBottom: 30, opacity: labelOp}}>
|
||||
实时变体 · TWEAKS
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 120, fontWeight: 500, color: INK,
|
||||
lineHeight: 1.05, letterSpacing: '-0.01em',
|
||||
opacity: mainOp, transform: `translateY(${mainY}px)`}}>
|
||||
一个 HTML · <span style={{fontStyle:'italic', color: TERRA}}>多种</span>设计
|
||||
</div>
|
||||
<div style={{height: 1, background: INK, width: lineW, marginTop: 40}} />
|
||||
<div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 22,
|
||||
color: ASH, marginTop: 26, opacity: subOp, letterSpacing:'0.02em'}}>
|
||||
不需要重新生成代码 · 只切参数
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 2: Main stage — control panel + live card (3 – 12s) ──
|
||||
function Scene2_MainStage() {
|
||||
const { elapsed } = useSprite();
|
||||
|
||||
// Decide current Tweaks state based on elapsed time inside the scene.
|
||||
// Scene2 elapsed: 0 – 9s
|
||||
// 0 – 4s : warm + serif + 40
|
||||
// 4 – 7s : olive + serif + 40 (palette change @ 4s)
|
||||
// 7 – 9s : olive + sans + 40 (font change @ 7s)
|
||||
let palette = 'warm';
|
||||
let font = 'serif';
|
||||
const density = 40;
|
||||
|
||||
if (elapsed >= 4) palette = 'olive';
|
||||
if (elapsed >= 7) font = 'sans';
|
||||
|
||||
// Ripple trigger times
|
||||
const rippleTimes = [4, 7];
|
||||
const ripples = rippleTimes.map(t => {
|
||||
const e = elapsed - t;
|
||||
if (e < 0 || e > 1.2) return null;
|
||||
return { t, progress: e / 1.2 };
|
||||
}).filter(Boolean);
|
||||
|
||||
// Fade-in intro
|
||||
const introOp = interpolate(elapsed, [0, 0.4], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [8.6, 9.0], [1, 0]);
|
||||
|
||||
const pal = PALETTES[palette];
|
||||
const fnt = FONTS[font];
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM,
|
||||
display:'flex', opacity: introOp * fadeOut}}>
|
||||
{/* Left: Control Panel (30%) */}
|
||||
<ControlPanel palette={palette} font={font} density={density} elapsed={elapsed} />
|
||||
|
||||
{/* Right: Live Card (70%) */}
|
||||
<div style={{flex: 1, position:'relative', padding: '60px 80px',
|
||||
display:'flex', alignItems:'center', justifyContent:'center',
|
||||
transition: 'background 600ms ease-in-out',
|
||||
background: pal.bg}}>
|
||||
<LiveCard palette={palette} font={font} density={density} />
|
||||
|
||||
{/* Ripples */}
|
||||
{ripples.map((r, i) => (
|
||||
<Ripple key={r.t} progress={r.progress}
|
||||
x={r.t === 4 ? 180 : 180}
|
||||
y={r.t === 4 ? 340 : 490} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlPanel({ palette, font, density, elapsed }) {
|
||||
return (
|
||||
<div style={{width: '30%', background: '#f2ece0',
|
||||
borderRight: `1px solid ${LINE}`, padding: '60px 44px 40px',
|
||||
display:'flex', flexDirection:'column', gap: 38,
|
||||
fontFamily: sans}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.35em',
|
||||
color: TERRA, marginBottom: 6}}>
|
||||
TWEAKS
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 28, fontWeight: 500, color: INK,
|
||||
letterSpacing:'-0.01em'}}>
|
||||
设计调参面板
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group 1: palette */}
|
||||
<ControlGroup label="01 · 配色方案" en="PALETTE">
|
||||
<Radio checked={palette==='warm'} label="暖米 + 赤陶" swatches={[CREAM, TERRA]} />
|
||||
<Radio checked={palette==='olive'} label="墨绿 + 鹅黄" swatches={['#f2efdf', OLIVE]} />
|
||||
<Radio checked={palette==='deep'} label="深蓝 + 沙金" swatches={['#f4efe6', DEEP_BLUE]} />
|
||||
</ControlGroup>
|
||||
|
||||
{/* Group 2: font */}
|
||||
<ControlGroup label="02 · 字型" en="TYPEFACE">
|
||||
<Radio checked={font==='serif'} label="Newsreader(衬线)" fontFamily={serif} />
|
||||
<Radio checked={font==='sans'} label="思源黑体" fontFamily={sansCN} />
|
||||
<Radio checked={font==='play'} label="Playfair Display" fontFamily={playfair} />
|
||||
</ControlGroup>
|
||||
|
||||
{/* Group 3: density */}
|
||||
<ControlGroup label="03 · 信息密度" en="DENSITY">
|
||||
<div style={{position:'relative', height: 4, background:'#e0dbcc',
|
||||
marginTop: 16, marginBottom: 10}}>
|
||||
<div style={{position:'absolute', left: 0, top: 0, height:'100%',
|
||||
width: `${density}%`, background: TERRA}} />
|
||||
<div style={{position:'absolute', left: `${density}%`, top: -6,
|
||||
transform:'translateX(-50%)', width: 16, height: 16,
|
||||
borderRadius:'50%', background: TERRA,
|
||||
boxShadow:'0 2px 6px rgba(0,0,0,0.15)'}} />
|
||||
</div>
|
||||
<div style={{display:'flex', justifyContent:'space-between',
|
||||
fontFamily: mono, fontSize: 9, letterSpacing:'0.2em', color: ASH}}>
|
||||
<span>克制</span><span style={{color: TERRA}}>标准</span><span>密集</span>
|
||||
</div>
|
||||
</ControlGroup>
|
||||
|
||||
<div style={{flex: 1}} />
|
||||
|
||||
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.12em',
|
||||
color: ASH, lineHeight: 1.6, borderTop: `1px solid ${LINE}`,
|
||||
paddingTop: 16}}>
|
||||
localStorage 持久化<br/>
|
||||
<span style={{color: TERRA}}>→</span> 刷新不丢
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlGroup({ label, en, children }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline', marginBottom: 14}}>
|
||||
<div style={{fontFamily: serif, fontSize: 15, fontWeight: 500, color: INK}}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 9, letterSpacing:'0.25em',
|
||||
color: ASH}}>{en}</div>
|
||||
</div>
|
||||
<div style={{display:'flex', flexDirection:'column', gap: 8}}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Radio({ checked, label, swatches, fontFamily }) {
|
||||
return (
|
||||
<div style={{display:'flex', alignItems:'center', gap: 12,
|
||||
padding:'9px 12px', background: checked ? '#fff' : 'transparent',
|
||||
border: `1px solid ${checked ? TERRA : 'transparent'}`,
|
||||
transition:'all 240ms ease-out'}}>
|
||||
<div style={{width: 14, height: 14, borderRadius:'50%',
|
||||
border: `1.5px solid ${checked ? TERRA : '#b0a898'}`,
|
||||
display:'flex', alignItems:'center', justifyContent:'center',
|
||||
flexShrink: 0}}>
|
||||
{checked && <div style={{width: 7, height: 7, borderRadius:'50%',
|
||||
background: TERRA}} />}
|
||||
</div>
|
||||
<div style={{flex: 1, fontFamily: fontFamily || sans, fontSize: 13,
|
||||
color: checked ? INK : '#4a4a4a'}}>{label}</div>
|
||||
{swatches && (
|
||||
<div style={{display:'flex', gap: 3}}>
|
||||
{swatches.map((c, i) => (
|
||||
<div key={i} style={{width: 12, height: 12, background: c,
|
||||
border:'1px solid rgba(0,0,0,0.06)'}} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Ripple({ progress, x, y }) {
|
||||
const size = progress * 420;
|
||||
const op = 1 - progress;
|
||||
return (
|
||||
<div style={{position:'absolute', left: x, top: y,
|
||||
width: size, height: size, borderRadius:'50%',
|
||||
border: `2px solid ${TERRA}`, opacity: op,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents:'none'}} />
|
||||
);
|
||||
}
|
||||
|
||||
function LiveCard({ palette, font, density }) {
|
||||
const pal = PALETTES[palette];
|
||||
const fnt = FONTS[font];
|
||||
|
||||
return (
|
||||
<div style={{width: '100%', maxWidth: 880, background: '#fff',
|
||||
border: `1px solid ${pal.line}`,
|
||||
transition:'border-color 600ms ease-in-out',
|
||||
position:'relative'}}>
|
||||
{/* Header bar */}
|
||||
<div style={{padding:'18px 32px', borderBottom:`1px solid ${pal.line}`,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'center',
|
||||
transition:'border-color 600ms ease-in-out'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.3em',
|
||||
color: pal.accent, transition:'color 600ms ease-in-out'}}>
|
||||
LUMINA · v3.2
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: pal.sub,
|
||||
letterSpacing:'0.15em', transition:'color 600ms ease-in-out'}}>
|
||||
PALETTE · {pal.enName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero content */}
|
||||
<div style={{padding:'56px 60px 48px', display:'grid',
|
||||
gridTemplateColumns:'1.4fr 1fr', gap: 48}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11,
|
||||
color: pal.accent, letterSpacing:'0.25em', marginBottom: 16,
|
||||
transition:'color 600ms ease-in-out'}}>
|
||||
READING · MEMORY
|
||||
</div>
|
||||
<div style={{fontFamily: fnt.display, fontSize: 68,
|
||||
fontWeight: font === 'sans' ? 700 : 500, color: pal.text,
|
||||
lineHeight: 1.05, letterSpacing:'-0.02em',
|
||||
transition:'color 600ms ease-in-out',
|
||||
marginBottom: 14}}>
|
||||
Lumina
|
||||
</div>
|
||||
<div style={{fontFamily: fnt.ui,
|
||||
fontStyle: font === 'play' ? 'italic' : 'normal',
|
||||
fontSize: 22, color: pal.sub, lineHeight: 1.4,
|
||||
letterSpacing: font === 'sans' ? 0 : '0.01em',
|
||||
transition:'color 600ms ease-in-out',
|
||||
marginBottom: 28}}>
|
||||
阅读记忆 · 让每一次阅读被看见
|
||||
</div>
|
||||
<div style={{fontFamily: fnt.ui, fontSize: 14, color: pal.text,
|
||||
lineHeight: 1.7, opacity: 0.78, marginBottom: 32,
|
||||
transition:'color 600ms ease-in-out'}}>
|
||||
把你读过的每一行、标注过的每一段,<br/>
|
||||
汇成一条属于你的阅读河流。
|
||||
</div>
|
||||
<div style={{display:'flex', gap: 12, alignItems:'center'}}>
|
||||
<div style={{padding:'12px 26px', background: pal.accent,
|
||||
color:'#fff', fontFamily: fnt.ui, fontSize: 13,
|
||||
letterSpacing: font === 'sans' ? '0.05em' : '0.12em',
|
||||
transition:'background 600ms ease-in-out'}}>
|
||||
{font === 'sans' ? '开始使用' : 'Start Reading'}
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: pal.sub,
|
||||
letterSpacing:'0.2em',
|
||||
transition:'color 600ms ease-in-out'}}>
|
||||
FREE · BETA
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right image block */}
|
||||
<div style={{background: pal.bg,
|
||||
border: `1px solid ${pal.line}`,
|
||||
transition:'all 600ms ease-in-out',
|
||||
aspectRatio: '3 / 4', position:'relative', overflow:'hidden'}}>
|
||||
{/* Abstract book spine illustration */}
|
||||
<svg width="100%" height="100%" viewBox="0 0 300 400"
|
||||
preserveAspectRatio="xMidYMid slice">
|
||||
{[0,1,2,3,4,5].map(i => (
|
||||
<rect key={i} x={40 + i * 35} y={60 + (i % 2) * 20}
|
||||
width={26} height={280 - (i % 3) * 30}
|
||||
fill="none" stroke={pal.accent}
|
||||
strokeWidth={i === 2 ? 2 : 1}
|
||||
opacity={0.55 + (i === 2 ? 0.4 : 0)}
|
||||
style={{transition:'stroke 600ms ease-in-out'}} />
|
||||
))}
|
||||
<circle cx={150} cy={200} r={58} fill="none"
|
||||
stroke={pal.accent} strokeWidth={1.5} opacity={0.5}
|
||||
style={{transition:'stroke 600ms ease-in-out'}} />
|
||||
<line x1={40} y1={350} x2={260} y2={350}
|
||||
stroke={pal.text} strokeWidth={0.8} opacity={0.5}
|
||||
style={{transition:'stroke 600ms ease-in-out'}} />
|
||||
</svg>
|
||||
<div style={{position:'absolute', bottom: 16, left: 18,
|
||||
fontFamily: mono, fontSize: 9, letterSpacing:'0.2em',
|
||||
color: pal.sub,
|
||||
transition:'color 600ms ease-in-out'}}>
|
||||
FIG. 01 — SHELF
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer meta */}
|
||||
<div style={{padding:'14px 32px', borderTop:`1px solid ${pal.line}`,
|
||||
display:'flex', justifyContent:'space-between',
|
||||
fontFamily: mono, fontSize: 10, letterSpacing:'0.2em',
|
||||
color: pal.sub,
|
||||
transition:'all 600ms ease-in-out'}}>
|
||||
<span>DENSITY · {density}</span>
|
||||
<span>FONT · {font.toUpperCase()}</span>
|
||||
<span>TWEAK ID · #{palette}-{font}-{density}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 3: Code view (12 – 17s) ─────────────────────────
|
||||
function Scene3_CodeView() {
|
||||
const { elapsed } = useSprite();
|
||||
const introOp = interpolate(elapsed, [0, 0.5], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [4.6, 5.0], [1, 0]);
|
||||
|
||||
// Code typing effect
|
||||
const fullCode = `// Tweaks via localStorage + CSS vars
|
||||
const tweaks = {
|
||||
palette: 'warm', // ← user 选
|
||||
font: 'serif',
|
||||
density: 40,
|
||||
};
|
||||
|
||||
document.documentElement.style
|
||||
.setProperty(
|
||||
'--accent',
|
||||
PALETTES[tweaks.palette].accent
|
||||
);
|
||||
|
||||
localStorage.setItem(
|
||||
'tweaks', JSON.stringify(tweaks)
|
||||
);`;
|
||||
|
||||
const typeProgress = Math.max(0, Math.min(1, (elapsed - 0.6) / 2.4));
|
||||
const visibleChars = Math.floor(fullCode.length * typeProgress);
|
||||
const visibleCode = fullCode.slice(0, visibleChars);
|
||||
const cursorBlink = Math.floor(elapsed * 2.5) % 2 === 0 && typeProgress < 1;
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM,
|
||||
display:'flex', flexDirection:'column', opacity: introOp * fadeOut,
|
||||
padding:'60px 80px'}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline', marginBottom: 36}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.35em',
|
||||
color: TERRA, marginBottom: 6}}>
|
||||
UNDER THE HOOD
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 52, fontWeight: 500,
|
||||
color: INK, letterSpacing:'-0.01em'}}>
|
||||
原理 · <span style={{fontStyle:'italic', color: TERRA}}>一行配置</span>,无限变体
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
|
||||
color: ASH, textAlign:'right', lineHeight: 1.5}}>
|
||||
纯前端 · 无后端依赖<br/>
|
||||
<span style={{fontSize: 14}}>刷新保留状态</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{display:'grid', gridTemplateColumns:'1fr 1.4fr', gap: 40,
|
||||
flex: 1}}>
|
||||
{/* Left: simplified tweak visualization */}
|
||||
<div style={{background:'#fff', border:`1px solid ${LINE}`,
|
||||
padding: 36, display:'flex', flexDirection:'column', gap: 28}}>
|
||||
<div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.3em',
|
||||
color: TERRA}}>TWEAK · STATE</div>
|
||||
|
||||
<MiniRow label="palette" value="warm" swatch={TERRA} />
|
||||
<MiniRow label="font" value="serif" />
|
||||
<MiniRow label="density" value="40" />
|
||||
|
||||
<div style={{height: 1, background: LINE, margin:'8px 0'}} />
|
||||
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 16,
|
||||
color: ASH, lineHeight: 1.55}}>
|
||||
三个参数的组合空间:<br/>
|
||||
<span style={{color: TERRA, fontFamily: mono, fontStyle:'normal',
|
||||
fontSize: 14}}>3 × 3 × ∞ = 无限</span>
|
||||
</div>
|
||||
|
||||
<div style={{flex: 1}} />
|
||||
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
|
||||
letterSpacing:'0.15em', lineHeight: 1.7}}>
|
||||
→ 改代码:不必要<br/>
|
||||
→ 重新生成:不必要<br/>
|
||||
→ 只改变量:30ms 生效
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: code block */}
|
||||
<div style={{background:'#0e1016', padding:'28px 32px',
|
||||
fontFamily: mono, fontSize: 15, color:'#d4c9b5',
|
||||
lineHeight: 1.7, position:'relative', overflow:'hidden'}}>
|
||||
<div style={{display:'flex', gap: 8, marginBottom: 20}}>
|
||||
<div style={{width: 10, height: 10, borderRadius:'50%',
|
||||
background:'#ff5f57'}} />
|
||||
<div style={{width: 10, height: 10, borderRadius:'50%',
|
||||
background:'#febc2e'}} />
|
||||
<div style={{width: 10, height: 10, borderRadius:'50%',
|
||||
background:'#28c840'}} />
|
||||
<div style={{marginLeft: 14, fontSize: 10, color:'#888',
|
||||
letterSpacing:'0.15em'}}>tweaks.js</div>
|
||||
</div>
|
||||
<pre style={{whiteSpace:'pre-wrap', margin: 0,
|
||||
fontFamily: mono, fontSize: 15, lineHeight: 1.65}}>
|
||||
<CodeColorize text={visibleCode} />
|
||||
{cursorBlink && <span style={{color:'#ff6a3d'}}>▌</span>}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniRow({ label, value, swatch }) {
|
||||
return (
|
||||
<div style={{display:'flex', alignItems:'center', gap: 14}}>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
|
||||
letterSpacing:'0.2em', width: 80}}>{label}</div>
|
||||
<div style={{flex: 1, fontFamily: mono, fontSize: 14, color: INK}}>
|
||||
{value}
|
||||
</div>
|
||||
{swatch && (
|
||||
<div style={{width: 14, height: 14, background: swatch}} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Very light syntax coloring
|
||||
function CodeColorize({ text }) {
|
||||
const lines = text.split('\n');
|
||||
return (
|
||||
<>
|
||||
{lines.map((line, i) => (
|
||||
<span key={i}>
|
||||
{colorizeLine(line)}
|
||||
{'\n'}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function colorizeLine(line) {
|
||||
const parts = [];
|
||||
let rest = line;
|
||||
|
||||
// comment
|
||||
const cIdx = rest.indexOf('//');
|
||||
if (cIdx >= 0) {
|
||||
const before = rest.slice(0, cIdx);
|
||||
const comment = rest.slice(cIdx);
|
||||
return (
|
||||
<>
|
||||
{tokenize(before)}
|
||||
<span style={{color:'#6a7d6a'}}>{comment}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return tokenize(line);
|
||||
}
|
||||
|
||||
function tokenize(s) {
|
||||
// keywords + strings
|
||||
const kw = ['const', 'let', 'var', 'function', 'return'];
|
||||
const words = s.split(/(\s+|[{}();,=.:'])/);
|
||||
return words.map((w, i) => {
|
||||
if (kw.includes(w)) return <span key={i} style={{color:'#c79cff'}}>{w}</span>;
|
||||
if (/^'[^']*'$/.test(w)) return <span key={i} style={{color:'#ffb86c'}}>{w}</span>;
|
||||
if (/^[0-9]+$/.test(w)) return <span key={i} style={{color:'#ff6a3d'}}>{w}</span>;
|
||||
if (['palette', 'font', 'density', 'tweaks', 'PALETTES', 'accent'].includes(w)) {
|
||||
return <span key={i} style={{color:'#8be9fd'}}>{w}</span>;
|
||||
}
|
||||
if (['document', 'localStorage'].includes(w)) {
|
||||
return <span key={i} style={{color:'#ff79c6'}}>{w}</span>;
|
||||
}
|
||||
return <span key={i}>{w}</span>;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Scene 4: Finale (17 – 20s) ────────────────────────────
|
||||
function Scene4_Final() {
|
||||
const { elapsed } = useSprite();
|
||||
const labelOp = interpolate(elapsed, [0.1, 0.7], [0, 1]);
|
||||
const mainY = interpolate(elapsed, [0.2, 1.2], [30, 0], Easing.easeOut);
|
||||
const mainOp = interpolate(elapsed, [0.2, 1.0], [0, 1]);
|
||||
const lineW = interpolate(elapsed, [1.0, 1.8], [0, 540]);
|
||||
const dimsOp = interpolate(elapsed, [1.3, 2.1], [0, 1]);
|
||||
|
||||
const dimensions = ['配色', '字型', '密度', '布局', '动画速度'];
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM,
|
||||
display:'flex', alignItems:'center', justifyContent:'center',
|
||||
flexDirection:'column'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
|
||||
color: TERRA, marginBottom: 26, opacity: labelOp}}>
|
||||
TWEAKS · EVERYTHING IS A VARIABLE
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 108, fontWeight: 500,
|
||||
color: INK, lineHeight: 1.05, letterSpacing:'-0.01em',
|
||||
opacity: mainOp, transform: `translateY(${mainY}px)`,
|
||||
textAlign:'center'}}>
|
||||
一个源文件 · <span style={{fontStyle:'italic', color: TERRA}}>无限</span>变体
|
||||
</div>
|
||||
<div style={{height: 1, background: INK, width: lineW, marginTop: 38}} />
|
||||
|
||||
<div style={{marginTop: 36, display:'flex', gap: 10,
|
||||
opacity: dimsOp, alignItems:'center'}}>
|
||||
{dimensions.map((d, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && (
|
||||
<span style={{fontFamily: mono, fontSize: 14,
|
||||
color: LINE, margin:'0 2px'}}>·</span>
|
||||
)}
|
||||
<span style={{fontFamily: mono, fontSize: 14,
|
||||
letterSpacing:'0.2em',
|
||||
color: i === 0 ? TERRA : ASH,
|
||||
padding:'6px 14px',
|
||||
border: `1px solid ${i === 0 ? TERRA : LINE}`}}>
|
||||
{d}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 20,
|
||||
color: ASH, marginTop: 36, opacity: dimsOp,
|
||||
maxWidth: 720, textAlign:'center', lineHeight: 1.5}}>
|
||||
设计不是一次性的结果 ——<br/>
|
||||
而是一组可以随时拨动的旋钮
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Watermark ─────────────────────────────────────────────
|
||||
function Watermark() {
|
||||
return (
|
||||
<div style={{position:'absolute', bottom: 24, right: 32,
|
||||
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
|
||||
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
|
||||
Created by Huashu-Design
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main composition ──────────────────────────────────────
|
||||
function App() {
|
||||
return (
|
||||
<Stage duration={20} width={1920} height={1080} bgColor={CREAM}>
|
||||
<Sprite start={0} end={3}><Scene1_Title /></Sprite>
|
||||
<Sprite start={3} end={12}><Scene2_MainStage /></Sprite>
|
||||
<Sprite start={12} end={17}><Scene3_CodeView /></Sprite>
|
||||
<Sprite start={17} end={20}><Scene4_Final /></Sprite>
|
||||
<Watermark />
|
||||
</Stage>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,767 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Huashu-Design · Infographic Demo</title>
|
||||
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<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,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; }
|
||||
body {
|
||||
background: #0c0c0c;
|
||||
font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
|
||||
color: #1a1a1a;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- animations.jsx inlined -->
|
||||
<script type="text/babel">
|
||||
(function() {
|
||||
const { createContext, useContext, useState, useEffect, useRef } = 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;
|
||||
},
|
||||
};
|
||||
function interpolate(t, input, output, easing) {
|
||||
const [a, b] = input, [x, y] = output;
|
||||
if (t <= a) return x; if (t >= b) return y;
|
||||
let p = (t - a) / (b - a); if (easing) p = easing(p);
|
||||
return x + (y - x) * p;
|
||||
}
|
||||
function useTime() { return useContext(TimeContext).time; }
|
||||
function useSprite() { return useContext(SpriteContext) || { t: 0, elapsed: 0, duration: 0 }; }
|
||||
function Stage({ duration = 10, width = 1920, height = 1080, 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 effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const s = Math.min(window.innerWidth / width, (window.innerHeight - 56) / height);
|
||||
setScale(s);
|
||||
};
|
||||
update(); window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, [width, height]);
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
let cancelled = false, last = null;
|
||||
function tick(now) {
|
||||
if (cancelled) return;
|
||||
if (last === null) { 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) return effectiveLoop ? 0 : duration - 0.001;
|
||||
return next;
|
||||
});
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
|
||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
|
||||
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
|
||||
}, [playing, duration, effectiveLoop]);
|
||||
const progress = time / duration;
|
||||
const ctx = { time, duration, playing, setPlaying, setTime };
|
||||
return (
|
||||
<TimeContext.Provider value={ctx}>
|
||||
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
|
||||
<div style={{position:'absolute', top:'50%', left:'50%', transformOrigin:'center center', width, height, background: bgColor, overflow:'hidden', transform:`translate(-50%, -50%) scale(${scale})`}}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<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}}>
|
||||
<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>
|
||||
<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>
|
||||
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
|
||||
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
|
||||
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
|
||||
</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));
|
||||
return (
|
||||
<SpriteContext.Provider value={{ t, elapsed, duration, start, end: actualEnd }}>
|
||||
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
|
||||
</SpriteContext.Provider>
|
||||
);
|
||||
}
|
||||
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Demo scene -->
|
||||
<script type="text/babel">
|
||||
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
|
||||
|
||||
// ── Design tokens ─────────────────────────────────────────
|
||||
const CREAM = '#FAF6EF';
|
||||
const INK = '#1a1a1a';
|
||||
const TERRA = '#C04A1A';
|
||||
const ASH = '#6b6b6b';
|
||||
const LINE = '#d9d2c5';
|
||||
const OLIVE = '#6a6b4e';
|
||||
const DEEP_BLUE = '#2a3552';
|
||||
|
||||
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
|
||||
const sans = "'Inter', -apple-system, sans-serif";
|
||||
const mono = "'JetBrains Mono', ui-monospace, monospace";
|
||||
|
||||
// ── Scene 1: Title (0 – 3s) ───────────────────────────────
|
||||
function Scene1_Title() {
|
||||
const { elapsed } = useSprite();
|
||||
const topOp = interpolate(elapsed, [0.0, 0.6], [0, 1]);
|
||||
const topLineW = interpolate(elapsed, [0.3, 1.0], [0, 220]);
|
||||
const mainOp = interpolate(elapsed, [0.5, 1.2], [0, 1]);
|
||||
const mainY = interpolate(elapsed, [0.5, 1.3], [32, 0], Easing.easeOut);
|
||||
const italicOp = interpolate(elapsed, [1.0, 1.6], [0, 1]);
|
||||
const subOp = interpolate(elapsed, [1.5, 2.1], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [2.6, 3.0], [1, 0], Easing.easeIn);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
|
||||
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
|
||||
<div style={{display:'flex', alignItems:'center', gap: 18, opacity: topOp, marginBottom: 48}}>
|
||||
<div style={{height: 1, background: TERRA, width: topLineW}}/>
|
||||
<div style={{fontFamily: mono, fontSize: 12, color: TERRA,
|
||||
letterSpacing:'0.35em'}}>
|
||||
信息图 · 数据驱动 · 印刷级
|
||||
</div>
|
||||
<div style={{height: 1, background: TERRA, width: topLineW}}/>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 160, fontWeight: 500,
|
||||
color: INK, lineHeight: 1, letterSpacing: '-0.02em',
|
||||
opacity: mainOp, transform: `translateY(${mainY}px)`}}>
|
||||
让数据<span style={{fontStyle:'italic', color: TERRA, opacity: italicOp}}>说话</span>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 24,
|
||||
color: ASH, marginTop: 44, opacity: subOp, letterSpacing: '0.02em'}}>
|
||||
精确排版 · 一眼看懂 · 可印刷
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 2: Full infographic layout (3 – 10s) ────────────
|
||||
// Uses a magazine spread style: headline, three columns (big numbers / bars / pie), trend line footer
|
||||
function Scene2_Spread() {
|
||||
const { elapsed } = useSprite();
|
||||
const headerOp = interpolate(elapsed, [0, 0.5], [0, 1]);
|
||||
const ruleW = interpolate(elapsed, [0.3, 1.0], [0, 1800]);
|
||||
const colDelay = [0.6, 1.0, 1.4];
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background: CREAM,
|
||||
padding: '60px 80px 50px', display:'flex', flexDirection:'column'}}>
|
||||
{/* Masthead */}
|
||||
<div style={{opacity: headerOp, display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline', marginBottom: 14, fontFamily: mono, fontSize: 11,
|
||||
letterSpacing: '0.3em', color: ASH}}>
|
||||
<span>HUASHU · INFOGRAPHIC REPORT</span>
|
||||
<span>VOL. 01 · 2026.04</span>
|
||||
</div>
|
||||
{/* Headline */}
|
||||
<div style={{opacity: headerOp, fontFamily: serif, fontSize: 72,
|
||||
fontWeight: 500, color: INK, lineHeight: 1.05, letterSpacing: '-0.01em',
|
||||
marginBottom: 8}}>
|
||||
2026 AI 写作工具
|
||||
<span style={{fontStyle:'italic', color: TERRA, marginLeft: 20}}>年度观察</span>
|
||||
</div>
|
||||
<div style={{opacity: headerOp, fontFamily: serif, fontStyle:'italic',
|
||||
fontSize: 20, color: ASH, marginBottom: 22}}>
|
||||
156 位创作者匿名问卷 · 3 月 15 日 – 4 月 10 日
|
||||
</div>
|
||||
{/* Top rule */}
|
||||
<div style={{width: ruleW, height: 1, background: INK, marginBottom: 28}}/>
|
||||
|
||||
{/* Three-column grid */}
|
||||
<div style={{display:'grid', gridTemplateColumns:'1fr 1px 1.15fr 1px 0.95fr',
|
||||
gap: 36, flex: 1}}>
|
||||
<ColumnLeft elapsed={elapsed - colDelay[0]} />
|
||||
<div style={{background: LINE}}/>
|
||||
<ColumnMid elapsed={elapsed - colDelay[1]} />
|
||||
<div style={{background: LINE}}/>
|
||||
<ColumnRight elapsed={elapsed - colDelay[2]} />
|
||||
</div>
|
||||
|
||||
{/* Footer trend line */}
|
||||
<FooterTrend elapsed={elapsed - 3.5} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColumnLeft({ elapsed }) {
|
||||
const e = Math.max(0, elapsed);
|
||||
const labelOp = interpolate(e, [0, 0.4], [0, 1]);
|
||||
// 87%
|
||||
const n1 = Math.round(interpolate(e, [0.3, 1.8], [0, 87], Easing.easeOut));
|
||||
const bar1 = interpolate(e, [0.3, 1.8], [0, 87], Easing.easeOut);
|
||||
// 3.2x
|
||||
const n2 = interpolate(e, [1.3, 2.8], [1.0, 3.2], Easing.easeOut);
|
||||
const bar2 = interpolate(e, [1.3, 2.8], [0, 3.2/5*100], Easing.easeOut);
|
||||
// 156
|
||||
const n3 = Math.round(interpolate(e, [2.3, 3.6], [0, 156], Easing.easeOut));
|
||||
const bar3 = interpolate(e, [2.3, 3.6], [0, 100], Easing.easeOut);
|
||||
|
||||
return (
|
||||
<div style={{display:'flex', flexDirection:'column', gap: 30}}>
|
||||
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.3em',
|
||||
color: TERRA, opacity: labelOp}}>
|
||||
COLUMN / 01 · 核心指标
|
||||
</div>
|
||||
<MetricRow
|
||||
value={`${n1}%`}
|
||||
width={`${bar1}%`}
|
||||
label="用户每周使用 AI 辅助写作"
|
||||
note="¹ 每周 ≥ 3 次"
|
||||
color={TERRA}
|
||||
/>
|
||||
<MetricRow
|
||||
value={`${n2.toFixed(1)}×`}
|
||||
width={`${bar2}%`}
|
||||
label="平均产出效率提升"
|
||||
note="² 自述周稿字数"
|
||||
color={OLIVE}
|
||||
/>
|
||||
<MetricRow
|
||||
value={String(n3)}
|
||||
width={`${bar3}%`}
|
||||
label="有效样本数"
|
||||
note="³ 剔除 AI 默认答卷"
|
||||
color={DEEP_BLUE}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricRow({ value, width, label, note, color }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{display:'flex', alignItems:'baseline', gap: 10, marginBottom: 8}}>
|
||||
<div style={{fontFamily: serif, fontSize: 72, fontWeight: 500,
|
||||
color: INK, lineHeight: 0.95, letterSpacing:'-0.02em',
|
||||
fontVariantNumeric:'tabular-nums'}}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{height: 6, background: '#eee7d7', width:'100%',
|
||||
marginBottom: 10, position:'relative'}}>
|
||||
<div style={{position:'absolute', top:0, left:0, height:'100%',
|
||||
width, background: color}}/>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 15, color: INK, lineHeight: 1.4}}>
|
||||
{label}
|
||||
<span style={{fontFamily: mono, fontSize: 9, color: ASH,
|
||||
verticalAlign:'super', marginLeft: 4}}>{note}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColumnMid({ elapsed }) {
|
||||
const e = Math.max(0, elapsed);
|
||||
const labelOp = interpolate(e, [0, 0.4], [0, 1]);
|
||||
// 5 bars, staggered 0.15s
|
||||
const bars = [
|
||||
{ name:'长文创作', pct: 78, color: TERRA },
|
||||
{ name:'短内容', pct: 64, color: OLIVE },
|
||||
{ name:'标题/文案', pct: 52, color: DEEP_BLUE },
|
||||
{ name:'润色校对', pct: 41, color: ASH },
|
||||
{ name:'翻译', pct: 29, color: ASH },
|
||||
];
|
||||
const chartH = 320;
|
||||
const maxPct = 100;
|
||||
|
||||
return (
|
||||
<div style={{display:'flex', flexDirection:'column', gap: 18}}>
|
||||
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.3em',
|
||||
color: TERRA, opacity: labelOp}}>
|
||||
COLUMN / 02 · 用途分布
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 28, fontWeight: 500,
|
||||
color: INK, lineHeight: 1.2, opacity: labelOp,
|
||||
letterSpacing: '-0.01em'}}>
|
||||
你最常用 AI 做什么?
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13,
|
||||
color: ASH, opacity: labelOp}}>
|
||||
多选题 · 百分比占全样本
|
||||
</div>
|
||||
{/* chart */}
|
||||
<div style={{position:'relative', height: chartH, display:'flex',
|
||||
alignItems:'flex-end', gap: 18, padding: '0 8px', marginTop: 4,
|
||||
borderBottom:`1px solid ${INK}`}}>
|
||||
{/* y-axis gridlines */}
|
||||
{[25, 50, 75, 100].map(v => (
|
||||
<div key={v} style={{position:'absolute', left: 0, right: 0,
|
||||
bottom: (v/maxPct)*chartH, height: 1,
|
||||
borderTop:`1px dashed ${LINE}`, pointerEvents:'none'}}>
|
||||
<div style={{position:'absolute', left: -36, top: -8,
|
||||
fontFamily: mono, fontSize: 9, color: ASH,
|
||||
letterSpacing:'0.05em'}}>
|
||||
{v}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{bars.map((b, i) => {
|
||||
const delay = 0.8 + i * 0.15;
|
||||
const growT = Math.max(0, Math.min(1, (e - delay) / 0.55));
|
||||
const h = growT * (b.pct / maxPct) * chartH;
|
||||
const labelOpB = Math.max(0, Math.min(1, (e - delay - 0.35) / 0.3));
|
||||
return (
|
||||
<div key={i} style={{flex: 1, position:'relative',
|
||||
display:'flex', flexDirection:'column', alignItems:'center',
|
||||
justifyContent:'flex-end', height:'100%'}}>
|
||||
<div style={{position:'absolute', top: -22,
|
||||
fontFamily: mono, fontSize: 11, color: INK,
|
||||
letterSpacing:'0.02em', fontVariantNumeric:'tabular-nums',
|
||||
opacity: labelOpB}}>
|
||||
{b.pct}%
|
||||
</div>
|
||||
<div style={{width: '100%', height: h, background: b.color,
|
||||
transition:'none'}}/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* x-axis labels */}
|
||||
<div style={{display:'flex', gap: 18, padding: '0 8px', marginTop: -8}}>
|
||||
{bars.map((b, i) => (
|
||||
<div key={i} style={{flex: 1, textAlign:'center',
|
||||
fontFamily: serif, fontSize: 12, color: INK,
|
||||
letterSpacing:'0.02em', opacity: labelOp}}>
|
||||
{b.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColumnRight({ elapsed }) {
|
||||
const e = Math.max(0, elapsed);
|
||||
const labelOp = interpolate(e, [0, 0.4], [0, 1]);
|
||||
// Three pie slices sweep in
|
||||
const slices = [
|
||||
{ label:'Claude', pct: 46, color: TERRA },
|
||||
{ label:'GPT', pct: 31, color: DEEP_BLUE },
|
||||
{ label:'GLM/国产', pct: 23, color: OLIVE },
|
||||
];
|
||||
const cx = 130, cy = 130, r = 104;
|
||||
const C = 2 * Math.PI * r;
|
||||
|
||||
// cumulative pct as fractions
|
||||
let acc = 0;
|
||||
const slicesCalc = slices.map((s, i) => {
|
||||
const delay = 0.6 + i * 0.45;
|
||||
const sweepT = Math.max(0, Math.min(1, (e - delay) / 0.7));
|
||||
const start = acc;
|
||||
const end = acc + s.pct / 100;
|
||||
acc = end;
|
||||
return { ...s, start, end, sweepT, delay };
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{display:'flex', flexDirection:'column', gap: 14}}>
|
||||
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.3em',
|
||||
color: TERRA, opacity: labelOp}}>
|
||||
COLUMN / 03 · 模型占有率
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 24, fontWeight: 500,
|
||||
color: INK, lineHeight: 1.2, opacity: labelOp,
|
||||
letterSpacing:'-0.01em'}}>
|
||||
主力模型分布
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 12,
|
||||
color: ASH, opacity: labelOp, marginBottom: 6}}>
|
||||
单选题 · 日常首选
|
||||
</div>
|
||||
|
||||
<div style={{display:'flex', alignItems:'center', gap: 18}}>
|
||||
<svg width="260" height="260" viewBox="0 0 260 260">
|
||||
{/* Background ring */}
|
||||
<circle cx={cx} cy={cy} r={r} fill="none"
|
||||
stroke={LINE} strokeWidth={1}/>
|
||||
{slicesCalc.map((s, i) => {
|
||||
// Draw partial arc with stroke-dasharray
|
||||
const sweepLen = (s.end - s.start) * s.sweepT;
|
||||
const dash = sweepLen * C;
|
||||
const gap = C - dash;
|
||||
const rot = s.start * 360 - 90;
|
||||
return (
|
||||
<circle key={i} cx={cx} cy={cy} r={r}
|
||||
fill="none" stroke={s.color} strokeWidth={28}
|
||||
strokeDasharray={`${dash} ${gap}`}
|
||||
strokeDashoffset={0}
|
||||
transform={`rotate(${rot} ${cx} ${cy})`}
|
||||
opacity={0.95}/>
|
||||
);
|
||||
})}
|
||||
{/* Inner text */}
|
||||
<text x={cx} y={cy - 4} textAnchor="middle"
|
||||
fontFamily={serif} fontSize={34} fill={INK}
|
||||
fontWeight={500} letterSpacing="-0.5">
|
||||
n=156
|
||||
</text>
|
||||
<text x={cx} y={cy + 22} textAnchor="middle"
|
||||
fontFamily={mono} fontSize={10} fill={ASH}
|
||||
letterSpacing="0.2em">
|
||||
TOTAL
|
||||
</text>
|
||||
</svg>
|
||||
<div style={{display:'flex', flexDirection:'column', gap: 14, flex: 1}}>
|
||||
{slicesCalc.map((s, i) => {
|
||||
const txtOp = Math.max(0, Math.min(1, (e - s.delay - 0.3) / 0.4));
|
||||
return (
|
||||
<div key={i} style={{display:'flex', alignItems:'baseline',
|
||||
gap: 10, opacity: txtOp}}>
|
||||
<div style={{width: 10, height: 10, background: s.color,
|
||||
marginTop: 4, flexShrink: 0}}/>
|
||||
<div style={{flex: 1}}>
|
||||
<div style={{fontFamily: serif, fontSize: 18,
|
||||
fontWeight: 500, color: INK, letterSpacing:'0.01em'}}>
|
||||
{s.label}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 22,
|
||||
fontWeight: 500, color: INK,
|
||||
fontVariantNumeric:'tabular-nums'}}>
|
||||
{s.pct}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterTrend({ elapsed }) {
|
||||
const e = Math.max(0, elapsed);
|
||||
const op = interpolate(e, [0, 0.5], [0, 1]);
|
||||
const data = [12, 18, 24, 31, 38, 48, 57, 64, 71, 78, 84, 87];
|
||||
const months = ['05','06','07','08','09','10','11','12','01','02','03','04'];
|
||||
const W = 1760, H = 86, PAD = 8;
|
||||
const maxV = 100;
|
||||
// progressive reveal of line
|
||||
const revealT = Math.max(0, Math.min(1, (e - 0.3) / 1.4));
|
||||
const nPoints = Math.max(1, Math.floor(revealT * data.length));
|
||||
const pts = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = (i / (data.length - 1)) * W;
|
||||
const y = H - (data[i] / maxV) * (H - PAD * 2) - PAD;
|
||||
pts.push([x, y]);
|
||||
}
|
||||
const visiblePts = pts.slice(0, nPoints);
|
||||
const d = visiblePts.map((p, i) => (i === 0 ? 'M' : 'L') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' ');
|
||||
const area = visiblePts.length > 1
|
||||
? d + ` L ${visiblePts[visiblePts.length-1][0].toFixed(1)} ${H} L 0 ${H} Z`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div style={{marginTop: 26, opacity: op}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline', marginBottom: 8}}>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: TERRA,
|
||||
letterSpacing:'0.3em'}}>TREND · 过去 12 个月 AI 周使用率(%)</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13,
|
||||
color: ASH}}>
|
||||
从 12% 到 87% · 增长 7.25×
|
||||
</div>
|
||||
</div>
|
||||
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}
|
||||
style={{display:'block', width:'100%', height: H}}>
|
||||
{area && <path d={area} fill={TERRA} opacity={0.08}/>}
|
||||
{d && <path d={d} fill="none" stroke={TERRA} strokeWidth={1.6}/>}
|
||||
{visiblePts.map((p, i) => (
|
||||
<circle key={i} cx={p[0]} cy={p[1]} r={2.4} fill={TERRA}/>
|
||||
))}
|
||||
{/* Axis labels */}
|
||||
{pts.map((p, i) => (
|
||||
<text key={i} x={p[0]} y={H - 0}
|
||||
textAnchor="middle" fontFamily={mono} fontSize={9} fill={ASH}
|
||||
opacity={0.6}>
|
||||
{months[i]}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 3: Typography close-up (10 – 17s) ───────────────
|
||||
function Scene3_Typography() {
|
||||
const { elapsed } = useSprite();
|
||||
const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [6.5, 7.0], [1, 0], Easing.easeIn);
|
||||
const opacity = Math.min(fadeIn, fadeOut);
|
||||
const labelOp = interpolate(elapsed, [0.2, 0.8], [0, 1]);
|
||||
const leftOp = interpolate(elapsed, [0.4, 1.2], [0, 1]);
|
||||
const compareOp = interpolate(elapsed, [1.8, 2.6], [0, 1]);
|
||||
const captionOp = interpolate(elapsed, [3.6, 4.4], [0, 1]);
|
||||
|
||||
// Pulsing scale on "87"
|
||||
const pulse = 1 + Math.sin(elapsed * 1.6) * 0.008;
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background: CREAM, opacity,
|
||||
padding: '60px 80px', display:'flex', flexDirection:'column'}}>
|
||||
{/* Top label */}
|
||||
<div style={{display:'flex', alignItems:'baseline',
|
||||
justifyContent:'space-between', marginBottom: 32, opacity: labelOp}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
letterSpacing:'0.3em', marginBottom: 6}}>DETAIL · ZOOM 1.5×</div>
|
||||
<div style={{fontFamily: serif, fontSize: 46, fontWeight: 500,
|
||||
color: INK, letterSpacing:'-0.01em'}}>
|
||||
排版细节:<span style={{fontStyle:'italic', color: TERRA}}>品味税</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
|
||||
color: ASH, textAlign:'right', maxWidth: 400, lineHeight: 1.5}}>
|
||||
"AI 能写中文,但分不清什么是好的中文排版"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{display:'grid', gridTemplateColumns:'1.25fr 1fr',
|
||||
gap: 56, flex: 1}}>
|
||||
{/* Left: Number gradient showcase */}
|
||||
<div style={{opacity: leftOp, display:'flex', flexDirection:'column',
|
||||
gap: 28}}>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
|
||||
letterSpacing:'0.3em'}}>01 · 字号梯度 · HIERARCHY</div>
|
||||
<div style={{display:'flex', alignItems:'baseline', gap: 36,
|
||||
borderBottom:`1px solid ${LINE}`, paddingBottom: 28}}>
|
||||
<div style={{fontFamily: serif, fontSize: 220, fontWeight: 500,
|
||||
color: INK, lineHeight: 0.88, letterSpacing:'-0.04em',
|
||||
fontVariantNumeric:'tabular-nums', display:'inline-block',
|
||||
transform:`scale(${pulse})`, transformOrigin:'left bottom'}}>
|
||||
87<span style={{fontSize: 80, color: TERRA,
|
||||
verticalAlign:'super', marginLeft: 4, fontStyle:'italic'}}>%</span>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 110, fontWeight: 400,
|
||||
color: OLIVE, lineHeight: 0.88, letterSpacing:'-0.02em',
|
||||
fontVariantNumeric:'tabular-nums'}}>
|
||||
3.2<span style={{fontSize: 44, fontStyle:'italic',
|
||||
color: ASH, marginLeft: 2}}>×</span>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 400,
|
||||
color: DEEP_BLUE, lineHeight: 0.88,
|
||||
fontVariantNumeric:'tabular-nums'}}>
|
||||
156
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 15, color: ASH,
|
||||
lineHeight: 1.55, maxWidth: 580}}>
|
||||
主数据 <span style={{color: INK, fontWeight: 500}}>220pt</span>、
|
||||
次级 <span style={{color: INK, fontWeight: 500}}>110pt</span>、
|
||||
辅助 <span style={{color: INK, fontWeight: 500}}>56pt</span>——
|
||||
梯度 2× 不是工程师拍脑袋,是几百年印刷品的视觉惯性。
|
||||
</div>
|
||||
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
|
||||
letterSpacing:'0.3em', marginTop: 10}}>02 · 换行 · TEXT-WRAP: PRETTY</div>
|
||||
<div style={{fontFamily: serif, fontSize: 34, fontWeight: 500,
|
||||
color: INK, lineHeight: 1.25, letterSpacing:'-0.01em',
|
||||
maxWidth: 620, textWrap:'pretty'}}>
|
||||
标题在该断的地方断开<br/>
|
||||
避免孤字和单字成行
|
||||
</div>
|
||||
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
|
||||
letterSpacing:'0.3em', marginTop: 4}}>03 · 上标辅注 · MONO FOOTNOTE</div>
|
||||
<div style={{fontFamily: serif, fontSize: 20, color: INK,
|
||||
lineHeight: 1.6, maxWidth: 620}}>
|
||||
87%
|
||||
<span style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
verticalAlign:'super', marginLeft: 4}}>¹</span>
|
||||
用户每周用 AI 辅助写作
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
|
||||
marginTop: 10, letterSpacing:'0.05em'}}>
|
||||
¹ 基于 156 位创作者调研,每周 ≥ 3 次
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: AI slop vs 精致 */}
|
||||
<div style={{opacity: compareOp, display:'flex', flexDirection:'column',
|
||||
gap: 20}}>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
|
||||
letterSpacing:'0.3em'}}>04 · AI SLOP vs 精致版</div>
|
||||
{/* Slop version */}
|
||||
<div style={{position:'relative', border: `1.5px dashed #c06060`,
|
||||
padding: '22px 22px', borderRadius: 16,
|
||||
background: 'linear-gradient(135deg, #6a47d4 0%, #3a1a7a 100%)'}}>
|
||||
<div style={{position:'absolute', top: -10, left: 14,
|
||||
background: '#c06060', color:'#fff', fontFamily: mono,
|
||||
fontSize: 9, padding:'2px 10px', letterSpacing:'0.2em'}}>
|
||||
✕ 反例 · 不要这样做
|
||||
</div>
|
||||
<div style={{fontFamily: sans, fontSize: 28, fontWeight: 700,
|
||||
color:'#fff', marginBottom: 6, letterSpacing:'-0.01em'}}>
|
||||
🚀 AI 写作工具爆发增长!
|
||||
</div>
|
||||
<div style={{fontFamily: sans, fontSize: 13, color:'rgba(255,255,255,0.85)',
|
||||
lineHeight: 1.5}}>
|
||||
✨ 87% 用户都在用!💡 效率提升 3.2 倍!🎯 赶紧加入!
|
||||
</div>
|
||||
<div style={{marginTop: 14, display:'flex', gap: 8}}>
|
||||
<div style={{background:'rgba(255,255,255,0.2)',
|
||||
padding:'6px 12px', borderRadius: 999,
|
||||
fontFamily: sans, fontSize: 11, color:'#fff'}}>
|
||||
#AI写作
|
||||
</div>
|
||||
<div style={{background:'rgba(255,255,255,0.2)',
|
||||
padding:'6px 12px', borderRadius: 999,
|
||||
fontFamily: sans, fontSize: 11, color:'#fff'}}>
|
||||
#爆款
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Good version */}
|
||||
<div style={{position:'relative', background:'#fff',
|
||||
border: `1px solid ${LINE}`, padding: '22px 22px'}}>
|
||||
<div style={{position:'absolute', top: -10, left: 14,
|
||||
background: TERRA, color:'#fff', fontFamily: mono,
|
||||
fontSize: 9, padding:'2px 10px', letterSpacing:'0.2em'}}>
|
||||
✓ 精致版 · DO THIS
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 9, color: TERRA,
|
||||
letterSpacing:'0.3em', marginBottom: 4}}>ESSAY · 2026.04</div>
|
||||
<div style={{fontFamily: serif, fontSize: 26, fontWeight: 500,
|
||||
color: INK, lineHeight: 1.2, letterSpacing:'-0.01em',
|
||||
marginBottom: 8}}>
|
||||
AI 写作<br/>
|
||||
<span style={{fontStyle:'italic'}}>悄然</span>改变创作者
|
||||
</div>
|
||||
<div style={{height: 1, background: INK, width: 70, marginBottom: 10}}/>
|
||||
<div style={{fontFamily: serif, fontSize: 13, color:'#444',
|
||||
lineHeight: 1.6}}>
|
||||
87% 的创作者已经把 AI 纳入日常工作流;
|
||||
效率提升 3.2×,但人味不减反增——
|
||||
工具不定义内容,品味才定义。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Caption */}
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 20,
|
||||
color: ASH, textAlign:'center', marginTop: 22, opacity: captionOp,
|
||||
letterSpacing:'0.02em'}}>
|
||||
排版细节是 AI 分不清的 <span style={{color: TERRA, fontWeight: 500,
|
||||
fontStyle:'normal'}}>品味税</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 4: Outro (17 – 22s) ─────────────────────────────
|
||||
function Scene4_Outro() {
|
||||
const { elapsed } = useSprite();
|
||||
const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1]);
|
||||
const mainY = interpolate(elapsed, [0, 1.2], [28, 0], Easing.easeOut);
|
||||
const italicOp = interpolate(elapsed, [0.8, 1.4], [0, 1]);
|
||||
const lineW = interpolate(elapsed, [1.0, 1.8], [0, 680]);
|
||||
const subOp = interpolate(elapsed, [1.6, 2.2], [0, 1]);
|
||||
const monoOp = interpolate(elapsed, [2.4, 3.2], [0, 1]);
|
||||
const monoLineW = interpolate(elapsed, [2.8, 3.8], [0, 520]);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeIn,
|
||||
display:'flex', alignItems:'center', justifyContent:'center',
|
||||
flexDirection:'column'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
|
||||
color: TERRA, marginBottom: 32, opacity: fadeIn}}>
|
||||
HUASHU-DESIGN · INFOGRAPHIC CAPABILITY
|
||||
</div>
|
||||
|
||||
<div style={{fontFamily: serif, fontSize: 148, fontWeight: 500,
|
||||
color: INK, lineHeight: 1, letterSpacing:'-0.02em',
|
||||
transform:`translateY(${mainY}px)`}}>
|
||||
<span style={{fontStyle:'italic', opacity: italicOp}}>数据</span>
|
||||
<span style={{opacity: fadeIn}}> 配得上 </span>
|
||||
<span style={{color: TERRA, opacity: italicOp}}>好看</span>
|
||||
</div>
|
||||
|
||||
<div style={{height: 1, background: INK, width: lineW, marginTop: 44}}/>
|
||||
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24,
|
||||
color: ASH, marginTop: 26, opacity: subOp, letterSpacing:'0.02em'}}>
|
||||
印刷级 · 不因为缩放而失真
|
||||
</div>
|
||||
|
||||
<div style={{marginTop: 60, opacity: monoOp,
|
||||
display:'flex', alignItems:'center', flexDirection:'column', gap: 14}}>
|
||||
<div style={{height: 1, background: LINE, width: monoLineW}}/>
|
||||
<div style={{fontFamily: mono, fontSize: 14, color: INK,
|
||||
letterSpacing:'0.18em'}}>
|
||||
export → <span style={{color: TERRA}}>PDF 矢量</span> /
|
||||
<span style={{color: OLIVE}}> PNG 300dpi</span> /
|
||||
<span style={{color: DEEP_BLUE}}> SVG 原生</span>
|
||||
</div>
|
||||
<div style={{height: 1, background: LINE, width: monoLineW}}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Watermark ─────────────────────────────────────────────
|
||||
function Watermark() {
|
||||
return (
|
||||
<div style={{position:'absolute', bottom: 24, right: 32,
|
||||
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
|
||||
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
|
||||
Created by Huashu-Design
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Composition ───────────────────────────────────────────
|
||||
function App() {
|
||||
return (
|
||||
<Stage duration={22} width={1920} height={1080} bgColor={CREAM}>
|
||||
<Sprite start={0} end={3}><Scene1_Title /></Sprite>
|
||||
<Sprite start={3} end={10}><Scene2_Spread /></Sprite>
|
||||
<Sprite start={10} end={17}><Scene3_Typography /></Sprite>
|
||||
<Sprite start={17} end={22}><Scene4_Outro /></Sprite>
|
||||
<Watermark />
|
||||
</Stage>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,652 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Huashu-Design · Expert Review</title>
|
||||
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<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@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; }
|
||||
body { background: #0c0c0c; font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif; color: #1a1a1a; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- animations.jsx inlined -->
|
||||
<script type="text/babel">
|
||||
(function() {
|
||||
const { createContext, useContext, useState, useEffect, useRef } = 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;
|
||||
},
|
||||
};
|
||||
function interpolate(t, input, output, easing) {
|
||||
const [a, b] = input, [x, y] = output;
|
||||
if (t <= a) return x; if (t >= b) return y;
|
||||
let p = (t - a) / (b - a); if (easing) p = easing(p);
|
||||
return x + (y - x) * p;
|
||||
}
|
||||
function useTime() { return useContext(TimeContext).time; }
|
||||
function useSprite() { return useContext(SpriteContext) || { t: 0, elapsed: 0, duration: 0 }; }
|
||||
function Stage({ duration = 10, width = 1920, height = 1080, 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 effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const s = Math.min(window.innerWidth / width, (window.innerHeight - 56) / height);
|
||||
setScale(s);
|
||||
};
|
||||
update(); window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, [width, height]);
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
let cancelled = false, last = null;
|
||||
function tick(now) {
|
||||
if (cancelled) return;
|
||||
if (last === null) { 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) return effectiveLoop ? 0 : duration - 0.001;
|
||||
return next;
|
||||
});
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
|
||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
|
||||
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
|
||||
}, [playing, duration, effectiveLoop]);
|
||||
const progress = time / duration;
|
||||
const ctx = { time, duration, playing, setPlaying, setTime };
|
||||
return (
|
||||
<TimeContext.Provider value={ctx}>
|
||||
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
|
||||
<div style={{position:'absolute', top:'50%', left:'50%', transformOrigin:'center center', width, height, background: bgColor, overflow:'hidden', transform:`translate(-50%, -50%) scale(${scale})`}}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<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}}>
|
||||
<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>
|
||||
<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>
|
||||
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
|
||||
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
|
||||
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
|
||||
</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));
|
||||
return (
|
||||
<SpriteContext.Provider value={{ t, elapsed, duration, start, end: actualEnd }}>
|
||||
<div style={{position:'absolute', inset:0, ...style}}>{children}</div>
|
||||
</SpriteContext.Provider>
|
||||
);
|
||||
}
|
||||
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Demo scene -->
|
||||
<script type="text/babel">
|
||||
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
|
||||
|
||||
// ── Design tokens ─────────────────────────────────────────
|
||||
const CREAM = '#FAF6EF';
|
||||
const INK = '#1a1a1a';
|
||||
const TERRA = '#C04A1A';
|
||||
const ASH = '#6b6b6b';
|
||||
const LINE = '#d9d2c5';
|
||||
const OLIVE = '#6a6b4e';
|
||||
const DEEP_BLUE = '#2a3552';
|
||||
|
||||
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
|
||||
const sans = "'Inter', -apple-system, sans-serif";
|
||||
const mono = "'JetBrains Mono', ui-monospace, monospace";
|
||||
|
||||
// ── 5 dimensions ──────────────────────────────────────────
|
||||
const DIMENSIONS = [
|
||||
{ no: '01', name: '哲学一致性', desc: '是否遵循既定的设计风格', score: 9 },
|
||||
{ no: '02', name: '视觉层级', desc: '信息优先级是否一目了然', score: 8 },
|
||||
{ no: '03', name: '细节执行', desc: '排版、间距、字重是否到位', score: 7 },
|
||||
{ no: '04', name: '功能性', desc: '交互是否顺畅、可用', score: 6 },
|
||||
{ no: '05', name: '创新性', desc: '是否超出了平均水准', score: 8 },
|
||||
];
|
||||
|
||||
const COMMENTS = [
|
||||
'赤陶橙贯穿,serif + 留白很 Kenya Hara',
|
||||
'Hero 和 body 强度接近,主次需再拉开',
|
||||
'行距、字号梯度还差一点点克制',
|
||||
'CTA 可达但色值和主色冲突',
|
||||
'版面节奏有想法,避开了模板感',
|
||||
];
|
||||
|
||||
// ── Scene 1: Title (0 – 3s) ────────────────────────────
|
||||
function Scene1_Title() {
|
||||
const { elapsed } = useSprite();
|
||||
const topOp = interpolate(elapsed, [0, 0.5], [0, 1]);
|
||||
const titleY = interpolate(elapsed, [0.2, 1.3], [50, 0], Easing.easeOut);
|
||||
const titleOp = interpolate(elapsed, [0.2, 1.1], [0, 1]);
|
||||
const lineW = interpolate(elapsed, [1.0, 1.8], [0, 540]);
|
||||
const subOp = interpolate(elapsed, [1.4, 2.1], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [2.6, 3.0], [1, 0]);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
|
||||
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
|
||||
color: TERRA, marginBottom: 28, opacity: topOp}}>
|
||||
设计评审 · 5 维度评分
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 120, fontWeight: 500, color: INK,
|
||||
lineHeight: 1, letterSpacing:'-0.02em', opacity: titleOp,
|
||||
transform: `translateY(${titleY}px)`}}>
|
||||
<span style={{fontStyle:'italic', color: TERRA}}>评</span>设计 · 不评设计师
|
||||
</div>
|
||||
<div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22, color: ASH,
|
||||
marginTop: 28, opacity: subOp, letterSpacing:'0.02em'}}>
|
||||
做完之后 · 用 5 个刻度看清楚
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 2: 5 dimensions intro (3 – 8s) ───────────────
|
||||
function Scene2_Dimensions() {
|
||||
const { elapsed } = useSprite();
|
||||
const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [4.5, 5.0], [1, 0]);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
|
||||
padding: '100px 100px 80px', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{opacity: titleOp, marginBottom: 60,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
letterSpacing:'0.3em', marginBottom: 6}}>步骤 1 / 3 · 评审维度</div>
|
||||
<div style={{fontFamily: serif, fontSize: 60, fontWeight: 500, color: INK,
|
||||
letterSpacing:'-0.01em'}}>
|
||||
五把<span style={{fontStyle:'italic', color: TERRA}}>尺子</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 19, color: ASH,
|
||||
textAlign:'right', lineHeight: 1.5}}>
|
||||
主观审美变不可辩论,<br/>
|
||||
客观维度变可打分
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{flex: 1, display:'grid', gridTemplateColumns:'repeat(5, 1fr)',
|
||||
gap: 22, alignItems:'stretch'}}>
|
||||
{DIMENSIONS.map((d, i) => {
|
||||
const appearStart = 0.6 + i * 0.4;
|
||||
const appearEnd = appearStart + 0.7;
|
||||
const op = interpolate(elapsed, [appearStart, appearEnd], [0, 1], Easing.easeOut);
|
||||
const ty = interpolate(elapsed, [appearStart, appearEnd], [30, 0], Easing.easeOut);
|
||||
return (
|
||||
<div key={i} style={{
|
||||
opacity: op,
|
||||
transform: `translateY(${ty}px)`,
|
||||
background: '#fff',
|
||||
border: `1px solid ${LINE}`,
|
||||
padding: '32px 26px 30px',
|
||||
display:'flex', flexDirection:'column',
|
||||
position:'relative',
|
||||
}}>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 72,
|
||||
fontWeight: 400, color: TERRA, lineHeight: 1,
|
||||
letterSpacing:'-0.02em', marginBottom: 20}}>
|
||||
{d.no}
|
||||
</div>
|
||||
<div style={{height: 1, background: INK, width: 40, marginBottom: 18}} />
|
||||
<div style={{fontFamily: serif, fontSize: 26, fontWeight: 500,
|
||||
color: INK, lineHeight: 1.15, marginBottom: 12}}>
|
||||
{d.name}
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
|
||||
color: ASH, lineHeight: 1.55, flex: 1}}>
|
||||
{d.desc}
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
|
||||
letterSpacing:'0.2em', marginTop: 22}}>
|
||||
0 – 10 PT
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 3: Radar + scoring (8 – 14s) ────────────────
|
||||
function Scene3_Radar() {
|
||||
const { elapsed } = useSprite();
|
||||
const headerOp = interpolate(elapsed, [0, 0.5], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [5.5, 6.0], [1, 0]);
|
||||
|
||||
// Radar reveal progress — polygon expands from center
|
||||
const reveal = interpolate(elapsed, [0.8, 2.4], [0, 1], Easing.easeOut);
|
||||
|
||||
// Total score count-up
|
||||
const totalT = interpolate(elapsed, [1.6, 2.8], [0, 1], Easing.easeOut);
|
||||
const totalVal = Math.round(totalT * 38);
|
||||
|
||||
// Radar geometry
|
||||
const cx = 340, cy = 440, R = 260;
|
||||
const N = 5;
|
||||
const maxScore = 10;
|
||||
const angle = i => -Math.PI/2 + i * 2 * Math.PI / N;
|
||||
|
||||
// Axis endpoints
|
||||
const axisPts = DIMENSIONS.map((_, i) => ({
|
||||
x: cx + Math.cos(angle(i)) * R,
|
||||
y: cy + Math.sin(angle(i)) * R,
|
||||
}));
|
||||
|
||||
// Score polygon points (animated)
|
||||
const scorePts = DIMENSIONS.map((d, i) => {
|
||||
const r = (d.score / maxScore) * R * reveal;
|
||||
return {
|
||||
x: cx + Math.cos(angle(i)) * r,
|
||||
y: cy + Math.sin(angle(i)) * r,
|
||||
};
|
||||
});
|
||||
const scorePath = scorePts.map(p => `${p.x},${p.y}`).join(' ');
|
||||
|
||||
// Concentric rings
|
||||
const rings = [2, 4, 6, 8, 10];
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
|
||||
padding: '70px 90px 50px', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{opacity: headerOp, marginBottom: 24,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
letterSpacing:'0.3em', marginBottom: 6}}>步骤 2 / 3 · 打分</div>
|
||||
<div style={{fontFamily: serif, fontSize: 54, fontWeight: 500, color: INK,
|
||||
letterSpacing:'-0.01em'}}>
|
||||
五边形 · <span style={{fontStyle:'italic'}}>照见</span>每一维
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
|
||||
textAlign:'right', lineHeight: 1.5}}>
|
||||
不是给个评价,<br/>
|
||||
是把问题「可视化」出来
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{flex: 1, display:'grid', gridTemplateColumns: '720px 1fr', gap: 60}}>
|
||||
{/* Radar */}
|
||||
<div style={{position:'relative', background:'#fff', border:`1px solid ${LINE}`}}>
|
||||
<svg viewBox="0 0 720 880" width="100%" height="100%" style={{display:'block'}}>
|
||||
{/* Concentric rings */}
|
||||
{rings.map((r, i) => {
|
||||
const ringR = (r / maxScore) * R;
|
||||
const pts = DIMENSIONS.map((_, k) => {
|
||||
const x = cx + Math.cos(angle(k)) * ringR;
|
||||
const y = cy + Math.sin(angle(k)) * ringR;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
return (
|
||||
<g key={i}>
|
||||
<polygon points={pts} fill="none" stroke={LINE} strokeWidth="1" />
|
||||
<text x={cx + 6} y={cy - ringR + 4}
|
||||
fontFamily={mono} fontSize="10" fill={ASH}
|
||||
letterSpacing="0.1em">{r}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* Axes */}
|
||||
{axisPts.map((p, i) => (
|
||||
<line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y}
|
||||
stroke={LINE} strokeWidth="1" />
|
||||
))}
|
||||
{/* Score polygon */}
|
||||
<polygon points={scorePath}
|
||||
fill={TERRA} fillOpacity="0.18"
|
||||
stroke={TERRA} strokeWidth="2" />
|
||||
{/* Score dots */}
|
||||
{scorePts.map((p, i) => reveal > 0.6 && (
|
||||
<circle key={i} cx={p.x} cy={p.y} r="5"
|
||||
fill={TERRA} opacity={Math.min(1, (reveal - 0.6) / 0.4)} />
|
||||
))}
|
||||
{/* Axis labels + score */}
|
||||
{DIMENSIONS.map((d, i) => {
|
||||
const labelR = R + 48;
|
||||
const lx = cx + Math.cos(angle(i)) * labelR;
|
||||
const ly = cy + Math.sin(angle(i)) * labelR;
|
||||
const anchor = Math.abs(Math.cos(angle(i))) < 0.2 ? 'middle'
|
||||
: Math.cos(angle(i)) > 0 ? 'start' : 'end';
|
||||
const showScore = elapsed > 2.4 + i * 0.15;
|
||||
return (
|
||||
<g key={i}>
|
||||
<text x={lx} y={ly}
|
||||
fontFamily={mono} fontSize="13" fill={INK}
|
||||
fontWeight="500" textAnchor={anchor}
|
||||
letterSpacing="0.08em">
|
||||
{d.name}
|
||||
</text>
|
||||
{showScore && (
|
||||
<text x={lx} y={ly + 20}
|
||||
fontFamily={serif} fontSize="22" fill={TERRA}
|
||||
fontStyle="italic" fontWeight="500" textAnchor={anchor}>
|
||||
{d.score}
|
||||
<tspan fontSize="13" fill={ASH} fontStyle="normal"> / 10</tspan>
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* Center total score */}
|
||||
<text x={cx} y={750}
|
||||
fontFamily={mono} fontSize="11" fill={ASH}
|
||||
letterSpacing="0.3em" textAnchor="middle">
|
||||
总分
|
||||
</text>
|
||||
<text x={cx} y={820}
|
||||
fontFamily={serif} fontSize="72" fill={INK}
|
||||
fontWeight="500" textAnchor="middle"
|
||||
letterSpacing="-0.02em">
|
||||
<tspan fontStyle="italic" fill={TERRA}>{totalVal}</tspan>
|
||||
<tspan fontSize="34" fill={ASH} letterSpacing="0"> / 50</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Right: breakdown list */}
|
||||
<div style={{display:'flex', flexDirection:'column', gap: 18, paddingTop: 4}}>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
|
||||
letterSpacing:'0.25em', marginBottom: 4}}>
|
||||
BREAKDOWN · 逐项
|
||||
</div>
|
||||
{DIMENSIONS.map((d, i) => {
|
||||
const rowAppear = 2.2 + i * 0.25;
|
||||
const op = interpolate(elapsed, [rowAppear, rowAppear + 0.6], [0, 1]);
|
||||
const barT = interpolate(elapsed, [rowAppear + 0.2, rowAppear + 0.9],
|
||||
[0, d.score / 10], Easing.easeOut);
|
||||
const tx = interpolate(elapsed, [rowAppear, rowAppear + 0.5], [20, 0], Easing.easeOut);
|
||||
return (
|
||||
<div key={i} style={{opacity: op, transform:`translateX(${tx}px)`,
|
||||
background:'#fff', border:`1px solid ${LINE}`, padding:'14px 20px'}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline', marginBottom: 8}}>
|
||||
<div style={{fontFamily: serif, fontSize: 22, fontWeight: 500, color: INK}}>
|
||||
<span style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
marginRight: 12, letterSpacing:'0.15em'}}>{d.no}</span>
|
||||
{d.name}
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 22, fontStyle:'italic',
|
||||
fontWeight: 500, color: TERRA}}>
|
||||
{d.score}<span style={{fontSize: 13, color: ASH, fontStyle:'normal'}}> / 10</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div style={{height: 4, background: LINE, position:'relative', marginBottom: 8}}>
|
||||
<div style={{position:'absolute', top:0, left:0, height:'100%',
|
||||
width: `${barT * 100}%`, background: TERRA}} />
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 14,
|
||||
color: ASH, lineHeight: 1.5}}>
|
||||
{COMMENTS[i]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 4: Keep / Fix / Quick Wins (14 – 20s) ──────
|
||||
function Scene4_Actions() {
|
||||
const { elapsed } = useSprite();
|
||||
const headerOp = interpolate(elapsed, [0, 0.4], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [5.5, 6.0], [1, 0]);
|
||||
|
||||
const keeps = [
|
||||
'赤陶橙 accent 贯穿全文',
|
||||
'serif display 给了文学气质',
|
||||
'留白足够 · 信息不挤',
|
||||
];
|
||||
const fixes = [
|
||||
{ tag: '致命', sev: TERRA, text: 'Hero 图和 body 抢焦点 · 降低 hero 字号' },
|
||||
{ tag: '重要', sev: OLIVE, text: '侧边 CTA 色和品牌主色冲突' },
|
||||
{ tag: '优化', sev: ASH, text: 'Footer 字号可以再小 2px' },
|
||||
];
|
||||
const wins = [
|
||||
'Hero 字号 96 → 72',
|
||||
'CTA 改成 terra 主色',
|
||||
'Footer 字号 14 → 12',
|
||||
];
|
||||
|
||||
const col1T = interpolate(elapsed, [0.4, 1.2], [0, 1], Easing.easeOut);
|
||||
const col2T = interpolate(elapsed, [0.8, 1.6], [0, 1], Easing.easeOut);
|
||||
const col3T = interpolate(elapsed, [1.2, 2.0], [0, 1], Easing.easeOut);
|
||||
const footerOp = interpolate(elapsed, [3.8, 4.6], [0, 1]);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
|
||||
padding: '70px 90px 60px', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{opacity: headerOp, marginBottom: 40,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
letterSpacing:'0.3em', marginBottom: 6}}>步骤 3 / 3 · 行动清单</div>
|
||||
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
|
||||
letterSpacing:'-0.01em'}}>
|
||||
Keep · Fix · <span style={{fontStyle:'italic', color: TERRA}}>Quick Wins</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
|
||||
textAlign:'right', lineHeight: 1.5}}>
|
||||
打完分 · 不是扔下报告,<br/>
|
||||
是给一张可执行的「修复清单」
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{flex: 1, display:'grid', gridTemplateColumns:'1fr 1.15fr 1fr',
|
||||
gap: 28}}>
|
||||
{/* KEEP */}
|
||||
<div style={{opacity: col1T, transform:`translateY(${(1-col1T)*20}px)`,
|
||||
background:'#fff', border:`1px solid ${LINE}`,
|
||||
borderTop: `4px solid ${OLIVE}`, padding: '30px 30px 28px',
|
||||
display:'flex', flexDirection:'column'}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline', marginBottom: 24}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: OLIVE,
|
||||
letterSpacing:'0.3em', marginBottom: 8}}>KEEP</div>
|
||||
<div style={{fontFamily: serif, fontSize: 32, fontWeight: 500, color: INK}}>
|
||||
保持这些
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 44, fontStyle:'italic',
|
||||
fontWeight: 400, color: OLIVE, lineHeight: 1}}>
|
||||
3
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display:'flex', flexDirection:'column', gap: 18, flex: 1}}>
|
||||
{keeps.map((k, i) => (
|
||||
<div key={i} style={{display:'flex', gap: 14, alignItems:'flex-start'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 16, color: OLIVE,
|
||||
fontWeight: 600, marginTop: 2}}>✓</div>
|
||||
<div style={{fontFamily: serif, fontSize: 19, color: INK,
|
||||
lineHeight: 1.5, flex: 1}}>{k}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FIX */}
|
||||
<div style={{opacity: col2T, transform:`translateY(${(1-col2T)*20}px)`,
|
||||
background:'#fff', border:`1px solid ${LINE}`,
|
||||
borderTop: `4px solid ${TERRA}`, padding: '30px 30px 28px',
|
||||
display:'flex', flexDirection:'column'}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline', marginBottom: 24}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
letterSpacing:'0.3em', marginBottom: 8}}>FIX</div>
|
||||
<div style={{fontFamily: serif, fontSize: 32, fontWeight: 500, color: INK}}>
|
||||
需修复 · 按严重度
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 44, fontStyle:'italic',
|
||||
fontWeight: 400, color: TERRA, lineHeight: 1}}>
|
||||
3
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display:'flex', flexDirection:'column', gap: 16, flex: 1}}>
|
||||
{fixes.map((f, i) => (
|
||||
<div key={i} style={{display:'flex', gap: 14, alignItems:'flex-start',
|
||||
paddingBottom: 14, borderBottom: i < fixes.length - 1 ? `1px solid ${LINE}` : 'none'}}>
|
||||
<div style={{
|
||||
background: f.sev, color: '#fff',
|
||||
fontFamily: mono, fontSize: 10, letterSpacing:'0.15em',
|
||||
padding: '4px 10px', marginTop: 4, minWidth: 58,
|
||||
textAlign: 'center', fontWeight: 600,
|
||||
}}>
|
||||
{f.tag}
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 17, color: INK,
|
||||
lineHeight: 1.5, flex: 1}}>{f.text}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QUICK WINS */}
|
||||
<div style={{opacity: col3T, transform:`translateY(${(1-col3T)*20}px)`,
|
||||
background:'#fff', border:`1px solid ${LINE}`,
|
||||
borderTop: `4px solid ${DEEP_BLUE}`, padding: '30px 30px 28px',
|
||||
display:'flex', flexDirection:'column'}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline', marginBottom: 24}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: DEEP_BLUE,
|
||||
letterSpacing:'0.3em', marginBottom: 8}}>QUICK WINS</div>
|
||||
<div style={{fontFamily: serif, fontSize: 32, fontWeight: 500, color: INK}}>
|
||||
5 分钟能做的
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
|
||||
letterSpacing:'0.2em', textAlign:'right', lineHeight: 1.6}}>
|
||||
TOP<br/>3
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display:'flex', flexDirection:'column', gap: 18, flex: 1}}>
|
||||
{wins.map((w, i) => (
|
||||
<div key={i} style={{display:'flex', gap: 16, alignItems:'flex-start'}}>
|
||||
<div style={{fontFamily: serif, fontSize: 32, fontStyle:'italic',
|
||||
color: DEEP_BLUE, fontWeight: 400, lineHeight: 1, minWidth: 32,
|
||||
marginTop: -4}}>
|
||||
{i+1}
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 19, color: INK,
|
||||
lineHeight: 1.5, flex: 1}}>{w}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer slogan */}
|
||||
<div style={{marginTop: 36, textAlign:'center', opacity: footerOp}}>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 26,
|
||||
color: TERRA, letterSpacing:'0.02em'}}>
|
||||
不是给个评价 · 是给个修复清单
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 5: Outro (20 – 22s) ─────────────────────────
|
||||
function Scene5_Outro() {
|
||||
const { elapsed } = useSprite();
|
||||
const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1], Easing.easeOut);
|
||||
const titleY = interpolate(elapsed, [0, 1.0], [40, 0], Easing.easeOut);
|
||||
const lineW = interpolate(elapsed, [0.5, 1.3], [0, 540]);
|
||||
const subOp = interpolate(elapsed, [0.9, 1.6], [0, 1]);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeIn,
|
||||
display:'flex', alignItems:'center', justifyContent:'center',
|
||||
flexDirection:'column'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
|
||||
color: TERRA, marginBottom: 22}}>
|
||||
5 维度 · 客观 · 可操作
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 108, fontWeight: 500,
|
||||
color: INK, lineHeight: 1, letterSpacing:'-0.015em',
|
||||
transform: `translateY(${titleY}px)`}}>
|
||||
先打分 · 再<span style={{fontStyle:'italic', color: TERRA}}>修</span>
|
||||
</div>
|
||||
<div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22, color: ASH,
|
||||
marginTop: 26, opacity: subOp, letterSpacing:'0.02em'}}>
|
||||
Huashu-Design · Expert Review
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Watermark ──────────────────────────────────────────
|
||||
function Watermark() {
|
||||
return (
|
||||
<div style={{position:'absolute', bottom: 24, right: 32,
|
||||
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
|
||||
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
|
||||
Created by Huashu-Design
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Stage duration={22} width={1920} height={1080} bgColor={CREAM}>
|
||||
<Sprite start={0} end={3}><Scene1_Title /></Sprite>
|
||||
<Sprite start={3} end={8}><Scene2_Dimensions /></Sprite>
|
||||
<Sprite start={8} end={14}><Scene3_Radar /></Sprite>
|
||||
<Sprite start={14} end={20}><Scene4_Actions /></Sprite>
|
||||
<Sprite start={20} end={22}><Scene5_Outro /></Sprite>
|
||||
<Watermark />
|
||||
</Stage>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,787 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Huashu-Design · 品牌资产协议 5 步硬流程</title>
|
||||
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<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,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; }
|
||||
body {
|
||||
background: #0c0c0c;
|
||||
font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
|
||||
color: #1a1a1a;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- animations.jsx inlined -->
|
||||
<script type="text/babel">
|
||||
(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;
|
||||
},
|
||||
};
|
||||
|
||||
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() { return useContext(TimeContext).time; }
|
||||
function useSprite() {
|
||||
const sprite = useContext(SpriteContext);
|
||||
return sprite || { t: 0, elapsed: 0, duration: 0 };
|
||||
}
|
||||
|
||||
function Stage({ duration = 10, width = 1920, height = 1080, 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 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) {
|
||||
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) return effectiveLoop ? 0 : duration - 0.001;
|
||||
return next;
|
||||
});
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
|
||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
|
||||
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
|
||||
}, [playing, duration, effectiveLoop]);
|
||||
|
||||
const progress = time / duration;
|
||||
const ctx = { time, duration, playing, setPlaying, setTime };
|
||||
|
||||
const canvasStyle = {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transformOrigin: 'center center',
|
||||
width,
|
||||
height,
|
||||
background: bgColor,
|
||||
overflow: 'hidden',
|
||||
transform: `translate(-50%, -50%) scale(${scale})`,
|
||||
};
|
||||
|
||||
return (
|
||||
<TimeContext.Provider value={ctx}>
|
||||
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
|
||||
<div style={canvasStyle}>{children}</div>
|
||||
</div>
|
||||
<div className="no-record" style={{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 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>
|
||||
<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>
|
||||
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
|
||||
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
|
||||
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Demo scene -->
|
||||
<script type="text/babel">
|
||||
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
|
||||
|
||||
// ── Design tokens ─────────────────────────────────────────
|
||||
const CREAM = '#FAF6EF';
|
||||
const INK = '#1a1a1a';
|
||||
const TERRA = '#C04A1A';
|
||||
const ASH = '#6b6b6b';
|
||||
const LINE = '#d9d2c5';
|
||||
const OLIVE = '#6a6b4e';
|
||||
|
||||
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
|
||||
const sans = "'Inter', -apple-system, sans-serif";
|
||||
const mono = "'JetBrains Mono', ui-monospace, monospace";
|
||||
|
||||
// ── Scene 1: Trigger (0 – 3s) ─────────────────────────────
|
||||
function Scene1_Trigger() {
|
||||
const { elapsed } = useSprite();
|
||||
const labelOp = interpolate(elapsed, [0, 0.5], [0, 1]);
|
||||
const titleY = interpolate(elapsed, [0, 1], [30, 0], Easing.easeOut);
|
||||
const titleOp = interpolate(elapsed, [0.2, 1], [0, 1]);
|
||||
const brandsOp = interpolate(elapsed, [1, 1.6], [0, 1]);
|
||||
const switchOp = interpolate(elapsed, [1.9, 2.4], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [2.6, 3], [1, 0]);
|
||||
|
||||
const brands = ['Kimi', 'Linear', 'Lovart', 'Stripe'];
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
|
||||
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
|
||||
color: TERRA, marginBottom: 28, opacity: labelOp}}>
|
||||
品 牌 资 产 协 议 · BRAND PROTOCOL
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 120, fontWeight: 500, color: INK,
|
||||
lineHeight: 1, letterSpacing:'-0.01em',
|
||||
opacity: titleOp, transform: `translateY(${titleY}px)`}}>
|
||||
涉及具体品牌<span style={{color: TERRA, fontStyle:'italic'}}>?</span>
|
||||
</div>
|
||||
|
||||
<div style={{display:'flex', gap: 48, marginTop: 64, opacity: brandsOp}}>
|
||||
{brands.map((b, i) => (
|
||||
<span key={i} style={{fontFamily: serif, fontStyle:'italic',
|
||||
fontSize: 44, color: ASH, letterSpacing:'0.02em'}}>
|
||||
{b}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{marginTop: 72, opacity: switchOp, textAlign:'center'}}>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 34,
|
||||
color: TERRA, letterSpacing:'0.02em'}}>
|
||||
先停下——走 5 步硬流程
|
||||
</div>
|
||||
<div style={{height:1, background: TERRA, width: 180, margin:'18px auto 0'}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 2: Step 1 & 2 (3 – 7s) ──────────────────────────
|
||||
function Scene2_AskAndSearch() {
|
||||
const { elapsed } = useSprite();
|
||||
const headOp = interpolate(elapsed, [0, 0.4], [0, 1]);
|
||||
const leftOp = interpolate(elapsed, [0.3, 0.9], [0, 1]);
|
||||
const leftY = interpolate(elapsed, [0.3, 0.9], [20, 0], Easing.easeOut);
|
||||
const rightOp = interpolate(elapsed, [0.8, 1.4], [0, 1]);
|
||||
const rightY = interpolate(elapsed, [0.8, 1.4], [20, 0], Easing.easeOut);
|
||||
const fadeOut = interpolate(elapsed, [3.5, 4], [1, 0]);
|
||||
|
||||
// cursor sweeping through search paths 1.6 → 3.2
|
||||
const paths = [
|
||||
'<brand>.com/brand',
|
||||
'<brand>.com/press',
|
||||
'brand.<brand>.com',
|
||||
'<brand>.github.io/brand',
|
||||
];
|
||||
// current active index based on time
|
||||
const activeIdx = Math.min(3, Math.max(0, Math.floor((elapsed - 1.6) / 0.4)));
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
|
||||
padding:'72px 120px', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline', opacity: headOp, marginBottom: 48}}>
|
||||
<div style={{fontFamily: serif, fontSize: 44, fontWeight: 500, color: INK}}>
|
||||
Step 1 & 2 · 问 · 搜
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: ASH, letterSpacing:'0.25em'}}>
|
||||
01 / 05 → 02 / 05
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap: 48, flex:1}}>
|
||||
{/* Left: Step 1 Ask */}
|
||||
<div style={{opacity: leftOp, transform:`translateY(${leftY}px)`,
|
||||
display:'flex', flexDirection:'column', gap: 24}}>
|
||||
<div style={{display:'flex', alignItems:'baseline', gap: 18}}>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
letterSpacing:'0.3em'}}>STEP · 01</div>
|
||||
<div style={{fontFamily: serif, fontSize: 40, color: INK,
|
||||
fontWeight: 500}}>问</div>
|
||||
</div>
|
||||
|
||||
<div style={{background:'#fff', border:`1px solid ${LINE}`,
|
||||
padding:'32px 34px', position:'relative'}}>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 64,
|
||||
color: TERRA, lineHeight:1, position:'absolute',
|
||||
top: 8, left: 14}}>「</div>
|
||||
<div style={{fontFamily: serif, fontSize: 24, color: INK,
|
||||
lineHeight: 1.55, paddingLeft: 36}}>
|
||||
这个品牌有 <span style={{fontStyle:'italic', color: TERRA}}>brand guidelines</span> 吗?
|
||||
</div>
|
||||
<div style={{height:1, background: LINE, margin:'22px 0'}} />
|
||||
<div style={{fontFamily: serif, fontSize: 20, color: ASH,
|
||||
lineHeight: 1.6, paddingLeft: 36}}>
|
||||
有的话直接给我;没有我去搜。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
|
||||
letterSpacing:'0.15em', marginTop: 6}}>
|
||||
▸ 用户提供 > AI 猜测
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Step 2 Search */}
|
||||
<div style={{opacity: rightOp, transform:`translateY(${rightY}px)`,
|
||||
display:'flex', flexDirection:'column', gap: 24}}>
|
||||
<div style={{display:'flex', alignItems:'baseline', gap: 18}}>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
letterSpacing:'0.3em'}}>STEP · 02</div>
|
||||
<div style={{fontFamily: serif, fontSize: 40, color: INK,
|
||||
fontWeight: 500}}>搜 官 方 品 牌 页</div>
|
||||
</div>
|
||||
|
||||
<div style={{background:'#1a1a1a', color:'#e8e3d6',
|
||||
padding:'24px 28px', fontFamily: mono, fontSize: 16,
|
||||
lineHeight: 1.9, flex: 1}}>
|
||||
<div style={{color:'#6b6b6b', fontSize: 11,
|
||||
letterSpacing:'0.2em', marginBottom: 14}}>
|
||||
$ HTTP GET · typical paths
|
||||
</div>
|
||||
{paths.map((p, i) => {
|
||||
const isActive = i === activeIdx && elapsed > 1.6 && elapsed < 3.4;
|
||||
const visited = i < activeIdx;
|
||||
return (
|
||||
<div key={i} style={{
|
||||
color: isActive ? TERRA : (visited ? '#8a8878' : '#e8e3d6'),
|
||||
background: isActive ? 'rgba(192,74,26,0.12)' : 'transparent',
|
||||
padding:'2px 8px', marginLeft:-8,
|
||||
display:'flex', alignItems:'baseline', gap: 14}}>
|
||||
<span style={{color: isActive ? TERRA : '#555', fontSize: 13}}>
|
||||
{isActive ? '▸' : (visited ? '✓' : ' ')}
|
||||
</span>
|
||||
<span style={{letterSpacing:'0.02em'}}>{p}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
|
||||
color: ASH, marginTop: 6}}>
|
||||
按顺序试,中断也要记 404 状态
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 3: Step 3 · Three fallbacks (7 – 12s) ───────────
|
||||
function Scene3_Fallbacks() {
|
||||
const { elapsed } = useSprite();
|
||||
const headOp = interpolate(elapsed, [0, 0.4], [0, 1]);
|
||||
const cardsOp = interpolate(elapsed, [0.3, 1], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [4.5, 5], [1, 0]);
|
||||
|
||||
// Card state: 0 idle, 1 active/lit, 2 failed/gray, 3 success
|
||||
// Card 1 active 1.2→2.0, then fails at 2.0
|
||||
// Card 2 active 2.2→3.0, then fails at 3.0
|
||||
// Card 3 active 3.2→4.2, succeeds at 4.2
|
||||
const card1State = elapsed < 1.2 ? 0 : elapsed < 2.0 ? 1 : 2;
|
||||
const card2State = elapsed < 2.2 ? 0 : elapsed < 3.0 ? 1 : (elapsed < 3.2 ? 2 : 2);
|
||||
const card3State = elapsed < 3.2 ? 0 : elapsed < 4.2 ? 1 : 3;
|
||||
|
||||
const captionOp = interpolate(elapsed, [4.2, 4.6], [0, 1]);
|
||||
|
||||
const cards = [
|
||||
{ n: '01', title: 'SVG 文件', cmd: 'curl -o logo.svg <url>', status: '最理想', state: card1State },
|
||||
{ n: '02', title: '官网 HTML', cmd: 'curl -A "Mozilla/5.0" \\\n -L <url>', status: '80% 场景必用', state: card2State },
|
||||
{ n: '03', title: '产品截图取色', cmd: 'macOS Preview · 吸管', status: 'App 必走', state: card3State },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
|
||||
padding:'70px 100px 60px', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{opacity: headOp, marginBottom: 40}}>
|
||||
<div style={{display:'flex', alignItems:'baseline', gap: 18, marginBottom: 6}}>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
letterSpacing:'0.3em'}}>STEP · 03</div>
|
||||
<div style={{fontFamily: serif, fontSize: 18, color: ASH,
|
||||
fontStyle:'italic'}}>下 · download</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 60, fontWeight: 500,
|
||||
color: INK, letterSpacing:'-0.01em'}}>
|
||||
三条兜底路径
|
||||
</div>
|
||||
<div style={{height:1, background: LINE, width:'100%', marginTop: 20}} />
|
||||
</div>
|
||||
|
||||
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr 1fr',
|
||||
gap: 28, flex:1, opacity: cardsOp}}>
|
||||
{cards.map((c, i) => {
|
||||
const isIdle = c.state === 0;
|
||||
const isActive = c.state === 1;
|
||||
const isFailed = c.state === 2;
|
||||
const isSuccess = c.state === 3;
|
||||
|
||||
const borderColor = isSuccess ? TERRA :
|
||||
isActive ? INK :
|
||||
isFailed ? '#c4bda7' : LINE;
|
||||
const borderWidth = (isActive || isSuccess) ? 2 : 1;
|
||||
const op = isFailed ? 0.42 : 1;
|
||||
const bg = isSuccess ? '#fffaf3' : '#fff';
|
||||
|
||||
return (
|
||||
<div key={i} style={{
|
||||
background: bg,
|
||||
border:`${borderWidth}px solid ${borderColor}`,
|
||||
opacity: op,
|
||||
padding:'28px 28px 24px',
|
||||
display:'flex', flexDirection:'column',
|
||||
position:'relative',
|
||||
transition: 'none',
|
||||
}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline', marginBottom: 20}}>
|
||||
<div style={{fontFamily: serif, fontSize: 56, fontWeight: 300,
|
||||
color: isSuccess ? TERRA : INK, lineHeight:1}}>
|
||||
{c.n}
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.2em',
|
||||
color: isSuccess ? TERRA : isFailed ? ASH : INK}}>
|
||||
{isIdle && 'READY'}
|
||||
{isActive && '▸ TRYING…'}
|
||||
{isFailed && '× FAILED'}
|
||||
{isSuccess && '✓ GOT IT'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{fontFamily: serif, fontSize: 28, fontWeight: 500,
|
||||
color: INK, marginBottom: 14, letterSpacing:'-0.005em'}}>
|
||||
{c.title}
|
||||
</div>
|
||||
|
||||
<div style={{background:'#1a1a1a', color:'#e8e3d6',
|
||||
fontFamily: mono, fontSize: 12, padding:'14px 16px',
|
||||
whiteSpace:'pre-wrap', lineHeight: 1.6,
|
||||
marginBottom: 20, borderLeft: `2px solid ${isSuccess ? TERRA : '#333'}`}}>
|
||||
{c.cmd}
|
||||
</div>
|
||||
|
||||
<div style={{marginTop:'auto', borderTop:`1px solid ${LINE}`,
|
||||
paddingTop: 14, display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline'}}>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic',
|
||||
fontSize: 15, color: ASH}}>
|
||||
场景
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 16,
|
||||
color: isSuccess ? TERRA : INK, fontWeight: 500}}>
|
||||
{c.status}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSuccess && (
|
||||
<div style={{position:'absolute', top:-10, right:-10,
|
||||
width: 28, height: 28, borderRadius:'50%', background: TERRA,
|
||||
color:'#fff', display:'flex', alignItems:'center',
|
||||
justifyContent:'center', fontFamily: serif, fontSize: 14,
|
||||
fontWeight: 600}}>
|
||||
✓
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{opacity: captionOp, marginTop: 22, textAlign:'center',
|
||||
fontFamily: serif, fontStyle:'italic', fontSize: 22, color: INK}}>
|
||||
前一条失败,<span style={{color: TERRA}}>立刻</span>走下一条——不要停
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 4: Step 4 · Grep colors (12 – 17s) ──────────────
|
||||
function Scene4_GrepColors() {
|
||||
const { elapsed } = useSprite();
|
||||
const headOp = interpolate(elapsed, [0, 0.4], [0, 1]);
|
||||
const cmdOp = interpolate(elapsed, [0.4, 0.9], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [4.5, 5], [1, 0]);
|
||||
|
||||
const results = [
|
||||
{ count: 47, hex: '#1783FF', label: 'Kimi primary' },
|
||||
{ count: 32, hex: '#FAFAFA', label: 'background' },
|
||||
{ count: 18, hex: '#1a1a1a', label: 'ink' },
|
||||
{ count: 12, hex: '#FF6B35', label: 'accent' },
|
||||
{ count: 8, hex: '#6A6B4E', label: 'muted' },
|
||||
];
|
||||
|
||||
// Stagger each result row, starting at t=1.5
|
||||
const rowStart = 1.5;
|
||||
const rowStep = 0.32;
|
||||
|
||||
const captionOp = interpolate(elapsed, [3.6, 4.1], [0, 1]);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:'#1a1a1a', opacity: fadeOut,
|
||||
padding:'64px 100px', display:'flex', flexDirection:'column',
|
||||
color:'#e8e3d6'}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline', marginBottom: 36, opacity: headOp}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
letterSpacing:'0.3em', marginBottom: 4}}>STEP · 04</div>
|
||||
<div style={{fontFamily: serif, fontSize: 54, fontWeight: 500,
|
||||
color:'#faf6ef', letterSpacing:'-0.01em'}}>
|
||||
Grep 色 值
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
|
||||
color:'#8a8878', textAlign:'right'}}>
|
||||
频次排序 = 品牌色权重<br/>
|
||||
<span style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.2em'}}>
|
||||
/ DON'T GUESS · COUNT
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Command */}
|
||||
<div style={{opacity: cmdOp, background:'#0e0e0e',
|
||||
border:'1px solid #333', padding:'20px 26px',
|
||||
fontFamily: mono, fontSize: 15, lineHeight: 1.7,
|
||||
marginBottom: 28}}>
|
||||
<div style={{color: TERRA, fontSize: 10, letterSpacing:'0.2em',
|
||||
marginBottom: 10}}>$ COMMAND</div>
|
||||
<div style={{color:'#e8e3d6'}}>
|
||||
<span style={{color:'#888'}}>$</span>{' '}
|
||||
<span style={{color:'#7dd3fc'}}>grep</span>{' '}
|
||||
<span style={{color:'#fbbf24'}}>-hoE</span>{' '}
|
||||
<span style={{color:'#a5f3c5'}}>'#[0-9A-Fa-f]{'{6}'}'</span>{' '}
|
||||
<span style={{color:'#e8e3d6'}}>assets/*.{'{svg,html}'}</span> \<br/>
|
||||
{' '}<span style={{color:'#666'}}>|</span>{' '}
|
||||
<span style={{color:'#7dd3fc'}}>sort</span>{' '}
|
||||
<span style={{color:'#666'}}>|</span>{' '}
|
||||
<span style={{color:'#7dd3fc'}}>uniq -c</span>{' '}
|
||||
<span style={{color:'#666'}}>|</span>{' '}
|
||||
<span style={{color:'#7dd3fc'}}>sort -rn</span>{' '}
|
||||
<span style={{color:'#666'}}>|</span>{' '}
|
||||
<span style={{color:'#7dd3fc'}}>head -20</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results + swatches */}
|
||||
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap: 60,
|
||||
flex: 1}}>
|
||||
{/* Left: result list */}
|
||||
<div style={{fontFamily: mono, fontSize: 18, lineHeight: 2}}>
|
||||
<div style={{color:'#555', fontSize: 10, letterSpacing:'0.2em',
|
||||
marginBottom: 12}}>▸ RESULT · top 5</div>
|
||||
{results.map((r, i) => {
|
||||
const visible = elapsed > (rowStart + i * rowStep);
|
||||
if (!visible) return <div key={i} style={{height: 36}}/>;
|
||||
const fadeIn = interpolate(elapsed,
|
||||
[rowStart + i*rowStep, rowStart + i*rowStep + 0.3],
|
||||
[0, 1]);
|
||||
return (
|
||||
<div key={i} style={{opacity: fadeIn, display:'flex',
|
||||
alignItems:'center', gap: 20}}>
|
||||
<span style={{color:'#fbbf24', minWidth: 40, textAlign:'right'}}>
|
||||
{String(r.count).padStart(3, ' ').replace(/ /g, '\u00A0')}
|
||||
</span>
|
||||
<span style={{color:'#e8e3d6'}}>{r.hex}</span>
|
||||
<span style={{color:'#666', fontSize: 14}}>←</span>
|
||||
<span style={{color:'#8a8878', fontStyle:'italic',
|
||||
fontFamily: serif, fontSize: 16}}>{r.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right: color swatches */}
|
||||
<div style={{display:'flex', flexDirection:'column', gap: 14}}>
|
||||
<div style={{color:'#555', fontFamily: mono, fontSize: 10,
|
||||
letterSpacing:'0.2em', marginBottom: 2}}>▸ PALETTE</div>
|
||||
{results.map((r, i) => {
|
||||
const visible = elapsed > (rowStart + i * rowStep);
|
||||
if (!visible) return <div key={i} style={{height: 54}}/>;
|
||||
const fadeIn = interpolate(elapsed,
|
||||
[rowStart + i*rowStep, rowStart + i*rowStep + 0.3],
|
||||
[0, 1]);
|
||||
const w = interpolate(elapsed,
|
||||
[rowStart + i*rowStep, rowStart + i*rowStep + 0.5],
|
||||
[0, r.count * 9]);
|
||||
return (
|
||||
<div key={i} style={{opacity: fadeIn, display:'flex',
|
||||
alignItems:'center', gap: 14, height: 50}}>
|
||||
<div style={{width: 50, height: 50, background: r.hex,
|
||||
border:'1px solid #333', flexShrink: 0}} />
|
||||
<div style={{height: 24, background: r.hex,
|
||||
width: `${w}px`, opacity: 0.7}} />
|
||||
<div style={{fontFamily: mono, fontSize: 12,
|
||||
color:'#8a8878'}}>×{r.count}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key caption */}
|
||||
<div style={{opacity: captionOp, textAlign:'center', marginTop: 18,
|
||||
paddingTop: 18, borderTop:'1px solid #333'}}>
|
||||
<span style={{fontFamily: serif, fontStyle:'italic', fontSize: 26,
|
||||
color: TERRA}}>
|
||||
不要凭记忆猜品牌色
|
||||
</span>
|
||||
<span style={{fontFamily: serif, fontSize: 20, color:'#8a8878',
|
||||
marginLeft: 14}}>
|
||||
——频次说了算
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 5: Step 5 · brand-spec.md (17 – 22s) ────────────
|
||||
function Scene5_SpecFile() {
|
||||
const { elapsed } = useSprite();
|
||||
const headOp = interpolate(elapsed, [0, 0.4], [0, 1]);
|
||||
const mdOp = interpolate(elapsed, [0.3, 0.9], [0, 1]);
|
||||
const mdX = interpolate(elapsed, [0.3, 0.9], [-40, 0], Easing.easeOut);
|
||||
const cssOp = interpolate(elapsed, [0.9, 1.5], [0, 1]);
|
||||
const cssX = interpolate(elapsed, [0.9, 1.5], [40, 0], Easing.easeOut);
|
||||
const arrowOp = interpolate(elapsed, [2.2, 2.7], [0, 1]);
|
||||
const captionOp = interpolate(elapsed, [3.4, 3.9], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [4.5, 5], [1, 0]);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
|
||||
padding:'64px 80px 48px', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{opacity: headOp, marginBottom: 28, display:'flex',
|
||||
justifyContent:'space-between', alignItems:'baseline'}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
letterSpacing:'0.3em', marginBottom: 4}}>STEP · 05 · FINAL</div>
|
||||
<div style={{fontFamily: serif, fontSize: 54, fontWeight: 500,
|
||||
color: INK, letterSpacing:'-0.01em'}}>
|
||||
固化为 <span style={{fontStyle:'italic'}}>brand-spec.md</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
|
||||
color: ASH, textAlign:'right'}}>
|
||||
单一真相源 · single source of truth
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{display:'grid', gridTemplateColumns:'1.1fr 0.9fr',
|
||||
gap: 40, flex:1, position:'relative'}}>
|
||||
{/* Left: .md preview */}
|
||||
<div style={{opacity: mdOp, transform:`translateX(${mdX}px)`,
|
||||
background:'#fff', border:`1px solid ${LINE}`,
|
||||
display:'flex', flexDirection:'column'}}>
|
||||
<div style={{padding:'12px 20px', borderBottom:`1px solid ${LINE}`,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'center',
|
||||
background:'#f7f2e8'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
|
||||
letterSpacing:'0.15em'}}>
|
||||
▸ brand-spec.md
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: TERRA,
|
||||
letterSpacing:'0.15em'}}>
|
||||
✓ COMMITTED
|
||||
</div>
|
||||
</div>
|
||||
<div style={{padding:'22px 28px', fontFamily: mono, fontSize: 12,
|
||||
lineHeight: 1.75, color: INK, flex:1, overflow:'hidden'}}>
|
||||
<div style={{color: ASH}}>---</div>
|
||||
<div><span style={{color: OLIVE}}>brand</span>: Kimi</div>
|
||||
<div><span style={{color: OLIVE}}>fetched</span>: 2026-04-20</div>
|
||||
<div style={{color: ASH}}>---</div>
|
||||
<div style={{height: 10}} />
|
||||
<div style={{fontFamily: serif, fontSize: 20, fontWeight: 500,
|
||||
marginBottom: 4}}>## 色板</div>
|
||||
<div>- primary: <span style={{color: TERRA}}>#1783FF</span> (47)</div>
|
||||
<div>- bg: <span style={{color: TERRA}}>#FAFAFA</span> (32)</div>
|
||||
<div>- ink: <span style={{color: TERRA}}>#1a1a1a</span> (18)</div>
|
||||
<div>- accent: <span style={{color: TERRA}}>#FF6B35</span> (12)</div>
|
||||
<div style={{height: 12}} />
|
||||
<div style={{fontFamily: serif, fontSize: 20, fontWeight: 500,
|
||||
marginBottom: 4}}>## 字型</div>
|
||||
<div>- display: <span style={{color: OLIVE}}>Newsreader</span></div>
|
||||
<div>- body: <span style={{color: OLIVE}}>Inter</span></div>
|
||||
<div>- mono: <span style={{color: OLIVE}}>JetBrains Mono</span></div>
|
||||
<div style={{height: 12}} />
|
||||
<div style={{fontFamily: serif, fontSize: 20, fontWeight: 500,
|
||||
marginBottom: 4}}>## 签名细节</div>
|
||||
<div>- 1px hairlines</div>
|
||||
<div>- 圆角半径统一 4px</div>
|
||||
<div style={{height: 12}} />
|
||||
<div style={{fontFamily: serif, fontSize: 20, fontWeight: 500,
|
||||
marginBottom: 4, color: TERRA}}>## 禁区</div>
|
||||
<div style={{color: ASH}}>- 不用紫渐变 / emoji / 4px 以上 shadow</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: CSS vars + consumers */}
|
||||
<div style={{opacity: cssOp, transform:`translateX(${cssX}px)`,
|
||||
display:'flex', flexDirection:'column', gap: 20}}>
|
||||
<div style={{background:'#0e0e0e', color:'#e8e3d6',
|
||||
fontFamily: mono, fontSize: 14, padding:'22px 26px',
|
||||
lineHeight: 1.7, flex: 1}}>
|
||||
<div style={{color:'#666', fontSize: 10, letterSpacing:'0.2em',
|
||||
marginBottom: 12}}>/* tokens.css */</div>
|
||||
<div><span style={{color:'#7dd3fc'}}>:root</span> {'{'}</div>
|
||||
<div> <span style={{color:'#a5f3c5'}}>--brand-primary</span>: <span style={{color:'#fbbf24'}}>#1783FF</span>;</div>
|
||||
<div> <span style={{color:'#a5f3c5'}}>--brand-bg</span>: <span style={{color:'#fbbf24'}}>#FAFAFA</span>;</div>
|
||||
<div> <span style={{color:'#a5f3c5'}}>--brand-ink</span>: <span style={{color:'#fbbf24'}}>#1a1a1a</span>;</div>
|
||||
<div> <span style={{color:'#a5f3c5'}}>--brand-accent</span>: <span style={{color:'#fbbf24'}}>#FF6B35</span>;</div>
|
||||
<div>{'}'}</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow cascade */}
|
||||
<div style={{opacity: arrowOp, position:'relative',
|
||||
background:'#fff', border:`1px solid ${LINE}`,
|
||||
padding:'20px 24px'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: TERRA,
|
||||
letterSpacing:'0.2em', marginBottom: 12}}>▸ CONSUMERS</div>
|
||||
<div style={{display:'flex', alignItems:'center', gap: 16,
|
||||
fontFamily: mono, fontSize: 13, color: INK, flexWrap:'wrap'}}>
|
||||
<span style={{padding:'4px 10px', border:`1px solid ${TERRA}`,
|
||||
color: TERRA}}>brand-spec.md</span>
|
||||
<span style={{color: TERRA}}>→</span>
|
||||
<span style={{padding:'4px 10px', border:`1px solid ${INK}`}}>:root</span>
|
||||
<span style={{color: TERRA}}>→</span>
|
||||
<span style={{padding:'4px 10px', background: INK, color:'#fff'}}>all .html</span>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
|
||||
color: ASH, marginTop: 14, lineHeight: 1.5}}>
|
||||
一个文件改动,所有 HTML 自动跟随。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{opacity: captionOp, marginTop: 22, textAlign:'center',
|
||||
fontFamily: serif, fontStyle:'italic', fontSize: 22, color: INK}}>
|
||||
全协议的<span style={{color: TERRA}}>制胜点</span>——让所有 CSS 引用这份文件
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 6: Final (22 – 24s) ─────────────────────────────
|
||||
function Scene6_Final() {
|
||||
const { elapsed } = useSprite();
|
||||
const fadeIn = interpolate(elapsed, [0, 0.5], [0, 1], Easing.easeOut);
|
||||
const titleY = interpolate(elapsed, [0, 0.8], [24, 0], Easing.easeOut);
|
||||
const lineW = interpolate(elapsed, [0.5, 1.3], [0, 580]);
|
||||
const subOp = interpolate(elapsed, [0.8, 1.4], [0, 1]);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeIn,
|
||||
display:'flex', alignItems:'center', justifyContent:'center',
|
||||
flexDirection:'column'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
|
||||
color: TERRA, marginBottom: 24}}>
|
||||
01 → 02 → 03 → 04 → <span style={{color: INK}}>05 · DONE</span>
|
||||
</div>
|
||||
|
||||
<div style={{fontFamily: serif, fontSize: 92, fontWeight: 500,
|
||||
color: INK, lineHeight: 1.1, letterSpacing:'-0.01em',
|
||||
textAlign:'center', transform: `translateY(${titleY}px)`}}>
|
||||
<span style={{fontStyle:'italic', color: TERRA}}>15 分钟</span>的投资<br/>
|
||||
省下 <span style={{fontStyle:'italic'}}>1–2 小时</span> 返工
|
||||
</div>
|
||||
|
||||
<div style={{height:1, background: INK, width: lineW, marginTop: 40}} />
|
||||
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24,
|
||||
color: ASH, marginTop: 28, opacity: subOp,
|
||||
letterSpacing:'0.02em'}}>
|
||||
这是稳定性最便宜的投资。
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Watermark ─────────────────────────────────────────────
|
||||
function Watermark() {
|
||||
return (
|
||||
<div style={{position:'absolute', bottom: 24, right: 32,
|
||||
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
|
||||
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
|
||||
Created by Huashu-Design
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main composition ──────────────────────────────────────
|
||||
function App() {
|
||||
return (
|
||||
<Stage duration={24} width={1920} height={1080} bgColor={CREAM}>
|
||||
<Sprite start={0} end={3}><Scene1_Trigger /></Sprite>
|
||||
<Sprite start={3} end={7}><Scene2_AskAndSearch /></Sprite>
|
||||
<Sprite start={7} end={12}><Scene3_Fallbacks /></Sprite>
|
||||
<Sprite start={12} end={17}><Scene4_GrepColors /></Sprite>
|
||||
<Sprite start={17} end={22}><Scene5_SpecFile /></Sprite>
|
||||
<Sprite start={22} end={24}><Scene6_Final /></Sprite>
|
||||
<Watermark />
|
||||
</Stage>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,856 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Huashu-Design · Junior Designer 模式</title>
|
||||
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<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,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; }
|
||||
body {
|
||||
background: #0c0c0c;
|
||||
font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
|
||||
color: #1a1a1a;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- animations.jsx inlined -->
|
||||
<script type="text/babel">
|
||||
(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;
|
||||
},
|
||||
};
|
||||
|
||||
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() { return useContext(TimeContext).time; }
|
||||
function useSprite() {
|
||||
const sprite = useContext(SpriteContext);
|
||||
return sprite || { t: 0, elapsed: 0, duration: 0 };
|
||||
}
|
||||
|
||||
function Stage({ duration = 10, width = 1920, height = 1080, 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 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) {
|
||||
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) return effectiveLoop ? 0 : duration - 0.001;
|
||||
return next;
|
||||
});
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
|
||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
|
||||
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
|
||||
}, [playing, duration, effectiveLoop]);
|
||||
|
||||
const progress = time / duration;
|
||||
const ctx = { time, duration, playing, setPlaying, setTime };
|
||||
|
||||
const canvasStyle = {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transformOrigin: 'center center',
|
||||
width,
|
||||
height,
|
||||
background: bgColor,
|
||||
overflow: 'hidden',
|
||||
transform: `translate(-50%, -50%) scale(${scale})`,
|
||||
};
|
||||
|
||||
return (
|
||||
<TimeContext.Provider value={ctx}>
|
||||
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
|
||||
<div style={canvasStyle}>{children}</div>
|
||||
</div>
|
||||
<div className="no-record" style={{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 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>
|
||||
<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>
|
||||
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
|
||||
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
|
||||
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Demo scene -->
|
||||
<script type="text/babel">
|
||||
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
|
||||
|
||||
// ── Design tokens ─────────────────────────────────────────
|
||||
const CREAM = '#FAF6EF';
|
||||
const INK = '#1a1a1a';
|
||||
const TERRA = '#C04A1A';
|
||||
const ASH = '#6b6b6b';
|
||||
const LINE = '#d9d2c5';
|
||||
const OLIVE = '#6a6b4e';
|
||||
|
||||
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
|
||||
const sans = "'Inter', -apple-system, sans-serif";
|
||||
const mono = "'JetBrains Mono', ui-monospace, monospace";
|
||||
|
||||
// ── Scene 1: Two working modes contrast (0 – 3s) ──────────
|
||||
function Scene1_Contrast() {
|
||||
const { elapsed } = useSprite();
|
||||
const titleOp = interpolate(elapsed, [0, 0.6], [0, 1]);
|
||||
const leftOp = interpolate(elapsed, [0.3, 1], [0, 1]);
|
||||
const rightOp = interpolate(elapsed, [0.5, 1.2], [0, 1]);
|
||||
|
||||
// Left: long bar filling slowly, then user frown at end
|
||||
const badBar = Math.min(1, Math.max(0, (elapsed - 1.0) / 1.6));
|
||||
const badReveal = elapsed > 2.5 ? 1 : 0;
|
||||
|
||||
// Right: small milestones ticking one by one
|
||||
const ticks = [0.9, 1.3, 1.7, 2.1, 2.5];
|
||||
const fadeOut = interpolate(elapsed, [2.85, 3], [1, 0], Easing.easeIn);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
|
||||
padding:'80px 120px', display:'flex', flexDirection:'column'}}>
|
||||
|
||||
{/* Title */}
|
||||
<div style={{textAlign:'center', marginBottom: 60, opacity: titleOp}}>
|
||||
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
|
||||
color: TERRA, marginBottom: 16}}>
|
||||
JUNIOR DESIGNER MODE · 工作方式对比
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 68, fontWeight: 500,
|
||||
color: INK, lineHeight: 1.1, letterSpacing:'-0.01em'}}>
|
||||
理解错了 <span style={{fontStyle:'italic', color: TERRA}}>早改</span> 比 <span style={{fontStyle:'italic', color: TERRA}}>晚改</span> 便宜 100 倍
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two columns */}
|
||||
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap: 60, flex: 1}}>
|
||||
|
||||
{/* Left: bad mode */}
|
||||
<div style={{opacity: leftOp, background:'#f4eee3', padding:'40px 44px',
|
||||
display:'flex', flexDirection:'column', position:'relative'}}>
|
||||
<div style={{display:'flex', alignItems:'center', gap: 14, marginBottom: 28}}>
|
||||
<div style={{width: 32, height: 32, borderRadius:'50%',
|
||||
background:'#ccc', color:'#fff', fontFamily: serif, fontSize: 20,
|
||||
fontWeight: 600, display:'flex', alignItems:'center',
|
||||
justifyContent:'center', lineHeight: 1}}>✗</div>
|
||||
<div style={{fontFamily: serif, fontSize: 30, fontWeight: 500,
|
||||
color:'#555'}}>闷头做大招</div>
|
||||
</div>
|
||||
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: ASH,
|
||||
letterSpacing: '0.2em', marginBottom: 14}}>
|
||||
6 HOURS · 0 FEEDBACK
|
||||
</div>
|
||||
|
||||
<div style={{height: 18, background:'#e2dbcd', position:'relative',
|
||||
marginBottom: 36}}>
|
||||
<div style={{position:'absolute', top:0, left:0, height:'100%',
|
||||
width: `${badBar * 100}%`, background:'#999'}} />
|
||||
</div>
|
||||
|
||||
<div style={{fontFamily: serif, fontSize: 16, color: ASH,
|
||||
lineHeight: 1.7, flex: 1}}>
|
||||
埋头 6 小时<br/>
|
||||
一次性端出成品<br/>
|
||||
默祷「方向没理解错」
|
||||
</div>
|
||||
|
||||
{badReveal > 0 && (
|
||||
<div style={{marginTop: 20, padding:'14px 18px', background:'#fff',
|
||||
border: `1px solid #d0c8b8`, opacity: badReveal,
|
||||
display:'flex', alignItems:'center', gap: 12}}>
|
||||
<div style={{fontFamily: serif, fontSize: 22, color:'#888'}}>😐</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
|
||||
color:'#666', lineHeight: 1.4}}>
|
||||
「这方向不对…<br/>得重来」
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: good mode */}
|
||||
<div style={{opacity: rightOp, background:'#fff', padding:'40px 44px',
|
||||
border: `1.5px solid ${TERRA}`, display:'flex', flexDirection:'column',
|
||||
position:'relative'}}>
|
||||
<div style={{display:'flex', alignItems:'center', gap: 14, marginBottom: 28}}>
|
||||
<div style={{width: 32, height: 32, borderRadius:'50%',
|
||||
background: TERRA, color:'#fff', fontFamily: serif, fontSize: 20,
|
||||
fontWeight: 600, display:'flex', alignItems:'center',
|
||||
justifyContent:'center', lineHeight: 1}}>✓</div>
|
||||
<div style={{fontFamily: serif, fontSize: 30, fontWeight: 500,
|
||||
color: INK}}>Junior Designer 模式</div>
|
||||
</div>
|
||||
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
letterSpacing: '0.2em', marginBottom: 14}}>
|
||||
5 CHECKPOINTS · CONTINUOUS FEEDBACK
|
||||
</div>
|
||||
|
||||
{/* Timeline with ticks */}
|
||||
<div style={{position:'relative', height: 18, marginBottom: 36}}>
|
||||
<div style={{position:'absolute', top: 8, left: 0, right: 0,
|
||||
height: 2, background:'#e8e1d3'}} />
|
||||
{ticks.map((tick, i) => {
|
||||
const show = elapsed > tick ? 1 : 0;
|
||||
const pct = (i + 1) / ticks.length * 100;
|
||||
return (
|
||||
<div key={i} style={{position:'absolute',
|
||||
left: `calc(${pct}% - 10px)`, top: 0, width: 20, height: 20,
|
||||
borderRadius:'50%', background: TERRA, color:'#fff',
|
||||
fontSize: 11, display:'flex', alignItems:'center',
|
||||
justifyContent:'center', opacity: show,
|
||||
transform: `scale(${show})`, transition:'none',
|
||||
fontFamily: sans, fontWeight: 600}}>✓</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{fontFamily: serif, fontSize: 16, color: INK,
|
||||
lineHeight: 1.7, flex: 1}}>
|
||||
假设 · 骨架 · 粗图<br/>
|
||||
每步都 show 一下<br/>
|
||||
错得早,改得小
|
||||
</div>
|
||||
|
||||
{elapsed > 2.5 && (
|
||||
<div style={{marginTop: 20, padding:'14px 18px', background:'#faf6ef',
|
||||
border: `1px solid ${LINE}`, display:'flex', alignItems:'center', gap: 12,
|
||||
opacity: interpolate(elapsed, [2.5, 2.85], [0, 1])}}>
|
||||
<div style={{fontFamily: serif, fontSize: 22}}>🙂</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
|
||||
color: INK, lineHeight: 1.4}}>
|
||||
「方向对,<br/>继续推进」
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 2: HTML skeleton with assumptions + placeholders (3 – 8s) ──
|
||||
function Scene2_Skeleton() {
|
||||
const { elapsed } = useSprite();
|
||||
const opacity = interpolate(elapsed, [0, 0.5], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [4.6, 5], [1, 0]);
|
||||
|
||||
// Typewriter content
|
||||
const fullText = `<!--
|
||||
ASSUMPTIONS:
|
||||
- 主色用暖调橙(未确认,等品牌 spec)
|
||||
- 3 屏切换,Today / Memory / Chat
|
||||
- 字体:Newsreader + Noto Serif SC
|
||||
|
||||
PLACEHOLDERS:
|
||||
- Hero 图暂用灰块 + 文字标签(有图再替换)
|
||||
- AI 洞察文本用「等用户提供真实数据」
|
||||
|
||||
REASONING:
|
||||
- 选橙调是因为客户说要「温暖感」
|
||||
- Serif 因为是阅读类 App
|
||||
-->`;
|
||||
|
||||
// Type at ~50 chars/sec
|
||||
const charCount = Math.floor(interpolate(elapsed, [0.4, 4.0], [0, fullText.length]));
|
||||
const shownText = fullText.slice(0, charCount);
|
||||
const cursorBlink = Math.floor(elapsed * 2.4) % 2 === 0 && charCount < fullText.length;
|
||||
|
||||
// Hint callout appears after most text typed
|
||||
const hintOp = interpolate(elapsed, [3.5, 4.2], [0, 1]);
|
||||
const hintBob = Math.sin(elapsed * 3) * 4;
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM, opacity: opacity * fadeOut,
|
||||
padding:'60px 100px', display:'flex', flexDirection:'column'}}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline', marginBottom: 32}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
letterSpacing: '0.3em', marginBottom: 6}}>STEP 1 / 3</div>
|
||||
<div style={{fontFamily: serif, fontSize: 52, fontWeight: 500, color: INK}}>
|
||||
先写 <span style={{fontStyle:'italic', color: TERRA}}>骨架</span> · 不写渲染
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
|
||||
color: ASH, textAlign: 'right', lineHeight: 1.5}}>
|
||||
Assumptions + Placeholders + Reasoning<br/>
|
||||
<span style={{fontSize: 14}}>— 把「猜」写在代码里</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div style={{flex: 1, background:'#1a1a1a', position:'relative',
|
||||
display:'flex', flexDirection:'column', overflow:'hidden'}}>
|
||||
|
||||
{/* Editor top bar */}
|
||||
<div style={{padding:'12px 20px', background:'#222',
|
||||
borderBottom:'1px solid #2e2e2e', display:'flex',
|
||||
alignItems:'center', gap: 16}}>
|
||||
<div style={{display:'flex', gap: 8}}>
|
||||
<div style={{width: 12, height: 12, borderRadius:'50%', background:'#ff5f57'}} />
|
||||
<div style={{width: 12, height: 12, borderRadius:'50%', background:'#febc2e'}} />
|
||||
<div style={{width: 12, height: 12, borderRadius:'50%', background:'#28c840'}} />
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color:'#888',
|
||||
letterSpacing: '0.1em'}}>
|
||||
index.html · junior-pass-v0
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code body */}
|
||||
<div style={{flex: 1, padding:'32px 40px', fontFamily: mono,
|
||||
fontSize: 19, lineHeight: 1.7, color:'#8a8a8a',
|
||||
whiteSpace:'pre-wrap', position:'relative'}}>
|
||||
<span style={{color:'#6a8a56'}}>{shownText}</span>
|
||||
{cursorBlink && <span style={{background:'#c04a1a', display:'inline-block',
|
||||
width: 10, height: 22, verticalAlign:'text-bottom'}} />}
|
||||
</div>
|
||||
|
||||
{/* Bottom status bar */}
|
||||
<div style={{padding:'10px 20px', background:'#161616',
|
||||
borderTop:'1px solid #2a2a2a', display:'flex',
|
||||
justifyContent:'space-between', fontFamily: mono, fontSize: 10,
|
||||
color:'#666', letterSpacing:'0.12em'}}>
|
||||
<span>HTML · COMMENTS ONLY</span>
|
||||
<span>LN {Math.min(15, Math.floor(charCount / 30) + 1)} / 15</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Callout */}
|
||||
<div style={{position:'absolute', right: 120, bottom: 100,
|
||||
opacity: hintOp, transform: `translateY(${hintBob}px)`,
|
||||
display:'flex', alignItems:'center', gap: 14}}>
|
||||
<svg width="48" height="24" viewBox="0 0 48 24">
|
||||
<path d="M 46 12 L 6 12 M 14 6 L 6 12 L 14 18"
|
||||
fill="none" stroke={TERRA} strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22,
|
||||
color: TERRA, letterSpacing:'0.02em'}}>
|
||||
还没写一行渲染代码
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 3: First show to user (8 – 13s) ─────────────────
|
||||
function Scene3_FirstShow() {
|
||||
const { elapsed } = useSprite();
|
||||
const opacity = interpolate(elapsed, [0, 0.5], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [4.6, 5], [1, 0]);
|
||||
|
||||
// Wireframe fade in
|
||||
const wireOp = interpolate(elapsed, [0.2, 0.9], [0, 1]);
|
||||
|
||||
// Chat bubbles stagger in
|
||||
const b1Op = interpolate(elapsed, [1.0, 1.5], [0, 1]);
|
||||
const b2Op = interpolate(elapsed, [2.0, 2.5], [0, 1]);
|
||||
const b3Op = interpolate(elapsed, [2.9, 3.4], [0, 1]);
|
||||
|
||||
// Bottom subtitle
|
||||
const subOp = interpolate(elapsed, [3.8, 4.3], [0, 1]);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM, opacity: opacity * fadeOut,
|
||||
padding:'60px 100px', display:'flex', flexDirection:'column'}}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline', marginBottom: 28}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
letterSpacing: '0.3em', marginBottom: 6}}>STEP 2 / 3</div>
|
||||
<div style={{fontFamily: serif, fontSize: 52, fontWeight: 500, color: INK}}>
|
||||
第一次 <span style={{fontStyle:'italic', color: TERRA}}>show</span> 给用户
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
|
||||
color: ASH, textAlign:'right', lineHeight: 1.5}}>
|
||||
「先给你看方向 —— 对吗?」<br/>
|
||||
<span style={{fontSize: 14}}>— 10 分钟对齐</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two panels */}
|
||||
<div style={{display:'grid', gridTemplateColumns:'1fr 1px 1.1fr', gap: 40,
|
||||
flex: 1, alignItems:'stretch'}}>
|
||||
|
||||
{/* Left: wireframe */}
|
||||
<div style={{background:'#fff', border: `1px solid ${LINE}`,
|
||||
padding: 28, opacity: wireOp, display:'flex', flexDirection:'column'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
|
||||
letterSpacing:'0.2em', marginBottom: 14}}>
|
||||
WIREFRAME · NO STYLING
|
||||
</div>
|
||||
|
||||
{/* Hero placeholder */}
|
||||
<div style={{height: 180, background:'#ececec', border:'1.5px dashed #bbb',
|
||||
display:'flex', alignItems:'center', justifyContent:'center',
|
||||
fontFamily: mono, fontSize: 13, color:'#888',
|
||||
letterSpacing:'0.15em', marginBottom: 20}}>
|
||||
[ HERO IMAGE ]
|
||||
</div>
|
||||
|
||||
{/* Title placeholder */}
|
||||
<div style={{height: 26, background:'#e5e0d3', width:'75%',
|
||||
marginBottom: 10}} />
|
||||
<div style={{height: 16, background:'#ede8db', width:'55%',
|
||||
marginBottom: 24}} />
|
||||
|
||||
{/* AI insight block */}
|
||||
<div style={{background:'#f5f0e3', padding:'16px 18px', marginBottom: 20,
|
||||
border: `1px dashed ${LINE}`}}>
|
||||
<div style={{fontFamily: mono, fontSize: 9, color: ASH,
|
||||
letterSpacing:'0.2em', marginBottom: 8}}>[ AI INSIGHT · 3 行 ]</div>
|
||||
<div style={{height: 8, background:'#d9d2c5', width:'92%', marginBottom: 6}} />
|
||||
<div style={{height: 8, background:'#d9d2c5', width:'88%', marginBottom: 6}} />
|
||||
<div style={{height: 8, background:'#d9d2c5', width:'70%'}} />
|
||||
</div>
|
||||
|
||||
<div style={{fontFamily: serif, fontSize: 14, color:'#888',
|
||||
fontStyle:'italic', marginTop:'auto', textAlign:'center',
|
||||
paddingTop: 14, borderTop: `1px solid ${LINE}`}}>
|
||||
Marcus Aurelius · 第四卷
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{background: LINE, width: 1}} />
|
||||
|
||||
{/* Right: chat */}
|
||||
<div style={{display:'flex', flexDirection:'column', gap: 18,
|
||||
paddingTop: 10}}>
|
||||
{/* Designer bubble */}
|
||||
<div style={{opacity: b1Op, display:'flex', gap: 14,
|
||||
alignItems:'flex-start'}}>
|
||||
<div style={{width: 40, height: 40, background: TERRA, color:'#fff',
|
||||
fontFamily: serif, fontSize: 16, fontWeight: 600,
|
||||
display:'flex', alignItems:'center', justifyContent:'center',
|
||||
flexShrink: 0, borderRadius: 2, letterSpacing:'0.05em'}}>JD</div>
|
||||
<div style={{background:'#fff', border: `1px solid ${LINE}`,
|
||||
padding:'16px 20px', maxWidth: '85%'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 9, color: ASH,
|
||||
letterSpacing:'0.2em', marginBottom: 8}}>
|
||||
JUNIOR DESIGNER
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 19, color: INK,
|
||||
lineHeight: 1.5}}>
|
||||
我对方向有几个假设,先给你看线框——想法对吗?
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User bubble 1 */}
|
||||
<div style={{opacity: b2Op, display:'flex', gap: 14,
|
||||
alignItems:'flex-start', justifyContent:'flex-end'}}>
|
||||
<div style={{background: OLIVE, color:'#fff',
|
||||
padding:'14px 20px', maxWidth: '80%'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 9,
|
||||
color:'rgba(255,255,255,0.6)', letterSpacing:'0.2em',
|
||||
marginBottom: 8}}>USER · FEEDBACK</div>
|
||||
<div style={{fontFamily: serif, fontSize: 18, lineHeight: 1.5}}>
|
||||
橙调 OK。AI 洞察那块我想要 <span style={{fontStyle:'italic',
|
||||
textDecoration:'underline'}}>两行</span> 不要三行
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User bubble 2 */}
|
||||
<div style={{opacity: b3Op, display:'flex', gap: 14,
|
||||
alignItems:'flex-start', justifyContent:'flex-end'}}>
|
||||
<div style={{background: OLIVE, color:'#fff',
|
||||
padding:'14px 20px', maxWidth: '80%'}}>
|
||||
<div style={{fontFamily: serif, fontSize: 18, lineHeight: 1.5}}>
|
||||
Hero 图要 <span style={{fontStyle:'italic',
|
||||
textDecoration:'underline'}}>摄影</span> 不要插画
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<div style={{marginTop:'auto', paddingTop: 24, opacity: subOp,
|
||||
borderTop: `1px solid ${LINE}`}}>
|
||||
<div style={{fontFamily: mono, fontSize: 11,
|
||||
color: TERRA, letterSpacing:'0.3em', marginBottom: 6}}>
|
||||
ALIGNMENT
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 30, fontWeight: 500,
|
||||
color: INK, lineHeight: 1.2}}>
|
||||
对齐 = <span style={{color: TERRA}}>10 分钟</span>
|
||||
<span style={{fontStyle:'italic', color: ASH, fontSize: 22}}> · 不是 2 小时</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ArtBlock: oil painting hero (from c1-ios-prototype) ───
|
||||
function ArtBlock({ mood = 'warm' }) {
|
||||
const palettes = {
|
||||
warm: ['#8b4a2b', '#c67b4a', '#e3a876', '#f2d4a7'],
|
||||
quiet: ['#3d4a3a', '#6a8066', '#a8b89c', '#e0d8b8'],
|
||||
};
|
||||
const p = palettes[mood];
|
||||
return (
|
||||
<div style={{
|
||||
width:'100%', height:'100%', position:'relative', overflow:'hidden',
|
||||
background: `linear-gradient(135deg, ${p[0]} 0%, ${p[1]} 35%, ${p[2]} 70%, ${p[3]} 100%)`,
|
||||
}}>
|
||||
<div style={{
|
||||
position:'absolute', inset: 0,
|
||||
background: `
|
||||
radial-gradient(ellipse 80px 30px at 30% 40%, ${p[3]}44, transparent 70%),
|
||||
radial-gradient(ellipse 60px 20px at 70% 60%, ${p[0]}33, transparent 70%),
|
||||
radial-gradient(ellipse 100px 40px at 50% 80%, ${p[2]}44, transparent 70%),
|
||||
radial-gradient(ellipse 50px 25px at 20% 70%, ${p[1]}55, transparent 70%)
|
||||
`,
|
||||
filter:'blur(1px)',
|
||||
}} />
|
||||
<svg width="100%" height="100%" style={{position:'absolute', inset:0, opacity: 0.18}}>
|
||||
<filter id="paint-noise-w2">
|
||||
<feTurbulence baseFrequency="0.9" numOctaves="2" />
|
||||
<feColorMatrix values="0 0 0 0 0.3 0 0 0 0 0.2 0 0 0 0 0.1 0 0 0 1 0" />
|
||||
</filter>
|
||||
<rect width="100%" height="100%" filter="url(#paint-noise-w2)" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 4: Full pass fills in (13 – 18s) ────────────────
|
||||
function Scene4_FullPass() {
|
||||
const { elapsed } = useSprite();
|
||||
const opacity = interpolate(elapsed, [0, 0.5], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [4.6, 5], [1, 0]);
|
||||
|
||||
// Staggered reveal of real content
|
||||
const heroReveal = interpolate(elapsed, [0.6, 1.8], [0, 1], Easing.easeOut);
|
||||
const titleReveal = interpolate(elapsed, [1.5, 2.3], [0, 1]);
|
||||
const insightReveal = interpolate(elapsed, [2.2, 3.1], [0, 1]);
|
||||
const meta = interpolate(elapsed, [3.0, 3.8], [0, 1]);
|
||||
|
||||
// Bottom subtitle
|
||||
const subOp = interpolate(elapsed, [3.9, 4.4], [0, 1]);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM, opacity: opacity * fadeOut,
|
||||
padding:'60px 100px', display:'flex', flexDirection:'column'}}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline', marginBottom: 28}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
letterSpacing: '0.3em', marginBottom: 6}}>STEP 3 / 3</div>
|
||||
<div style={{fontFamily: serif, fontSize: 52, fontWeight: 500, color: INK}}>
|
||||
Full pass · <span style={{fontStyle:'italic', color: TERRA}}>灰块变真图</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
|
||||
color: ASH, textAlign:'right', lineHeight: 1.5}}>
|
||||
Placeholders → Content<br/>
|
||||
<span style={{fontSize: 14}}>— 方向对了再填色</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two panels: before wireframe (thumbnail) vs after (full card) */}
|
||||
<div style={{display:'grid', gridTemplateColumns:'0.55fr 60px 1fr', gap: 0,
|
||||
flex: 1, alignItems:'center'}}>
|
||||
|
||||
{/* Left: wireframe thumbnail, still, for contrast */}
|
||||
<div style={{background:'#fff', border: `1px solid ${LINE}`,
|
||||
padding: 20, opacity: 0.55, transform:'scale(0.92)',
|
||||
transformOrigin:'center right'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 9, color: ASH,
|
||||
letterSpacing:'0.2em', marginBottom: 10}}>
|
||||
BEFORE
|
||||
</div>
|
||||
<div style={{height: 100, background:'#ececec',
|
||||
border:'1.5px dashed #bbb', display:'flex',
|
||||
alignItems:'center', justifyContent:'center',
|
||||
fontFamily: mono, fontSize: 10, color:'#888',
|
||||
letterSpacing:'0.15em', marginBottom: 12}}>
|
||||
[ HERO IMAGE ]
|
||||
</div>
|
||||
<div style={{height: 14, background:'#e5e0d3', width:'75%', marginBottom: 6}} />
|
||||
<div style={{height: 10, background:'#ede8db', width:'55%', marginBottom: 14}} />
|
||||
<div style={{background:'#f5f0e3', padding:'10px 12px',
|
||||
border: `1px dashed ${LINE}`}}>
|
||||
<div style={{height: 6, background:'#d9d2c5', width:'92%', marginBottom: 4}} />
|
||||
<div style={{height: 6, background:'#d9d2c5', width:'88%', marginBottom: 4}} />
|
||||
<div style={{height: 6, background:'#d9d2c5', width:'70%'}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div style={{display:'flex', alignItems:'center', justifyContent:'center'}}>
|
||||
<svg width="40" height="24" viewBox="0 0 40 24">
|
||||
<path d="M 2 12 L 38 12 M 30 4 L 38 12 L 30 20"
|
||||
fill="none" stroke={TERRA} strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Right: full card */}
|
||||
<div style={{background:'#fff', border: `1px solid ${LINE}`,
|
||||
boxShadow:'0 20px 60px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.04)',
|
||||
display:'flex', flexDirection:'column', overflow:'hidden'}}>
|
||||
|
||||
<div style={{padding:'14px 24px', borderBottom:`1px solid ${LINE}`,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'center'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: TERRA,
|
||||
letterSpacing:'0.25em'}}>AFTER · FULL PASS</div>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
|
||||
letterSpacing:'0.15em'}}>STOIC READER · v0.3</div>
|
||||
</div>
|
||||
|
||||
{/* Hero image: oil painting */}
|
||||
<div style={{height: 220, position:'relative', overflow:'hidden'}}>
|
||||
<div style={{position:'absolute', inset: 0, opacity: heroReveal}}>
|
||||
<ArtBlock mood="warm" />
|
||||
</div>
|
||||
{heroReveal < 0.95 && (
|
||||
<div style={{position:'absolute', inset: 0, background:'#ececec',
|
||||
opacity: 1 - heroReveal, display:'flex', alignItems:'center',
|
||||
justifyContent:'center', fontFamily: mono, fontSize: 12,
|
||||
color:'#888', letterSpacing:'0.15em',
|
||||
border:'1.5px dashed #bbb'}}>
|
||||
[ HERO IMAGE ]
|
||||
</div>
|
||||
)}
|
||||
{/* Photo credit */}
|
||||
<div style={{position:'absolute', bottom: 10, right: 12,
|
||||
fontFamily: mono, fontSize: 9, color:'rgba(255,255,255,0.7)',
|
||||
letterSpacing:'0.2em', opacity: heroReveal}}>
|
||||
PHOTO · J. TURNER
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div style={{padding:'26px 32px 10px', opacity: titleReveal}}>
|
||||
<div style={{fontFamily: serif, fontSize: 36, fontWeight: 500,
|
||||
color: INK, lineHeight: 1.15, letterSpacing:'-0.01em',
|
||||
marginBottom: 6}}>
|
||||
The Obstacle<br/>
|
||||
<span style={{fontStyle:'italic', color: TERRA}}>is the Way</span>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
|
||||
color: ASH}}>
|
||||
Marcus Aurelius · 第四卷 · 今日阅读
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI insight — NOW 2 lines, not 3 */}
|
||||
<div style={{margin:'10px 32px 20px', padding:'16px 20px',
|
||||
background:'#faf6ef', borderLeft: `3px solid ${TERRA}`,
|
||||
opacity: insightReveal}}>
|
||||
<div style={{fontFamily: mono, fontSize: 9, color: TERRA,
|
||||
letterSpacing:'0.25em', marginBottom: 8}}>AI INSIGHT · 2 LINES</div>
|
||||
<div style={{fontFamily: serif, fontSize: 15, color: INK,
|
||||
lineHeight: 1.55}}>
|
||||
你最近笔记里「困难」出现 7 次。<br/>
|
||||
Aurelius 今天正好在说:障碍本身就是路。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta row */}
|
||||
<div style={{padding:'14px 32px', borderTop:`1px solid ${LINE}`,
|
||||
display:'flex', justifyContent:'space-between',
|
||||
fontFamily: mono, fontSize: 10, color: ASH,
|
||||
letterSpacing:'0.15em', opacity: meta}}>
|
||||
<span>READ · 4 MIN</span>
|
||||
<span>MEMORY · 12 NOTES</span>
|
||||
<span>CHAT · ASK AURELIUS</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<div style={{textAlign:'center', marginTop: 30, opacity: subOp}}>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22,
|
||||
color: INK}}>
|
||||
迭代到 80%,再做最后 20% 的抛光
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 5: Closing + 4 checkpoints (18 – 22s) ───────────
|
||||
function Scene5_Closing() {
|
||||
const { elapsed } = useSprite();
|
||||
const opacity = interpolate(elapsed, [0, 0.6], [0, 1]);
|
||||
|
||||
const titleY = interpolate(elapsed, [0, 1.0], [30, 0], Easing.easeOut);
|
||||
const lineW = interpolate(elapsed, [0.8, 1.6], [0, 620]);
|
||||
|
||||
const items = [
|
||||
{ text: '问 clarifying questions(一次一批)' },
|
||||
{ text: '写 assumptions + placeholders' },
|
||||
{ text: '尽早 show(哪怕只是灰块)' },
|
||||
{ text: '迭代再抛光,不一次做完' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM, opacity,
|
||||
display:'flex', alignItems:'center', justifyContent:'center',
|
||||
flexDirection:'column', padding:'60px 120px'}}>
|
||||
|
||||
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
|
||||
color: TERRA, marginBottom: 24}}>
|
||||
JUNIOR DESIGNER · RECAP
|
||||
</div>
|
||||
|
||||
<div style={{fontFamily: serif, fontSize: 100, fontWeight: 500,
|
||||
color: INK, lineHeight: 1.05, letterSpacing:'-0.015em',
|
||||
transform: `translateY(${titleY}px)`, textAlign:'center'}}>
|
||||
早 <span style={{fontStyle:'italic', color: TERRA}}>show</span>
|
||||
<span style={{color: ASH, fontWeight: 400, margin:'0 18px'}}>·</span>
|
||||
早 <span style={{fontStyle:'italic', color: TERRA}}>改</span>
|
||||
<span style={{color: ASH, fontWeight: 400, margin:'0 18px'}}>·</span>
|
||||
早 <span style={{fontStyle:'italic', color: TERRA}}>对齐</span>
|
||||
</div>
|
||||
|
||||
<div style={{height: 1, background: INK, width: lineW, marginTop: 34,
|
||||
marginBottom: 46}} />
|
||||
|
||||
{/* 4 checkpoints */}
|
||||
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:'18px 60px',
|
||||
maxWidth: 1100, width:'100%'}}>
|
||||
{items.map((it, i) => {
|
||||
const appear = interpolate(elapsed, [1.6 + i * 0.25, 2.2 + i * 0.25], [0, 1]);
|
||||
const checkAppear = interpolate(elapsed, [2.0 + i * 0.25, 2.5 + i * 0.25], [0, 1], Easing.spring);
|
||||
return (
|
||||
<div key={i} style={{display:'flex', alignItems:'center', gap: 20,
|
||||
opacity: appear, borderBottom:`1px solid ${LINE}`, paddingBottom: 14}}>
|
||||
<div style={{width: 36, height: 36, border:`2px solid ${TERRA}`,
|
||||
display:'flex', alignItems:'center', justifyContent:'center',
|
||||
flexShrink: 0, background: checkAppear > 0.5 ? TERRA : 'transparent',
|
||||
color:'#fff', fontFamily: serif, fontWeight: 600, fontSize: 20,
|
||||
lineHeight: 1, transform: `scale(${0.7 + checkAppear * 0.3})`}}>
|
||||
{checkAppear > 0.5 ? '✓' : ''}
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 26, color: INK,
|
||||
lineHeight: 1.35, letterSpacing:'0.005em'}}>
|
||||
{it.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Watermark ─────────────────────────────────────────────
|
||||
function Watermark() {
|
||||
return (
|
||||
<div style={{position:'absolute', bottom: 24, right: 32,
|
||||
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
|
||||
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
|
||||
Created by Huashu-Design
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main composition ──────────────────────────────────────
|
||||
function App() {
|
||||
return (
|
||||
<Stage duration={22} width={1920} height={1080} bgColor={CREAM}>
|
||||
<Sprite start={0} end={3}><Scene1_Contrast /></Sprite>
|
||||
<Sprite start={3} end={8}><Scene2_Skeleton /></Sprite>
|
||||
<Sprite start={8} end={13}><Scene3_FirstShow /></Sprite>
|
||||
<Sprite start={13} end={18}><Scene4_FullPass /></Sprite>
|
||||
<Sprite start={18} end={22}><Scene5_Closing /></Sprite>
|
||||
<Watermark />
|
||||
</Stage>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,747 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Huashu-Design · Fallback 设计顾问</title>
|
||||
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<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,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; }
|
||||
body {
|
||||
background: #0c0c0c;
|
||||
font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
|
||||
color: #1a1a1a;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- animations.jsx inlined -->
|
||||
<script type="text/babel">
|
||||
(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;
|
||||
},
|
||||
};
|
||||
|
||||
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() { return useContext(TimeContext).time; }
|
||||
function useSprite() {
|
||||
const sprite = useContext(SpriteContext);
|
||||
return sprite || { t: 0, elapsed: 0, duration: 0 };
|
||||
}
|
||||
|
||||
function Stage({ duration = 10, width = 1920, height = 1080, 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 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) {
|
||||
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) return effectiveLoop ? 0 : duration - 0.001;
|
||||
return next;
|
||||
});
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
|
||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
|
||||
return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
|
||||
}, [playing, duration, effectiveLoop]);
|
||||
|
||||
const progress = time / duration;
|
||||
const ctx = { time, duration, playing, setPlaying, setTime };
|
||||
|
||||
const canvasStyle = {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transformOrigin: 'center center',
|
||||
width,
|
||||
height,
|
||||
background: bgColor,
|
||||
overflow: 'hidden',
|
||||
transform: `translate(-50%, -50%) scale(${scale})`,
|
||||
};
|
||||
|
||||
return (
|
||||
<TimeContext.Provider value={ctx}>
|
||||
<div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
|
||||
<div style={canvasStyle}>{children}</div>
|
||||
</div>
|
||||
<div className="no-record" style={{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 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>
|
||||
<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>
|
||||
<div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
|
||||
<div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
|
||||
<div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Demo scene -->
|
||||
<script type="text/babel">
|
||||
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
|
||||
|
||||
// ── Design tokens ─────────────────────────────────────────
|
||||
const CREAM = '#FAF6EF';
|
||||
const INK = '#1a1a1a';
|
||||
const TERRA = '#C04A1A';
|
||||
const ASH = '#6b6b6b';
|
||||
const LINE = '#d9d2c5';
|
||||
|
||||
// ── 20 design philosophies ────────────────────────────────
|
||||
const PHILOSOPHIES = [
|
||||
{ n: 'Pentagram', school: '信息建筑', en: 'INFO-ARCH' },
|
||||
{ n: 'Massimo Vignelli', school: '信息建筑', en: 'INFO-ARCH' },
|
||||
{ n: 'Dieter Rams', school: '信息建筑', en: 'INFO-ARCH' },
|
||||
{ n: 'Otl Aicher', school: '信息建筑', en: 'INFO-ARCH' },
|
||||
{ n: 'Field.io', school: '运动诗学', en: 'KINETIC' },
|
||||
{ n: 'Active Theory', school: '运动诗学', en: 'KINETIC' },
|
||||
{ n: 'Locomotive', school: '运动诗学', en: 'KINETIC' },
|
||||
{ n: 'Joshua Davis', school: '运动诗学', en: 'KINETIC' },
|
||||
{ n: 'Kenya Hara', school: '东方哲学', en: 'EASTERN' },
|
||||
{ n: 'Naoto Fukasawa', school: '东方哲学', en: 'EASTERN' },
|
||||
{ n: 'Kashiwa Sato', school: '东方哲学', en: 'EASTERN' },
|
||||
{ n: 'John Maeda', school: '东方哲学', en: 'EASTERN' },
|
||||
{ n: 'Sagmeister', school: '实验先锋', en: 'AVANT' },
|
||||
{ n: 'David Carson', school: '实验先锋', en: 'AVANT' },
|
||||
{ n: 'Paula Scher', school: '实验先锋', en: 'AVANT' },
|
||||
{ n: 'Tomato', school: '实验先锋', en: 'AVANT' },
|
||||
{ n: 'Dan Flavin', school: '极简主义', en: 'MINIMAL' },
|
||||
{ n: 'Ryuichi Sakamoto', school: '极简主义', en: 'MINIMAL' },
|
||||
{ n: 'Agnes Martin', school: '极简主义', en: 'MINIMAL' },
|
||||
{ n: 'Donald Judd', school: '极简主义', en: 'MINIMAL' },
|
||||
];
|
||||
|
||||
const SELECTED_INDICES = [0, 4, 8]; // Pentagram, Field.io, Kenya Hara
|
||||
|
||||
// ── Shared typography helpers ─────────────────────────────
|
||||
const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
|
||||
const sans = "'Inter', -apple-system, sans-serif";
|
||||
const mono = "'JetBrains Mono', ui-monospace, monospace";
|
||||
|
||||
// ── Scene 1: Vague brief (0 – 3.5s) ───────────────────────
|
||||
function Scene1_VagueBrief() {
|
||||
const { t, elapsed } = useSprite();
|
||||
const charCount = Math.floor(interpolate(elapsed, [0.3, 1.8], [0, 9]));
|
||||
const text = '做个好看的页面'.slice(0, charCount);
|
||||
const cursorBlink = Math.floor(elapsed * 2.4) % 2 === 0;
|
||||
const questionOpacity = interpolate(elapsed, [1.8, 2.4], [0, 1]);
|
||||
const questionBob = Math.sin(elapsed * 4) * 6;
|
||||
const fadeOut = interpolate(elapsed, [2.8, 3.5], [1, 0], Easing.easeIn);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
|
||||
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
|
||||
<div style={{fontFamily: sans, fontSize:14, letterSpacing:'0.3em',
|
||||
color: ASH, marginBottom: 40}}>
|
||||
用户需求
|
||||
</div>
|
||||
<div style={{display:'flex', alignItems:'flex-start', gap: 36}}>
|
||||
<div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 120,
|
||||
color: TERRA, lineHeight: 1, marginTop: -20}}>「</div>
|
||||
<div style={{fontFamily: serif, fontSize: 96, fontWeight: 400,
|
||||
color: INK, letterSpacing: '0.02em', position: 'relative'}}>
|
||||
{text}
|
||||
<span style={{opacity: cursorBlink ? 1 : 0, color: TERRA,
|
||||
marginLeft: 4, fontWeight: 300}}>|</span>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 120,
|
||||
color: TERRA, lineHeight: 1, marginTop: -20}}>」</div>
|
||||
</div>
|
||||
<div style={{fontFamily: sans, fontSize: 20, color: ASH, marginTop: 60,
|
||||
opacity: questionOpacity, transform: `translateY(${questionBob}px)`,
|
||||
letterSpacing: '0.05em'}}>
|
||||
<span style={{color: TERRA, fontSize: 28, marginRight: 12}}>?</span>
|
||||
风格、受众、情感基调—— 都没说
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 2: Advisor activates (3.5 – 6.5s) ───────────────
|
||||
function Scene2_AdvisorIntro() {
|
||||
const { elapsed } = useSprite();
|
||||
const mainY = interpolate(elapsed, [0, 1.2], [40, 0], Easing.easeOut);
|
||||
const mainOpacity = interpolate(elapsed, [0, 0.8], [0, 1]);
|
||||
const lineWidth = interpolate(elapsed, [0.8, 1.8], [0, 320]);
|
||||
const subOpacity = interpolate(elapsed, [1.2, 2], [0, 1]);
|
||||
const fadeOut = interpolate(elapsed, [2.5, 3], [1, 0]);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
|
||||
display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
|
||||
<div style={{fontFamily: sans, fontSize: 12, letterSpacing: '0.4em',
|
||||
color: TERRA, marginBottom: 24, opacity: mainOpacity}}>
|
||||
设计方向顾问 · Fallback
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 132, fontWeight: 500,
|
||||
color: INK, lineHeight: 1, letterSpacing: '-0.01em',
|
||||
opacity: mainOpacity, transform: `translateY(${mainY}px)`}}>
|
||||
推荐 <span style={{fontStyle:'italic', color: TERRA}}>3</span> 个方向
|
||||
</div>
|
||||
<div style={{height: 1, background: INK, width: lineWidth, marginTop: 36}} />
|
||||
<div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 26,
|
||||
color: ASH, marginTop: 28, opacity: subOpacity}}>
|
||||
从 20 种设计哲学里,按 5 个不同流派差异化推荐
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 3: 20 philosophies grid scan (6.5 – 10.5s) ──────
|
||||
function Scene3_GridScan() {
|
||||
const { elapsed } = useSprite();
|
||||
const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM,
|
||||
padding: '80px 120px', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline', opacity: titleOp, marginBottom: 50}}>
|
||||
<div style={{fontFamily: serif, fontSize: 52, fontWeight: 500, color: INK}}>
|
||||
设计哲学库
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 14, color: ASH, letterSpacing:'0.1em'}}>
|
||||
20 位设计师 · 5 个流派
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display:'grid', gridTemplateColumns:'repeat(5, 1fr)', gap: 20, flex: 1}}>
|
||||
{PHILOSOPHIES.map((p, i) => {
|
||||
const stagger = i * 0.06;
|
||||
const appearT = Math.max(0, Math.min(1, (elapsed - 0.5 - stagger) / 0.4));
|
||||
const op = appearT;
|
||||
const ty = (1 - appearT) * 24;
|
||||
|
||||
// Scanner highlight: sweeps through 20 cards from t=2.2 to t=3.2
|
||||
const scannerStart = 2.2 + i * 0.04;
|
||||
const scannerEnd = scannerStart + 0.25;
|
||||
const scanHighlight = elapsed > scannerStart && elapsed < scannerEnd ? 1 : 0;
|
||||
|
||||
// Selected cards get circled at t=3.3+
|
||||
const isSelected = SELECTED_INDICES.includes(i);
|
||||
const selectT = Math.max(0, Math.min(1, (elapsed - 3.3) / 0.5));
|
||||
const selectOp = isSelected ? selectT : 0;
|
||||
const selectDim = !isSelected && elapsed > 3.4 ? interpolate(elapsed, [3.4, 3.8], [1, 0.28]) : 1;
|
||||
|
||||
return (
|
||||
<div key={i} style={{
|
||||
opacity: op * selectDim,
|
||||
transform: `translateY(${ty}px)`,
|
||||
background: scanHighlight ? '#fff' : 'transparent',
|
||||
border: `1px solid ${isSelected && selectT > 0.3 ? TERRA : LINE}`,
|
||||
borderWidth: isSelected && selectT > 0.3 ? 2 : 1,
|
||||
padding: '20px 18px',
|
||||
position: 'relative',
|
||||
transition: 'none',
|
||||
}}>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
|
||||
letterSpacing: '0.15em', marginBottom: 10}}>
|
||||
{String(i+1).padStart(2,'0')} · {p.en}
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 22, fontWeight: 500,
|
||||
color: INK, lineHeight: 1.15, marginBottom: 6}}>
|
||||
{p.n}
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 14,
|
||||
color: ASH}}>
|
||||
{p.school}
|
||||
</div>
|
||||
{isSelected && selectT > 0.4 && (
|
||||
<div style={{position:'absolute', top: -10, right: -10,
|
||||
width: 26, height: 26, borderRadius: '50%', background: TERRA,
|
||||
color: '#fff', display:'flex', alignItems:'center',
|
||||
justifyContent:'center', fontFamily: serif, fontSize: 14,
|
||||
fontWeight: 600, opacity: selectOp}}>
|
||||
{SELECTED_INDICES.indexOf(i) + 1}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 4: Three-panel parallel demo generation (10.5 – 19s) ──
|
||||
function Scene4_ParallelDemos() {
|
||||
const { elapsed } = useSprite();
|
||||
const slideIn = interpolate(elapsed, [0, 1], [200, 0], Easing.easeOut);
|
||||
const opacity = interpolate(elapsed, [0, 0.6], [0, 1]);
|
||||
|
||||
const panels = [
|
||||
{ name: 'Pentagram', school: '信息建筑派', en: 'Information Architecture',
|
||||
delay: 0, render: 'pentagram' },
|
||||
{ name: 'Field.io', school: '运动诗学派', en: 'Kinetic Poetry',
|
||||
delay: 0.3, render: 'field' },
|
||||
{ name: 'Kenya Hara', school: '东方哲学派', en: 'Eastern Minimalism',
|
||||
delay: 0.6, render: 'hara' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM,
|
||||
padding: '60px 60px 40px', display:'flex', flexDirection:'column',
|
||||
opacity}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline', marginBottom: 28}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
letterSpacing: '0.3em', marginBottom: 4}}>步骤 3 / 4</div>
|
||||
<div style={{fontFamily: serif, fontSize: 44, fontWeight: 500, color: INK}}>
|
||||
并行生成视觉 Demo
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
|
||||
color: ASH, textAlign: 'right'}}>
|
||||
"看到比说到更有效"<br/>
|
||||
<span style={{fontSize: 14}}>— 设计顾问模式 · Phase 5</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap: 24,
|
||||
flex: 1, transform: `translateY(${slideIn}px)`}}>
|
||||
{panels.map((p, i) => (
|
||||
<DemoPanel key={i} panel={p} localElapsed={elapsed - p.delay} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DemoPanel({ panel, localElapsed }) {
|
||||
const progressT = Math.max(0, Math.min(1, localElapsed / 3.0));
|
||||
const progressPct = progressT * 100;
|
||||
const done = progressT >= 0.92;
|
||||
// Content fades in during the last 0.7s of generation — overlaps with
|
||||
// skeleton fade-out so there's no empty-canvas gap when "READY" appears.
|
||||
const contentReveal = interpolate(localElapsed, [2.4, 3.2], [0, 1], Easing.easeOut);
|
||||
const skeletonOp = interpolate(localElapsed, [2.4, 3.2], [1, 0], Easing.easeOut);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background:'#fff',
|
||||
border: `1px solid ${LINE}`,
|
||||
display:'flex', flexDirection:'column',
|
||||
position:'relative',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{padding: '18px 22px', borderBottom: `1px solid ${LINE}`,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
|
||||
<div>
|
||||
<div style={{fontFamily: serif, fontSize: 28, fontWeight: 500, color: INK}}>
|
||||
{panel.name}
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13,
|
||||
color: ASH, marginTop: 2}}>
|
||||
{panel.en}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: done ? TERRA : ASH,
|
||||
letterSpacing: '0.15em'}}>
|
||||
{done ? '✓ READY' : 'GENERATING'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<div style={{flex: 1, position: 'relative', overflow: 'hidden'}}>
|
||||
{skeletonOp > 0.02 && (
|
||||
<div style={{position:'absolute', inset:0, opacity: skeletonOp}}>
|
||||
<GenerationSkeleton progress={progressT} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{position:'absolute', inset:0, opacity: contentReveal}}>
|
||||
{panel.render === 'pentagram' && <PentagramDemo />}
|
||||
{panel.render === 'field' && <FieldDemo elapsed={localElapsed - 3.2} />}
|
||||
{panel.render === 'hara' && <HaraDemo />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div style={{height: 2, background: '#eee', position: 'relative'}}>
|
||||
<div style={{position:'absolute', top:0, left:0, height:'100%',
|
||||
width: `${progressPct}%`, background: TERRA,
|
||||
transition:'none'}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GenerationSkeleton({ progress }) {
|
||||
const bars = [60, 85, 40, 72, 90, 55, 68];
|
||||
return (
|
||||
<div style={{padding: 24, display:'flex', flexDirection:'column', gap: 14}}>
|
||||
{bars.map((w, i) => (
|
||||
<div key={i} style={{height: 10, width: `${w}%`,
|
||||
background: `linear-gradient(90deg, ${LINE} 0%, ${LINE} ${100-progress*80}%, #fff ${100-progress*80}%)`,
|
||||
opacity: 0.6 + progress * 0.4}} />
|
||||
))}
|
||||
<div style={{fontFamily: mono, fontSize: 10, color: ASH,
|
||||
marginTop: 20, letterSpacing:'0.1em'}}>
|
||||
{progress < 0.3 && '▸ loading style tokens...'}
|
||||
{progress >= 0.3 && progress < 0.6 && '▸ composing layout...'}
|
||||
{progress >= 0.6 && progress < 0.9 && '▸ applying typography...'}
|
||||
{progress >= 0.9 && '▸ finalizing...'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pentagram demo: serif editorial, strict grid, monochrome ──
|
||||
function PentagramDemo() {
|
||||
return (
|
||||
<div style={{padding: '28px 28px 24px', background:'#fafafa', height:'100%',
|
||||
display:'flex', flexDirection:'column', fontFamily: serif, color:'#111'}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between',
|
||||
borderBottom:'1px solid #111', paddingBottom: 10, marginBottom: 16,
|
||||
fontFamily: mono, fontSize: 9, letterSpacing:'0.2em'}}>
|
||||
<span>VOL. 01 · MMXXVI</span>
|
||||
<span>NO. 043</span>
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.3em',
|
||||
color:'#888', marginBottom: 10}}>ESSAY</div>
|
||||
<div style={{fontSize: 40, lineHeight: 1.05, fontWeight: 500,
|
||||
letterSpacing: '-0.02em', marginBottom: 18}}>
|
||||
A Pure<br/>
|
||||
<span style={{fontStyle:'italic'}}>Information</span><br/>
|
||||
Architecture
|
||||
</div>
|
||||
<div style={{height: 1, background:'#111', margin:'8px 0 14px'}} />
|
||||
<div style={{fontSize: 13, lineHeight: 1.55, color:'#333', flex: 1}}>
|
||||
Designed not to impress, but to inform. The grid carries meaning; typography does the work.
|
||||
</div>
|
||||
<div style={{borderTop:'1px solid #111', paddingTop: 10, marginTop: 14,
|
||||
display:'flex', justifyContent:'space-between', fontFamily: mono,
|
||||
fontSize: 9, letterSpacing:'0.2em', color:'#888'}}>
|
||||
<span>NEW YORK</span>
|
||||
<span>PENTAGRAM</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Field.io demo: dark, kinetic geometric shapes ──
|
||||
function FieldDemo({ elapsed }) {
|
||||
const e = Math.max(0, elapsed || 0);
|
||||
return (
|
||||
<div style={{padding: 0, background:'#0e1016', height:'100%',
|
||||
position:'relative', overflow:'hidden'}}>
|
||||
<svg viewBox="0 0 400 500" width="100%" height="100%"
|
||||
style={{position:'absolute', inset:0}} preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<linearGradient id="fg1" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#ff6a3d" />
|
||||
<stop offset="100%" stopColor="#c04a1a" />
|
||||
</linearGradient>
|
||||
<linearGradient id="fg2" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#4a9eff" />
|
||||
<stop offset="100%" stopColor="#1a4fc0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Concentric circles breathing */}
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
<circle key={i} cx="200" cy="280"
|
||||
r={40 + i * 50 + Math.sin(e * 1.2 + i) * 10}
|
||||
fill="none" stroke="url(#fg1)" strokeWidth={1.5}
|
||||
opacity={0.4 - i * 0.08} />
|
||||
))}
|
||||
{/* Rotating triangle */}
|
||||
<g transform={`translate(200 280) rotate(${e * 20})`}>
|
||||
<polygon points="0,-70 60,35 -60,35" fill="url(#fg2)" opacity="0.7" />
|
||||
</g>
|
||||
{/* Orbiting dots */}
|
||||
{[0, 1, 2, 3, 4, 5].map(i => {
|
||||
const angle = (e * 0.8 + i * Math.PI / 3);
|
||||
return <circle key={i} cx={200 + Math.cos(angle) * 150}
|
||||
cy={280 + Math.sin(angle) * 150} r={4} fill="#ff6a3d" opacity={0.9}/>;
|
||||
})}
|
||||
</svg>
|
||||
<div style={{position:'absolute', top: 24, left: 24, right: 24,
|
||||
display:'flex', justifyContent:'space-between',
|
||||
fontFamily: mono, fontSize: 10, letterSpacing:'0.3em', color:'#fff', opacity: 0.7}}>
|
||||
<span>FIELD.IO</span>
|
||||
<span>LIVE · RECORDING</span>
|
||||
</div>
|
||||
<div style={{position:'absolute', bottom: 24, left: 24, right: 24,
|
||||
fontFamily: serif, fontStyle:'italic', fontSize: 20, color:'#fff',
|
||||
letterSpacing:'0.02em'}}>
|
||||
kinetic identity<br/>
|
||||
<span style={{fontFamily: mono, fontSize: 10, fontStyle:'normal',
|
||||
letterSpacing:'0.2em', color:'#ff6a3d', opacity: 0.8}}>
|
||||
/ motion is the brand
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Kenya Hara demo: vast white space, tiny dot, haiku ──
|
||||
function HaraDemo() {
|
||||
return (
|
||||
<div style={{padding: 0, background:'#fdfbf6', height:'100%',
|
||||
position:'relative'}}>
|
||||
<div style={{position:'absolute', top: 28, left: 32,
|
||||
fontFamily: mono, fontSize: 10, letterSpacing:'0.3em', color:'#aaa'}}>
|
||||
HARA · MMXXVI
|
||||
</div>
|
||||
<div style={{position:'absolute', top: '42%', left:'50%',
|
||||
transform:'translate(-50%, -50%)', width: 14, height: 14,
|
||||
borderRadius:'50%', background:'#1a1a1a'}} />
|
||||
<div style={{position:'absolute', top:'58%', left:'50%',
|
||||
transform:'translateX(-50%)', fontFamily: serif, fontStyle:'italic',
|
||||
fontSize: 14, color:'#1a1a1a', letterSpacing:'0.1em'}}>
|
||||
white.
|
||||
</div>
|
||||
<div style={{position:'absolute', bottom: 32, right: 32,
|
||||
writingMode:'vertical-rl', fontFamily: "'Noto Serif SC', serif",
|
||||
fontSize: 16, color:'#888', letterSpacing:'0.2em'}}>
|
||||
原 研 哉
|
||||
</div>
|
||||
<div style={{position:'absolute', bottom: 28, left: 32,
|
||||
fontFamily: serif, fontStyle:'italic', fontSize: 11, color:'#999',
|
||||
maxWidth: 200, lineHeight: 1.6}}>
|
||||
"Emptiness is not nothing—<br/>it is everything that could be."
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 5: User selects Kenya Hara (19 – 22s) ───────────
|
||||
function Scene5_Select() {
|
||||
const { elapsed } = useSprite();
|
||||
// Cursor travels from right edge toward middle panel
|
||||
const cursorX = interpolate(elapsed, [0, 1.2], [1750, 960], Easing.easeInOut);
|
||||
const cursorY = interpolate(elapsed, [0, 1.2], [240, 540], Easing.easeInOut);
|
||||
const cursorOp = interpolate(elapsed, [0, 0.2], [0, 1]);
|
||||
|
||||
// Middle panel selection lock-in
|
||||
const selectLock = Math.max(0, Math.min(1, (elapsed - 1.2) / 0.4));
|
||||
|
||||
// Left + right panels dim + shrink
|
||||
const sideDim = interpolate(elapsed, [1.2, 1.8], [1, 0.2]);
|
||||
const sideScale = interpolate(elapsed, [1.2, 1.8], [1, 0.92], Easing.easeOut);
|
||||
|
||||
// Middle scales up
|
||||
const midScale = interpolate(elapsed, [1.2, 1.8], [1, 1.06], Easing.easeOut);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM,
|
||||
padding: '60px 60px 40px', display:'flex', flexDirection:'column'}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between',
|
||||
alignItems:'baseline', marginBottom: 28}}>
|
||||
<div>
|
||||
<div style={{fontFamily: mono, fontSize: 11, color: TERRA,
|
||||
letterSpacing: '0.3em', marginBottom: 4}}>步骤 4 / 4</div>
|
||||
<div style={{fontFamily: serif, fontSize: 44, fontWeight: 500, color: INK}}>
|
||||
用户选定方向
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH}}>
|
||||
——或混合:"A 的配色 + C 的布局"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap: 24,
|
||||
flex: 1}}>
|
||||
<StaticPanel which="pentagram" opacity={sideDim} scale={sideScale} />
|
||||
<StaticPanel which="hara" opacity={1} scale={midScale} selected={selectLock > 0.5}/>
|
||||
<StaticPanel which="field" opacity={sideDim} scale={sideScale} />
|
||||
</div>
|
||||
|
||||
{/* Cursor */}
|
||||
<div style={{position:'absolute', left: cursorX, top: cursorY,
|
||||
opacity: cursorOp, pointerEvents:'none', zIndex: 50,
|
||||
filter:'drop-shadow(0 4px 8px rgba(0,0,0,0.2))'}}>
|
||||
<svg width="36" height="44" viewBox="0 0 36 44">
|
||||
<path d="M 2 2 L 2 38 L 11 30 L 16 42 L 22 40 L 17 28 L 28 28 Z"
|
||||
fill="#fff" stroke="#1a1a1a" strokeWidth="2" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* "Selected" callout */}
|
||||
{selectLock > 0.5 && (
|
||||
<div style={{position:'absolute', left:'50%', top: 140,
|
||||
transform:'translateX(-50%)', background: TERRA, color:'#fff',
|
||||
padding:'10px 24px', fontFamily: mono, fontSize: 12,
|
||||
letterSpacing:'0.25em', opacity: selectLock, zIndex: 40}}>
|
||||
✓ SELECTED
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StaticPanel({ which, opacity, scale, selected }) {
|
||||
const titles = {
|
||||
pentagram: { n: 'Pentagram', en: 'Information Architecture' },
|
||||
hara: { n: 'Kenya Hara', en: 'Eastern Minimalism' },
|
||||
field: { n: 'Field.io', en: 'Kinetic Poetry' },
|
||||
};
|
||||
const t = titles[which];
|
||||
return (
|
||||
<div style={{
|
||||
background:'#fff',
|
||||
border: selected ? `3px solid ${TERRA}` : `1px solid ${LINE}`,
|
||||
opacity, transform: `scale(${scale})`, transformOrigin:'center center',
|
||||
display:'flex', flexDirection:'column',
|
||||
}}>
|
||||
<div style={{padding: '18px 22px', borderBottom: `1px solid ${LINE}`,
|
||||
display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
|
||||
<div>
|
||||
<div style={{fontFamily: serif, fontSize: 28, fontWeight: 500, color: INK}}>
|
||||
{t.n}
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13,
|
||||
color: ASH, marginTop: 2}}>
|
||||
{t.en}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontFamily: mono, fontSize: 10,
|
||||
color: selected ? TERRA : '#999',
|
||||
letterSpacing: '0.15em'}}>
|
||||
{selected ? '✓ SELECTED' : 'READY'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{flex: 1, position:'relative', overflow:'hidden'}}>
|
||||
{which === 'pentagram' && <PentagramDemo />}
|
||||
{which === 'field' && <FieldDemo elapsed={10} />}
|
||||
{which === 'hara' && <HaraDemo />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Scene 6: Ready to execute (22 – 24s) ──────────────────
|
||||
function Scene6_Final() {
|
||||
const { elapsed } = useSprite();
|
||||
const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1], Easing.easeOut);
|
||||
const lineW = interpolate(elapsed, [0.6, 1.4], [0, 600]);
|
||||
|
||||
return (
|
||||
<div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeIn,
|
||||
display:'flex', alignItems:'center', justifyContent:'center',
|
||||
flexDirection:'column'}}>
|
||||
<div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
|
||||
color: TERRA, marginBottom: 20}}>
|
||||
NEXT · JUNIOR DESIGNER PASS
|
||||
</div>
|
||||
<div style={{fontFamily: serif, fontSize: 104, fontWeight: 500,
|
||||
color: INK, lineHeight: 1, letterSpacing:'-0.01em'}}>
|
||||
开始 <span style={{fontStyle:'italic', color: TERRA}}>Kenya Hara</span> 风格
|
||||
</div>
|
||||
<div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
|
||||
<div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22,
|
||||
color: ASH, marginTop: 28, maxWidth: 700, textAlign:'center', lineHeight: 1.5}}>
|
||||
"方向确认 → 回到 Junior Designer 主干流程<br/>
|
||||
这时已有明确的 design context,不再是凭空做"
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Watermark (always visible) ────────────────────────────
|
||||
function Watermark() {
|
||||
return (
|
||||
<div style={{position:'absolute', bottom: 24, right: 32,
|
||||
fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
|
||||
fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
|
||||
Created by Huashu-Design
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main composition ──────────────────────────────────────
|
||||
function App() {
|
||||
return (
|
||||
<Stage duration={24} width={1920} height={1080} bgColor={CREAM}>
|
||||
<Sprite start={0} end={3.5}><Scene1_VagueBrief /></Sprite>
|
||||
<Sprite start={3.5} end={6.5}><Scene2_AdvisorIntro /></Sprite>
|
||||
<Sprite start={6.5} end={10.5}><Scene3_GridScan /></Sprite>
|
||||
<Sprite start={10.5} end={19}><Scene4_ParallelDemos /></Sprite>
|
||||
<Sprite start={19} end={22}><Scene5_Select /></Sprite>
|
||||
<Sprite start={22} end={24}><Scene6_Final /></Sprite>
|
||||
<Watermark />
|
||||
</Stage>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user