refactor(mediabunny): [PKG-MB-08] checkpoint typed entry and compatible media declarations

This commit is contained in:
Harvey Zhao committed 2026-09-12 21:31:34 +08:00
1 parent 8a2b56e829
commit 5e28dfee95
39 files changed
+3007 -287

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
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 core of ['published', 'candidate']) {
for (const scenario of ['native-cleanup', 'replacement']) {
test(`MediaBunny ${implementation.name}: ${core} core native Canvas entry ${scenario}`, async ({ page }, testInfo) => {
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports;
${implementation.code}; window.mbFactory = module.exports.default || module.exports; })();` })
const result = await page.evaluate((scenario) => {
const registered = []
const removed = []
const art = new window.Artplayer({
container: '.player',
url: '',
proxy(player) {
const on = player.on
const off = player.off
player.on = function (name, callback) {
registered.push({ name, callback })
return on.call(this, name, callback)
}
player.off = function (name, callback) {
removed.push({ name, callback })
return off.call(this, name, callback)
}
return window.mbFactory()(player)
},
})
const canvas = art.video
const shim = art.mediabunny
const resize = registered.find(item => item.name === 'resize')
const metadata = registered.find(item => item.name === 'video:loadedmetadata')
const destroy = registered.find(item => item.name === 'destroy')
const receiver = canvas.getContext
const bounds = canvas.getBoundingClientRect
const attributes = canvas.setAttribute
const identity = canvas instanceof HTMLCanvasElement && shim.canvas === canvas
const context = receiver('2d')?.canvas === canvas
const width = bounds().width
let clickCount = 0
canvas.addEventListener('click', () => clickCount++)
canvas.dispatchEvent(new MouseEvent('click'))
attributes('src', 'native-only.mp4')
canvas.volume = 0.35
art.emit('resize')
// Core sizing listeners may reset inline styles after proxy resize; isolate its callback.
resize.callback()
const before = { identity, context, width, clickCount, source: shim.src, attribute: canvas.getAttribute('src'), volume: shim.volume, style: canvas.style.width, maybe: canvas.canPlayType('video/unknown') }
const marker = { owner: 'replacement' }
if (scenario === 'replacement')
art.mediabunny = marker
art.destroy(false)
canvas.style.width = '43px'
resize.callback()
metadata.callback()
const cleanup = {
released: [resize, metadata, destroy].map(item => removed.some(entry => entry.name === item.name && entry.callback === item.callback)),
destroyed: shim.engine.destroyed,
styleAfter: canvas.style.width,
alias: scenario === 'replacement' ? art.mediabunny === marker : !Object.hasOwn(art, 'mediabunny'),
}
return { before, cleanup, coreVersion: window.Artplayer.version }
}, scenario)
expect(result.before).toEqual({ identity: true, context: true, width: 640, clickCount: 1, source: null, attribute: 'native-only.mp4', volume: 0.35, style: '100%', maybe: 'maybe' })
expect(result.cleanup).toEqual({ released: [true, true, true], destroyed: true, styleAfter: '43px', alias: true })
await testInfo.attach('mediabunny-entry', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), core, scenario, result, scope: 'Actual browser Canvas descriptors and published/candidate core lifecycle. Empty source deliberately excludes decoding; existing media suites cover playback separately.' }) })
})
}
}
+229
View File
@@ -0,0 +1,229 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Actual entry bridge and lifecycle with controlled native-like Canvas descriptors.
import test from 'node:test'
import { mbCandidate, mbEnvironment } from './helpers/mediabunny.js'
const implementation = await mbCandidate()
function environment() {
const env = mbEnvironment(implementation)
env.art.off = (name, callback) => {
env.handlers.set(name, (env.handlers.get(name) || []).filter(item => item !== callback))
}
return env
}
test('MediaBunny entry keeps a callable factory and a writable historical default alias', () => {
const env = environment()
assert.equal(typeof env.exported, 'function')
assert.equal(env.exported.default, env.exported)
assert.equal(typeof env.exported.default(), 'function')
assert.deepEqual(Object.keys(env.exported), [])
const replacement = () => {}
env.exported.default = replacement
assert.equal(env.exported.default, replacement)
assert.equal(typeof env.exported(), 'function')
})
test('MediaBunny entry preserves canvas identity, native receiver and shim binding', async () => {
const env = environment()
const canvas = env.factory()(env.art)
assert.equal(canvas, env.canvas)
assert.equal(canvas.canvas, canvas)
assert.equal(canvas.art, env.art)
assert.equal(env.art.mediabunny.engine, canvas.engine)
assert.notEqual(canvas.play, canvas.play)
const pause = canvas.pause
assert.equal(pause(), undefined)
canvas.getContext('2d')
assert.equal(env.nativeCalls.at(-1)[2], true)
const pending = Promise.resolve()
canvas.engine.play = () => pending
assert.equal(canvas.play(), pending)
env.art.emit('destroy')
})
test('MediaBunny entry preserves enumerable forwarding and readonly setter failure', () => {
const env = environment()
const canvas = env.factory()(env.art)
const descriptor = Object.getOwnPropertyDescriptor(canvas, 'duration')
assert.equal(descriptor.enumerable, true)
assert.equal(descriptor.configurable, true)
assert.equal(typeof descriptor.get, 'function')
assert.equal(typeof descriptor.set, 'function')
assert.throws(() => {
canvas.duration = 4
}, { name: 'TypeError' })
env.art.emit('destroy')
})
test('MediaBunny entry preserves native collisions and direct-prototype forwarding boundary', () => {
const env = environment()
env.canvas.currentTime = 13
const native = env.canvas.setAttribute
const canvas = env.factory()(env.art)
assert.equal(canvas.currentTime, 13)
assert.notEqual(canvas.setAttribute, native)
canvas.setAttribute('src', 'native-only.mp4')
assert.equal(env.art.mediabunny.src, null)
assert.equal(Object.hasOwn(canvas, 'toString'), false)
assert.equal(Object.hasOwn(canvas, 'constructor'), false)
env.art.emit('destroy')
})
test('MediaBunny resize preserves default styling and autoSize behavior', () => {
const env = environment()
const canvas = env.factory()(env.art)
assert.deepEqual(canvas.style, {})
env.art.emit('resize')
assert.deepEqual(canvas.style, { width: '100%', height: '100%', objectFit: 'contain' })
canvas.style.width = '42px'
env.art.option.autoSize = true
env.art.emit('video:loadedmetadata')
assert.equal(canvas.style.width, '42px')
env.art.emit('destroy')
})
test('MediaBunny entry destruction unregisters its resize and metadata callbacks', () => {
const env = environment()
const canvas = env.factory()(env.art)
env.art.emit('destroy')
assert.equal(env.handlers.get('resize')?.length || 0, 0)
assert.equal(env.handlers.get('video:loadedmetadata')?.length || 0, 0)
assert.equal(env.handlers.get('destroy')?.length || 0, 0)
assert.equal(canvas.engine.destroyed, true)
})
test('MediaBunny saved resize callback cannot write after destruction', () => {
const env = environment()
const canvas = env.factory()(env.art)
const callback = env.handlers.get('resize')[0]
env.art.emit('destroy')
canvas.style.width = '42px'
callback()
assert.equal(canvas.style.width, '42px')
})
test('MediaBunny destruction preserves a replacement art.mediabunny alias', () => {
const env = environment()
const canvas = env.factory()(env.art)
const replacement = {}
env.art.mediabunny = replacement
env.art.emit('destroy')
assert.equal(env.art.mediabunny, replacement)
assert.equal(canvas.engine.destroyed, true)
})
test('MediaBunny entry attempts listener cleanup even when engine destroy throws', () => {
const env = environment()
const canvas = env.factory()(env.art)
const failure = new Error('engine cleanup failed')
canvas.engine.destroy = () => {
throw failure
}
assert.throws(() => env.art.emit('destroy'), error => error === failure)
assert.equal(env.art.mediabunny, undefined)
assert.equal(env.handlers.get('resize')?.length || 0, 0)
assert.equal(env.handlers.get('destroy')?.length || 0, 0)
assert.doesNotThrow(() => env.art.emit('destroy'))
})
test('MediaBunny entry attempts all cleanup after one listener removal fails', () => {
const env = environment()
const canvas = env.factory()(env.art)
const off = env.art.off
const removed = []
const failure = new Error('off failed')
env.art.off = (name, callback) => {
removed.push(name)
off(name, callback)
if (name === 'resize')
throw failure
}
assert.throws(() => env.art.emit('destroy'), error => error === failure)
assert.deepEqual(removed, ['resize', 'video:loadedmetadata', 'destroy'])
assert.equal(canvas.engine.destroyed, true)
assert.equal(env.art.mediabunny, undefined)
})
test('MediaBunny entry keeps legacy host adapters without off usable and callbacks inert after destroy', () => {
const env = environment()
delete env.art.off
const canvas = env.factory()(env.art)
env.art.emit('destroy')
canvas.style.width = '42px'
env.art.emit('resize')
assert.equal(canvas.style.width, '42px')
})
test('MediaBunny bridge setup failure releases the assigned shim and preserves the original failure', () => {
const env = environment()
Object.preventExtensions(env.canvas)
let captured
Object.defineProperty(env.art, 'mediabunny', {
configurable: true,
get() { return captured },
set(value) { captured = value },
})
assert.throws(() => env.factory()(env.art), { name: 'TypeError' })
assert.equal(captured.engine.destroyed, true)
assert.equal(env.art.mediabunny, undefined)
assert.equal(env.handlers.get('resize')?.length || 0, 0)
})
test('MediaBunny entry calls createElement with the historical detached receiver', () => {
const env = environment()
let receiver = 'unset'
env.art.constructor.utils.createElement = function () {
receiver = this
return env.canvas
}
env.factory()(env.art)
assert.equal(receiver, undefined)
env.art.emit('destroy')
})
test('MediaBunny bridge preserves the two native method getter reads and bound invocation', () => {
const env = environment()
let reads = 0
const receivers = []
Object.defineProperty(env.canvas, 'probe', {
enumerable: true,
configurable: true,
get() {
reads++
return function () {
receivers.push(this)
}
},
set(value) {
Object.defineProperty(env.canvas, 'probe', { value, configurable: true })
},
})
env.factory()(env.art)
assert.equal(reads, 2)
const probe = env.canvas.probe
probe()
assert.equal(receivers[0], env.canvas)
env.art.emit('destroy')
})
test('MediaBunny partial HLS registration failure cleans the entry, shim and installed callbacks', () => {
const env = environment()
const failure = new Error('registration failed')
const on = env.art.on
env.art.on = (name, callback) => {
on(name, callback)
if (name === 'restart')
throw failure
}
let shim
Object.defineProperty(env.art, 'mediabunny', {
configurable: true,
get() { return shim },
set(value) { shim = value },
})
assert.throws(() => env.factory({ m3u8: {} })(env.art), error => error === failure)
assert.equal(shim.engine.destroyed, true)
assert.equal(env.art.mediabunny, undefined)
assert.equal([...env.handlers.values()].flat().length, 0)
})
+3 -3
View File
@@ -257,11 +257,11 @@ test('MediaBunny HLS control update reentry cannot write later settings or audio
test('MediaBunny HLS destruction removes its refresh listeners and preserves proxy teardown', async () => {
const env = environment()
await env.refresh()
const metadataBefore = env.handlers.get('video:loadedmetadata').length
const metadataListener = env.handlers.get('video:loadedmetadata').at(-1)
env.art.emit('destroy')
assert.equal(env.canvas.engine.destroyed, true)
assert.equal(env.art.mediabunny, undefined)
assert.equal(env.handlers.get('video:loadedmetadata').length, metadataBefore - 1)
assert.equal(env.handlers.get('video:loadedmetadata').includes(metadataListener), false)
assert.equal(env.handlers.get('restart')?.length || 0, 0)
})
@@ -402,7 +402,7 @@ test('MediaBunny HLS failing listener removal still attempts remaining removals
throw new Error('off failed')
}
env.art.emit('destroy')
assert.equal(removed.length, 5)
assert.deepEqual(removed.slice(0, 5), ['video:loadedmetadata', 'restart', 'video:loadstart', 'video:error', 'destroy'])
assert.equal(env.canvas.engine.destroyed, true)
assert.equal(env.warnings.length, 1)
})
+82
View File
@@ -0,0 +1,82 @@
import type { HlsState, MediaBunnyCanvas, MediaBunnyPlayer, MediaBunnyShim, Option, Result, SyntheticFrameCallback } from 'artplayer-proxy-mediabunny'
import Artplayer from 'artplayer'
import factory from 'artplayer-proxy-mediabunny'
import legacy from 'artplayer-proxy-mediabunny/legacy'
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false
type Assert<T extends true> = T
type ExactFactory = Assert<Equal<typeof factory, (option?: Option) => (art: Artplayer) => HTMLCanvasElement>>
type ExactParameters = Assert<Equal<Parameters<typeof factory>, [option?: Option]>>
type ExactResult = Assert<Equal<ReturnType<ReturnType<typeof factory>>, HTMLCanvasElement>>
type ExactResultAlias = Assert<Equal<Result, HTMLCanvasElement>>
type NativeAttributes = Assert<Equal<MediaBunnyCanvas['setAttribute'], HTMLCanvasElement['setAttribute']>>
type NativeEvents = Assert<Equal<MediaBunnyCanvas['addEventListener'], HTMLCanvasElement['addEventListener']>>
const art: MediaBunnyPlayer = new Artplayer({ container: '#player', url: 'movie.mp4', proxy: factory() })
const replacement: typeof factory = (_option?: Option) => (_art: Artplayer) => document.createElement('canvas')
const canvas: Result = factory(undefined)(art)
const fromLegacy: typeof factory = legacy
const toLegacy: typeof legacy = factory
declare const shim: MediaBunnyShim
const playing: Promise<void> = shim.play()
const paused: void = shim.pause()
const loading: void = shim.load()
const state: Promise<HlsState | null> = shim.getM3u8State()
const source: unknown = shim.currentSrc
shim.src = new Blob([])
shim.currentTime = 2
shim.volume = 0.5
shim.muted = false
const callback: SyntheticFrameCallback = (now, metadata) => {
const values: number[] = [now, metadata.presentationTime, metadata.expectedDisplayTime, metadata.width, metadata.height, metadata.mediaTime, metadata.presentedFrames, metadata.processingDuration, metadata.captureTime, metadata.receiveTime, metadata.rtpTimestamp]
void values
}
shim.cancelVideoFrameCallback(shim.requestVideoFrameCallback(callback))
const maybe: 'maybe' = shim.canPlayType('video/mp4')
art.mediabunny?.pause()
delete art.mediabunny
declare const extended: MediaBunnyCanvas
const native: HTMLCanvasElement = extended
extended.play()
extended.addEventListener('click', event => event.clientX)
extended.setAttribute('src', 'native-attribute-only')
state.then((value) => {
if (!value)
return
const height: number | undefined = value.currentLevel?.height
const language: string | undefined = value.currentAudio?.language
const track: unknown = value.levels[0]?.track
// @ts-expect-error SDK internals require explicit narrowing, not ambient SDK declarations.
track.decode()
void [height, language]
})
// @ts-expect-error The default factory does not promise shim members on a replacement canvas.
canvas.play()
// @ts-expect-error The default result is the historical exact DOM Canvas.
const requiredShim: MediaBunnyCanvas = canvas
// @ts-expect-error Alias does not exist before initialization or after destroy.
art.mediabunny.play()
// @ts-expect-error Factory options are optional but not null.
factory(null)
// @ts-expect-error Volume remains numeric.
factory({ volume: '0.7' })
// @ts-expect-error Historical Option does not widen arbitrary SDK objects.
factory({ source: {} })
// @ts-expect-error Option stream remains byte-oriented.
factory({ source: new ReadableStream<string>() })
// @ts-expect-error Unknown options remain errors.
factory({ arbitrary: true })
// @ts-expect-error The initializer still requires its host.
factory()()
// @ts-expect-error Synchronous registration remains synchronous.
const promise: Promise<Result> = factory()(art)
// @ts-expect-error Computed duration has no writable setter.
shim.duration = 10
// @ts-expect-error Synchronous load does not falsely promise awaitable completion.
const loaded: Promise<void> = shim.load()
// @ts-expect-error Canvas retains its native string attribute API.
extended.setAttribute('src', new Blob([]))
// @ts-expect-error Manual/auto are the only track modes.
const mode: NonNullable<HlsState>['videoMode'] = 'adaptive'
void [replacement, fromLegacy, toLegacy, playing, paused, loading, state, source, maybe, native, requiredShim, promise, loaded, mode]
export type { ExactFactory, ExactParameters, ExactResult, ExactResultAlias, NativeAttributes, NativeEvents }