mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
refactor(mediabunny): [PKG-MB-08] finish typed media capability boundaries
This commit is contained in:
1 parent
5e28dfee95
commit
fb9fb23f53
25 files changed
+2698
-30
No files matched your search
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { mbCandidate } from '../helpers/mediabunny.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const implementation = await mbCandidate()
|
||||
const manifest = JSON.parse(fs.readFileSync(new URL('./media/hls/manifest.json', import.meta.url)))
|
||||
const hls = new Map(Object.entries(manifest.files).map(([name, expected]) => {
|
||||
const bytes = fs.readFileSync(new URL(`./media/hls/${name}`, import.meta.url))
|
||||
assert.equal(hash(bytes), expected.sha256)
|
||||
return [name, bytes]
|
||||
}))
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
for (const scenario of ['video-unsupported', 'audio-unsupported', 'mixed-video-unsupported', 'mixed-audio-unsupported', 'replacement']) {
|
||||
test(`MediaBunny ${implementation.name}: ${core} core decoder capability ${scenario}`, async ({ page, browserName }, testInfo) => {
|
||||
await page.route('**/mb-cap-hls/**', async (route) => {
|
||||
const name = new URL(route.request().url()).pathname.split('/').at(-1)
|
||||
const body = hls.get(name)
|
||||
await route.fulfill({ status: body ? 200 : 404, body: body || 'Missing fixture', contentType: name.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t' })
|
||||
})
|
||||
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 capabilities = await page.evaluate(() => ({ AudioContext: typeof AudioContext, webkitAudioContext: typeof window.webkitAudioContext, AudioDecoder: typeof AudioDecoder, VideoDecoder: typeof VideoDecoder }))
|
||||
let outcome = 'unverified'
|
||||
let result
|
||||
try {
|
||||
await page.evaluate((scenario) => {
|
||||
window.mbEvents = []
|
||||
window.mbQueries = { video: 0, audio: 0 }
|
||||
window.mbBlock = scenario === 'replacement' ? [] : [scenario.includes('video-') ? 'video' : 'audio']
|
||||
window.art = new window.Artplayer({
|
||||
container: '.player',
|
||||
url: '',
|
||||
muted: true,
|
||||
setting: true,
|
||||
proxy: window.mbFactory({ m3u8: { quality: { control: true, setting: true }, audio: { control: true, setting: true } } }),
|
||||
})
|
||||
window.mbCanvas = window.art.video
|
||||
const engine = window.mbCanvas.engine
|
||||
for (const kind of ['video', 'audio']) {
|
||||
const load = engine[kind].load.bind(engine[kind])
|
||||
engine[kind].load = (media, ...args) => {
|
||||
const track = media[`${kind}Track`]
|
||||
if (track && window.mbBlock.includes(kind)) {
|
||||
track.canDecode = async () => {
|
||||
window.mbQueries[kind]++
|
||||
return false
|
||||
}
|
||||
}
|
||||
return load(media, ...args)
|
||||
}
|
||||
}
|
||||
for (const name of ['loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough', 'seeked', 'error'])
|
||||
window.mbCanvas.events.addEventListener(name, () => window.mbEvents.push(name))
|
||||
const source = scenario === 'video-unsupported' ? '/test/pattern.mp4' : scenario === 'audio-unsupported' ? '/test/audio-tone.m4a' : '/mb-cap-hls/master.m3u8'
|
||||
window.mbLoading = engine.load(source)
|
||||
document.querySelector('#play').onclick = () => window.art.play()
|
||||
}, scenario)
|
||||
await page.evaluate(() => window.mbLoading)
|
||||
if (capabilities.AudioContext === 'undefined' && capabilities.webkitAudioContext === 'undefined') {
|
||||
expect(browserName).toBe('webkit')
|
||||
expect(await page.evaluate(() => window.mbCanvas.error?.code)).toBe(4)
|
||||
expect(await page.evaluate(() => window.art.isReady)).toBe(false)
|
||||
outcome = 'unsupported-capability-control'
|
||||
return
|
||||
}
|
||||
if (scenario === 'replacement') {
|
||||
expect(await page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
await expect(page.locator('.art-control-mediabunny-quality')).toHaveCount(1)
|
||||
const error = await page.evaluate(async () => {
|
||||
window.mbEvents.length = 0
|
||||
window.mbBlock = ['video', 'audio']
|
||||
try {
|
||||
await window.mbCanvas.switchM3u8Quality('auto')
|
||||
return null
|
||||
}
|
||||
catch (error) { return error.message }
|
||||
})
|
||||
expect(error).toBe('Input has no decodable audio or video tracks.')
|
||||
await expect(page.locator('.art-control-mediabunny-quality')).toHaveCount(0)
|
||||
await expect(page.locator('.art-control-mediabunny-audio')).toHaveCount(0)
|
||||
}
|
||||
if (scenario.startsWith('mixed-')) {
|
||||
expect(await page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.mbCanvas.currentTime)).toBeGreaterThan(0.2)
|
||||
result = await page.evaluate(() => ({ video: Boolean(window.mbCanvas.engine.video.videoSink), audio: Boolean(window.mbCanvas.engine.audio.audioSink), nodes: window.mbCanvas.engine.audio.queuedNodes.size, width: window.mbCanvas.videoWidth, events: window.mbEvents, error: window.mbCanvas.error, queries: window.mbQueries }))
|
||||
expect(result.error).toBe(null)
|
||||
expect(result.events).toEqual(['loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough'])
|
||||
expect(result.video).toBe(scenario === 'mixed-audio-unsupported')
|
||||
expect(result.audio).toBe(scenario === 'mixed-video-unsupported')
|
||||
if (result.audio)
|
||||
expect(result.nodes).toBeGreaterThan(0)
|
||||
if (result.video)
|
||||
expect(result.width).toBeGreaterThan(0)
|
||||
outcome = 'native-partial-decoder-playback-with-controlled-unsupported-track'
|
||||
}
|
||||
else {
|
||||
result = await page.evaluate(() => ({ readyState: window.mbCanvas.readyState, networkState: window.mbCanvas.networkState, error: window.mbCanvas.error, events: window.mbEvents, queries: window.mbQueries }))
|
||||
expect(result.readyState).toBe(0)
|
||||
expect(result.networkState).toBe(3)
|
||||
expect(result.error).toEqual({ code: 4, message: 'Input has no decodable audio or video tracks.' })
|
||||
expect(result.events).toEqual(['error'])
|
||||
outcome = 'controlled-unsupported-track-rejected-before-readiness'
|
||||
}
|
||||
const kind = scenario.includes('video-') ? 'video' : 'audio'
|
||||
expect(result.queries).toEqual(scenario === 'replacement' ? { video: 1, audio: 1 } : { video: kind === 'video' ? 1 : 0, audio: kind === 'audio' ? 1 : 0 })
|
||||
}
|
||||
finally {
|
||||
await page.evaluate(() => window.art?.destroy(false))
|
||||
await testInfo.attach('mediabunny-capability', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), core, scenario, capabilities, outcome, result, scope: 'Actual SDK parsing, Canvas, Web Audio and two cores. canDecode=false is deliberately controlled for selected tracks; partial cases play the remaining native decoder. Windows WebKit missing APIs is a separate control, not playback.' }) })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Actual SDK tracks and decoder/coordinator code with controlled capability responses.
|
||||
import test from 'node:test'
|
||||
import { mbCandidate, mbEnvironment } from './helpers/mediabunny.js'
|
||||
|
||||
const implementation = await mbCandidate()
|
||||
const sources = {
|
||||
video: new Blob([fs.readFileSync(new URL('./browser/media/pattern.mp4', import.meta.url))]),
|
||||
audio: new Blob([fs.readFileSync(new URL('./browser/media/audio-tone.m4a', import.meta.url))]),
|
||||
}
|
||||
const readiness = ['loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough']
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve))
|
||||
const message = 'Input has no decodable audio or video tracks.'
|
||||
function deferred() {
|
||||
let resolve, reject
|
||||
const promise = new Promise((yes, no) => {
|
||||
resolve = yes
|
||||
reject = no
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
function environment(kind, response = false) {
|
||||
const env = mbEnvironment(implementation)
|
||||
env.globals.console = { ...console, error() {}, warn() {} }
|
||||
env.globals.window.AudioContext = class {
|
||||
currentTime = 0
|
||||
state = 'running'
|
||||
destination = {}
|
||||
createGain() { return { gain: { value: 0 }, connect() {}, disconnect() {} } }
|
||||
async close() { this.state = 'closed' }
|
||||
}
|
||||
const canvas = env.factory()(env.art)
|
||||
const engine = canvas.engine
|
||||
const events = []
|
||||
const queries = []
|
||||
for (const name of [...readiness, 'error', 'play', 'playing', 'seeked'])
|
||||
canvas.events.addEventListener(name, () => events.push(name))
|
||||
const decoder = engine[kind]
|
||||
const load = decoder.load.bind(decoder)
|
||||
decoder.load = (media, ...args) => {
|
||||
const track = media[`${kind}Track`]
|
||||
assert(track, 'The real input must contain the selected track')
|
||||
if (response === null) {
|
||||
Object.defineProperty(track, 'codec', { value: null, configurable: true })
|
||||
}
|
||||
else {
|
||||
track.canDecode = () => {
|
||||
queries.push(track)
|
||||
return typeof response === 'function' ? response() : Promise.resolve(response)
|
||||
}
|
||||
}
|
||||
return load(media, ...args)
|
||||
}
|
||||
return { ...env, canvas, engine, decoder, events, queries }
|
||||
}
|
||||
|
||||
for (const kind of ['video', 'audio']) {
|
||||
for (const response of [false, null]) {
|
||||
test(`MediaBunny ${kind}-only input with capability ${response} rejects before any readiness`, async () => {
|
||||
const env = environment(kind, response)
|
||||
await env.engine.load(sources[kind])
|
||||
assert.equal(env.canvas.readyState, 0)
|
||||
assert.deepEqual({ ...env.canvas.error }, { code: 4, message })
|
||||
assert.equal(env.canvas.networkState, 3)
|
||||
assert.deepEqual(env.events, ['error'])
|
||||
assert.equal(env.engine.input, null)
|
||||
assert.equal(env.engine.media, null)
|
||||
assert.equal(env.queries.length, response === null ? 0 : 1)
|
||||
assert.equal(env.canvas.canPlayType('video/mp4'), 'maybe')
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
}
|
||||
test(`MediaBunny ${kind} track replacement cannot publish readiness without a decoder`, async () => {
|
||||
const env = environment(kind)
|
||||
let prepared = false
|
||||
const load = env.decoder.load
|
||||
env.decoder.load = (media, ...args) => {
|
||||
if (!prepared) {
|
||||
prepared = true
|
||||
// Prepare a supported decoder without native decoding in Node; keep the real Input alive.
|
||||
env.decoder[`${kind}Sink`] = {}
|
||||
args[0]?.()
|
||||
return Promise.resolve()
|
||||
}
|
||||
return load(media, ...args)
|
||||
}
|
||||
await env.engine.load(sources[kind])
|
||||
assert.equal(env.canvas.readyState, 4)
|
||||
assert.equal(env.canvas.error, null)
|
||||
env.events.length = 0
|
||||
await assert.rejects(env.engine.replaceTracks({}), error => error.message === message)
|
||||
assert.equal(env.canvas.readyState, 0)
|
||||
assert.equal(env.canvas.paused, true)
|
||||
assert.equal(env.canvas.seeking, false)
|
||||
assert.equal(env.canvas.networkState, 3)
|
||||
assert.deepEqual(env.events, ['error'])
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
}
|
||||
|
||||
test('MediaBunny audio-only input preserves one readiness sequence when its decoder is supported', async () => {
|
||||
const env = environment('audio', true)
|
||||
await env.engine.load(sources.audio)
|
||||
assert.equal(env.canvas.readyState, 4)
|
||||
assert.equal(env.canvas.error, null)
|
||||
assert(env.engine.audio.audioSink)
|
||||
assert.equal(env.engine.video.videoSink, null)
|
||||
assert.deepEqual(env.events, readiness)
|
||||
assert.equal(env.queries.length, 1)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
for (const ending of ['source', 'destroy']) {
|
||||
for (const result of ['false', 'reject']) {
|
||||
test(`MediaBunny late capability ${result} after ${ending} cannot publish readiness or errors`, async () => {
|
||||
const pending = deferred()
|
||||
const env = environment('video', () => pending.promise)
|
||||
const loading = env.engine.load(sources.video)
|
||||
await flush()
|
||||
assert.equal(env.queries.length, 1)
|
||||
if (ending === 'source') {
|
||||
env.engine.performLoad = async () => {}
|
||||
await env.engine.load('replacement.mp4')
|
||||
}
|
||||
else {
|
||||
env.art.emit('destroy')
|
||||
}
|
||||
await loading
|
||||
env.events.length = 0
|
||||
if (result === 'false')
|
||||
pending.resolve(false)
|
||||
else pending.reject(new Error('stale decoder query'))
|
||||
await flush()
|
||||
assert.deepEqual(env.events, [])
|
||||
assert.equal(env.canvas.error, null)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const supported of [false, true]) {
|
||||
test(`MediaBunny pause during capability query preserves load result ${supported} without autoplay`, async () => {
|
||||
const pending = deferred()
|
||||
const env = environment('audio', () => pending.promise)
|
||||
const loading = env.engine.load(sources.audio)
|
||||
await flush()
|
||||
env.engine.pause()
|
||||
pending.resolve(supported)
|
||||
await loading
|
||||
assert.equal(env.canvas.paused, true)
|
||||
assert.deepEqual(env.events, supported ? readiness : ['error'])
|
||||
assert.equal(env.queries.length, 1)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
}
|
||||
|
||||
for (const kind of ['video', 'audio']) {
|
||||
test(`MediaBunny active ${kind} capability rejection keeps the SDK error instead of inventing unsupported status`, async () => {
|
||||
const env = environment(kind, () => Promise.reject(new Error('Decoder capability query failed')))
|
||||
await env.engine.load(sources[kind])
|
||||
assert.deepEqual({ ...env.canvas.error }, { code: 4, message: 'Decoder capability query failed' })
|
||||
assert.deepEqual(env.events, ['error'])
|
||||
assert.equal(env.canvas.readyState, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
}
|
||||
|
||||
test('MediaBunny supported source can recover after an unsupported source without keeping its error', async () => {
|
||||
let supported = false
|
||||
const env = environment('audio', () => Promise.resolve(supported))
|
||||
await env.engine.load(sources.audio)
|
||||
assert.equal(env.canvas.error?.message, message)
|
||||
supported = true
|
||||
env.events.length = 0
|
||||
await env.engine.load(sources.audio)
|
||||
assert.equal(env.canvas.error, null)
|
||||
assert.equal(env.canvas.readyState, 4)
|
||||
assert.equal(env.canvas.networkState, 1)
|
||||
assert.deepEqual(env.events, readiness)
|
||||
assert.equal(env.queries.length, 2)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
@@ -363,6 +363,8 @@ async function loadingEnvironment() {
|
||||
const audio = deferred()
|
||||
const metadata = {}
|
||||
env.engine.video.load = (media, callback) => {
|
||||
// This fixture represents a supported decoder whose metadata/completion are delayed.
|
||||
env.engine.video.videoSink = {}
|
||||
metadata.video = callback
|
||||
return video.promise
|
||||
}
|
||||
@@ -493,6 +495,8 @@ function replacementEnvironment() {
|
||||
const track = { isLive: async () => false }
|
||||
const input = { getDurationFromMetadata: async () => 8 }
|
||||
env.engine.media = { input, videoTrack: track, audioTrack: null, isHls: true, videoMode: 'auto', audioMode: 'auto', duration: 8 }
|
||||
// The already selected media has a working video decoder before a replacement starts.
|
||||
env.engine.video.videoSink = {}
|
||||
env.engine.video.load = async () => env.calls.push('video:load')
|
||||
env.engine.audio.load = async () => env.calls.push('audio:load')
|
||||
return env
|
||||
|
||||
Reference in new issue
Block a user