test(mediabunny): [PKG-MB-02] establish native input and lifecycle baselines

This commit is contained in:
Harvey Zhao committed 2026-09-12 17:43:56 +08:00
1 parent 63c8938dd7
commit 03451c029b
12 files changed
+7583 -13

No files matched your search

+3 -2
View File
@@ -51,7 +51,7 @@
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
"test:unit": "node --test test/mediabunny.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
@@ -75,7 +75,8 @@
"test:canvas": "node --test test/canvas.test.js test/canvas-lifecycle.test.js",
"test:canvas-types-package": "node refactor/scripts/canvas-package-types.mjs",
"test:dpip": "node --test test/dpip.test.js test/dpip-lifecycle.test.js",
"test:dpip-types-package": "node refactor/scripts/dpip-package-types.mjs"
"test:dpip-types-package": "node refactor/scripts/dpip-package-types.mjs",
"test:mediabunny": "node --test test/mediabunny.test.js"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,71 @@
# PKG-MB-02 媒体、事件与资源基线
此记录接续63c8938d检查点。完成状态以[验证记录](../baselines/mb-behavior-validation.json)和
独立完成提交为准;历史负例通过表示问题已经复现,不表示生产实现已修复。
## 浏览器覆盖
两个实际npm proxy 1.0.0/1.2.0,三引擎,共42项:
- 6项完整核心5.4.0组合:4次真实MP4播放/像素/暂停成功,2次Windows WebKit能力失败对照。
- 36项真实SDK输入,使用实际核心utils/config与受控ArtPlayer宿主:WebM、MP4 Blob、4096字节分块
ReadableStream、纯音频、HLS、无轨道容器。受控宿主用于隔离核心自动重连,不模拟浏览器解码器。
- 后36项中16项完成真实输入播放、暂停、精确seek和恢复;10项缺失WebCodecs/Web Audio构造器;
3项1.0.0流锁定错误,3项1.0.0 HLS不支持;4项无轨道容器错误地声称ready的历史行为。
- 总计20次真实播放成功、12次环境能力对照、6次历史输入失败、4次无轨道就绪行为;不能称42次播放成功。
1.0.0的ReadableStream同时被旧音/视频输入读取,三个浏览器报精确的stream locked错误;
1.0.0的HLS错误为`Input has an unsupported or unrecognizable format.`。
这些在1.2.0已正常,候选必须保持修正。Windows WebKit的构造器缺失单独判断,不能把同一环境
中的历史stream/HLS错误都归为环境问题,也不能将Windows WebKit结论扩大为全部Safari。
## 就绪、seek、HLS与时钟
就绪顺序和次数使用逐事件断言:1.0.0 WebM/Blob初次有两个loadeddata,seek又发一次;1.2.0无该重复。
视频为空但音频存在的正常容器单独验证;无任何轨道的容器由仓库MP4移除trak后确定性生成,
记录父文件和派生文件哈希,不下载不明媒体。两代均readyState=4且尺寸0,1.2.0的loadedmetadata
发两次、1.0.0一次。MB-READY-01保留后续协调修复责任。
seek测试观测公开currentTime setter调用实际engine.seek的Promise,不替换实现;验证目标时间、
seeking/paused和精确事件序列。HLS使用既有哈希校验的本地12秒样本,实际切到90p、法语音轨,
核对manual模式,再切回MP4并确认HLS状态null。真实控件/设置DOM、并发选轨和组合长播仍由MB-07/09验收。
纯音频/HLS检查实际AudioBufferSink和已排程Web Audio节点。视频恢复播放时观测实际CanvasSink
迭代器的frame timestamp和真正drawImage时的audio.currentTime,保留原始样本与偏差统计。
这是短时SDK时钟基线,不能当作声学音画延迟、长期漂移或各设备音频输出都已验证;对应后续MB-06/09。
settled playback完成后验证创建的AudioContext关闭、所观测代理RAF队列归零。
不据此宣称所有内部资源或pending操作都已释放;后者有明确失败基线。
## 生命周期与Range
24项Node测试使用三份实际历史主产物、原始engine方法,控制外部Promise、音频动作和timer:
1. destroy后晚load失败仍回写error/networkState,waiting/loadstart两个timer仍执行。
2. pending play遇到pause,晚返回仍video.start并发play/playing,paused却为true。
3. pending play遇到destroy,晚返回仍video.start并发事件。
4. audio.play拒绝后engine.paused保持false,第二次play不再重试。
5. pending seek遇到destroy,晚返回仍seeked并恢复play。
6. 成功load与destroy都未回收loadTimeout timer;已settled的race继续观察其晚拒绝。
7. 正常超时仍保持code4/Load timeout,不被底层更晚的拒绝覆盖。
8. HEAD Range预检拒绝缺失响应头、网络异常警告后继续;不设置engine.error;1.0.0仍预检HLS,1.2.0跳过。
前6组共18项复现历史问题;后2组6项保留正常/版本边界。不会将受控宿主发事件直接等同于
完整ArtPlayer核心destroy后用户仍收到事件;也不会用模拟音频动作宣称真实音画同步通过。
## 检查与后续
```sh
node --test test/mediabunny.test.js
yarn test:browser test/browser/mediabunny-inputs.spec.js test/browser/mediabunny.spec.js
yarn ci:check
```
早期30项输入实验中的四个Chromium/Firefox 1.0.0错误已保留完整报告、trace及截图,随后才建立
精确历史负例。各次浏览器report和整个results目录分别归档,未覆盖失败证据。
下一步MB-03处理input、Range和取消,MB-04处理主状态/事件协调,05/06处理帧及音频调度,
07处理轨道/控件。这些缺陷均未在本步修改生产代码;工厂类型待决项仍只影响Canvas/Ambilight。
最终CI1185项(1030单元+14工程+141基线)通过,另44重复契约;290生产TS严格检查。
新增test:mediabunny并将24项加入test:unit,补齐首次CI未包含该文件的入口遗漏后重跑全量CI。
17个冻结生产/demo/产物和五个最终测试文件哈希已复核;本任务独立本地提交。
+7 -3
View File
@@ -1,7 +1,8 @@
# MediaBunny proxy 验证入口
开始改动前读取[冻结契约](baselines/mb-contract.md)、[接口快照](baselines/mb-surface.json)、
[归档/源码哈希](baselines/mb-release.json)。当前阶段为PKG-MB-01;不得把接口基线称为真实媒体验收。
[归档/源码哈希](baselines/mb-release.json)。MB-01接口基线与
[MB-02行为基线](changes/2026-09-12-PKG-MB-02-baseline.md)分开记录;不得把接口测试称为真实媒体验收。
## 可重复命令
@@ -10,6 +11,8 @@
```sh
node refactor/scripts/mb-contract.mjs
node --test refactor/scripts/mb-contract.test.mjs
node --test test/mediabunny.test.js
yarn test:browser test/browser/mediabunny-inputs.spec.js test/browser/mediabunny.spec.js
yarn ci:check
```
@@ -22,8 +25,9 @@ play/load/seek转发测试刻意截获engine方法;不得宣传为真实播放
## 后续顺序
1. MB-02建立真实MP4/WebM/HLS/Blob/Stream和失败样本;三浏览器能力分别记录。
复现pending load/destroy、超时后释放、无轨道、并发seek/选轨、metadata重复与晚帧/音频回调。
1. MB-02的输入、seek、HLS轨道、无轨道和失败样本见行为基线。42项浏览器检查须区分
20次实际播放、12次能力对照、6次历史输入失败及4次无轨道就绪行为;24项生命周期测试含18个历史缺陷。
后续修改须复跑对应正常和负例,不把全部负例变为候选豁免;并发选轨、晚帧和音频回调的完整修复分别在04/05/06/07。
2. MB-03输入/取消,MB-04事件/状态协调,MB-05帧调度,MB-06音频时钟,MB-07配对轨道/控件。
按已批准计划分步迁移,不等到MB-08才一次性堆积全部源码修改。
3. MB-08完成严格TS和公开声明兼容矩阵;保留HTMLCanvasElement精确Result、可选工厂和全工厂
+3 -3
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 222 项,范围 22 个包及工作区/示例。
状态:todo 116 / doing 8 / blocked 0 / done 98 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 116 / doing 7 / blocked 0 / done 99 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -330,7 +330,7 @@
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| PKG-MB-01 | artplayer-proxy-mediabunny<br>核对 shim 与媒体契约 | BASE-05 | 全部 option、art.mediabunny、canvas/shim 属性及事件表 | 公开暴露成员和实际发布依赖/资源范围明确;核实 third-party.json 所记录依赖 MPL-2.0 元数据与实际 bundle 的许可/通知范围,不从根 MIT 推断 | H | done |
| PKG-MB-02 | artplayer-proxy-mediabunny<br>建立真实媒体与事件基线 | PKG-MB-01, ENG-05, ENG-08 | MP4/WebM/HLS/Blob/Stream、seek/错误/轨道切换样本 | ready 事件顺序、音画同步、超时和释放可重跑 | H | doing |
| PKG-MB-02 | artplayer-proxy-mediabunny<br>建立真实媒体与事件基线 | PKG-MB-01, ENG-05, ENG-08 | MP4/WebM/HLS/Blob/Stream、seek/错误/轨道切换样本 | ready 事件顺序、音画同步、超时和释放可重跑 | H | done |
| PKG-MB-03 | artplayer-proxy-mediabunny<br>整理 input 与加载取消 | PKG-MB-02, CORE-09 | input.js 源检测、输入资源、Range 与取消 | HLS 检测在 input 边界,旧来源类型和超时保持 | H | todo |
| PKG-MB-04 | artplayer-proxy-mediabunny<br>整理 VideoShim/EventTarget/Engine 协调 | PKG-MB-03, CORE-06, CORE-11 | shim 描述符、事件桥、加载状态和操作隔离 | 公开属性/同步异步返回与事件重入顺序保持 | H | todo |
| PKG-MB-05 | artplayer-proxy-mediabunny<br>整理视频解码、seek 和帧释放 | PKG-MB-04 | VideoEngine 调度、队列、晚帧处理和资源释放 | 暂停/seek/切源无旧帧覆盖,默认丢帧策略不变 | H | todo |
@@ -521,5 +521,5 @@
- PKG-CANVAS-03: [记录](changes/2026-09-12-PKG-CANVAS-03-lifecycle.md) [记录](baselines/canvas-lifecycle-validation.json) [记录](canvas-validation.md)
- PKG-CANVAS-04: [记录](changes/2026-09-12-PKG-CANVAS-04-types.md) [记录](baselines/canvas-types-validation.json) [记录](canvas-validation.md)
- PKG-MB-01: [记录](baselines/mb-release.json) [记录](baselines/mb-surface.json) [记录](baselines/mb-contract.md) [记录](baselines/mb-contract-validation.json) [记录](mb-validation.md) [记录](changes/2026-09-12-PKG-MB-01-contract.md)
- PKG-MB-02: [记录](changes/2026-09-12-PKG-MB-02-checkpoint.md) [记录](baselines/mb-media-checkpoint.json) [记录](mb-validation.md)
- PKG-MB-02: [记录](changes/2026-09-12-PKG-MB-02-checkpoint.md) [记录](baselines/mb-media-checkpoint.json) [记录](mb-validation.md) [记录](changes/2026-09-12-PKG-MB-02-baseline.md) [记录](baselines/mb-behavior-validation.json)
- PKG-FACTORY-01: [记录](baselines/factory-assignment-gaps.json) [记录](baselines/factory-compatibility-proposals.json) [记录](factory-compatibility-decision.md) [记录](changes/2026-09-12-PKG-FACTORY-01-decision.md)
+10
View File
@@ -1,5 +1,15 @@
# 进度与证据
## 最新完成:PKG-MB-02 媒体、事件与资源基线
24项历史生命周期检查通过,已接入统一CI;42项真实浏览器检查包含20次播放、12次环境能力
对照、6次历史输入失败和4次无轨道就绪行为,不能当作42次播放成功。短时帧/音频时钟、seek、
HLS轨道状态及正常AudioContext/RAF释放均有证据;待完成操作的泄漏和陈旧回调已复现。
完整CI1185项(1030单元+14工程+141基线)通过,另44重复契约,290生产TS。
见[任务记录](changes/2026-09-12-PKG-MB-02-baseline.md)和[验证证据](baselines/mb-behavior-validation.json)。
生产源码未在本步变更;接着MB-03输入与加载取消。222项:99 done、7 doing、116 todo。
Canvas/Ambilight类型决定仍待确认;未推送或发布。
## 当前检查点:PKG-MB-02 真实MP4与销毁负例
22项Node(含3个销毁后晚加载历史缺陷)通过;6项浏览器检查中,4项Chromium/Firefox
+1
View File
@@ -172,3 +172,4 @@
| MB-LIFE-01 | open / 已复现 | MediaBunny load, track replacement, timeout, RAF and audio callbacks lack one shared terminal owner | PKG-MB-02, PKG-MB-03, PKG-MB-04, PKG-MB-05, PKG-MB-06, PKG-MB-07, PKG-MB-09 |
| MB-TYPE-01 | open / 已复现 | MediaBunny proxy declares only Canvas while exposing a shim and changes CommonJS export generation between releases | PKG-MB-08, PKG-MB-10 |
| MB-CAP-01 | open / 已复现 | Current Windows WebKit lacks WebCodecs and Web Audio constructors needed by historical MediaBunny proxies | PKG-MB-02, PKG-MB-09 |
| MB-READY-01 | open / 已复现 | MediaBunny historical readiness can repeat for trackless input and occur after capability errors | PKG-MB-04, PKG-MB-09 |
+23 -3
View File
@@ -3648,7 +3648,7 @@
"PKG-MB-07",
"PKG-MB-09"
],
"compatibleResolution": "Reproduce pending operations and destroy/source-switch races before migrating each owner; preserve public event and promise behavior while preventing stale resource use. MB-02 now reproduces late load rejection and uncancelled waiting/loadstart timers after destroy in all three historical implementations using a controlled host. Other interleavings and full-core native behavior are not yet verified.",
"compatibleResolution": "Reproduce pending operations and destroy/source-switch races before migrating each owner; preserve public event and promise behavior while preventing stale resource use. MB-02 now reproduces late load rejection and uncancelled waiting/loadstart timers after destroy in all three historical implementations using a controlled host. Other interleavings and full-core native behavior are not yet verified. Expanded MB-02 baseline reproduces play-after-pause/destroy, rejected-play false state, seek-after-destroy and retained successful-load timeout timers in all three actual historical implementations; Range and active-timeout normal controls remain separate.",
"closureCriteria": "Historical failing interleavings and candidate cancellation/cleanup pass with real media and event evidence, including long playback and track topology.",
"status": "open",
"evidence": [
@@ -3656,7 +3656,9 @@
"refactor/baselines/mb-contract.md",
"refactor/changes/2026-09-12-PKG-MB-01-contract.md",
"refactor/changes/2026-09-12-PKG-MB-02-checkpoint.md",
"refactor/baselines/mb-media-checkpoint.json"
"refactor/baselines/mb-media-checkpoint.json",
"refactor/changes/2026-09-12-PKG-MB-02-baseline.md",
"refactor/baselines/mb-behavior-validation.json"
]
},
{
@@ -3689,7 +3691,25 @@
"closureCriteria": "Per-browser native media/support matrix and predictable unsupported-capability behavior verified, including target Safari evidence where support is claimed.",
"evidence": [
"refactor/changes/2026-09-12-PKG-MB-02-checkpoint.md",
"refactor/baselines/mb-media-checkpoint.json"
"refactor/baselines/mb-media-checkpoint.json",
"refactor/changes/2026-09-12-PKG-MB-02-baseline.md",
"refactor/baselines/mb-behavior-validation.json"
]
},
{
"id": "MB-READY-01",
"title": "MediaBunny historical readiness can repeat for trackless input and occur after capability errors",
"status": "open",
"confirmation": "reproduced",
"owners": [
"PKG-MB-04",
"PKG-MB-09"
],
"compatibleResolution": "Use explicit coordinated readiness and failure state, preserving valid event order while preventing duplicate or stale callbacks; evaluate trackless behavior separately from normal media.",
"closureCriteria": "Candidate metadata/data/canplay ordering and counts pass normal, absent-track and failure interleavings in native browsers and controlled race tests.",
"evidence": [
"refactor/changes/2026-09-12-PKG-MB-02-baseline.md",
"refactor/baselines/mb-behavior-validation.json"
]
}
]
+4 -2
View File
@@ -3416,14 +3416,16 @@
"ENG-05",
"ENG-08"
],
"status": "doing",
"status": "done",
"risk": "H",
"deliverable": "MP4/WebM/HLS/Blob/Stream、seek/错误/轨道切换样本",
"acceptance": "ready 事件顺序、音画同步、超时和释放可重跑",
"evidence": [
"changes/2026-09-12-PKG-MB-02-checkpoint.md",
"baselines/mb-media-checkpoint.json",
"mb-validation.md"
"mb-validation.md",
"changes/2026-09-12-PKG-MB-02-baseline.md",
"baselines/mb-behavior-validation.json"
]
},
{
+215
View File
@@ -0,0 +1,215 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { hash } from '../../refactor/scripts/releases.mjs'
import { mbHistorical } from '../helpers/mediabunny.js'
import { tracklessMp4 } from '../helpers/trackless-mp4.js'
import { expect, test } from './fixtures.js'
const implementations = (await mbHistorical()).filter(item => item.name.startsWith('published'))
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 (capabilities.AudioContext === 'undefined' && capabilities.webkitAudioContext === 'undefined') {
expect(browserName).toBe('webkit')
expect(capabilities).toEqual({ VideoDecoder: 'undefined', AudioDecoder: 'undefined', AudioContext: 'undefined', webkitAudioContext: 'undefined' })
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.2.0' ? 2 : 1)
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 canvases = video.videoSink.canvases.bind(video.videoSink)
video.videoSink.canvases = (...args) => {
const iterator = canvases(...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 (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, 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, 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: 'Actual published 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.' }) })
}
})
}
}
+29
View File
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
// Derive a trackless container from the small, repository-owned MP4 fixture.
export function tracklessMp4(bytes) {
function boxes(bytes) {
const result = []
let offset = 0
while (offset < bytes.length) {
assert(offset + 8 <= bytes.length)
const size = bytes.readUInt32BE(offset)
assert(size >= 8 && offset + size <= bytes.length, 'Fixture requires bounded 32-bit boxes')
result.push({ type: bytes.toString('ascii', offset + 4, offset + 8), bytes: bytes.subarray(offset, offset + size) })
offset += size
}
return result
}
const top = boxes(bytes)
const ftyp = top.find(box => box.type === 'ftyp')
const moov = top.find(box => box.type === 'moov')
assert(ftyp && moov)
const children = boxes(moov.bytes.subarray(8))
assert(children.some(box => box.type === 'trak'))
const body = Buffer.concat(children.filter(box => box.type !== 'trak').map(box => box.bytes))
const header = Buffer.alloc(8)
header.writeUInt32BE(body.length + 8)
header.write('moov', 4)
return Buffer.concat([ftyp.bytes, header, body])
}
+159
View File
@@ -3,6 +3,32 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import { mbEnvironment, mbHistorical } from './helpers/mediabunny.js'
function deferred() {
let resolve
let reject
const promise = new Promise((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
function timedEnvironment(implementation, option) {
const timers = new Map()
let sequence = 0
const env = mbEnvironment(implementation, {
setTimeout(fn, delay) {
const id = sequence++
timers.set(id, { fn, delay })
return id
},
clearTimeout(id) { timers.delete(id) },
})
env.eventNames.push('playing')
const canvas = env.factory(option)(env.art)
return { ...env, canvas, engine: canvas.engine, timers }
}
for (const implementation of await mbHistorical()) {
test(`MediaBunny historical ${implementation.name}: destroy still permits late load errors and scheduled events`, async () => {
const timers = new Map()
@@ -40,4 +66,137 @@ for (const implementation of await mbHistorical()) {
assert.deepEqual({ ...engine.error }, { code: 4, message: 'late controlled rejection' })
assert.equal(engine.networkState, 3)
})
test(`MediaBunny historical ${implementation.name}: pending play starts video after a newer pause`, async () => {
const { engine, emitted } = timedEnvironment(implementation)
const audio = deferred()
let starts = 0
engine.audio.play = () => audio.promise
engine.video.start = () => starts++
const playing = engine.play()
assert.equal(engine.paused, false)
engine.pause()
audio.resolve()
await playing
assert.equal(engine.paused, true)
assert.equal(starts, 1)
assert.deepEqual(emitted.map(item => item.name), ['video:pause', 'video:play', 'video:playing'])
})
test(`MediaBunny historical ${implementation.name}: pending play starts video after destroy`, async () => {
const { art, engine, emitted } = timedEnvironment(implementation)
const audio = deferred()
let starts = 0
engine.audio.play = () => audio.promise
engine.video.start = () => starts++
const playing = engine.play()
art.emit('destroy')
const cursor = emitted.length
audio.resolve()
await playing
assert.equal(starts, 1)
assert.deepEqual(emitted.slice(cursor).map(item => item.name), ['video:play', 'video:playing'])
assert.equal(engine.paused, true)
})
test(`MediaBunny historical ${implementation.name}: rejected audio play leaves false paused state and prevents retry`, async () => {
const { engine } = timedEnvironment(implementation)
const error = new Error('controlled audio resume rejection')
let attempts = 0
engine.audio.play = () => {
attempts++
return Promise.reject(error)
}
await assert.rejects(engine.play(), item => item === error)
assert.equal(engine.paused, false)
await engine.play()
assert.equal(attempts, 1)
})
test(`MediaBunny historical ${implementation.name}: pending seek emits and resumes playback after destroy`, async () => {
const { art, engine, canvas } = timedEnvironment(implementation)
const audio = deferred()
const events = []
for (const name of ['seeking', 'waiting', 'pause', 'seeked'])
canvas.events.addEventListener(name, () => events.push(name))
engine.paused = false
engine.audio.seek = () => audio.promise
engine.video.seek = () => Promise.resolve()
engine.play = () => {
events.push('resumed-after-destroy')
return Promise.resolve()
}
const seeking = engine.seek(1)
art.emit('destroy')
audio.resolve()
await seeking
assert.deepEqual(events, ['seeking', 'waiting', 'pause', 'seeked', 'resumed-after-destroy'])
assert.equal(engine.seeking, false)
})
test(`MediaBunny historical ${implementation.name}: successful load and destroy retain their timeout timer`, async () => {
const { art, engine, timers } = timedEnvironment(implementation, { loadTimeout: 1234 })
engine.performLoad = () => Promise.resolve()
await engine.load('controlled-input')
assert.equal(timers.size, 3)
assert.equal([...timers.values()].filter(item => item.delay === 1234).length, 1)
art.emit('destroy')
assert.equal(timers.size, 3)
for (const { fn } of timers.values()) fn()
timers.clear()
await Promise.resolve()
assert.equal(engine.error, null, 'Settled Promise.race observes the late timeout rejection without changing the successful result')
})
test(`MediaBunny historical ${implementation.name}: active timeout keeps its error when the underlying load rejects later`, async () => {
const { engine, timers } = timedEnvironment(implementation, { loadTimeout: 5678 })
const pending = deferred()
engine.performLoad = () => pending.promise
const loading = engine.load('controlled-input')
const timeout = [...timers.values()].find(item => item.delay === 5678)
timeout.fn()
await loading
assert.deepEqual({ ...engine.error }, { code: 4, message: 'Load timeout' })
const error = engine.error
pending.reject(new Error('late underlying rejection'))
await Promise.resolve()
await Promise.resolve()
assert.equal(engine.error, error)
timers.clear()
})
test(`MediaBunny historical ${implementation.name}: Range preflight rejects absent support, tolerates fetch errors and keeps its HLS version boundary`, async () => {
const { engine, canvas, globals } = timedEnvironment(implementation, { preflightRange: true })
const calls = []
const errors = []
const warnings = []
canvas.events.addEventListener('error', event => errors.push(event.detail.type))
globals.console = { ...console, warn: (...args) => warnings.push(args) }
globals.fetch = async (url, options) => {
calls.push({ url, options: { ...options } })
return { headers: { get: () => 'bytes' } }
}
assert.equal(await engine.video.preflight('https://example.invalid/media.mp4'), true)
assert.deepEqual(calls, [{ url: 'https://example.invalid/media.mp4', options: { method: 'HEAD' } }])
globals.fetch = async () => ({ headers: { get: () => null } })
assert.equal(await engine.video.preflight('https://example.invalid/media.mp4'), false)
assert.deepEqual(errors, ['RangeNotSupported'])
assert.equal(engine.error, null, 'Range refusal is an event, not engine.error state')
const failure = new Error('controlled HEAD rejection')
globals.fetch = async () => {
throw failure
}
assert.equal(await engine.video.preflight('https://example.invalid/media.mp4'), true)
assert.equal(warnings.length, 1)
assert.equal(warnings[0][1], failure)
let requests = 0
globals.fetch = async () => {
requests++
return { headers: { get: () => 'bytes' } }
}
assert.equal(await engine.video.preflight('https://example.invalid/media.m3u8'), true)
assert.equal(requests, implementation.name === 'published-1.0.0' ? 1 : 0)
assert.equal(await engine.video.preflight(new Blob([])), true)
assert.equal(requests, implementation.name === 'published-1.0.0' ? 1 : 0)
})
}