mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-10 12:46:15 -08:00
260 lines
13 KiB
JavaScript
260 lines
13 KiB
JavaScript
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()
|
|
|
|
test.beforeEach(async ({ browserName }, testInfo) => {
|
|
await testInfo.attach('mediabunny-available-inputs', { contentType: 'application/json', body: JSON.stringify([implementation].map(item => ({ browserName, name: item.name, sha256: hash(item.code), provenance: item.provenance || { kind: item.name } }))) })
|
|
})
|
|
|
|
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 scenario of ['playback', 'buffer-pause', 'buffer-rate', 'resume-pause', 'resume-source', 'resume-destroy', 'av-clock', 'no-track']) {
|
|
test(`MediaBunny ${implementation.name}: native audio ownership ${scenario}`, async ({ page, browserName }, testInfo) => {
|
|
await page.route('**/mb-audio-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=published')
|
|
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
|
|
await page.evaluate(async (scenario) => {
|
|
const listeners = new Map()
|
|
window.mbEvents = []
|
|
window.mbNodes = []
|
|
window.mbClockSamples = []
|
|
window.mbFlush = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
|
window.mbHost = {
|
|
constructor: window.Artplayer,
|
|
option: { url: '', autoSize: false },
|
|
template: { $player: document.querySelector('.player') },
|
|
on(name, fn) {
|
|
const list = listeners.get(name) || []
|
|
list.push(fn)
|
|
listeners.set(name, list)
|
|
},
|
|
emit(name, ...args) {
|
|
window.mbEvents.push(name)
|
|
for (const fn of listeners.get(name) || []) fn(...args)
|
|
},
|
|
}
|
|
const canvas = window.mbFactory()(window.mbHost)
|
|
window.mbCanvas = canvas
|
|
document.querySelector('.player').append(canvas)
|
|
await canvas.engine.load(scenario === 'av-clock' ? '/mb-audio-hls/master.m3u8' : scenario === 'no-track' ? '/test/pattern.mp4' : '/test/audio-tone.m4a')
|
|
const audio = canvas.engine.audio
|
|
const context = audio.audioContext
|
|
window.mbContext = context
|
|
if (context) {
|
|
const create = context.createBufferSource.bind(context)
|
|
context.createBufferSource = () => {
|
|
const node = create()
|
|
const record = { node, stops: 0, disconnects: 0, starts: [] }
|
|
const stop = node.stop.bind(node)
|
|
const disconnect = node.disconnect.bind(node)
|
|
const start = node.start.bind(node)
|
|
node.stop = (...args) => {
|
|
record.stops++
|
|
return stop(...args)
|
|
}
|
|
node.disconnect = (...args) => {
|
|
record.disconnects++
|
|
return disconnect(...args)
|
|
}
|
|
node.start = (...args) => {
|
|
record.starts.push({ args, rate: node.playbackRate.value, at: context.currentTime })
|
|
return start(...args)
|
|
}
|
|
window.mbNodes.push(record)
|
|
return node
|
|
}
|
|
}
|
|
canvas.muted = true
|
|
document.querySelector('#play').onclick = () => {
|
|
window.mbPlay = canvas.play()
|
|
}
|
|
}, scenario)
|
|
try {
|
|
if (capabilities.AudioContext === 'undefined' && capabilities.webkitAudioContext === 'undefined') {
|
|
expect(browserName).toBe('webkit')
|
|
expect(capabilities.AudioDecoder).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)
|
|
if (scenario.startsWith('resume-')) {
|
|
await page.evaluate(async () => {
|
|
const context = window.mbContext
|
|
await context.suspend()
|
|
const resume = context.resume.bind(context)
|
|
context.resume = () => {
|
|
const native = resume()
|
|
window.mbResumeCalled = true
|
|
return native.then(() => new Promise((resolve) => {
|
|
window.mbReleaseResume = resolve
|
|
}))
|
|
}
|
|
})
|
|
await page.click('#play')
|
|
await expect.poll(() => page.evaluate(() => typeof window.mbReleaseResume)).toBe('function')
|
|
result = await page.evaluate(async (scenario) => {
|
|
if (scenario === 'resume-pause')
|
|
window.mbCanvas.pause()
|
|
else if (scenario === 'resume-source')
|
|
await window.mbCanvas.engine.load('/test/pattern.mp4')
|
|
else window.mbHost.emit('destroy')
|
|
window.mbReleaseResume()
|
|
await window.mbPlay
|
|
await window.mbFlush()
|
|
const audio = window.mbCanvas.engine.audio
|
|
return { paused: audio.paused, queued: audio.queuedNodes.size, created: window.mbNodes.length, iterator: Boolean(audio.audioIterator), audioSink: Boolean(audio.audioSink) }
|
|
}, scenario)
|
|
expect(result).toMatchObject({ paused: true, queued: 0, created: 0, iterator: false })
|
|
if (scenario !== 'resume-pause')
|
|
expect(result.audioSink).toBe(false)
|
|
}
|
|
else if (scenario.startsWith('buffer-')) {
|
|
await page.evaluate(() => {
|
|
const sink = window.mbCanvas.engine.audio.audioSink
|
|
const buffers = sink.buffers.bind(sink)
|
|
let first = true
|
|
sink.buffers = (...args) => {
|
|
const iterator = buffers(...args)
|
|
const next = iterator.next.bind(iterator)
|
|
iterator.next = async (...args) => {
|
|
const value = await next(...args)
|
|
if (first && !value.done) {
|
|
first = false
|
|
window.mbHeldBuffer = value.value.buffer
|
|
return new Promise((resolve) => {
|
|
window.mbReleaseBuffer = () => resolve(value)
|
|
})
|
|
}
|
|
return value
|
|
}
|
|
return iterator
|
|
}
|
|
})
|
|
await page.click('#play')
|
|
await expect.poll(() => page.evaluate(() => typeof window.mbReleaseBuffer)).toBe('function')
|
|
await page.evaluate((scenario) => {
|
|
if (scenario === 'buffer-pause')
|
|
window.mbCanvas.pause()
|
|
else window.mbCanvas.playbackRate = 2
|
|
window.mbReleaseBuffer()
|
|
}, scenario)
|
|
if (scenario === 'buffer-rate')
|
|
await expect.poll(() => page.evaluate(() => window.mbNodes.length)).toBeGreaterThan(0)
|
|
result = await page.evaluate(async () => {
|
|
await window.mbFlush()
|
|
const audio = window.mbCanvas.engine.audio
|
|
return { obsoleteScheduled: window.mbNodes.some(record => record.node.buffer === window.mbHeldBuffer), rates: window.mbNodes.flatMap(record => record.starts.map(start => start.rate)), queued: audio.queuedNodes.size, paused: audio.paused }
|
|
})
|
|
expect(result.obsoleteScheduled).toBe(false)
|
|
if (scenario === 'buffer-pause')
|
|
expect(result).toMatchObject({ queued: 0, paused: true })
|
|
else expect(result.rates.every(rate => rate === 2)).toBe(true)
|
|
}
|
|
else {
|
|
if (scenario === 'av-clock') {
|
|
await page.evaluate(async () => {
|
|
const video = window.mbCanvas.engine.video
|
|
const times = new WeakMap()
|
|
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)
|
|
if (!result.done)
|
|
times.set(result.value.canvas, result.value.timestamp)
|
|
return result
|
|
}
|
|
return iterator
|
|
}
|
|
const draw = video.ctx.drawImage.bind(video.ctx)
|
|
video.ctx.drawImage = (...args) => {
|
|
if (times.has(args[0]))
|
|
window.mbClockSamples.push({ frame: times.get(args[0]), audio: window.mbCanvas.engine.audio.currentTime, rate: window.mbCanvas.playbackRate, paused: window.mbCanvas.paused })
|
|
return draw(...args)
|
|
}
|
|
await window.mbCanvas.engine.seek(1)
|
|
})
|
|
}
|
|
await page.click('#play')
|
|
await expect.poll(() => page.evaluate(() => window.mbCanvas.currentTime)).toBeGreaterThan(scenario === 'av-clock' ? 1.3 : 0.3)
|
|
if (scenario !== 'no-track')
|
|
await expect.poll(() => page.evaluate(() => window.mbCanvas.engine.audio.queuedNodes.size)).toBeGreaterThan(0)
|
|
const pause = await page.evaluate(async () => {
|
|
window.mbCanvas.pause()
|
|
const time = window.mbCanvas.currentTime
|
|
await window.mbFlush()
|
|
return { time, after: window.mbCanvas.currentTime, queued: window.mbCanvas.engine.audio.queuedNodes.size, retained: window.mbNodes.filter(record => !record.disconnects || record.node.onended !== null).length }
|
|
})
|
|
expect(pause.after).toBe(pause.time)
|
|
expect(pause).toMatchObject({ queued: 0, retained: 0 })
|
|
await page.evaluate(() => {
|
|
window.mbCanvas.volume = 0.5
|
|
window.mbCanvas.muted = false
|
|
})
|
|
expect(await page.evaluate(() => window.mbCanvas.engine.audio.gainNode.gain.value)).toBeCloseTo(0.25, 6)
|
|
await page.evaluate(() => {
|
|
window.mbCanvas.muted = true
|
|
window.mbCanvas.playbackRate = 2
|
|
})
|
|
expect(await page.evaluate(() => window.mbCanvas.engine.audio.gainNode.gain.value)).toBe(0)
|
|
await page.click('#play')
|
|
await expect.poll(() => page.evaluate(() => window.mbCanvas.currentTime)).toBeGreaterThan(pause.time + 0.35)
|
|
result = await page.evaluate(() => {
|
|
const audio = window.mbCanvas.engine.audio
|
|
const clock = audio.currentTime
|
|
const elapsed = audio.audioContext.currentTime - audio.audioContextStartTime
|
|
const nodes = [...audio.queuedNodes]
|
|
return { clock, rate: audio.playbackRate, anchor: audio.playbackTimeAtStart, elapsed, nodeRates: nodes.map(node => node.playbackRate.value), hasSink: Boolean(audio.audioSink), samples: window.mbClockSamples, pause: window.mbCanvas.paused }
|
|
})
|
|
expect(Math.abs(result.clock - (result.anchor + result.elapsed * 2))).toBeLessThan(0.03)
|
|
expect(result).toMatchObject({ rate: 2, pause: false, hasSink: scenario !== 'no-track' })
|
|
if (scenario === 'no-track') {
|
|
expect(result.nodeRates).toEqual([])
|
|
}
|
|
else {
|
|
expect(result.nodeRates.length).toBeGreaterThan(0)
|
|
expect(result.nodeRates.every(rate => rate === 2)).toBe(true)
|
|
}
|
|
if (scenario === 'av-clock') {
|
|
const playing = result.samples.filter(sample => !sample.paused)
|
|
expect(playing.filter(sample => sample.rate === 1).length).toBeGreaterThan(2)
|
|
expect(playing.filter(sample => sample.rate === 2).length).toBeGreaterThan(2)
|
|
expect(Math.max(...playing.map(sample => Math.abs(sample.frame - sample.audio)))).toBeLessThan(0.25)
|
|
}
|
|
}
|
|
outcome = 'native-audio-ownership-observed'
|
|
}
|
|
finally {
|
|
await page.evaluate(() => window.mbHost.emit('destroy'))
|
|
if (capabilities.AudioContext !== 'undefined' || capabilities.webkitAudioContext !== 'undefined') {
|
|
await expect.poll(() => page.evaluate(() => window.mbContext.state)).toBe('closed')
|
|
expect(await page.evaluate(() => window.mbCanvas.engine.audio.queuedNodes.size)).toBe(0)
|
|
}
|
|
const state = await page.evaluate(() => ({ context: window.mbContext?.state, nodes: window.mbNodes.map(record => ({ stops: record.stops, disconnects: record.disconnects, starts: record.starts })), error: window.mbCanvas.error, events: window.mbEvents }))
|
|
await testInfo.attach('mediabunny-audio', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, provenance: implementation.provenance, sha256: hash(implementation.code), scenario, capabilities, outcome, result, state, scope: 'Native AudioContext, BufferSourceNodes and SDK decode. Delayed resume/buffer delivery is controlled. AV samples compare decoded canvas timestamps to the audio clock over short 1x/2x playback; acoustic output, long-run drift and physical devices remain MB-09.' }) })
|
|
}
|
|
})
|
|
}
|