UNPKG

@meframe/core

Version:

Next generation media processing framework based on WebCodecs

322 lines (321 loc) 9.62 kB
import { MeframeEvent } from "../event/events.js"; import { quantizeTimestampToFrame } from "../utils/time-utils.js"; import { WaiterReplacedError } from "../utils/errors.js"; class PlaybackController { orchestrator; eventBus; canvas; ctx; // Playback state currentTimeUs = 0; state = "idle"; playbackRate = 1; volume = 1; loop = false; // Animation loop rafId = null; startTime = 0; // Frame tracking frameCount = 0; lastFrameTime = 0; fps = 0; audioContext = null; audioSession = null; // Buffering state isBuffering = false; currentSeekId = 0; wasPlayingBeforeSeek = false; constructor(orchestrator, eventBus, options) { this.orchestrator = orchestrator; this.eventBus = eventBus; this.canvas = options.canvas; const ctx = this.canvas.getContext("2d", { alpha: false, desynchronized: true, colorSpace: "srgb" }); if (!ctx) { throw new Error("Failed to get 2D context from canvas"); } this.ctx = ctx; this.ctx.imageSmoothingEnabled = true; this.ctx.imageSmoothingQuality = "high"; if (options.startUs !== void 0) { this.currentTimeUs = options.startUs; } if (options.rate !== void 0) { this.playbackRate = options.rate; } if (options.loop !== void 0) { this.loop = options.loop; } if (options.autoStart) { this.play(); } this.setupListeners(); } // Playback control play() { if (this.state === "playing") return; this.wasPlayingBeforeSeek = true; void this.startPlayback(); } async startPlayback() { const wasIdle = this.state === "idle"; const seekId = this.currentSeekId; try { await this.renderCurrentFrame(this.currentTimeUs, true); if (seekId !== this.currentSeekId) { return; } this.state = "playing"; this.startTime = performance.now() - this.currentTimeUs / 1e3 / this.playbackRate; await this.ensureAudioContext(); if (this.audioSession && this.audioContext) { await this.audioSession.startPlayback(this.currentTimeUs, this.audioContext); } this.playbackLoop(); this.eventBus.emit(MeframeEvent.PlaybackPlay); } catch (error) { console.error("[PlaybackController] Failed to start playback:", error); this.state = wasIdle ? "idle" : "paused"; this.eventBus.emit(MeframeEvent.PlaybackError, error); } } pause() { if (this.state !== "playing") return; this.state = "paused"; this.wasPlayingBeforeSeek = false; if (this.rafId !== null) { cancelAnimationFrame(this.rafId); this.rafId = null; } this.audioSession?.stopPlayback(); this.eventBus.emit(MeframeEvent.PlaybackPause); } stop() { this.pause(); this.currentTimeUs = 0; this.state = "idle"; this.wasPlayingBeforeSeek = false; this.frameCount = 0; this.lastFrameTime = 0; this.fps = 0; this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.audioSession?.reset(); this.eventBus.emit(MeframeEvent.PlaybackStop); } async seek(timeUs) { const previousState = this.state; if (this.rafId !== null) { cancelAnimationFrame(this.rafId); this.rafId = null; } this.audioSession?.stopPlayback(); const clamped = this.clampTime(timeUs); this.currentTimeUs = clamped; this.currentSeekId++; this.isBuffering = false; this.state = "seeking"; const seekId = this.currentSeekId; try { await this.renderCurrentFrame(clamped, false); if (seekId !== this.currentSeekId) { return; } this.eventBus.emit(MeframeEvent.PlaybackSeek, { timeUs: this.currentTimeUs }); if (this.wasPlayingBeforeSeek) { await this.startPlayback(); } else { this.state = previousState === "idle" ? "idle" : "paused"; } } catch (error) { if (seekId !== this.currentSeekId) { return; } console.error("[PlaybackController] Seek error:", error); this.eventBus.emit(MeframeEvent.PlaybackError, error); this.state = previousState === "idle" ? "idle" : "paused"; } } // Playback properties setRate(rate) { const elapsed = performance.now() - this.startTime; this.playbackRate = rate; this.startTime = performance.now() - elapsed / rate; this.eventBus.emit(MeframeEvent.PlaybackRateChange, { rate }); this.audioSession?.setPlaybackRate(this.playbackRate); } setVolume(volume) { this.volume = Math.max(0, Math.min(1, volume)); this.eventBus.emit(MeframeEvent.PlaybackVolumeChange, { volume: this.volume }); this.audioSession?.setVolume(this.volume); } setMute(muted) { if (muted) { this.audioSession?.stopPlayback(); } else if (this.state === "playing" && this.audioContext) { this.audioSession?.startPlayback(this.currentTimeUs, this.audioContext); } } setLoop(loop) { this.loop = loop; } get duration() { const modelDuration = this.orchestrator.compositionModel?.durationUs; if (modelDuration !== void 0) { return modelDuration; } return 0; } get isPlaying() { return this.state === "playing"; } setAudioSession(session) { this.audioSession = session; } // Resume is just an alias for play resume() { this.play(); } on(event, handler) { this.eventBus.on(event, handler); } off(event, handler) { this.eventBus.off(event, handler); } setupListeners() { this.orchestrator.on(MeframeEvent.CacheCover, (event) => { if (this.state === "playing" || this.state === "buffering") { return; } this.renderCurrentFrame(event.timeUs); }); } // Private methods playbackLoop() { if (this.state !== "playing") { if (this.rafId !== null) { cancelAnimationFrame(this.rafId); this.rafId = null; } return; } this.rafId = requestAnimationFrame(async () => { if (this.state !== "playing") { return; } this.updateTime(); this.audioSession?.updateTime(this.currentTimeUs); await this.renderCurrentFrame(this.currentTimeUs, true); if (this.state !== "playing") { return; } const now = performance.now(); if (this.lastFrameTime > 0) { const deltaTime = now - this.lastFrameTime; const instantFps = 1e3 / deltaTime; this.fps = this.fps > 0 ? this.fps * 0.9 + instantFps * 0.1 : instantFps; } this.lastFrameTime = now; this.frameCount++; this.playbackLoop(); }); } updateTime() { const elapsed = (performance.now() - this.startTime) * this.playbackRate; const rawTimeUs = elapsed * 1e3; const fps = this.orchestrator.compositionModel?.fps; this.currentTimeUs = quantizeTimestampToFrame(rawTimeUs, 0, fps, "nearest"); if (this.currentTimeUs >= this.duration) { if (this.loop) { this.currentTimeUs = 0; this.startTime = performance.now(); } else { this.currentTimeUs = this.duration; this.pause(); this.state = "ended"; this.eventBus.emit(MeframeEvent.PlaybackEnded, { timeUs: this.currentTimeUs }); } } this.eventBus.emit(MeframeEvent.PlaybackTimeUpdate, { timeUs: this.currentTimeUs }); } async renderCurrentFrame(timeUs, immediate = true) { try { const rcFrame = await this.orchestrator.renderFrame(timeUs, { immediate }); if (!rcFrame) { if (this.state === "playing") { await this.handlePlaybackBuffering(timeUs); } return; } await rcFrame.use((frame) => { this.ctx.imageSmoothingEnabled = true; this.ctx.imageSmoothingQuality = "high"; this.ctx.drawImage(frame, 0, 0, this.canvas.width, this.canvas.height); }); } catch (error) { console.error("Render error:", error); this.eventBus.emit(MeframeEvent.PlaybackError, error); } } async handlePlaybackBuffering(timeUs) { if (this.isBuffering) { return; } const wasPlaying = this.state === "playing"; if (!wasPlaying) return; const seekId = this.currentSeekId; this.isBuffering = true; this.state = "buffering"; this.eventBus.emit(MeframeEvent.PlaybackBuffering); try { const ready = await this.orchestrator.waitForClipReady(timeUs, { minFrameCount: 3, // 等待 3 帧,确保连续播放不会立即再次 miss timeoutMs: 5e3 }); if (seekId !== this.currentSeekId) { return; } if (!ready) { console.warn("[PlaybackController] Buffering timeout during playback", timeUs); } this.state = "playing"; this.startTime = performance.now() - timeUs / 1e3 / this.playbackRate; this.eventBus.emit(MeframeEvent.PlaybackPlay); if (!this.rafId) { this.playbackLoop(); } } catch (error) { if (error instanceof WaiterReplacedError) { return; } if (seekId !== this.currentSeekId) { return; } console.error("[PlaybackController] Buffering error:", error); this.state = "paused"; this.eventBus.emit(MeframeEvent.PlaybackError, error); } finally { this.isBuffering = false; } } clampTime(timeUs) { return Math.max(0, Math.min(timeUs, this.duration)); } // Cleanup dispose() { this.stop(); } async ensureAudioContext() { if (this.audioContext) { return; } this.audioContext = new AudioContext(); } } export { PlaybackController }; //# sourceMappingURL=PlaybackController.js.map