Files
Starlight_Lancher/.agents/skills/lark-apps/creative-design/starter-components/animations.jsx

774 lines
25 KiB
JavaScript

/* BEGIN USAGE */
// animations.jsx
// Reusable animation starter: Stage, Timeline, Sprite, easing helpers.
// Exports (to window): Stage, Sprite, PlaybackBar, TextSprite, ImageSprite, RectSprite,
// useTime, useTimeline, useSprite, Easing, interpolate, animate, clamp.
//
// Usage (in an HTML file that loads React + Babel):
//
// <Stage width={1280} height={720} duration={10} background="#f6f4ef">
// <MyScene />
// </Stage>
//
// <Stage> auto-scales to the viewport and provides the scrubber, play/pause,
// ←/→ seek, space, and 0-to-reset controls, and persists the playhead.
// Set the optional `poster` prop (seconds) to the moment your opening scene is
// fully composed — the product thumbnail freezes on that frame (default ~1s).
// Inside <Stage>, any child can call useTime() to read the current
// playhead (seconds). Or wrap content in <Sprite start={1} end={4}>...</Sprite>
// to only render during that window -- children receive a `localTime` and
// `progress` via the useSprite() hook. Use Easing + interpolate()/animate()
// for tweens; TextSprite / ImageSprite / RectSprite have built-in entry/exit.
// Build YOUR scenes by composing Sprites inside a Stage.
/* END USAGE */
// ─────────────────────────────────────────────────────────────────────────────
// ── Easing functions (hand-rolled, Popmotion-style) ─────────────────────────
// All easings take t ∈ [0,1] and return eased t ∈ [0,1] (may overshoot for back/elastic).
const Easing = {
linear: (t) => t,
// Quad
easeInQuad: (t) => t * t,
easeOutQuad: (t) => t * (2 - t),
easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
// Cubic
easeInCubic: (t) => t * t * t,
easeOutCubic: (t) => (--t) * t * t + 1,
easeInOutCubic: (t) => (t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1),
// Quart
easeInQuart: (t) => t * t * t * t,
easeOutQuart: (t) => 1 - (--t) * t * t * t,
easeInOutQuart: (t) => (t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t),
// Expo
easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * (t - 1))),
easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
easeInOutExpo: (t) => {
if (t === 0) return 0;
if (t === 1) return 1;
if (t < 0.5) return 0.5 * Math.pow(2, 20 * t - 10);
return 1 - 0.5 * Math.pow(2, -20 * t + 10);
},
// Sine
easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
// Back (overshoot)
easeOutBack: (t) => {
const c1 = 1.70158, c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
},
easeInBack: (t) => {
const c1 = 1.70158, c3 = c1 + 1;
return c3 * t * t * t - c1 * t * t;
},
easeInOutBack: (t) => {
const c1 = 1.70158, c2 = c1 * 1.525;
return t < 0.5
? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
: (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
},
// Elastic
easeOutElastic: (t) => {
const c4 = (2 * Math.PI) / 3;
if (t === 0) return 0;
if (t === 1) return 1;
return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
},
};
// ── Core interpolation helpers ──────────────────────────────────────────────
// Clamp a value to [min, max]
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
// interpolate([0, 0.5, 1], [0, 100, 50], ease?) -> fn(t)
// Popmotion-style: linearly maps t across input keyframes to output values,
// with optional easing per segment (single fn or array of fns).
function interpolate(input, output, ease = Easing.linear) {
return (t) => {
if (t <= input[0]) return output[0];
if (t >= input[input.length - 1]) return output[output.length - 1];
for (let i = 0; i < input.length - 1; i++) {
if (t >= input[i] && t <= input[i + 1]) {
const span = input[i + 1] - input[i];
const local = span === 0 ? 0 : (t - input[i]) / span;
const easeFn = Array.isArray(ease) ? (ease[i] || Easing.linear) : ease;
const eased = easeFn(local);
return output[i] + (output[i + 1] - output[i]) * eased;
}
}
return output[output.length - 1];
};
}
// animate({from, to, start, end, ease})(t) — simpler single-segment tween.
// Returns `from` before `start`, `to` after `end`.
function animate({ from = 0, to = 1, start = 0, end = 1, ease = Easing.easeInOutCubic }) {
return (t) => {
if (t <= start) return from;
if (t >= end) return to;
const local = (t - start) / (end - start);
return from + (to - from) * ease(local);
};
}
// ── Timeline context ────────────────────────────────────────────────────────
const TimelineContext = React.createContext({ time: 0, duration: 10, playing: false });
const useTime = () => React.useContext(TimelineContext).time;
const useTimeline = () => React.useContext(TimelineContext);
// ── Sprite ──────────────────────────────────────────────────────────────────
// Renders children only when the playhead is inside [start, end]. Provides
// a sub-context with `localTime` (seconds since start) and `progress` (0..1).
//
// <Sprite start={2} end={5}>
// {({ localTime, progress }) => <Thing x={progress * 100} />}
// </Sprite>
//
// Or as a plain wrapper — children can call useSprite() themselves.
const SpriteContext = React.createContext({ localTime: 0, progress: 0, duration: 0 });
const useSprite = () => React.useContext(SpriteContext);
function Sprite({ start = 0, end = Infinity, children, keepMounted = false }) {
const { time } = useTimeline();
const visible = time >= start && time <= end;
if (!visible && !keepMounted) return null;
const duration = end - start;
const localTime = Math.max(0, time - start);
const progress = duration > 0 && isFinite(duration)
? clamp(localTime / duration, 0, 1)
: 0;
const value = { localTime, progress, duration, visible };
return (
<SpriteContext.Provider value={value}>
{typeof children === 'function' ? children(value) : children}
</SpriteContext.Provider>
);
}
// ── Sample sprite components ────────────────────────────────────────────────
// TextSprite: fades/slides text in on entry, holds, then fades out on exit.
// Props: text, x, y, size, color, font, entryDur, exitDur, align
function TextSprite({
text,
x = 0, y = 0,
size = 48,
color = '#111',
font = 'Inter, system-ui, sans-serif',
weight = 600,
entryDur = 0.45,
exitDur = 0.35,
entryEase = Easing.easeOutBack,
exitEase = Easing.easeInCubic,
align = 'left',
letterSpacing = '-0.01em',
}) {
const { localTime, duration } = useSprite();
const exitStart = Math.max(0, duration - exitDur);
let opacity = 1;
let ty = 0;
if (localTime < entryDur) {
const t = entryEase(clamp(localTime / entryDur, 0, 1));
opacity = t;
ty = (1 - t) * 16;
} else if (localTime > exitStart) {
const t = exitEase(clamp((localTime - exitStart) / exitDur, 0, 1));
opacity = 1 - t;
ty = -t * 8;
}
const translateX = align === 'center' ? '-50%' : align === 'right' ? '-100%' : '0';
return (
<div style={{
position: 'absolute',
left: x, top: y,
transform: `translate(${translateX}, ${ty}px)`,
opacity,
fontFamily: font,
fontSize: size,
fontWeight: weight,
color,
letterSpacing,
whiteSpace: 'pre',
lineHeight: 1.1,
willChange: 'transform, opacity',
}}>
{text}
</div>
);
}
// ImageSprite: scales + fades in; optional Ken Burns drift during hold.
function ImageSprite({
src,
x = 0, y = 0,
width = 400, height = 300,
entryDur = 0.6,
exitDur = 0.4,
kenBurns = false,
kenBurnsScale = 1.08,
radius = 12,
fit = 'cover',
placeholder = null, // {label: string} for striped placeholder
}) {
const { localTime, duration } = useSprite();
const exitStart = Math.max(0, duration - exitDur);
let opacity = 1;
let scale = 1;
if (localTime < entryDur) {
const t = Easing.easeOutCubic(clamp(localTime / entryDur, 0, 1));
opacity = t;
scale = 0.96 + 0.04 * t;
} else if (localTime > exitStart) {
const t = Easing.easeInCubic(clamp((localTime - exitStart) / exitDur, 0, 1));
opacity = 1 - t;
scale = (kenBurns ? kenBurnsScale : 1) + 0.02 * t;
} else if (kenBurns) {
const holdSpan = exitStart - entryDur;
const holdT = holdSpan > 0 ? (localTime - entryDur) / holdSpan : 0;
scale = 1 + (kenBurnsScale - 1) * holdT;
}
const content = placeholder ? (
<div style={{
width: '100%', height: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'repeating-linear-gradient(135deg, #e9e6df 0 10px, #dcd8cf 10px 20px)',
color: '#6b6458',
fontFamily: 'JetBrains Mono, ui-monospace, monospace',
fontSize: 13,
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}>
{placeholder.label || 'image'}
</div>
) : (
<img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: fit, display: 'block' }} />
);
return (
<div style={{
position: 'absolute',
left: x, top: y,
width, height,
opacity,
transform: `scale(${scale})`,
transformOrigin: 'center',
borderRadius: radius,
overflow: 'hidden',
willChange: 'transform, opacity',
}}>
{content}
</div>
);
}
// RectSprite: simple rectangle that animates position/size/color via props.
// Useful demo primitive — takes a `render` fn for per-frame customization.
function RectSprite({
x = 0, y = 0,
width = 100, height = 100,
color = '#111',
radius = 8,
entryDur = 0.4,
exitDur = 0.3,
render, // optional: (ctx) => style overrides
}) {
const spriteCtx = useSprite();
const { localTime, duration } = spriteCtx;
const exitStart = Math.max(0, duration - exitDur);
let opacity = 1;
let scale = 1;
if (localTime < entryDur) {
const t = Easing.easeOutBack(clamp(localTime / entryDur, 0, 1));
opacity = clamp(localTime / entryDur, 0, 1);
scale = 0.4 + 0.6 * t;
} else if (localTime > exitStart) {
const t = Easing.easeInQuad(clamp((localTime - exitStart) / exitDur, 0, 1));
opacity = 1 - t;
scale = 1 - 0.15 * t;
}
const overrides = render ? render(spriteCtx) : {};
return (
<div style={{
position: 'absolute',
left: x, top: y,
width, height,
background: color,
borderRadius: radius,
opacity,
transform: `scale(${scale})`,
transformOrigin: 'center',
willChange: 'transform, opacity',
...overrides,
}} />
);
}
function Stage({
width = 1280,
height = 720,
duration = 10,
background = '#f6f4ef',
fps = 60,
loop = true,
autoplay = true,
poster = null,
persistKey = 'animstage',
children,
}) {
// Thumbnail capture mode: the host appends ?thumbnail=1 before screenshotting.
// Freeze on a representative still — the author-declared `poster` second, or
// ~1s as a fallback — paused, with the playback bar hidden, so the product
// thumbnail is a deterministic frame of the first composed scene.
const captureMode = typeof location !== 'undefined' && /[?&]thumbnail=/.test(location.search || '');
const [time, setTime] = React.useState(() => {
if (captureMode) return clamp(poster == null ? 1 : poster, 0, duration);
try {
const v = parseFloat(localStorage.getItem(persistKey + ':t') || '0');
return isFinite(v) ? clamp(v, 0, duration) : 0;
} catch { return 0; }
});
const [playing, setPlaying] = React.useState(captureMode ? false : autoplay);
const [scale, setScale] = React.useState(1);
const stageRef = React.useRef(null);
const canvasRef = React.useRef(null);
const rafRef = React.useRef(null);
const lastTsRef = React.useRef(null);
// Persist playhead
React.useEffect(() => {
try { localStorage.setItem(persistKey + ':t', String(time)); } catch {}
}, [time, persistKey]);
// Auto-scale to fit viewport
React.useEffect(() => {
if (!stageRef.current) return;
const el = stageRef.current;
const measure = () => {
const barH = captureMode ? 0 : 44; // playback bar height (hidden in capture mode)
const s = Math.min(
el.clientWidth / width,
(el.clientHeight - barH) / height
);
setScale(Math.max(0.05, s));
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
window.addEventListener('resize', measure);
return () => {
ro.disconnect();
window.removeEventListener('resize', measure);
};
}, [width, height]);
// Animation loop
React.useEffect(() => {
if (!playing) {
lastTsRef.current = null;
return;
}
const step = (ts) => {
if (lastTsRef.current == null) lastTsRef.current = ts;
const dt = (ts - lastTsRef.current) / 1000;
lastTsRef.current = ts;
setTime((t) => {
let next = t + dt;
if (next >= duration) {
if (loop) next = next % duration;
else { next = duration; setPlaying(false); }
}
return next;
});
rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
lastTsRef.current = null;
};
}, [playing, duration, loop]);
// Keyboard: space = play/pause, ← → = seek
React.useEffect(() => {
const onKey = (e) => {
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
if (e.code === 'Space') {
e.preventDefault();
setPlaying(p => !p);
} else if (e.code === 'ArrowLeft') {
setTime(t => clamp(t - (e.shiftKey ? 1 : 0.1), 0, duration));
} else if (e.code === 'ArrowRight') {
setTime(t => clamp(t + (e.shiftKey ? 1 : 0.1), 0, duration));
} else if (e.key === '0' || e.code === 'Home') {
setTime(0);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [duration]);
const ctxValue = React.useMemo(
() => ({ time, duration, playing, setTime, setPlaying }),
[time, duration, playing]
);
return (
<div
ref={stageRef}
style={{
position: 'absolute', inset: 0,
display: 'flex', flexDirection: 'column',
alignItems: 'center',
background: '#0a0a0a',
fontFamily: 'Inter, system-ui, sans-serif',
}}
>
{/* Canvas area — vertically centered in remaining space */}
<div style={{
flex: 1,
width: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
minHeight: 0,
}}>
<div
ref={canvasRef}
style={{
width, height,
background,
position: 'relative',
transform: `scale(${scale})`,
transformOrigin: 'center',
flexShrink: 0,
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
overflow: 'hidden',
}}
>
<TimelineContext.Provider value={ctxValue}>
{children}
</TimelineContext.Provider>
</div>
</div>
{/* Playback bar — stacked below canvas, never overlapping. Hidden in
capture mode so the thumbnail is just the frame, no chrome. */}
{!captureMode && (
<PlaybackBar
time={time}
duration={duration}
playing={playing}
onPlayPause={() => setPlaying(p => !p)}
onReset={() => { setTime(0); }}
onSeek={(t) => setTime(t)}
/>
)}
</div>
);
}
// ── Playback bar ────────────────────────────────────────────────────────────
// Play/pause, return-to-begin, scrub track, time display.
// Uses fixed-width time fields so layout doesn't thrash.
function PlaybackBar({ time, duration, playing, onPlayPause, onReset, onSeek }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
const [trackHover, setTrackHover] = React.useState(null); // { x, t } — px within track + hovered time
const posFromEvent = React.useCallback((e) => {
const rect = trackRef.current.getBoundingClientRect();
const x = clamp(e.clientX - rect.left, 0, rect.width);
const t = rect.width > 0 ? (x / rect.width) * duration : 0;
return { x, t };
}, [duration]);
const onTrackMove = (e) => {
if (!trackRef.current) return;
const { x, t } = posFromEvent(e);
setTrackHover({ x, t });
if (dragging) onSeek(t);
};
const onTrackLeave = () => {
if (!dragging) setTrackHover(null);
};
const onTrackDown = (e) => {
const { x, t } = posFromEvent(e);
setDragging(true);
setTrackHover({ x, t });
onSeek(t);
};
// Grab the knob in place: begin dragging without seeking (no jump).
// stopPropagation keeps the track's click-to-seek from also firing.
const onBallDown = (e) => {
e.stopPropagation();
setDragging(true);
setTrackHover(posFromEvent(e));
};
React.useEffect(() => {
if (!dragging) return;
const prevCursor = document.body.style.cursor;
document.body.style.cursor = 'grabbing'; // stays grabbing even if the pointer leaves the knob mid-drag
const onUp = () => {
setDragging(false);
setTrackHover(null);
};
const onMove = (e) => {
if (!trackRef.current) return;
const { x, t } = posFromEvent(e);
setTrackHover({ x, t });
onSeek(t);
};
window.addEventListener('mouseup', onUp);
window.addEventListener('mousemove', onMove);
return () => {
window.removeEventListener('mouseup', onUp);
window.removeEventListener('mousemove', onMove);
document.body.style.cursor = prevCursor;
};
}, [dragging, posFromEvent, onSeek]);
const pct = duration > 0 ? (time / duration) * 100 : 0;
const fmt = (t) => {
const total = Math.max(0, t);
const m = Math.floor(total / 60);
const s = Math.floor(total % 60);
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
};
const numFont = '"PingFang SC", -apple-system, BlinkMacSystemFont, system-ui, sans-serif';
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px',
background: 'linear-gradient(0deg, rgba(0, 0, 0, 0.30) 0%, rgba(0, 0, 0, 0.00) 100%)',
width: '100%',
color: '#fff',
fontFamily: numFont,
userSelect: 'none',
flexShrink: 0,
boxSizing: 'border-box',
}}>
{/* Play / pause — bare white triangle, no button chrome */}
<IconButton onClick={onPlayPause} tooltip={playing ? '暂停' : '播放'}>
{playing ? (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.33333 1.33398C2.59695 1.33398 2 1.93094 2 2.66732V13.334C2 14.0704 2.59695 14.6673 3.33333 14.6673H4.66667C5.40305 14.6673 6 14.0704 6 13.334V2.66732C6 1.93094 5.40305 1.33398 4.66667 1.33398H3.33333Z" fill="currentColor"/>
<path d="M11.3333 1.33398C10.597 1.33398 10 1.93094 10 2.66732V13.334C10 14.0704 10.597 14.6673 11.3333 14.6673H12.6667C13.403 14.6673 14 14.0704 14 13.334V2.66732C14 1.93094 13.403 1.33398 12.6667 1.33398H11.3333Z" fill="currentColor"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.0489 9.13127C14.873 8.60116 14.873 7.39754 14.0489 6.86743L4.74461 0.882617C3.84764 0.305661 2.66699 0.948902 2.66699 2.01454V13.9842C2.66699 15.0498 3.84764 15.693 4.74461 15.1161L14.0489 9.13127Z" fill="currentColor"/>
</svg>
)}
</IconButton>
{/* Current time */}
<div style={{
fontFamily: numFont,
fontSize: 14,
fontWeight: 400,
fontVariantNumeric: 'tabular-nums',
color: '#fff',
minWidth: 40,
textAlign: 'center'
}}>
{fmt(time)}
</div>
{/* Scrub track — white fill on translucent-white rail + draggable knob */}
<div
ref={trackRef}
onMouseMove={onTrackMove}
onMouseLeave={onTrackLeave}
onMouseDown={onTrackDown}
style={{
flex: 1,
height: 20,
position: 'relative',
cursor: 'pointer',
display: 'flex', alignItems: 'center',
}}
>
<div style={{
position: 'absolute',
left: 0, right: 0, height: 4,
background: 'rgba(255,255,255,0.6)',
borderRadius: 2,
}}/>
<div style={{
position: 'absolute',
left: 0, width: `${pct}%`, height: 4,
background: 'rgba(255,255,255,0.9)',
borderRadius: 2,
}}/>
{/* Progress knob — outer div is an enlarged transparent hit area for easier grabbing */}
<div
onMouseDown={onBallDown}
style={{
position: 'absolute',
left: `${pct}%`, top: '50%',
transform: 'translate(-50%, -50%)',
width: 20, height: 20,
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: dragging ? 'grabbing' : 'grab',
zIndex: 5,
}}
>
<div style={{
width: dragging ? 14 : 12,
height: dragging ? 14 : 12,
background: '#fff',
borderRadius: '50%',
border: '0.5px solid #D2D5D8',
boxShadow: '0 2px 6px rgba(0,0,0,0.35)',
transition: 'width 100ms, height 100ms',
}}/>
</div>
{trackHover && (
<div style={{
position: 'absolute',
left: trackHover.x,
bottom: '100%',
transform: 'translateX(-50%)',
marginBottom: 2,
pointerEvents: 'none',
zIndex: 10,
}}>
<TooltipBubble text={fmt(trackHover.t)} />
</div>
)}
</div>
{/* Duration — dimmed */}
<div style={{
fontFamily: numFont,
fontSize: 14,
fontWeight: 400,
fontVariantNumeric: 'tabular-nums',
color: 'rgba(255,255,255,0.6)',
minWidth: 40,
textAlign: 'center',
}}>
{fmt(duration)}
</div>
</div>
);
}
function IconButton({ children, onClick, tooltip }) {
const [hover, setHover] = React.useState(false);
return (
<button
onClick={onClick}
aria-label={tooltip}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
position: 'relative',
width: 24, height: 24,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: hover ? 'rgba(255,255,255,0.1)' : 'transparent',
border: 'none',
borderRadius: 6,
color: '#fff',
cursor: 'pointer',
padding: 0,
transition: 'background 120ms',
}}
>
{children}
{tooltip && (
<div style={{
position: 'absolute',
bottom: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginBottom: 8,
pointerEvents: 'none',
opacity: hover ? 1 : 0,
transition: 'opacity 120ms',
zIndex: 10,
}}>
<TooltipBubble text={tooltip} />
</div>
)}
</button>
);
}
// ── Tooltip bubble ────────────────────────────────────────────────────────────
// Dark rounded bubble with a downward tail. Positioning is up to the caller.
function TooltipBubble({ text }) {
return (
<div style={{ position: 'relative', display: 'inline-block' }}>
<div style={{
background: '#1F2329',
color: '#fff',
fontSize: 12,
lineHeight: '16px',
padding: '6px 12px',
borderRadius: 6,
whiteSpace: 'nowrap',
fontFamily: '"PingFang SC", -apple-system, BlinkMacSystemFont, system-ui, sans-serif',
fontVariantNumeric: 'tabular-nums',
boxShadow: '0 4px 8px -8px rgba(0, 0, 0, 0.06), 0 6px 12px 0 rgba(0, 0, 0, 0.04), 0 8px 24px 8px rgba(0, 0, 0, 0.04)',
}}>
{text}
</div>
<div style={{
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translate(-50%, -50%) rotate(45deg)',
width: 9, height: 9,
borderRadius: '0 0 3px 0',
background: '#1F2329',
}}/>
</div>
);
}
Object.assign(window, {
Easing, interpolate, animate, clamp,
TimelineContext, useTime, useTimeline,
Sprite, SpriteContext, useSprite,
TextSprite, ImageSprite, RectSprite,
Stage, PlaybackBar,
});