import assert from 'node:assert/strict' import fs from 'node:fs' import { hash } from '../../refactor/scripts/releases.mjs' import { mbBrowserImplementations } from '../helpers/mediabunny.js' import { tracklessMp4 } from '../helpers/trackless-mp4.js' import { expect, test } from './fixtures.js' const implementations = await mbBrowserImplementations() test.beforeEach(async ({ browserName }, testInfo) => { await testInfo.attach('mediabunny-available-inputs', { contentType: 'application/json', body: JSON.stringify(implementations.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] })) const pattern = fs.readFileSync(new URL('./media/pattern.mp4', import.meta.url)) const trackless = tracklessMp4(pattern) for (const implementation of implementations) { for (const input of ['webm', 'blob', 'stream', 'audio', 'hls', 'trackless']) { test(`MediaBunny ${implementation.name}: native ${input} input and seek baseline`, async ({ page, browserName }, testInfo) => { await page.route('**/mb-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.route('**/mb-trackless.mp4', route => route.fulfill({ status: 200, body: trackless, contentType: 'video/mp4' })) await page.goto('/test/player.html?core=published') await page.addScriptTag({ content: `(() => { const pendingFrames = new Set(); const requestAnimationFrame = callback => { const id = window.requestAnimationFrame(time => { pendingFrames.delete(id); callback(time); }); pendingFrames.add(id); return id; }; const cancelAnimationFrame = id => { pendingFrames.delete(id); window.cancelAnimationFrame(id); }; window.mbPendingFrames = pendingFrames; const module = { exports: {} }; const exports = module.exports; ${implementation.code}; window.mbFactory = module.exports.default || module.exports; })();` }) const capabilities = await page.evaluate(() => ({ VideoDecoder: typeof VideoDecoder, AudioDecoder: typeof AudioDecoder, AudioContext: typeof AudioContext, webkitAudioContext: typeof window.webkitAudioContext })) let outcome = 'unverified' try { await page.evaluate(async (input) => { const listeners = new Map() window.mbEvents = [] window.mbActions = [] 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, currentTime: window.mbCanvas?.currentTime, readyState: window.mbCanvas?.readyState }) for (const fn of listeners.get(name) || []) fn(...args) }, } const canvas = window.mbFactory({ muted: true })(window.mbHost) window.mbCanvas = canvas document.querySelector('.player').appendChild(canvas) let source = input === 'webm' ? '/test/pattern.webm' : input === 'audio' ? '/test/audio-tone.m4a' : input === 'hls' ? '/mb-hls/master.m3u8' : input === 'trackless' ? '/mb-trackless.mp4' : '/test/pattern.mp4' if (input === 'blob' || input === 'stream') { const bytes = new Uint8Array(await (await fetch(source)).arrayBuffer()) window.mbInputBytes = bytes.length source = input === 'blob' ? new Blob([bytes], { type: 'video/mp4' }) : new ReadableStream({ start(controller) { for (let offset = 0; offset < bytes.length; offset += 4096) controller.enqueue(bytes.slice(offset, offset + 4096)) controller.close() }, cancel() { window.mbStreamCancelled = true }, }) } const load = canvas.engine.load.bind(canvas.engine) canvas.engine.load = (source) => { window.mbLoad = load(source) return window.mbLoad } canvas.src = source await window.mbLoad document.querySelector('#play').onclick = () => canvas.play().catch(error => window.mbActions.push({ name: error.name, message: error.message })) }, input) const loaded = await page.evaluate(() => ({ error: window.mbCanvas.error, readyState: window.mbCanvas.readyState, dimensions: [window.mbCanvas.videoWidth, window.mbCanvas.videoHeight] })) if (implementation.name === 'published-1.0.0' && ['stream', 'hls'].includes(input)) { const messages = { chromium: 'Failed to execute \'getReader\' on \'ReadableStream\': ReadableStreamDefaultReader constructor can only accept readable streams that are not yet locked to a reader', firefox: 'ReadableStream.getReader: Cannot get a new reader for a readable stream already locked by another reader.', webkit: 'ReadableStream is locked', } expect(loaded.error).toEqual({ code: 4, message: input === 'hls' ? 'Input has an unsupported or unrecognizable format.' : messages[browserName] }) expect(loaded.readyState).toBe(0) expect(loaded.dimensions).toEqual([0, 0]) outcome = input === 'hls' ? 'historical-unsupported-hls-control' : 'historical-stream-lock-control' } else if (implementation.name.startsWith('candidate') && input === 'trackless') { expect(loaded.error).toEqual({ code: 4, message: 'Input has no audio or video tracks.' }) expect(loaded.readyState).toBe(0) expect(loaded.dimensions).toEqual([0, 0]) expect(await page.evaluate(() => window.mbEvents.filter(item => ['video:loadedmetadata', 'video:loadeddata', 'video:canplay'].includes(item.name)))).toEqual([]) outcome = 'trackless-rejected-before-decoder-setup' } else if (capabilities.AudioContext === 'undefined' && capabilities.webkitAudioContext === 'undefined') { expect(browserName).toBe('webkit') expect(capabilities).toEqual({ VideoDecoder: 'undefined', AudioDecoder: 'undefined', AudioContext: 'undefined', webkitAudioContext: 'undefined' }) if (implementation.name.startsWith('candidate')) { expect(loaded.error.code).toBe(4) expect(loaded.error.message).toMatch(/^undefined is not a constructor \(evaluating 'new [\w$]+(?:\(\))?'\)$/) } else { expect(loaded.error).toEqual({ code: 4, message: 'undefined is not a constructor (evaluating \'new t\')' }) } expect(loaded.readyState).toBeLessThan(4) outcome = 'unsupported-capability-control' } else { expect(loaded.error).toBeNull() expect(loaded.readyState).toBe(4) if (input === 'trackless') { expect(loaded.dimensions).toEqual([0, 0]) expect(await page.evaluate(() => window.mbEvents.filter(item => item.name === 'video:loadedmetadata').length)).toBe(implementation.name === 'published-1.0.0' ? 1 : 2) expect(await page.evaluate(() => window.mbEvents.filter(item => item.name === 'video:canplay').length)).toBe(1) outcome = 'historical-trackless-readiness-control' return } const readiness = await page.evaluate(() => window.mbEvents.map(item => item.name).filter(name => ['video:loadedmetadata', 'video:loadeddata', 'video:canplay', 'video:canplaythrough'].includes(name))) const repeatedData = implementation.name === 'published-1.0.0' && input !== 'audio' expect(readiness).toEqual(['video:loadedmetadata', 'video:loadeddata', ...(repeatedData ? ['video:loadeddata'] : []), 'video:canplay', 'video:canplaythrough']) if (input === 'audio') expect(loaded.dimensions).toEqual([0, 0]) else expect(loaded.dimensions[0]).toBeGreaterThan(0) await page.click('#play') await expect.poll(() => page.evaluate(() => window.mbCanvas.currentTime)).toBeGreaterThan(0.15) if (['audio', 'hls'].includes(input)) { expect(await page.evaluate(() => Boolean(window.mbCanvas.engine.audio.audioSink))).toBe(true) expect(await page.evaluate(() => window.mbCanvas.engine.audio.queuedNodes.size)).toBeGreaterThan(0) } await page.evaluate(() => window.mbCanvas.pause()) await page.evaluate(async () => { const canvas = window.mbCanvas window.mbSeekEventsStart = window.mbEvents.length window.mbFrameClockSamples = [] const video = canvas.engine.video if (video.videoSink) { 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 && result.value) times.set(result.value.canvas, result.value.timestamp) return result } return iterator } const draw = video.ctx.drawImage.bind(video.ctx) video.ctx.drawImage = (...args) => { const result = draw(...args) if (times.has(args[0])) window.mbFrameClockSamples.push({ frame: times.get(args[0]), audioClock: canvas.engine.audio.currentTime, paused: canvas.paused }) return result } } const seek = canvas.engine.seek.bind(canvas.engine) canvas.engine.seek = (time) => { window.mbSeek = seek(time) return window.mbSeek } window.mbSeekTarget = Math.min(0.5, canvas.duration / 2) canvas.currentTime = window.mbSeekTarget await window.mbSeek }) expect(await page.evaluate(() => window.mbCanvas.seeking)).toBe(false) expect(await page.evaluate(() => window.mbCanvas.paused)).toBe(true) expect(await page.evaluate(() => window.mbCanvas.currentTime)).toBeGreaterThan(0.2) expect(await page.evaluate(() => window.mbCanvas.currentTime === window.mbSeekTarget)).toBe(true) expect(await page.evaluate(() => window.mbEvents.slice(window.mbSeekEventsStart).map(item => item.name))).toEqual(['video:seeking', 'video:waiting', ...(repeatedData ? ['video:loadeddata'] : []), 'video:seeked']) if (input !== 'audio') { await page.click('#play') await expect.poll(() => page.evaluate(() => window.mbFrameClockSamples.filter(sample => !sample.paused).length)).toBeGreaterThan(2) await page.evaluate(() => window.mbCanvas.pause()) } if (implementation.name.startsWith('candidate') && input === 'webm') { await page.evaluate(() => { const canvas = window.mbCanvas window.mbReentrantStart = window.mbEvents.length const pause = () => { canvas.events.removeEventListener('play', pause) canvas.pause() } canvas.events.addEventListener('play', pause) }) await page.click('#play') await expect.poll(() => page.evaluate(() => window.mbEvents.slice(window.mbReentrantStart).filter(item => ['video:play', 'video:pause', 'video:playing'].includes(item.name)).map(item => item.name))).toEqual(['video:play', 'video:pause']) expect(await page.evaluate(() => window.mbCanvas.paused)).toBe(true) await page.evaluate(() => window.mbReentrantPlay = { paused: window.mbCanvas.paused, events: window.mbEvents.slice(window.mbReentrantStart).filter(item => ['video:play', 'video:pause', 'video:playing'].includes(item.name)).map(item => item.name) }) } if (input === 'hls') { const tracks = await page.evaluate(async () => { const canvas = window.mbCanvas const initial = await canvas.getM3u8State() const low = initial.levels.find(level => level.height === 90) await canvas.switchM3u8Quality(low.id) const quality = await canvas.getM3u8State() const french = quality.audios.find(track => track.lang === 'fr') await canvas.switchM3u8Audio(french.id) const audio = await canvas.getM3u8State() window.mbTrackTransitions = { initial: initial.currentLevel.height, quality: quality.currentLevel.height, videoMode: quality.videoMode, language: audio.currentAudio.lang, audioMode: audio.audioMode } canvas.src = '/test/pattern.mp4' await window.mbLoad window.mbTrackTransitions.afterSourceSwitch = await canvas.getM3u8State() return window.mbTrackTransitions }) expect(tracks).toEqual({ initial: 180, quality: 90, videoMode: 'manual', language: 'fr', audioMode: 'manual', afterSourceSwitch: null }) } expect(await page.evaluate(() => window.mbActions)).toEqual([]) outcome = 'native-input-playback-and-seek' } } finally { const state = await page.evaluate(async () => { const canvas = window.mbCanvas if (!canvas) return null const hls = canvas.getM3u8State ? await canvas.getM3u8State().catch(error => ({ error: error.message })) : null return { events: window.mbEvents, actions: window.mbActions, dimensions: [canvas.width, canvas.height, canvas.videoWidth, canvas.videoHeight], currentTime: canvas.currentTime, duration: canvas.duration, error: canvas.error, readyState: canvas.readyState, audioState: canvas.engine.audio.audioContext?.state, frameClockSamples: window.mbFrameClockSamples, tracks: window.mbTrackTransitions, reentrantPlay: window.mbReentrantPlay, hls: hls && { levels: hls.levels?.map(({ id, height }) => ({ id, height })), audios: hls.audios?.map(({ id, lang }) => ({ id, lang })), error: hls.error }, inputBytes: window.mbInputBytes } }).catch(error => ({ error: error.message })) await page.evaluate(() => { window.mbContext = window.mbCanvas?.engine.audio.audioContext window.mbHost?.emit('destroy') }) await expect.poll(() => page.evaluate(() => window.mbContext?.state || 'not-created')).toMatch(/^(?:closed|not-created)$/) expect(await page.evaluate(() => window.mbPendingFrames.size)).toBe(0) const cleanup = await page.evaluate(() => ({ audioContext: window.mbContext?.state || 'not-created', pendingFrames: window.mbPendingFrames.size, streamCancelled: window.mbStreamCancelled || false })) await testInfo.attach('mediabunny-native-input', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, provenance: implementation.provenance, sha256: hash(implementation.code), input, capabilities, outcome, state, cleanup, hlsManifest: input === 'hls' ? manifest : null, tracklessFixture: input === 'trackless' ? { parentSha256: hash(pattern), sha256: hash(trackless) } : null, scope: 'Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless rejection is checked separately before decoder setup; this is not full release acceptance.' }) }) } }) } }