mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-24 14:41:20 -08:00
refactor(mediabunny): [PKG-MB-08] checkpoint typed entry and compatible media declarations
This commit is contained in:
1 parent
8a2b56e829
commit
5e28dfee95
39 files changed
+3007
-287
No files matched your search
@@ -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.' }) })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
Reference in new issue
Block a user