| name | frontend-scene-timeline-playback |
| description | Use when implementing or modifying timeline playback, animation loops, or audio synchronization in the Scene Editor |
Frontend: Scene Timeline Playback
Guidelines for implementing smooth, performant playback in the Scene Editor timeline. Covers RAF architecture, audio sync, and common pitfalls.
Critical Rule: Single RAF Loop Architecture
NEVER create multiple independent requestAnimationFrame loops for the same feature.
The Scene Editor uses a single RAF loop in PageScene_Timeline.tsx that:
- Advances time based on
performance.now() delta
- Updates
timelineTimeRef.current (the source of truth)
- Updates playhead DOM directly (no React re-renders)
- Updates time display DOM directly
Why Multiple RAF Loops Are Bad
useEffect(() => {
const tick = () => {
syncAudio(timelineTimeRef.current);
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
}, []);
const tick = (timestamp: number) => {
const newTime = calculateNewTime(timestamp);
timelineTimeRef.current = newTime;
audioManager.syncToTime(newTime);
eventBus.emit("timelineTime", newTime);
updatePlayheadDOM(newTime);
rafRef.current = requestAnimationFrame(tick);
};
Reference Implementation
The main playback loop is in:
PageScene_Timeline.tsx:22-138 - usePageScene_Timeline_Playback hook
Key refs used:
timelineTimeRef - Current playback time (source of truth during playback)
playheadRef - DOM element for direct manipulation
pxPerSecRef, scrollOffsetPxRef - Cached zoom/scroll values
Audio Synchronization Guidelines
Problem: HTML5 Audio Timing is Imprecise
<audio> element uses browser's audio clock (independent from performance.now())
- Setting
audio.currentTime is a blocking seek operation that causes frame drops
- Clocks naturally drift apart over time
Anti-patterns to Avoid
const tick = () => {
const drift = Math.abs(audio.currentTime - expectedOffset);
if (drift > 0.05) {
audio.currentTime = expectedOffset;
}
rafRef.current = requestAnimationFrame(tick);
};
useEffect(() => {
const tick = () => {
clips.forEach((clip) => checkAndSync(clip));
rafRef.current = requestAnimationFrame(tick);
};
}, []);
Recommended Approaches
Option A: Fire-and-Forget (Simple)
if (!clip.isPlaying && isInClip) {
clip.audio.currentTime = audioOffset;
clip.audio.play();
clip.isPlaying = true;
}
Option B: Infrequent Drift Correction
frameCountRef.current++;
if (frameCountRef.current % 30 === 0) {
const drift = Math.abs(audio.currentTime - expectedOffset);
if (drift > 0.2) {
audio.currentTime = expectedOffset;
}
}
Option C: Web Audio API (Professional-grade) - IMPLEMENTED
The Scene Editor uses Web Audio API for audio playback:
webAudioManager.syncPlayback(audioClipsDataRef.current, newTime, true);
Integration Points
When adding new features that need time sync:
- Read from
timelineTimeRef.current - Don't create your own time tracking
- Don't start new RAF loops - Hook into the existing playback system
- For audio: Consider Web Audio API for professional timing needs
- For animations: Use CSS animations or integrate with main RAF loop
Implementation Status
Playhead jitter during audio playback - RESOLVED (2025-12-18)
Root cause was multiple RAF loops (per-track) + HTML5 audio drift correction.
Fixed by migrating to Web Audio API with single RAF loop architecture.
See docs/spark/frontend/my-vite-app/audio-drag-drop-spec.md Section 15 for historical context.
Related Skills
- frontend-provider-context - Provider patterns used by timeline state
- frontend-fiber-canvas - 3D canvas integration that also uses timeline time