mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
518 lines
18 KiB
JavaScript
518 lines
18 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
// eslint-disable-next-line test/no-import-node-test -- Real compiled video engine with controlled SDK iterator interleavings.
|
|
import test from 'node:test'
|
|
import { mbCandidate, mbEnvironment } from './helpers/mediabunny.js'
|
|
|
|
const implementation = await mbCandidate()
|
|
const flush = () => new Promise(resolve => setImmediate(resolve))
|
|
function deferred() {
|
|
let resolve, reject
|
|
const promise = new Promise((yes, no) => {
|
|
resolve = yes
|
|
reject = no
|
|
})
|
|
return { promise, resolve, reject }
|
|
}
|
|
const frame = (name, timestamp = 0) => ({ canvas: { name }, timestamp, duration: 0.1 })
|
|
const item = value => ({ value, done: !value })
|
|
function iterator(values = []) {
|
|
let reads = 0
|
|
let returns = 0
|
|
return {
|
|
next: async () => {
|
|
reads++
|
|
return item(values.shift())
|
|
},
|
|
return: async () => {
|
|
returns++
|
|
return item()
|
|
},
|
|
get reads() { return reads },
|
|
get returns() { return returns },
|
|
}
|
|
}
|
|
function environment(options = {}) {
|
|
const env = mbEnvironment(implementation)
|
|
env.eventNames.push('timeupdate', 'ended', 'waiting', 'playing', 'canplay')
|
|
const canvas = env.factory(options)(env.art)
|
|
const video = canvas.engine.video
|
|
canvas.engine.audio.seek = async () => {}
|
|
const draws = []
|
|
env.context.drawImage = source => draws.push(source.name)
|
|
video.audioClock = { currentTime: 1 }
|
|
return { ...env, canvas, video, draws }
|
|
}
|
|
|
|
test('MediaBunny video preserves first seek frame and queues the second with exact iterator timestamp', async () => {
|
|
const env = environment()
|
|
const frames = iterator([frame('first', 3), frame('second', 3.1)])
|
|
env.video.videoSink = { canvases: (time) => {
|
|
assert.equal(time, 3)
|
|
return frames
|
|
} }
|
|
await env.video.seek(3)
|
|
assert.deepEqual(env.draws, ['first'])
|
|
assert.equal(env.video.nextFrame.canvas.name, 'second')
|
|
assert.equal(frames.reads, 2)
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
test('MediaBunny video detaches iterator before awaiting release, and releases it once', async () => {
|
|
const env = environment()
|
|
const release = deferred()
|
|
let returns = 0
|
|
env.video.videoIterator = { return: () => {
|
|
returns++
|
|
return release.promise
|
|
} }
|
|
const closing = env.video.stopIterator()
|
|
assert.equal(env.video.videoIterator, null)
|
|
const again = env.video.stopIterator()
|
|
release.resolve(item())
|
|
await Promise.all([closing, again])
|
|
assert.equal(returns, 1)
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
test('MediaBunny old iterator return cannot erase a newer iterator', async () => {
|
|
const env = environment()
|
|
const release = deferred()
|
|
env.video.videoIterator = { return: () => release.promise }
|
|
const closing = env.video.stopIterator()
|
|
const next = iterator()
|
|
env.video.videoIterator = next
|
|
release.resolve(item())
|
|
await closing
|
|
assert.equal(env.video.videoIterator, next)
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
for (const ending of ['stop', 'destroy']) {
|
|
test(`MediaBunny pending render frame cannot draw after ${ending}`, async () => {
|
|
const env = environment()
|
|
const pending = deferred()
|
|
let reads = 0
|
|
env.video.videoIterator = { next: () => ++reads === 1 ? pending.promise : Promise.resolve(item()), return: async () => item() }
|
|
const reading = env.video.updateNextFrame(env.video.asyncId)
|
|
if (ending === 'stop')
|
|
env.video.stop()
|
|
else env.art.emit('destroy')
|
|
pending.resolve(item(frame('obsolete')))
|
|
await reading
|
|
assert.deepEqual(env.draws, [])
|
|
assert.equal(env.video.nextFrame, null)
|
|
env.art.emit('destroy')
|
|
})
|
|
}
|
|
|
|
for (const ending of ['destroy', 'seek']) {
|
|
test(`MediaBunny pending first seek frame cannot consume or overwrite frames after ${ending}`, async () => {
|
|
const env = environment()
|
|
const pending = deferred()
|
|
let oldReads = 0
|
|
const old = { next: async () => {
|
|
oldReads++
|
|
return oldReads === 1 ? pending.promise : item(frame('old-second'))
|
|
}, return: async () => item() }
|
|
const latest = iterator([frame('new-first'), frame('new-second')])
|
|
env.video.videoSink = { canvases: time => time === 1 ? old : latest }
|
|
const seeking = env.video.seek(1)
|
|
await flush()
|
|
if (ending === 'destroy')
|
|
env.art.emit('destroy')
|
|
else await env.video.seek(2)
|
|
pending.resolve(item(frame('old-first')))
|
|
await seeking
|
|
assert.deepEqual(env.draws, ending === 'destroy' ? [] : ['new-first'])
|
|
assert.equal(oldReads, 1)
|
|
if (ending === 'seek')
|
|
assert.equal(env.video.nextFrame.canvas.name, 'new-second')
|
|
env.art.emit('destroy')
|
|
})
|
|
}
|
|
|
|
test('MediaBunny a new load does not wait for obsolete iterator disposal', async () => {
|
|
const env = environment()
|
|
const release = deferred()
|
|
env.video.videoIterator = { return: () => release.promise }
|
|
let metadata = 0
|
|
const loading = env.video.load({ input: {}, videoTrack: null, duration: 9 }, () => metadata++)
|
|
await flush()
|
|
assert.equal(metadata, 1)
|
|
assert.equal(env.video.duration, 9)
|
|
release.resolve(item())
|
|
await loading
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
for (const ending of ['destroy', 'source']) {
|
|
test(`MediaBunny stale codec capability result cannot alter metadata after ${ending}`, async () => {
|
|
const env = environment()
|
|
const capability = deferred()
|
|
let metadata = 0
|
|
const loading = env.video.load({ input: {}, videoTrack: { codec: 'avc', canDecode: () => capability.promise }, duration: 8 }, () => metadata++)
|
|
await flush()
|
|
if (ending === 'destroy')
|
|
env.art.emit('destroy')
|
|
else await env.video.load({ input: {}, videoTrack: null, duration: 4 })
|
|
env.canvas.width = 42
|
|
capability.resolve(false)
|
|
await loading
|
|
assert.equal(metadata, 0)
|
|
assert.equal(env.canvas.width, 42)
|
|
env.art.emit('destroy')
|
|
})
|
|
}
|
|
|
|
for (const ending of ['stop', 'destroy']) {
|
|
test(`MediaBunny timeupdate listener ${ending} prevents rendering and RAF rescheduling`, () => {
|
|
const env = environment()
|
|
env.video.nextFrame = frame('due')
|
|
env.video.events.addEventListener('timeupdate', () => ending === 'stop' ? env.video.stop() : env.art.emit('destroy'))
|
|
env.video.render()
|
|
assert.deepEqual(env.draws, [])
|
|
assert.equal(env.frames.size, 0)
|
|
env.art.emit('destroy')
|
|
})
|
|
}
|
|
|
|
test('MediaBunny repeated video start owns only one native RAF including id zero', async () => {
|
|
const env = environment()
|
|
env.video.start({ currentTime: 0 })
|
|
assert.equal(env.frames.size, 1)
|
|
env.video.start({ currentTime: 0 })
|
|
assert.equal(env.frames.size, 1)
|
|
env.video.stop()
|
|
assert.equal(env.frames.size, 0)
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
test('MediaBunny already queued RAF callback stays inert after stop', () => {
|
|
const env = environment()
|
|
env.video.start({ currentTime: 0 })
|
|
const callback = [...env.frames.values()][0]
|
|
env.video.stop()
|
|
env.emitted.length = 0
|
|
callback()
|
|
assert.deepEqual(env.emitted, [])
|
|
assert.equal(env.frames.size, 0)
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
for (const ending of ['destroy', 'source']) {
|
|
test(`MediaBunny poster callback cannot overwrite canvas after ${ending}`, async () => {
|
|
const env = environment({ poster: 'poster.jpg' })
|
|
const images = []
|
|
env.globals.Image = class {
|
|
constructor() {
|
|
this.name = 'poster'
|
|
this.naturalWidth = 320
|
|
this.naturalHeight = 180
|
|
images.push(this)
|
|
}
|
|
}
|
|
env.video.drawPoster()
|
|
const callback = images[0].onload
|
|
if (ending === 'destroy') {
|
|
env.art.emit('destroy')
|
|
}
|
|
else {
|
|
env.video.poster = ''
|
|
await env.video.load({ input: {}, videoTrack: null, duration: 9 })
|
|
}
|
|
env.canvas.width = 42
|
|
callback()
|
|
assert.deepEqual(env.draws, [])
|
|
assert.equal(env.canvas.width, 42)
|
|
env.art.emit('destroy')
|
|
})
|
|
}
|
|
|
|
for (const dropLateFrames of [false, true]) {
|
|
test(`MediaBunny video retains default/tolerance frame selection with dropLateFrames=${dropLateFrames}`, async () => {
|
|
const env = environment({ dropLateFrames, avSyncTolerance: 0.12 })
|
|
env.video.playbackRate = 2
|
|
env.video.videoIterator = iterator([frame('late', 0.8), frame('near', 0.97), frame('future', 1.2)])
|
|
await env.video.updateNextFrame(env.video.asyncId)
|
|
assert.deepEqual(env.draws, dropLateFrames ? ['near'] : ['late', 'near'])
|
|
assert.equal(env.video.nextFrame.canvas.name, 'future')
|
|
env.art.emit('destroy')
|
|
})
|
|
}
|
|
|
|
test('MediaBunny waiting listener pause prevents a new RAF', () => {
|
|
const env = environment()
|
|
env.video.duration = 8
|
|
env.video.events.addEventListener('waiting', () => env.video.stop())
|
|
env.video.render()
|
|
assert.equal(env.frames.size, 0)
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
test('MediaBunny stalled canplay listener pause suppresses playing and another RAF', () => {
|
|
const env = environment()
|
|
env.video.stalled = true
|
|
env.video.nextFrame = frame('due')
|
|
env.video.events.addEventListener('canplay', () => env.video.stop())
|
|
env.video.render()
|
|
assert.equal(env.emitted.some(event => event.name === 'video:playing'), false)
|
|
assert.equal(env.frames.size, 0)
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
test('MediaBunny seek during pending capability detection preserves the current load metadata', async () => {
|
|
const env = environment()
|
|
const capability = deferred()
|
|
let metadata = 0
|
|
const loading = env.video.load({ input: {}, videoTrack: { codec: 'avc', canDecode: () => capability.promise }, duration: 8 }, () => metadata++)
|
|
await flush()
|
|
const seeking = env.video.seek(3)
|
|
capability.resolve(false)
|
|
await Promise.all([loading, seeking])
|
|
assert.equal(metadata, 1)
|
|
assert.equal(env.video.duration, 8)
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
test('MediaBunny new source cancels a pending seek before its own track selection finishes', async () => {
|
|
const env = environment()
|
|
const pending = deferred()
|
|
let reads = 0
|
|
env.video.videoSink = { canvases: () => ({ next: async () => ++reads === 1 ? pending.promise : item(frame('second')), return: async () => item() }) }
|
|
const seeking = env.canvas.engine.seek(2)
|
|
await flush()
|
|
const source = deferred()
|
|
env.canvas.engine.performLoad = () => source.promise
|
|
const loading = env.canvas.engine.load('new.mp4')
|
|
pending.resolve(item(frame('old-source')))
|
|
await seeking
|
|
await flush()
|
|
assert.deepEqual(env.draws, [])
|
|
source.resolve()
|
|
await loading
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
test('MediaBunny paused seek still renders its requested first frame', async () => {
|
|
const env = environment()
|
|
env.video.stop()
|
|
env.video.videoSink = { canvases: () => iterator([frame('target'), frame('next')]) }
|
|
await env.canvas.engine.seek(3)
|
|
assert.deepEqual(env.draws, ['target'])
|
|
assert.equal(env.canvas.paused, true)
|
|
assert.equal(env.frames.size, 0)
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
test('MediaBunny late seek failure cannot release the current iterator', async () => {
|
|
const env = environment()
|
|
const pending = deferred()
|
|
const latest = iterator([frame('current'), frame('next')])
|
|
env.video.videoSink = { canvases: time => time === 1 ? { next: () => pending.promise, return: async () => item() } : latest }
|
|
const old = env.video.seek(1)
|
|
await flush()
|
|
await env.video.seek(2)
|
|
pending.reject(new Error('old decoder failed'))
|
|
await old
|
|
assert.equal(env.video.videoIterator, latest)
|
|
assert.equal(latest.returns, 0)
|
|
assert.deepEqual(env.draws, ['current'])
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
test('MediaBunny stale transparency rejection is observed after source replacement', async () => {
|
|
const env = environment()
|
|
const pending = deferred()
|
|
const loading = env.video.load({ input: {}, videoTrack: { codec: 'avc', canDecode: async () => true, canBeTransparent: () => pending.promise }, duration: 8 })
|
|
await flush()
|
|
await env.video.load({ input: {}, videoTrack: null, duration: 4 })
|
|
pending.reject(new Error('old capability failed'))
|
|
await loading
|
|
assert.equal(env.video.duration, 4)
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
test('MediaBunny old frame fetch completion cannot unlock a newer pending fetch', async () => {
|
|
const env = environment()
|
|
const old = deferred()
|
|
const latest = deferred()
|
|
env.video.videoIterator = { next: () => old.promise, return: async () => item() }
|
|
const reading = env.video.updateNextFrame(env.video.asyncId)
|
|
await flush()
|
|
env.video.stop()
|
|
env.video.videoIterator = { next: () => latest.promise, return: async () => item() }
|
|
env.video.start({ currentTime: 1 })
|
|
await flush()
|
|
old.resolve(item(frame('obsolete')))
|
|
await reading
|
|
assert.equal(env.video.isFetching, true)
|
|
latest.resolve(item(frame('future', 3)))
|
|
await flush()
|
|
assert.equal(env.video.isFetching, false)
|
|
assert.equal(env.video.nextFrame.canvas.name, 'future')
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
test('MediaBunny stop/start shares a pending read without concurrent iterator.next calls', async () => {
|
|
const env = environment()
|
|
const pending = deferred()
|
|
let reads = 0
|
|
env.video.videoIterator = { next: () => {
|
|
reads++
|
|
return pending.promise
|
|
}, return: async () => item() }
|
|
const old = env.video.updateNextFrame(env.video.asyncId)
|
|
await flush()
|
|
env.video.stop()
|
|
env.video.start({ currentTime: 1 })
|
|
await flush()
|
|
assert.equal(reads, 1)
|
|
pending.resolve(item(frame('future', 2)))
|
|
await old
|
|
await flush()
|
|
assert.equal(env.video.nextFrame.canvas.name, 'future')
|
|
assert.equal(reads, 1)
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
test('MediaBunny renderer observes decoder failure, stops and reports one media error', async () => {
|
|
const env = environment()
|
|
const failure = new Error('decode failed')
|
|
const active = iterator()
|
|
active.next = async () => {
|
|
throw failure
|
|
}
|
|
env.video.videoIterator = active
|
|
env.video.start({ currentTime: 1 })
|
|
await flush()
|
|
assert.deepEqual({ ...env.canvas.error }, { code: 4, message: failure.message })
|
|
assert.equal(env.emitted.filter(event => event.name === 'video:error').length, 1)
|
|
assert.equal(env.frames.size, 0)
|
|
assert.equal(active.returns, 1)
|
|
assert.equal(env.video.videoIterator, null)
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
test('MediaBunny explicit iterator release preserves rejection after detachment', async () => {
|
|
const env = environment()
|
|
const failure = new Error('release failed')
|
|
env.video.videoIterator = { return: async () => {
|
|
throw failure
|
|
} }
|
|
await assert.rejects(env.video.stopIterator(), error => error === failure)
|
|
assert.equal(env.video.videoIterator, null)
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
test('MediaBunny normal ended sequence remains timeupdate, ended, pause, canplay', () => {
|
|
const env = environment()
|
|
env.video.duration = 1
|
|
env.video.render()
|
|
assert.deepEqual(env.emitted.map(event => event.name), ['video:timeupdate', 'video:ended', 'video:pause', 'video:canplay'])
|
|
assert.equal(env.frames.size, 0)
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
test('MediaBunny ended listener restart suppresses old pause/canplay without canceling its new RAF', () => {
|
|
const env = environment()
|
|
env.video.duration = 1
|
|
env.video.events.addEventListener('ended', () => env.video.start({ currentTime: 0 }))
|
|
env.video.render()
|
|
assert.deepEqual(env.emitted.map(event => event.name), ['video:timeupdate', 'video:ended'])
|
|
assert.equal(env.frames.size, 1)
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
test('MediaBunny inactive instance cleanup cannot cancel another instance RAF id zero', () => {
|
|
const env = environment()
|
|
const other = new env.video.constructor({ canvas: { width: 0, height: 0 }, ctx: env.context, events: env.video.events })
|
|
env.video.start({ currentTime: 0 })
|
|
assert.equal(env.frames.size, 1)
|
|
other.destroy()
|
|
assert.equal(env.frames.size, 1)
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
test('MediaBunny decoder failure remains observable if audio cleanup throws', async () => {
|
|
const env = environment()
|
|
env.globals.console = { ...console, warn() {} }
|
|
env.canvas.engine.paused = false
|
|
env.canvas.engine.audio.pause = () => {
|
|
throw new Error('audio cleanup failed')
|
|
}
|
|
env.video.videoIterator = { next: async () => {
|
|
throw new Error('original decode failure')
|
|
}, return: async () => item() }
|
|
env.video.start({ currentTime: 1 })
|
|
await flush()
|
|
env.canvas.engine.audio.pause = () => {}
|
|
assert.equal(env.canvas.error.message, 'original decode failure')
|
|
assert.equal(env.canvas.paused, true)
|
|
assert.equal(env.frames.size, 0)
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
test('MediaBunny destroy observes iterator release failure and keeps terminal references cleared', async () => {
|
|
const env = environment()
|
|
const warnings = []
|
|
env.globals.console = { ...console, warn: (...values) => warnings.push(values) }
|
|
const failure = new Error('iterator return failed')
|
|
env.video.videoIterator = { return: async () => {
|
|
throw failure
|
|
} }
|
|
env.art.emit('destroy')
|
|
await flush()
|
|
assert.equal(warnings.length, 1)
|
|
assert.equal(warnings[0][1], failure)
|
|
assert.equal(env.video.videoIterator, null)
|
|
assert.equal(env.video.nextFrame, null)
|
|
assert.equal(env.video.input, null)
|
|
assert.equal(env.video.videoSink, null)
|
|
})
|
|
|
|
for (const ending of ['destroy', 'seek']) {
|
|
test(`MediaBunny pending second seek frame cannot overwrite the current image after ${ending}`, async () => {
|
|
const env = environment()
|
|
const second = deferred()
|
|
let reads = 0
|
|
const old = { next: async () => ++reads === 1 ? item(frame('old-first')) : second.promise, return: async () => item() }
|
|
env.video.videoSink = { canvases: time => time === 1 ? old : iterator([frame('new-first'), frame('new-second')]) }
|
|
const seeking = env.video.seek(1)
|
|
await flush()
|
|
assert.equal(reads, 2)
|
|
if (ending === 'destroy')
|
|
env.art.emit('destroy')
|
|
else await env.video.seek(2)
|
|
second.resolve(item(frame('old-second')))
|
|
await seeking
|
|
assert.deepEqual(env.draws, ending === 'destroy' ? [] : ['new-first'])
|
|
env.art.emit('destroy')
|
|
})
|
|
}
|
|
|
|
test('MediaBunny missing 2D context rejects loading with a descriptive error', async () => {
|
|
const env = environment()
|
|
env.video.ctx = null
|
|
await assert.rejects(env.video.load({ input: {}, videoTrack: null, duration: 8 }), /Canvas 2D context is unavailable/)
|
|
env.art.emit('destroy')
|
|
})
|
|
|
|
test('MediaBunny start retains the queued second frame until its clock time before fetching another', async () => {
|
|
const env = environment()
|
|
const frames = iterator([frame('third', 1)])
|
|
env.video.nextFrame = frame('second', 0.5)
|
|
env.video.videoIterator = frames
|
|
const clock = { currentTime: 0 }
|
|
env.video.start(clock)
|
|
await flush()
|
|
assert.equal(frames.reads, 0)
|
|
assert.equal(env.video.nextFrame.canvas.name, 'second')
|
|
clock.currentTime = 0.5
|
|
env.video.render()
|
|
await flush()
|
|
assert.deepEqual(env.draws, ['second'])
|
|
assert.equal(frames.reads, 1)
|
|
assert.equal(env.video.nextFrame.canvas.name, 'third')
|
|
env.art.emit('destroy')
|
|
})
|