AgentSkillsCN

frontend-scene-timeline-playback

当您需要在场景编辑器中实现或修改时间轴播放、动画循环,或进行音频同步时,可使用此技能。

SKILL.md
--- frontmatter
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:

  1. Advances time based on performance.now() delta
  2. Updates timelineTimeRef.current (the source of truth)
  3. Updates playhead DOM directly (no React re-renders)
  4. Updates time display DOM directly

Why Multiple RAF Loops Are Bad

typescript
// ❌ BAD: Multiple independent RAF loops
// Each AudioLane running its own loop
useEffect(() => {
    const tick = () => {
        syncAudio(timelineTimeRef.current);
        rafRef.current = requestAnimationFrame(tick);
    };
    rafRef.current = requestAnimationFrame(tick);
}, []);

// Problems:
// 1. Loops fire at different times within the same frame
// 2. Unsynchronized reads from shared state (timelineTimeRef)
// 3. Accumulated CPU overhead (N tracks = N+1 loops)
// 4. Can cause jitter and timing conflicts
typescript
// ✅ GOOD: Single RAF loop with callbacks/events
// Main loop notifies subsystems
const tick = (timestamp: number) => {
    const newTime = calculateNewTime(timestamp);
    timelineTimeRef.current = newTime;

    // Option A: Direct call to audio manager
    audioManager.syncToTime(newTime);

    // Option B: Event-based notification
    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

typescript
// ❌ BAD: Checking drift every frame and seeking
const tick = () => {
    const drift = Math.abs(audio.currentTime - expectedOffset);
    if (drift > 0.05) {
        // 50ms threshold
        audio.currentTime = expectedOffset; // CAUSES JITTER!
    }
    rafRef.current = requestAnimationFrame(tick);
};
typescript
// ❌ BAD: Multiple RAF loops for audio sync
// Each track has its own loop
useEffect(() => {
    const tick = () => {
        clips.forEach((clip) => checkAndSync(clip));
        rafRef.current = requestAnimationFrame(tick);
    };
    // ...
}, []);

Recommended Approaches

Option A: Fire-and-Forget (Simple)

typescript
// Start audio at correct offset, let it play naturally
// Accept minor drift as acceptable tradeoff
if (!clip.isPlaying && isInClip) {
    clip.audio.currentTime = audioOffset;
    clip.audio.play();
    clip.isPlaying = true;
}
// NO drift correction during playback

Option B: Infrequent Drift Correction

typescript
// Check drift less frequently (every 30 frames ≈ 0.5s)
// Use larger threshold (200-300ms)
frameCountRef.current++;
if (frameCountRef.current % 30 === 0) {
    const drift = Math.abs(audio.currentTime - expectedOffset);
    if (drift > 0.2) {
        // 200ms threshold
        audio.currentTime = expectedOffset;
    }
}

Option C: Web Audio API (Professional-grade) - IMPLEMENTED

The Scene Editor uses Web Audio API for audio playback:

typescript
// WebAudioManager singleton (webAudioManager.ts)
// - AudioContext for precise timing
// - AudioBuffer cache per file
// - GainNode per clip for instant volume changes
// - AudioBufferSourceNode per playback (fire-and-forget)

// Main RAF loop calls syncPlayback with current clip data
webAudioManager.syncPlayback(audioClipsDataRef.current, newTime, true);

// Key files:
// - webAudioManager.ts - Singleton service
// - useAudioPlayback_PageScene_Timeline.ts - Centralized hook
// - PageScene_Timeline.tsx - Integration point (line 117)

Integration Points

When adding new features that need time sync:

  1. Read from timelineTimeRef.current - Don't create your own time tracking
  2. Don't start new RAF loops - Hook into the existing playback system
  3. For audio: Consider Web Audio API for professional timing needs
  4. 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
<!-- Last compacted: 2025-12-18 -->