mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-18 10:41:20 -08:00
fix(dash): [PKG-DASH-SEEK-01] recover paused boundary seeks in SDK 4.5.2
This commit is contained in:
1 parent
442e1dcc96
commit
30f646be97
22 files changed
+1725
-57
No files matched your search
@@ -4,6 +4,7 @@ import process from 'node:process'
|
||||
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
|
||||
import { compilePackage } from '../helpers/load.js'
|
||||
import { observeDashBuffers } from './dash-buffer-observer.js'
|
||||
import { probeBufferGetter, probeBufferMetric } from './dash-seek-diagnostics.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const sdks = new Map()
|
||||
@@ -14,6 +15,9 @@ let mediaManifest
|
||||
let pluginRelease
|
||||
const diagnosticSDK = process.env.ARTPLAYER_DASH_DIAGNOSTIC_SDK || 'none'
|
||||
assert(['none', 'upstream4', 'bufferlevel4'].includes(diagnosticSDK), 'Unknown diagnostic SDK mode')
|
||||
const recoveryModes = ['ARTPLAYER_DASH_DIAGNOSE_STALL', 'ARTPLAYER_DASH_DIAGNOSE_GETTER', 'ARTPLAYER_DASH_DIAGNOSE_METRICS'].filter(name => process.env[name] === '1')
|
||||
assert(recoveryModes.length <= 1, 'Choose only one DASH recovery diagnostic')
|
||||
assert(!recoveryModes.length || diagnosticSDK === 'none', 'Recovery diagnostics require the unchanged published SDK')
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const baseline = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/dash-sdk.json', import.meta.url)))
|
||||
@@ -81,6 +85,22 @@ test.afterEach(async ({ page }, testInfo) => {
|
||||
recovery.final = await page.evaluate(() => window.dashBuffers.snapshot())
|
||||
await testInfo.attach('diagnostic-synthetic-timeupdate', { contentType: 'application/json', body: JSON.stringify(recovery) })
|
||||
}
|
||||
if (process.env.ARTPLAYER_DASH_DIAGNOSE_GETTER === '1' && testInfo.status !== testInfo.expectedStatus && state.seeking && state.time === 6) {
|
||||
const recovery = await page.evaluate(probeBufferGetter)
|
||||
recovery.advanced = await page.waitForFunction(() => (window.art?.video || window.nativeVideo).currentTime > 6.2, null, { timeout: 3000 }).then(() => true, () => false)
|
||||
recovery.final = await page.evaluate(() => {
|
||||
for (const restore of window.dashGetterRestores)
|
||||
restore()
|
||||
return { state: window.dashBuffers.snapshot(), records: window.dashGetterRecords }
|
||||
})
|
||||
await testInfo.attach('diagnostic-buffer-getter', { contentType: 'application/json', body: JSON.stringify(recovery) })
|
||||
}
|
||||
if (process.env.ARTPLAYER_DASH_DIAGNOSE_METRICS === '1' && testInfo.status !== testInfo.expectedStatus && state.seeking && state.time === 6) {
|
||||
const recovery = await page.evaluate(probeBufferMetric)
|
||||
recovery.advanced = await page.waitForFunction(() => (window.art?.video || window.nativeVideo).currentTime > 6.2, null, { timeout: 3000 }).then(() => true, () => false)
|
||||
recovery.final = await page.evaluate(() => window.dashBuffers.snapshot())
|
||||
await testInfo.attach('diagnostic-buffer-metric', { contentType: 'application/json', body: JSON.stringify(recovery) })
|
||||
}
|
||||
if (!page.isClosed()) {
|
||||
await page.evaluate(() => {
|
||||
if (window.art && !window.art.isDestroy)
|
||||
@@ -208,6 +228,60 @@ for (const core of ['published', 'candidate']) {
|
||||
}
|
||||
}
|
||||
|
||||
async function seekAtEmptyBoundary(page) {
|
||||
await expect.poll(() => page.evaluate(() => (window.art?.video || window.nativeVideo).readyState)).toBeGreaterThanOrEqual(2)
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => (window.art?.video || window.nativeVideo).currentTime)).toBeGreaterThan(0.3)
|
||||
await page.evaluate(() => {
|
||||
const dash = (window.art?.dash || window.nativeDash)
|
||||
dash.updateSettings({ streaming: { abr: { autoSwitchBitrate: { video: false } } } })
|
||||
if (dash.setQualityFor)
|
||||
dash.setQualityFor('video', 1)
|
||||
else
|
||||
dash.setRepresentationForTypeById('video', '1')
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => (window.art?.video || window.nativeVideo).videoHeight)).toBe(180)
|
||||
// Match the failed integrated run: video data ends at the next seek target.
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const video = (window.art?.video || window.nativeVideo)
|
||||
return video.currentTime >= 2.8 && Math.abs(video.buffered.end(video.buffered.length - 1) - 6) < 0.0001
|
||||
})).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
const dash = (window.art?.dash || window.nativeDash)
|
||||
dash.updateSettings({ streaming: { abr: { autoSwitchBitrate: { video: true } } } })
|
||||
dash.setCurrentTrack(dash.getTracksFor('audio').find(track => track.lang === 'fr'))
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => (window.art?.dash || window.nativeDash).getCurrentTrackFor('audio').lang)).toBe('fr')
|
||||
// The recorded integrated stall happened after the audio switch was rendered.
|
||||
await expect.poll(() => page.evaluate(() => window.sdkEvents.some(event => event.name === 'TRACK_CHANGE_RENDERED' && event.mediaType === 'audio'))).toBe(true)
|
||||
await page.locator('#pause').click()
|
||||
// Observe a genuinely held pause: no clock/append events for half a second.
|
||||
// This separates the seek from progress events still queued by the track switch.
|
||||
await page.waitForFunction(() => (window.art?.video || window.nativeVideo).paused && performance.now() - window.dashBuffers.events.at(-1).at >= 500)
|
||||
expect(await page.evaluate(() => (window.art?.video || window.nativeVideo).paused)).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
window.dashBuffers.mark('before-seek')
|
||||
if (window.art)
|
||||
window.art.seek = 6
|
||||
else
|
||||
window.nativeVideo.currentTime = 6
|
||||
window.dashBuffers.mark('assigned-seek')
|
||||
})
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => (window.art?.video || window.nativeVideo).currentTime)).toBeGreaterThan(6.2)
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
for (const version of ['4.5.2', '5.2.1']) {
|
||||
test(`${core} core / candidate DASH ${version}: stable paused boundary seek preserves media and SDK ownership`, async ({ page }, testInfo) => {
|
||||
await openDash(page, version, core, 'candidate', testInfo)
|
||||
await seekAtEmptyBoundary(page)
|
||||
expect(await page.evaluate(() => window.sdkErrors)).toEqual([])
|
||||
expect(await page.evaluate(() => window.art.dash.getVideoElement() === window.art.video && window.sdkDestroyed === 0)).toBe(true)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const version of ['4.5.2', '5.2.1']) {
|
||||
test(`dash.js ${version}: native SDK track switch and paused seek without ArtPlayer`, async ({ page }, testInfo) => {
|
||||
const capability = await loadSDK(page, version, 'candidate', testInfo)
|
||||
@@ -246,43 +320,7 @@ for (const version of ['4.5.2', '5.2.1']) {
|
||||
}
|
||||
document.querySelector('#pause').onclick = () => video.pause()
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.nativeVideo.readyState)).toBeGreaterThanOrEqual(2)
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.nativeVideo.currentTime)).toBeGreaterThan(0.3)
|
||||
await page.evaluate(() => {
|
||||
const dash = window.nativeDash
|
||||
dash.updateSettings({ streaming: { abr: { autoSwitchBitrate: { video: false } } } })
|
||||
if (dash.setQualityFor)
|
||||
dash.setQualityFor('video', 1)
|
||||
else
|
||||
dash.setRepresentationForTypeById('video', '1')
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.nativeVideo.videoHeight)).toBe(180)
|
||||
// Match the failed integrated run: video data ends at the next seek target.
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const video = window.nativeVideo
|
||||
return video.currentTime >= 2.8 && Math.abs(video.buffered.end(video.buffered.length - 1) - 6) < 0.0001
|
||||
})).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
const dash = window.nativeDash
|
||||
dash.updateSettings({ streaming: { abr: { autoSwitchBitrate: { video: true } } } })
|
||||
dash.setCurrentTrack(dash.getTracksFor('audio').find(track => track.lang === 'fr'))
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.nativeDash.getCurrentTrackFor('audio').lang)).toBe('fr')
|
||||
// The recorded integrated stall happened after the audio switch was rendered.
|
||||
await expect.poll(() => page.evaluate(() => window.sdkEvents.some(event => event.name === 'TRACK_CHANGE_RENDERED' && event.mediaType === 'audio'))).toBe(true)
|
||||
await page.locator('#pause').click()
|
||||
// Observe a genuinely held pause: no clock/append events for half a second.
|
||||
// This separates the seek from progress events still queued by the track switch.
|
||||
await page.waitForFunction(() => window.nativeVideo.paused && performance.now() - window.dashBuffers.events.at(-1).at >= 500)
|
||||
expect(await page.evaluate(() => window.nativeVideo.paused)).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
window.dashBuffers.mark('before-seek')
|
||||
window.nativeVideo.currentTime = 6
|
||||
window.dashBuffers.mark('assigned-seek')
|
||||
})
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.nativeVideo.currentTime)).toBeGreaterThan(6.2)
|
||||
await seekAtEmptyBoundary(page)
|
||||
expect(await page.evaluate(() => window.nativePlay)).toBe('fulfilled')
|
||||
expect(await page.evaluate(() => window.sdkErrors)).toEqual([])
|
||||
expect(await page.evaluate(() => window.Artplayer.instances.length)).toBe(0)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Page-only counterfactuals, called after a failed SDK assertion. Never production recovery.
|
||||
|
||||
export function probeBufferGetter() {
|
||||
const dash = window.art?.dash || window.nativeDash
|
||||
const video = window.art?.video || window.nativeVideo
|
||||
const before = window.dashBuffers.snapshot()
|
||||
const records = []
|
||||
window.dashGetterRestores = []
|
||||
for (const processor of dash.getActiveStream().getProcessors()) {
|
||||
const buffer = processor.getBufferController()
|
||||
const original = buffer.getBufferLevel
|
||||
const record = { type: processor.getType(), reads: 0, adjusted: 0 }
|
||||
const wrapped = function (...args) {
|
||||
const cached = Reflect.apply(original, this, args)
|
||||
record.reads++
|
||||
const time = video.currentTime
|
||||
const range = buffer.getRangeAt(time)
|
||||
if (video.seeking && cached > 0 && (!range || range.end <= time) && !buffer.getIsPruningInProgress() && buffer.getAllRangesWithSafetyFactor(time).length === 0) {
|
||||
record.adjusted++
|
||||
return 0
|
||||
}
|
||||
return cached
|
||||
}
|
||||
buffer.getBufferLevel = wrapped
|
||||
records.push(record)
|
||||
window.dashGetterRestores.push(() => {
|
||||
if (buffer.getBufferLevel === wrapped)
|
||||
buffer.getBufferLevel = original
|
||||
})
|
||||
}
|
||||
window.dashGetterRecords = records
|
||||
return { before, mode: 'conditional-buffer-getter', acceptance: false }
|
||||
}
|
||||
|
||||
export function probeBufferMetric() {
|
||||
const dash = window.art?.dash || window.nativeDash
|
||||
const video = window.art?.video || window.nativeVideo
|
||||
const metrics = dash.getDashMetrics()
|
||||
const before = window.dashBuffers.snapshot()
|
||||
const settings = JSON.stringify(dash.getSettings())
|
||||
const records = []
|
||||
for (const processor of dash.getActiveStream().getProcessors()) {
|
||||
const type = processor.getType()
|
||||
const buffer = processor.getBufferController()
|
||||
const time = video.currentTime
|
||||
const cached = metrics.getCurrentBufferLevel(type)
|
||||
const range = buffer.getRangeAt(time)
|
||||
if (video.seeking && cached > 0 && (!range || range.end <= time) && !buffer.getIsPruningInProgress() && buffer.getAllRangesWithSafetyFactor(time).length === 0) {
|
||||
metrics.addBufferLevel(type, new Date(), 0)
|
||||
records.push({ type, cached, corrected: metrics.getCurrentBufferLevel(type), time, range })
|
||||
}
|
||||
}
|
||||
return { before, mode: 'measured-empty-buffer-metric', acceptance: false, records, afterMetric: window.dashBuffers.snapshot(), settingsUnchanged: settings === JSON.stringify(dash.getSettings()) }
|
||||
}
|
||||
@@ -20,6 +20,180 @@ function eventHost(version) {
|
||||
}, listenerCount: () => [...events.values()].reduce((sum, callbacks) => sum + callbacks.size, 0) }
|
||||
}
|
||||
|
||||
function seekHost() {
|
||||
const host = eventHost(4)
|
||||
Object.assign(host.video, { currentTime: 6, seeking: true, dispatchEvent() {
|
||||
assert.fail('Recovery must not dispatch native media events')
|
||||
} })
|
||||
const samples = []
|
||||
const levels = { video: 3, audio: 5 }
|
||||
const buffers = Object.fromEntries(['video', 'audio'].map(type => [type, {
|
||||
end: type === 'video' ? 6 : 11,
|
||||
pruning: false,
|
||||
removals: [],
|
||||
getRangeAt(time) { return this.end > time ? { end: this.end } : null },
|
||||
getIsPruningInProgress() { return this.pruning },
|
||||
getAllRangesWithSafetyFactor() { return this.removals },
|
||||
clearBuffers() { assert.fail('Recovery must not clear buffers') },
|
||||
pruneAllSafely() { assert.fail('Recovery must not prune buffers') },
|
||||
}]))
|
||||
const metrics = {
|
||||
getCurrentBufferLevel(type) { return levels[type] },
|
||||
addBufferLevel(type, at, level) {
|
||||
assert(Number.isFinite(at.valueOf()))
|
||||
samples.push({ type, level })
|
||||
levels[type] = level
|
||||
},
|
||||
}
|
||||
const state = { stream: { getProcessors: () => Object.entries(buffers).map(([type, buffer]) => ({ getType: () => type, getBufferController: () => buffer })) } }
|
||||
Object.assign(host.dash, { getVersion: () => '4.5.2', getDashMetrics: () => metrics, getActiveStream: () => state.stream })
|
||||
return { ...host, samples, levels, buffers, metrics, seekState: state }
|
||||
}
|
||||
|
||||
for (const { name, factory } of candidates.filter(item => item.sdk === 4)) {
|
||||
test(`DASH (${name}): seeking records only measured empty stale metrics without changing SDK methods or media`, () => {
|
||||
const host = seekHost()
|
||||
const getters = [host.metrics.getCurrentBufferLevel, host.buffers.video.getRangeAt]
|
||||
const settings = JSON.stringify(host.state.settings)
|
||||
factory(bothMenus())(host.art).update()
|
||||
assert.equal(host.listenerCount(), 8)
|
||||
host.fire('playbackSeeking')
|
||||
host.fire('playbackSeeking')
|
||||
assert.deepEqual(host.samples, [{ type: 'video', level: 0 }])
|
||||
assert.equal(host.levels.audio, 5)
|
||||
assert.equal(host.video.currentTime, 6)
|
||||
assert.equal(JSON.stringify(host.state.settings), settings)
|
||||
assert.deepEqual([host.metrics.getCurrentBufferLevel, host.buffers.video.getRangeAt], getters)
|
||||
})
|
||||
|
||||
test(`DASH (${name}): recovery excludes other SDK versions and unavailable optional capabilities`, () => {
|
||||
for (const version of ['5.2.1', '4.5.1', undefined]) {
|
||||
const host = seekHost()
|
||||
host.dash.getVersion = () => version
|
||||
factory(bothMenus())(host.art).update()
|
||||
assert.equal(host.listenerCount(), 7)
|
||||
host.fire('playbackSeeking')
|
||||
assert.equal(host.samples.length, 0)
|
||||
}
|
||||
const host = seekHost()
|
||||
delete host.dash.getDashMetrics
|
||||
factory(bothMenus())(host.art).update()
|
||||
assert.equal(host.listenerCount(), 7)
|
||||
})
|
||||
|
||||
test(`DASH (${name}): nonempty, pruning, unready and invalid seek measurements do not overwrite metrics`, () => {
|
||||
const changes = [
|
||||
h => h.video.seeking = false,
|
||||
h => h.video.currentTime = Number.NaN,
|
||||
h => h.levels.video = 0,
|
||||
h => h.levels.video = Number.NaN,
|
||||
h => h.levels.video = Number.POSITIVE_INFINITY,
|
||||
h => h.buffers.video.end = 8,
|
||||
h => h.buffers.video.getRangeAt = () => ({ end: Number.NaN }),
|
||||
h => h.buffers.video.getRangeAt = () => undefined,
|
||||
h => h.buffers.video.pruning = true,
|
||||
h => h.buffers.video.pruning = undefined,
|
||||
h => h.buffers.video.removals.push({ start: 0, end: 1 }),
|
||||
h => delete h.buffers.video.getRangeAt,
|
||||
h => h.seekState.stream = null,
|
||||
]
|
||||
for (const change of changes) {
|
||||
const host = seekHost()
|
||||
factory(bothMenus())(host.art).update()
|
||||
change(host)
|
||||
host.fire('playbackSeeking')
|
||||
assert.equal(host.samples.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test(`DASH (${name}): escaped recovery callbacks are inert after SDK replacement or destruction`, () => {
|
||||
const host = seekHost()
|
||||
factory(bothMenus())(host.art).update()
|
||||
const retained = [...host.events.get('playbackSeeking')][0]
|
||||
host.art.dash = {}
|
||||
retained()
|
||||
host.art.dash = host.dash
|
||||
host.art.destroy()
|
||||
retained()
|
||||
assert.equal(host.samples.length, 0)
|
||||
assert.equal(host.listenerCount(), 0)
|
||||
})
|
||||
|
||||
test(`DASH (${name}): stream teardown suspends recovery until the active stream is initialized`, async () => {
|
||||
const host = seekHost()
|
||||
factory(bothMenus())(host.art).update()
|
||||
host.fire('streamTeardownComplete')
|
||||
host.fire('playbackSeeking')
|
||||
assert.equal(host.samples.length, 0)
|
||||
host.fire('streamInitialized')
|
||||
await flush()
|
||||
host.fire('playbackSeeking')
|
||||
assert.deepEqual(host.samples, [{ type: 'video', level: 0 }])
|
||||
})
|
||||
|
||||
test(`DASH (${name}): reentrant metrics and destruction cannot write a second stream measurement`, () => {
|
||||
const host = seekHost()
|
||||
host.buffers.audio.end = 6
|
||||
const add = host.metrics.addBufferLevel
|
||||
host.metrics.addBufferLevel = (...args) => {
|
||||
host.fire('playbackSeeking')
|
||||
add(...args)
|
||||
host.art.destroy()
|
||||
}
|
||||
factory(bothMenus())(host.art).update()
|
||||
host.fire('playbackSeeking')
|
||||
assert.deepEqual(host.samples, [{ type: 'video', level: 0 }])
|
||||
assert.equal(host.listenerCount(), 0)
|
||||
})
|
||||
|
||||
test(`DASH (${name}): source, video and time changes during measurement cancel the stale write`, () => {
|
||||
for (const change of [h => h.seekState.stream = {}, h => h.state.video = {}, h => h.video.currentTime = 7, h => h.dash.getDashMetrics = () => null]) {
|
||||
const host = seekHost()
|
||||
host.buffers.video.getRangeAt = () => {
|
||||
change(host)
|
||||
return null
|
||||
}
|
||||
factory(bothMenus())(host.art).update()
|
||||
host.fire('playbackSeeking')
|
||||
assert.equal(host.samples.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test(`DASH (${name}): metric errors keep menus and allow a later recovery attempt`, (t) => {
|
||||
const host = seekHost()
|
||||
const add = host.metrics.addBufferLevel
|
||||
const failure = new Error('Metric write failed')
|
||||
const warnings = []
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args))
|
||||
host.metrics.addBufferLevel = () => {
|
||||
throw failure
|
||||
}
|
||||
factory(bothMenus())(host.art).update()
|
||||
host.fire('playbackSeeking')
|
||||
assert.equal(warnings.length, 1)
|
||||
assert.equal(warnings[0][1], failure)
|
||||
assert.equal(host.controls.size + host.settings.size, 4)
|
||||
assert.equal(host.listenerCount(), 8)
|
||||
host.metrics.addBufferLevel = add
|
||||
host.fire('playbackSeeking')
|
||||
assert.equal(host.samples.length, 1)
|
||||
})
|
||||
|
||||
test(`DASH (${name}): destruction from the final SDK media check prevents a late metric write`, () => {
|
||||
const host = seekHost()
|
||||
factory(bothMenus())(host.art).update()
|
||||
let reads = 0
|
||||
host.dash.getVideoElement = () => {
|
||||
if (++reads === 2)
|
||||
host.art.destroy()
|
||||
return host.video
|
||||
}
|
||||
host.fire('playbackSeeking')
|
||||
assert.equal(host.samples.length, 0)
|
||||
assert.equal(host.listenerCount(), 0)
|
||||
})
|
||||
}
|
||||
|
||||
for (const { name, sdk, factory } of candidates) {
|
||||
test(`DASH ${sdk} (${name}): SDK events coalesce without interrupting synchronous selection`, async () => {
|
||||
const host = eventHost(sdk)
|
||||
|
||||
Reference in new issue
Block a user