mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-16 01:26:16 -08:00
refactor(mediabunny): [PKG-MB-05] own video iterators, frame pools and rendering
This commit is contained in:
1 parent
5d0b128483
commit
9d037dcbf9
27 files changed
+3652
-595
No files matched your search
@@ -145,9 +145,10 @@ for (const implementation of implementations) {
|
||||
const video = canvas.engine.video
|
||||
if (video.videoSink) {
|
||||
const times = new WeakMap()
|
||||
const canvases = video.videoSink.canvases.bind(video.videoSink)
|
||||
video.videoSink.canvases = (...args) => {
|
||||
const iterator = canvases(...args)
|
||||
const prototype = Object.getPrototypeOf(video.videoSink)
|
||||
const canvases = prototype.canvases
|
||||
prototype.canvases = function (...args) {
|
||||
const iterator = canvases.apply(this, args)
|
||||
const next = iterator.next.bind(iterator)
|
||||
iterator.next = async (...values) => {
|
||||
const result = await next(...values)
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { mbCandidate } from '../helpers/mediabunny.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const implementation = await mbCandidate()
|
||||
for (const scenario of ['pause-frame', 'seek-source', 'seek-newer', 'timeupdate-pause', 'seek-pool']) {
|
||||
test(`MediaBunny ${implementation.name}: native video ownership ${scenario}`, async ({ page, browserName }, testInfo) => {
|
||||
await page.goto('/test/player.html?core=published')
|
||||
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports;
|
||||
${implementation.code}; window.mbFactory = module.exports.default || module.exports; })();` })
|
||||
let outcome = 'unverified'
|
||||
let result
|
||||
await page.evaluate(async () => {
|
||||
const listeners = new Map()
|
||||
window.mbEvents = []
|
||||
window.mbDraws = []
|
||||
window.mbFrames = new Set()
|
||||
const raf = requestAnimationFrame.bind(window)
|
||||
const cancel = cancelAnimationFrame.bind(window)
|
||||
window.mbFlush = () => new Promise(resolve => raf(() => raf(resolve)))
|
||||
window.requestAnimationFrame = (callback) => {
|
||||
const id = raf((time) => {
|
||||
window.mbFrames.delete(id)
|
||||
callback(time)
|
||||
})
|
||||
window.mbFrames.add(id)
|
||||
return id
|
||||
}
|
||||
window.cancelAnimationFrame = (id) => {
|
||||
window.mbFrames.delete(id)
|
||||
cancel(id)
|
||||
}
|
||||
window.mbHost = {
|
||||
constructor: window.Artplayer,
|
||||
option: { url: '', autoSize: false },
|
||||
template: { $player: document.querySelector('.player') },
|
||||
on(name, callback) {
|
||||
const list = listeners.get(name) || []
|
||||
list.push(callback)
|
||||
listeners.set(name, list)
|
||||
},
|
||||
emit(name, ...args) {
|
||||
window.mbEvents.push(name)
|
||||
for (const callback of listeners.get(name) || []) callback(...args)
|
||||
},
|
||||
}
|
||||
window.mbCanvas = window.mbFactory()(window.mbHost)
|
||||
const context = window.mbCanvas.engine.video.ctx
|
||||
const draw = context.drawImage.bind(context)
|
||||
context.drawImage = (...args) => {
|
||||
window.mbDraws.push({ time: window.mbCanvas.currentTime, paused: window.mbCanvas.paused })
|
||||
return draw(...args)
|
||||
}
|
||||
await window.mbCanvas.engine.load('/test/pattern.mp4')
|
||||
const button = document.createElement('button')
|
||||
button.id = 'mb-play'
|
||||
button.textContent = 'Play'
|
||||
button.onclick = () => {
|
||||
window.mbPlay = window.mbCanvas.play()
|
||||
}
|
||||
document.body.append(button)
|
||||
window.mbHold = (iterator) => {
|
||||
const next = iterator.next.bind(iterator)
|
||||
let first = true
|
||||
iterator.next = async (...args) => {
|
||||
const value = await next(...args)
|
||||
if (first) {
|
||||
first = false
|
||||
window.mbHeldTimestamp = value.value?.timestamp
|
||||
window.mbHeldReads = 1
|
||||
return new Promise((resolve) => {
|
||||
window.mbRelease = () => resolve(value)
|
||||
})
|
||||
}
|
||||
window.mbHeldReads++
|
||||
return value
|
||||
}
|
||||
return iterator
|
||||
}
|
||||
})
|
||||
try {
|
||||
const capabilities = await page.evaluate(() => ({ VideoDecoder: typeof VideoDecoder, AudioContext: typeof AudioContext, webkitAudioContext: typeof window.webkitAudioContext }))
|
||||
if (capabilities.AudioContext === 'undefined' && capabilities.webkitAudioContext === 'undefined') {
|
||||
expect(browserName).toBe('webkit')
|
||||
expect(capabilities.VideoDecoder).toBe('undefined')
|
||||
expect(await page.evaluate(() => window.mbCanvas.error?.code)).toBe(4)
|
||||
expect(await page.evaluate(() => window.mbCanvas.readyState)).toBeLessThan(4)
|
||||
outcome = 'unsupported-capability-control'
|
||||
return
|
||||
}
|
||||
expect(await page.evaluate(() => window.mbCanvas.error)).toBeNull()
|
||||
expect(await page.evaluate(() => window.mbCanvas.readyState)).toBe(4)
|
||||
expect(await page.evaluate(() => [window.mbCanvas.videoWidth, window.mbCanvas.videoHeight])).toEqual([320, 180])
|
||||
if (scenario === 'pause-frame') {
|
||||
await page.evaluate(() => window.mbHold(window.mbCanvas.engine.video.videoIterator))
|
||||
await page.click('#mb-play')
|
||||
await expect.poll(() => page.evaluate(() => typeof window.mbRelease)).toBe('function')
|
||||
await expect.poll(() => page.evaluate(() => window.mbCanvas.currentTime > window.mbHeldTimestamp + 0.1)).toBe(true)
|
||||
result = await page.evaluate(async () => {
|
||||
window.mbCanvas.pause()
|
||||
const before = window.mbDraws.length
|
||||
window.mbRelease()
|
||||
await window.mbPlay
|
||||
await window.mbFlush()
|
||||
return { before, after: window.mbDraws.length, paused: window.mbCanvas.paused, frames: window.mbFrames.size }
|
||||
})
|
||||
expect(result.after).toBe(result.before)
|
||||
expect(result.paused).toBe(true)
|
||||
expect(result.frames).toBe(0)
|
||||
}
|
||||
else if (scenario === 'seek-pool') {
|
||||
await page.evaluate(() => {
|
||||
const prototype = Object.getPrototypeOf(window.mbCanvas.engine.video.videoSink._videoSampleSink)
|
||||
const samples = prototype.samples
|
||||
window.mbSampleReleases = {}
|
||||
prototype.samples = function (...args) {
|
||||
const iterator = samples.apply(this, args)
|
||||
const next = iterator.next.bind(iterator)
|
||||
let first = true
|
||||
iterator.next = async (...values) => {
|
||||
const result = await next(...values)
|
||||
if (first && [1, 3].includes(args[0]) && !result.done) {
|
||||
first = false
|
||||
return new Promise((resolve) => {
|
||||
window.mbSampleReleases[args[0]] = () => resolve(result)
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
return iterator
|
||||
}
|
||||
window.mbOldFirst = window.mbCanvas.engine.seek(1)
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => typeof window.mbSampleReleases[1])).toBe('function')
|
||||
await page.evaluate(() => {
|
||||
window.mbOldSecond = window.mbCanvas.engine.seek(3)
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => typeof window.mbSampleReleases[3])).toBe('function')
|
||||
result = await page.evaluate(async () => {
|
||||
await window.mbCanvas.engine.seek(5)
|
||||
const frame = window.mbCanvas.engine.video.nextFrame
|
||||
const fingerprint = () => {
|
||||
let hash = 2166136261
|
||||
for (const value of frame.canvas.getContext('2d').getImageData(0, 0, frame.canvas.width, frame.canvas.height).data)
|
||||
hash = Math.imul(hash ^ value, 16777619) >>> 0
|
||||
return hash
|
||||
}
|
||||
const before = fingerprint()
|
||||
window.mbSampleReleases[1]()
|
||||
window.mbSampleReleases[3]()
|
||||
await Promise.all([window.mbOldFirst, window.mbOldSecond])
|
||||
await window.mbFlush()
|
||||
return { before, after: fingerprint(), timestamp: frame.timestamp, currentFrameUnchanged: frame === window.mbCanvas.engine.video.nextFrame, time: window.mbCanvas.currentTime }
|
||||
})
|
||||
expect(result.after).toBe(result.before)
|
||||
expect(result.currentFrameUnchanged).toBe(true)
|
||||
expect(result.timestamp).toBeGreaterThan(5)
|
||||
expect(result.time).toBe(5)
|
||||
}
|
||||
else if (scenario.startsWith('seek-')) {
|
||||
await page.evaluate(() => {
|
||||
const prototype = Object.getPrototypeOf(window.mbCanvas.engine.video.videoSink)
|
||||
const canvases = prototype.canvases
|
||||
prototype.canvases = function (...args) {
|
||||
const iterator = canvases.apply(this, args)
|
||||
return args[0] === 3 ? window.mbHold(iterator) : iterator
|
||||
}
|
||||
window.mbSeek = window.mbCanvas.engine.seek(3)
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => typeof window.mbRelease)).toBe('function')
|
||||
result = await page.evaluate(async (scenario) => {
|
||||
if (scenario === 'seek-source')
|
||||
await window.mbCanvas.engine.load('/test/pattern.mp4?replacement=1')
|
||||
else await window.mbCanvas.engine.seek(5)
|
||||
const iterator = window.mbCanvas.engine.video.videoIterator
|
||||
const before = window.mbDraws.length
|
||||
window.mbRelease()
|
||||
await window.mbSeek
|
||||
await window.mbFlush()
|
||||
return { before, after: window.mbDraws.length, heldReads: window.mbHeldReads, currentIteratorKept: Boolean(iterator) && iterator === window.mbCanvas.engine.video.videoIterator, frames: window.mbFrames.size, time: window.mbCanvas.currentTime, error: window.mbCanvas.error }
|
||||
}, scenario)
|
||||
expect(result.after).toBe(result.before)
|
||||
expect(result.heldReads).toBe(1)
|
||||
expect(result.currentIteratorKept).toBe(true)
|
||||
expect(result.frames).toBe(0)
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.time).toBe(scenario === 'seek-source' ? 0 : 5)
|
||||
}
|
||||
else {
|
||||
await page.evaluate(() => window.mbCanvas.events.addEventListener('timeupdate', () => window.mbCanvas.pause()))
|
||||
await page.click('#mb-play')
|
||||
await expect.poll(() => page.evaluate(() => window.mbEvents.includes('video:timeupdate'))).toBe(true)
|
||||
result = await page.evaluate(async () => {
|
||||
await window.mbFlush()
|
||||
return { paused: window.mbCanvas.paused, frames: window.mbFrames.size }
|
||||
})
|
||||
expect(result).toEqual({ paused: true, frames: 0 })
|
||||
}
|
||||
outcome = 'native-decoded-video-race'
|
||||
}
|
||||
finally {
|
||||
const state = await page.evaluate(async () => {
|
||||
const canvas = window.mbCanvas
|
||||
window.mbRelease?.()
|
||||
for (const release of Object.values(window.mbSampleReleases || {})) release()
|
||||
const context = canvas.engine.audio.audioContext
|
||||
window.mbCleanupContext = context
|
||||
window.mbHost.emit('destroy')
|
||||
await window.mbFlush()
|
||||
return { events: window.mbEvents, draws: window.mbDraws.length, error: canvas.error, audioContext: context?.state || 'not-created', frames: window.mbFrames.size }
|
||||
})
|
||||
expect(state.frames).toBe(0)
|
||||
await expect.poll(() => page.evaluate(() => window.mbCleanupContext?.state || 'not-created')).toMatch(/^(?:closed|not-created)$/)
|
||||
state.audioContext = await page.evaluate(() => window.mbCleanupContext?.state || 'not-created')
|
||||
await testInfo.attach('mediabunny-video-ownership', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), scenario, outcome, result, state, scope: 'Actual SDK MP4 decoding and browser clock/RAF; frame delivery or two raw VideoSample deliveries before CanvasSink conversion control interleaving. Source/seek ownership, pause and timeupdate reentry verified; not long-run AV sync or a mock codec.' }) })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
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')
|
||||
})
|
||||
Reference in new issue
Block a user