test(mediabunny): [PKG-MB-09] add sustained playback checkpoint

This commit is contained in:
Harvey Zhao committed 2026-09-12 22:24:04 +08:00
1 parent 190fdb2776
commit 401c5e9684
12 files changed
+7815 -7

No files matched your search

+3 -1
View File
@@ -77,7 +77,9 @@
"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:mediabunny": "node --test test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js",
"test:mediabunny-types-package": "node refactor/scripts/mb-package-types.mjs"
"test:mediabunny-types-package": "node refactor/scripts/mb-package-types.mjs",
"test:mediabunny-soak": "playwright test --config playwright.soak.config.js",
"generate:media:hls": "node scripts/generate-hls-fixture.mjs"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from '@playwright/test'
import base from './playwright.config.js'
export default defineConfig({
...base,
testDir: './test/soak',
timeout: 270000,
workers: 2,
outputDir: 'refactor/.cache/soak/results',
reporter: [
['list'],
['json', { outputFile: 'refactor/.cache/soak/report.json' }],
],
use: { ...base.use, trace: 'off', video: 'off' },
})
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,68 @@
# PKG-MB-09 持续播放检查点
任务保持doing。本轮新增独立的持续播放测试入口,验证发布核心与候选核心下的MediaBunny
已构建main产物。没有更改生产实现、公开API、依赖或发布版本。
## 可重复运行
HLS生成脚本新增第三个参数duration(12到3600整数秒),省略时仍生成原12秒素材。
禁止覆盖已存在目录,manifest记录FFmpeg版本、完整参数、每个文件的SHA256及大小。
600秒素材包含160×90/320×180、24fps H.264和English/French两条AAC正弦音轨。
长素材放在缓存,未替换已经冻结的短素材。每次测试验证manifest中所有媒体文件哈希。
```powershell
yarn generate:media:hls refactor/.cache/mb-long-hls 600
$env:ARTPLAYER_MB_ARTIFACT = (Resolve-Path packages/artplayer-proxy-mediabunny/dist/artplayer-proxy-mediabunny.js).Path
$env:ARTPLAYER_MB_SOAK_MEDIA = (Resolve-Path refactor/.cache/mb-long-hls).Path
yarn test:mediabunny-soak
```
使用现有已验证的构建流程生成待测产物。显式产物和媒体目录为必需参数,不允许悄悄回退
到源码构建。测试核心来源仍由browser-evidence中的manifest标识,默认候选核心为工作区
构建;若使用ARTPLAYER_BROWSER_ARTIFACTS,则按既有服务器规则读取指定核心产物。
独立playwright.soak.config.js读取test/soak,两个worker,每项270秒超时、零重试,
不会被日常test:browser自动发现。沿用8084服务器,禁止与普通浏览器测试同时运行。
结果保存refactor/.cache/soak/report.json和results;下一轮前归档整个目录。
当前入口用于本地专项验收,尚未接入GitHub托管长播放job;后续CI/发布复盘需明确安排。
## 验证内容
- 每个支持环境连续90秒墙钟1×、90秒墙钟2×,不循环短素材、不跳转伪造持续时间。
- 每5秒确认媒体时钟与画面绘制继续前进、前台可见、未暂停或报错,以及实际播放速率。
- 包装真实SDK迭代器取得解码Canvas时间戳,比较实际AudioContext主时钟;每阶段保留
帧数、最大/累计偏差和固定大小的毫秒直方图,阈值250ms。切换操作之间单独记录阶段。
- 连续播放后seek到100秒,再切换90P与French音轨,各阶段继续实际播放至少5秒。
- 包装真实AudioContext节点创建/断开,仅持有在用节点和未完成迭代器;每次采样节点少于96、
迭代器不超过2。销毁后节点/队列/迭代器归零,AudioContext关闭,创建与断开数量相同,
再等待300ms确认没有新节点或画面绘制。
- 缺少必要原生构造器的环境单独记录能力对照,不构造播放器,不计作播放通过。
本轮结果与完整CI已写入下方证据。失败记录保留,遇到实际缺陷先定位、修复并回归,
不会通过放宽阈值或把失败改为能力缺失来完成验收。
## 证据边界
这是至少195秒的实际墙钟组合,不能推导数小时音画漂移、声学输出同步、GC堆无泄漏、
后台节流或物理设备支持。资源断开计数只证明被测原生节点/迭代器的生命周期,不是完整
内存分析。前面的原生Document PiP检查点也只覆盖可见opener;MB-CAP-01、MB-LIFE-01
和MB-LICENSE-01仍按各自完整关闭条件继续,不由本检查点自动关闭。
## 本机验收结果
六项全部通过,其中Chromium/Firefox四项实际播放;两项Windows WebKit缺失能力对照
不计播放。四个原生场景都完成至少195秒墙钟播放、seek/quality/audio及destroy清理。
| 引擎 / 核心 | 五阶段墙钟合计 | 最大AV时钟偏差 | 创建 / 断开节点 | 同时在用峰值 |
| --- | --- | --- | --- | --- |
| chromium / published | 195.48s | 42.00ms | 13661 / 13661 | 64 |
| chromium / candidate | 195.52s | 41.33ms | 13661 / 13661 | 64 |
| firefox / published | 195.62s | 45.67ms | 13659 / 13659 | 64 |
| firefox / candidate | 195.62s | 51.33ms | 13675 / 13675 | 64 |
完整CI1418项和44重复契约通过,324生产TS。默认12秒生成结果与原冻结manifest、
FFmpeg版本、命令和32文件哈希完全一致。新长素材1208文件的manifest按LF归一化保存,
缓存报告保留原始字节哈希及完整results;每项原始附件包含固定直方图及逐5秒采样。
见[持续播放证据](../baselines/mb-sustained-validation.json)和[素材清单](../baselines/mb-sustained-media.json)。
本轮未发现需要修改生产实现的新运行时缺陷;新增的是可重复专项验证与工程入口。
任务保持doing并专用检查点提交,无推送或发布。
+11
View File
@@ -110,3 +110,14 @@ video-only输入及替换无sink仍readyState=4;任务保持doing,下一步
关闭字段遗漏,失败日志保留。见[变更](changes/2026-09-12-PKG-MB-09-native-pip-checkpoint.md)
和[证据](baselines/mb-native-pip-checkpoint.json)。222项:105 done、8 doing、109 todo。
MB-09仍doing;接续持续播放/AV同步/更多组合与设备,DPIP-05仍独立验收。
## PKG-MB-09 持续播放检查点(doing)
独立yarn test:mediabunny-soak读取600秒固定HLS,四个Chromium/Firefox新旧核心场景
各连续90秒1×/90秒2×,再seek/切质量/切音轨,共至少195秒实际墙钟。六项通过,
两项WebKit为缺失API对照。最大AV时钟偏差51.34ms;每组创建约1.37万节点,
同时在用峰值64,destroy后全部断开,迭代器/队列归零且无迟到绘制。
完整CI1418与44重复契约通过,324生产TS;默认生成器保留原32文件哈希。
见[变更](changes/2026-09-12-PKG-MB-09-sustained-playback-checkpoint.md)和
[证据](baselines/mb-sustained-validation.json)。222项:105 done、8 doing、109 todo。
MB-09仍doing;数小时/声学/物理设备/后台与完整插件组合未由本轮关闭,托管长测job待接续。
+1 -1
View File
@@ -528,5 +528,5 @@
- PKG-MB-06: [记录](baselines/mb-audio-source.json) [记录](changes/2026-09-12-PKG-MB-06-audio.md) [记录](baselines/mb-audio-validation.json)
- PKG-MB-07: [记录](baselines/mb-hls-source.json) [记录](changes/2026-09-12-PKG-MB-07-hls.md) [记录](baselines/mb-hls-validation.json)
- PKG-MB-08: [记录](baselines/mb-entry-source.json) [记录](changes/2026-09-12-PKG-MB-08-entry-checkpoint.md) [记录](baselines/mb-entry-checkpoint.json) [记录](baselines/mb-capability-source.json) [记录](changes/2026-09-12-PKG-MB-08-capability.md) [记录](baselines/mb-capability-validation.json)
- PKG-MB-09: [记录](changes/2026-09-12-PKG-MB-09-native-pip-checkpoint.md) [记录](baselines/mb-native-pip-checkpoint.json)
- PKG-MB-09: [记录](changes/2026-09-12-PKG-MB-09-native-pip-checkpoint.md) [记录](baselines/mb-native-pip-checkpoint.json) [记录](changes/2026-09-12-PKG-MB-09-sustained-playback-checkpoint.md) [记录](baselines/mb-sustained-validation.json) [记录](baselines/mb-sustained-media.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)
+11
View File
@@ -1,5 +1,16 @@
# 进度与证据
## PKG-MB-09 持续播放检查点(doing)
独立yarn test:mediabunny-soak读取600秒固定HLS,四个Chromium/Firefox新旧核心场景
各连续90秒1×/90秒2×,再seek/切质量/切音轨,共至少195秒实际墙钟。六项通过,
两项WebKit为缺失API对照。最大AV时钟偏差51.34ms;每组创建约1.37万节点,
同时在用峰值64,destroy后全部断开,迭代器/队列归零且无迟到绘制。
完整CI1418与44重复契约通过,324生产TS;默认生成器保留原32文件哈希。
见[变更](changes/2026-09-12-PKG-MB-09-sustained-playback-checkpoint.md)和
[证据](baselines/mb-sustained-validation.json)。222项:105 done、8 doing、109 todo。
MB-09仍doing;数小时/声学/物理设备/后台与完整插件组合未由本轮关闭,托管长测job待接续。
## PKG-MB-09 原生画中画检查点(doing)
36个新旧核心/媒体/关闭方式/浏览器场景通过,24项实际原生窗口中Canvas解码播放,
+4 -2
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. 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. MB-03 candidate owns pending Input before track selection and cancels load/HEAD/timers on source replacement, timeout and destroy; source/main/legacy regressions pass. Pending play/seek, late decoder work and full readiness coordination remain MB-04/05/06, so this risk stays open. MB-04 shim checkpoint fixes synthetic RAF retention and terminal event-listener cleanup, including engine cleanup exceptions. Closing the event bridge does not prove pending engine operations stopped; coordination remains in progress. MB-04 now guards play/seek/replacement completion, preserves explicit pause, observes stale failures and isolates reentrant event sequences. Decoder-internal late frames/audio and HLS UI races remain MB-05/06/07; risk stays open. MB-05 migrates VideoEngine and owns iterators, late first/second seek frames, pending reads, per-instance RAF and poster callbacks; same native race assertions fail on old main and pass on candidate. Independent CanvasSink pools also prevent multiple obsolete seek samples from overwriting queued current-frame pixels. Audio nodes, long-run AV sync and HLS UI topology remain MB-06/07/09. MB-06 migrates AudioEngine into six checked modules, owns context/gain/nodes/iterators and removable cancellation waits, preserves clock and gain formulas, and invalidates pending resume/decoding across pause/source/destroy. Same old main fails 29 of 37 audio assertions and four native pause-cleanup cases; candidate passes with short native 1x/2x AV-clock samples. Long-run AV sync, full combinations and HLS UI topology remain MB-07/09, so risk stays open. MB-07 migrates HLS UI to four checked modules, guards stale refresh/selection callbacks, cleans each surface and owns terminal listeners. Quality/audio queries share a latest-intent token and retry pairing against changed media snapshots. Forty-three old-main assertions yield 11 pass/32 fail; sixteen old native UI/query cases fail and twenty candidate native cases pass across old/new cores. Long-run AV sync and full combinations remain MB-09; capability/type/package gates remain MB-08/10.",
"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. MB-03 candidate owns pending Input before track selection and cancels load/HEAD/timers on source replacement, timeout and destroy; source/main/legacy regressions pass. Pending play/seek, late decoder work and full readiness coordination remain MB-04/05/06, so this risk stays open. MB-04 shim checkpoint fixes synthetic RAF retention and terminal event-listener cleanup, including engine cleanup exceptions. Closing the event bridge does not prove pending engine operations stopped; coordination remains in progress. MB-04 now guards play/seek/replacement completion, preserves explicit pause, observes stale failures and isolates reentrant event sequences. Decoder-internal late frames/audio and HLS UI races remain MB-05/06/07; risk stays open. MB-05 migrates VideoEngine and owns iterators, late first/second seek frames, pending reads, per-instance RAF and poster callbacks; same native race assertions fail on old main and pass on candidate. Independent CanvasSink pools also prevent multiple obsolete seek samples from overwriting queued current-frame pixels. Audio nodes, long-run AV sync and HLS UI topology remain MB-06/07/09. MB-06 migrates AudioEngine into six checked modules, owns context/gain/nodes/iterators and removable cancellation waits, preserves clock and gain formulas, and invalidates pending resume/decoding across pause/source/destroy. Same old main fails 29 of 37 audio assertions and four native pause-cleanup cases; candidate passes with short native 1x/2x AV-clock samples. Long-run AV sync, full combinations and HLS UI topology remain MB-07/09, so risk stays open. MB-07 migrates HLS UI to four checked modules, guards stale refresh/selection callbacks, cleans each surface and owns terminal listeners. Quality/audio queries share a latest-intent token and retry pairing against changed media snapshots. Forty-three old-main assertions yield 11 pass/32 fail; sixteen old native UI/query cases fail and twenty candidate native cases pass across old/new cores. Long-run AV sync and full combinations remain MB-09; capability/type/package gates remain MB-08/10. MB-09 checkpoints add 24 actual native Document PiP playback/lifecycle cases with visible openers and four native old/new-core sustained HLS cases, each at least 195 wall seconds across 1x/2x, seek, quality and audio. Maximum sampled frame/audio-clock delta is 51.34ms; active nodes peak at 64 and all approximately 13.7k created nodes per case disconnect on teardown, with iterators/queues empty. This does not close background behavior, complete plugin combinations, physical-device capability, acoustic synchronization or hour-scale validation.",
"closureCriteria": "Historical failing interleavings and candidate cancellation/cleanup pass with real media and event evidence, including long playback and track topology.",
"status": "open",
"evidence": [
@@ -3668,7 +3668,9 @@
"refactor/changes/2026-09-12-PKG-MB-06-audio.md",
"refactor/baselines/mb-audio-validation.json",
"refactor/changes/2026-09-12-PKG-MB-07-hls.md",
"refactor/baselines/mb-hls-validation.json"
"refactor/baselines/mb-hls-validation.json",
"refactor/baselines/mb-native-pip-checkpoint.json",
"refactor/baselines/mb-sustained-validation.json"
]
},
{
+4 -1
View File
@@ -3576,7 +3576,10 @@
"acceptance": "资源释放、事件顺序、AV sync 与支持范围满足基线",
"evidence": [
"changes/2026-09-12-PKG-MB-09-native-pip-checkpoint.md",
"baselines/mb-native-pip-checkpoint.json"
"baselines/mb-native-pip-checkpoint.json",
"changes/2026-09-12-PKG-MB-09-sustained-playback-checkpoint.md",
"baselines/mb-sustained-validation.json",
"baselines/mb-sustained-media.json"
]
},
{
+4 -2
View File
@@ -8,13 +8,15 @@ import { hash } from '../refactor/scripts/releases.mjs'
const root = fileURLToPath(new URL('../', import.meta.url))
const output = path.resolve(root, process.argv[2] || 'test/browser/media/hls')
const duration = Number(process.argv[3] || 12)
assert(Number.isInteger(duration) && duration >= 12 && duration <= 3600, 'Duration must be 12..3600 whole seconds')
assert(!fs.existsSync(output), 'Output must be a new directory; never overwrite frozen media')
const ffmpeg = process.env.ARTPLAYER_FFMPEG || 'ffmpeg'
const version = execFileSync(ffmpeg, ['-version'], { encoding: 'utf8' }).split(/\r?\n/)[0]
fs.mkdirSync(output, { recursive: true })
const commands = []
function generate(name, input, encoding) {
const args = ['-hide_banner', '-loglevel', 'error', '-n', '-f', 'lavfi', '-i', input, '-t', '12', ...encoding, '-f', 'hls', '-hls_time', '2', '-hls_playlist_type', 'vod', '-hls_segment_filename', `${name}-%02d.mpegts`, `${name}.m3u8`]
const args = ['-hide_banner', '-loglevel', 'error', '-n', '-f', 'lavfi', '-i', input, '-t', String(duration), ...encoding, '-f', 'hls', '-hls_time', '2', '-hls_playlist_type', 'vod', '-hls_segment_filename', `${name}-%02d.mpegts`, `${name}.m3u8`]
execFileSync(ffmpeg, args, { cwd: output, stdio: 'pipe' })
commands.push(args)
}
@@ -27,5 +29,5 @@ const variants = '#EXT-X-STREAM-INF:BANDWIDTH=200000,RESOLUTION=160x90,CODECS="a
fs.writeFileSync(path.join(output, 'master.m3u8'), `#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="English",LANGUAGE="en",DEFAULT=YES,AUTOSELECT=YES,URI="english.m3u8"\n#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="French",LANGUAGE="fr",DEFAULT=NO,AUTOSELECT=YES,URI="french.m3u8"\n${variants}`)
fs.writeFileSync(path.join(output, 'video-only.m3u8'), '#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-STREAM-INF:BANDWIDTH=200000,RESOLUTION=160x90,CODECS="avc1.42c01e"\nlow.m3u8\n')
const files = Object.fromEntries(fs.readdirSync(output).sort().map(name => [name, { sha256: hash(fs.readFileSync(path.join(output, name))), bytes: fs.statSync(path.join(output, name)).size }]))
fs.writeFileSync(path.join(output, 'manifest.json'), `${JSON.stringify({ kind: 'generated-hls-media', version, duration: 12, commands, files }, null, 2)}\n`)
fs.writeFileSync(path.join(output, 'manifest.json'), `${JSON.stringify({ kind: 'generated-hls-media', version, duration, commands, files }, null, 2)}\n`)
console.log(`Generated ${Object.keys(files).length} HLS files in ${output}`)
+197
View File
@@ -0,0 +1,197 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { expect, test } from '../browser/fixtures.js'
import { mbCandidate } from '../helpers/mediabunny.js'
const implementation = await mbCandidate()
assert(process.env.ARTPLAYER_MB_ARTIFACT, 'Soak requires an explicit built MediaBunny artifact')
assert(process.env.ARTPLAYER_MB_SOAK_MEDIA, 'Generate and specify the long HLS fixture directory')
const directory = path.resolve(process.env.ARTPLAYER_MB_SOAK_MEDIA)
const manifestBytes = fs.readFileSync(path.join(directory, 'manifest.json'))
const manifest = JSON.parse(manifestBytes)
assert(manifest.duration >= 600, 'Soak needs at least ten minutes of media')
const media = new Map(Object.entries(manifest.files).map(([name, expected]) => {
assert.equal(path.basename(name), name)
const bytes = fs.readFileSync(path.join(directory, name))
assert.equal(hash(bytes), expected.sha256, name)
return [name, bytes]
}))
for (const core of ['published', 'candidate']) {
test(`MediaBunny sustained HLS playback with ${core} core`, async ({ page }, testInfo) => {
await page.route('**/mb-soak/**', async (route) => {
const name = new URL(route.request().url()).pathname.split('/').at(-1)
const body = media.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}`)
const capabilities = await page.evaluate(() => ({ audio: typeof window.AudioContext, videoDecoder: typeof window.VideoDecoder, audioDecoder: typeof window.AudioDecoder }))
const evidence = { core, implementation: implementation.name, sha256: hash(implementation.code), manifestSha256: hash(manifestBytes), capabilities, outcome: 'incomplete', phases: [], checkpoints: [] }
if (Object.values(capabilities).some(value => value !== 'function')) {
evidence.outcome = 'missing-native-capability-control'
await testInfo.attach('mediabunny-soak', { contentType: 'application/json', body: JSON.stringify(evidence) })
return
}
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${implementation.code}; window.mbFactory = module.exports.default || module.exports; })();` })
await page.evaluate(() => {
const active = new Set()
const iterators = new Set()
const counters = { created: 0, disconnected: 0, maxActive: 0, draws: 0 }
window.mbSoak = { active, iterators, counters, phase: null, stats: {}, contexts: new Set() }
const create = AudioContext.prototype.createBufferSource
AudioContext.prototype.createBufferSource = function (...args) {
window.mbSoak.contexts.add(this)
const node = create.apply(this, args)
active.add(node)
counters.created++
counters.maxActive = Math.max(counters.maxActive, active.size)
const disconnect = node.disconnect.bind(node)
node.disconnect = (...args) => {
const result = disconnect(...args)
if (active.delete(node))
counters.disconnected++
return result
}
return node
}
window.art = new window.Artplayer({ container: '.player', url: '/mb-soak/master.m3u8', muted: true, proxy: window.mbFactory() })
window.mbCanvas = window.art.video
document.querySelector('#play').onclick = () => {
window.mbPlaying = window.art.play()
}
})
try {
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.evaluate(async () => {
const engine = window.mbCanvas.engine
const times = new WeakMap()
for (const [sink, method] of [[engine.video.videoSink, 'canvases'], [engine.audio.audioSink, 'buffers']]) {
const prototype = Object.getPrototypeOf(sink)
const original = prototype[method]
prototype[method] = function (...args) {
const iterator = original.apply(this, args)
window.mbSoak.iterators.add(iterator)
const next = iterator.next.bind(iterator)
const finish = iterator.return.bind(iterator)
iterator.next = async (...args) => {
try {
const value = await next(...args)
if (value.done)
window.mbSoak.iterators.delete(iterator)
else if (method === 'canvases')
times.set(value.value.canvas, value.value.timestamp)
return value
}
catch (error) {
window.mbSoak.iterators.delete(iterator)
throw error
}
}
iterator.return = async (...args) => {
try {
return await finish(...args)
}
finally { window.mbSoak.iterators.delete(iterator) }
}
return iterator
}
}
const draw = engine.video.ctx.drawImage.bind(engine.video.ctx)
engine.video.ctx.drawImage = (...args) => {
const state = window.mbSoak
state.counters.draws++
if (state.phase && times.has(args[0]) && !window.mbCanvas.paused) {
const stats = state.stats[state.phase]
const delta = Math.abs(times.get(args[0]) - engine.audio.currentTime)
stats.frames++
stats.maxError = Math.max(stats.maxError, delta)
stats.sumError += delta
stats.histogram[Math.min(1000, Math.floor(delta * 1000))]++
}
return draw(...args)
}
await engine.seek(1)
})
await page.click('#play')
await page.evaluate(() => window.mbPlaying)
const snapshot = () => page.evaluate(() => ({ wall: performance.now(), time: window.mbCanvas.currentTime, rate: window.mbCanvas.playbackRate, active: window.mbSoak.active.size, queued: window.mbCanvas.engine.audio.queuedNodes.size, iterators: window.mbSoak.iterators.size, draws: window.mbSoak.counters.draws, visibility: document.visibilityState, paused: window.mbCanvas.paused, error: window.mbCanvas.error }))
async function sustain(name, seconds, rate) {
await page.evaluate((name) => {
window.mbSoak.stats[name] = { frames: 0, maxError: 0, sumError: 0, histogram: Array.from({ length: 1001 }, () => 0) }
window.mbSoak.phase = name
}, name)
const start = await snapshot()
let last = start
while (last.wall - start.wall < seconds * 1000) {
await page.waitForTimeout(5000)
const current = await snapshot()
evidence.checkpoints.push({ phase: name, ...current })
expect(current.visibility).toBe('visible')
expect(current.error).toBeNull()
expect(current.paused).toBe(false)
expect(current.rate).toBe(rate)
expect(current.time).toBeGreaterThan(last.time + 2)
expect(current.draws).toBeGreaterThan(last.draws + 20)
expect(current.active).toBeLessThan(96)
expect(current.iterators).toBeLessThanOrEqual(2)
last = current
}
const stats = await page.evaluate(() => {
const state = window.mbSoak
const stats = state.stats[state.phase]
state.phase = null
return stats
})
const wallSeconds = (last.wall - start.wall) / 1000
evidence.phases.push({ name, wallSeconds, mediaSeconds: last.time - start.time, rate, ...stats })
expect(wallSeconds).toBeGreaterThanOrEqual(seconds)
expect(Math.abs(last.time - start.time - wallSeconds * rate)).toBeLessThan(1)
expect(stats.frames).toBeGreaterThan(seconds * 12)
expect(stats.maxError).toBeLessThan(0.25)
}
await sustain('continuous-1x', 90, 1)
await page.evaluate(() => {
window.mbCanvas.playbackRate = 2
})
await sustain('continuous-2x', 90, 2)
await page.evaluate(async () => {
window.mbCanvas.playbackRate = 1
await window.mbCanvas.engine.seek(100)
})
await sustain('after-seek', 5, 1)
await page.evaluate(async () => {
const state = await window.mbCanvas.getM3u8State()
await window.mbCanvas.switchM3u8Quality(state.levels.find(level => level.height === 90).id)
})
await sustain('after-quality', 5, 1)
await page.evaluate(async () => {
const state = await window.mbCanvas.getM3u8State()
await window.mbCanvas.switchM3u8Audio(state.audios.find(audio => audio.lang === 'fr').id)
})
await sustain('after-audio', 5, 1)
const selection = await page.evaluate(async () => {
const state = await window.mbCanvas.getM3u8State()
return { height: state.currentLevel.height, language: state.currentAudio.lang }
})
expect(selection).toEqual({ height: 90, language: 'fr' })
evidence.selection = selection
evidence.outcome = 'native-sustained-hls-playback'
}
finally {
await page.evaluate(() => window.art.destroy(false))
await expect.poll(() => page.evaluate(() => ({ active: window.mbSoak.active.size, iterators: window.mbSoak.iterators.size, contextsClosed: [...window.mbSoak.contexts].every(context => context.state === 'closed') }))).toEqual({ active: 0, iterators: 0, contextsClosed: true })
const cleanup = await page.evaluate(() => ({ ...window.mbSoak.counters, audioIterator: Boolean(window.mbCanvas.engine.audio.audioIterator), videoIterator: Boolean(window.mbCanvas.engine.video.videoIterator), queued: window.mbCanvas.engine.audio.queuedNodes.size, destroyed: window.mbCanvas.engine.destroyed }))
await page.waitForTimeout(300)
expect(await page.evaluate(() => window.mbSoak.counters.draws)).toBe(cleanup.draws)
expect(await page.evaluate(() => window.mbSoak.counters.created)).toBe(cleanup.created)
expect(cleanup).toMatchObject({ audioIterator: false, videoIterator: false, queued: 0, destroyed: true })
expect(cleanup.created).toBe(cleanup.disconnected)
evidence.cleanup = cleanup
evidence.scope = 'Actual 600s generated HLS input, at least 180s uninterrupted wall playback at 1x/2x then seek/quality/audio; decoded frame vs audio clock, not acoustic output, hour-scale drift, heap leak proof or physical devices. Only active nodes/iterators are retained by instrumentation.'
await testInfo.attach('mediabunny-soak', { contentType: 'application/json', body: JSON.stringify(evidence) })
}
})
}