);
}
// 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 (
);
}
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 (
{/* Canvas area — vertically centered in remaining space */}
{children}
{/* Playback bar — stacked below canvas, never overlapping. Hidden in
capture mode so the thumbnail is just the frame, no chrome. */}
{!captureMode && (
setPlaying(p => !p)}
onReset={() => { setTime(0); }}
onSeek={(t) => setTime(t)}
/>
)}
);
}
// ── 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 (
{/* Play / pause — bare white triangle, no button chrome */}
{playing ? (
) : (
)}
{/* Current time */}
{fmt(time)}
{/* Scrub track — white fill on translucent-white rail + draggable knob */}
{/* Progress knob — outer div is an enlarged transparent hit area for easier grabbing */}
{trackHover && (
)}
{/* Duration — dimmed */}
{fmt(duration)}
);
}
function IconButton({ children, onClick, tooltip }) {
const [hover, setHover] = React.useState(false);
return (
);
}
// ── Tooltip bubble ────────────────────────────────────────────────────────────
// Dark rounded bubble with a downward tail. Positioning is up to the caller.
function TooltipBubble({ text }) {
return (