refactor(mediabunny): [PKG-MB-05] own video iterators, frame pools and rendering

This commit is contained in:
Harvey Zhao committed 2026-09-12 19:47:46 +08:00
1 parent 5d0b128483
commit 9d037dcbf9
27 files changed
+3652 -595

No files matched your search

+4 -3
View File
@@ -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)
+218
View File
@@ -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.' }) })
}
})
}
+517
View File
@@ -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')
})