Files
ArtPlayer/packages/artplayer-proxy-mediabunny/src/VideoEngine.js
T

273 lines
6.2 KiB
JavaScript

/**
* 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
}
}