@meframe/core
Version:
Next generation media processing framework based on WebCodecs
322 lines (321 loc) • 9.62 kB
JavaScript
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