Files
ArtPlayer/test/mediabunny-video.test.js
T

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')
})