/** * Video Engine for MediaBunny * Handles video frame rendering and synchronization */ import { CanvasSink, } from 'mediabunny' import { preflightRange } from './preflight' export default class VideoEngine { constructor({ canvas, ctx, events, timeupdateInterval = 250, avSyncTolerance = 0.12, dropLateFrames = false, poster = '', preflightRange = false, }) { this.canvas = canvas this.ctx = ctx this.events = events this.timeupdateInterval = timeupdateInterval this.avSyncTolerance = avSyncTolerance this.dropLateFrames = dropLateFrames this.poster = poster this.preflightRange = preflightRange // MediaBunny instances this.input = null this.videoSink = null this.videoIterator = null // Frame rendering this.nextFrame = null this.rafId = 0 this.asyncId = 0 // Video properties this.width = 0 this.height = 0 this.duration = Number.NaN // Playback state this.audioClock = null this.lastTimeUpdate = 0 this.stalled = false this.playbackRate = 1 this.posterDrawn = false this.isFetching = false } async preflight(url, signal, isCurrent) { return preflightRange(url, this.preflightRange, this.events, signal, isCurrent) } drawPoster() { if (!this.poster || this.posterDrawn) return const img = new Image() img.onload = () => { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) this.canvas.width = img.naturalWidth || this.canvas.width this.canvas.height = img.naturalHeight || this.canvas.height this.ctx.drawImage(img, 0, 0, this.canvas.width, this.canvas.height) this.posterDrawn = true } img.src = this.poster } async stopIterator() { await this.videoIterator?.return() this.videoIterator = null } clear() { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) } async load(media, onMetadata) { ++this.asyncId await this.stopIterator() this.clear() this.posterDrawn = false const { input, videoTrack, duration } = media this.input = input this.duration = duration if (!videoTrack) { this.handleNoVideoTrack() onMetadata?.() return } if (videoTrack.codec === null || !(await videoTrack.canDecode())) { this.handleNoVideoTrack() onMetadata?.() return } const transparent = await videoTrack.canBeTransparent() this.videoSink = new CanvasSink(videoTrack, { poolSize: 2, fit: 'contain', alpha: transparent, }) this.width = videoTrack.displayWidth this.height = videoTrack.displayHeight this.canvas.width = this.width this.canvas.height = this.height onMetadata?.() await this.resetIterator(0) } handleNoVideoTrack() { this.videoSink = null this.width = 0 this.height = 0 this.canvas.width = 0 this.canvas.height = 0 this.clear() this.drawPoster() } async resetIterator(time) { await this.stopIterator() if (!this.videoSink) return this.videoIterator = this.videoSink.canvases(time) const first = (await this.videoIterator.next()).value ?? null const second = (await this.videoIterator.next()).value ?? null this.nextFrame = second if (first) { this.ctx.drawImage(first.canvas, 0, 0) } else { this.drawPoster() } } async updateNextFrame(localId) { if (!this.videoIterator || this.isFetching) return this.isFetching = true try { while (true) { const frame = (await this.videoIterator.next()).value ?? null if (!frame || localId !== this.asyncId) return const t = this.audioClock.currentTime const tolerance = this.dropLateFrames ? Math.max(0.06, this.avSyncTolerance / Math.max(1, this.playbackRate)) : 0 if (this.dropLateFrames && frame.timestamp < t - tolerance) { // Skip late frame continue } if (frame.timestamp <= t + tolerance) { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) this.ctx.drawImage(frame.canvas, 0, 0) if (!this.dropLateFrames && frame.timestamp > t) { this.nextFrame = null return } } else { this.nextFrame = frame return } } } finally { this.isFetching = false } } render() { if (!this.audioClock) return const t = this.audioClock.currentTime const now = Date.now() // Emit timeupdate event if (now - this.lastTimeUpdate >= this.timeupdateInterval) { this.events.emit('timeupdate') this.lastTimeUpdate = now } // Check if reached end if (Number.isFinite(this.duration) && t >= this.duration) { this.stop() this.stalled = false this.events.emit('ended') this.events.emit('pause') this.events.emit('canplay') return } // Render next frame if ready if (this.nextFrame && this.nextFrame.timestamp <= t) { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) this.ctx.drawImage(this.nextFrame.canvas, 0, 0) this.nextFrame = null this.updateNextFrame(this.asyncId) if (this.stalled) { this.events.emit('canplay') this.events.emit('playing') this.stalled = false } } else if (!this.nextFrame) { this.updateNextFrame(this.asyncId) if (!this.nextFrame && Number.isFinite(this.duration) && t < this.duration && !this.stalled) { this.stalled = true this.events.emit('waiting') } } this.rafId = requestAnimationFrame(() => this.render()) } start(audioEngine) { this.audioClock = audioEngine this.asyncId++ this.stalled = false this.updateNextFrame(this.asyncId) this.rafId = requestAnimationFrame(() => this.render()) } stop() { cancelAnimationFrame(this.rafId) } async seek(time) { this.asyncId++ await this.resetIterator(time) } setPlaybackRate(rate) { this.playbackRate = Math.max(0.1, Number(rate) || 1) } destroy() { this.asyncId++ this.stop() this.stopIterator() this.posterDrawn = false this.videoSink = null this.input = null } }