mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-21 21:01:20 -08:00
fix(danmuku): [PKG-DANMUKU-MASK-LOAD-01] recover stalled-frame rows and remove relaxed placement backlog
This commit is contained in:
1 parent
475d38043e
commit
314a1c3163
27 files changed
+14045
-195
No files matched your search
+5
-1
@@ -183,7 +183,11 @@ Document PiP: `yarn test:dpip` runs 48 historical window/DOM/lifecycle cases. Na
|
||||
|
||||
Danmuku: `yarn test:danmuku` covers historical behavior and current input, scheduling,
|
||||
settings, rendering and heatmap boundaries. `ARTPLAYER_DANMUKU_ARTIFACT` selects a
|
||||
built candidate for integration cases; direct internal tests remain source tests.
|
||||
built main/legacy candidate for integration cases; direct internal tests remain
|
||||
source tests. This controlled synchronous VM loader executes CommonJS/UMD scripts,
|
||||
not `.mjs` modules. Do not pass ESM bytes to that override or interpret its parser
|
||||
failure as a production ESM failure. Actual ESM imports are checked by the isolated
|
||||
package consumer; full native distribution coverage remains the package release gate.
|
||||
The candidate Worker helper executes the selected artifact's Blob or data URL bytes;
|
||||
only source-mode controlled imports compile `worker.ts` separately. The frozen
|
||||
published helper remains unchanged. `yarn test:danmuku-types` checks the unchanged
|
||||
|
||||
@@ -253,7 +253,14 @@ See refactor/changes/2026-09-14-PKG-MASK-05-native-checkpoint.md for exact limit
|
||||
Its combination cases now include frozen/current Danmuku and a bounded 120-row
|
||||
load while the actual model continues. `danmuku-combination-load.js` observes
|
||||
real media/RAF and cleans up its test listeners; it does not seed eligible rows.
|
||||
Candidate completeness failures remain failing under PKG-DANMUKU-MASK-LOAD-01.
|
||||
The candidate must deliver all 120 rows exactly once and recycle their references;
|
||||
original failures and the PKG-DANMUKU-MASK-LOAD-01 repair remain separate evidence.
|
||||
For a Chromium diagnostic only, set `ARTPLAYER_MASK_PROFILE=1` to attach the actual
|
||||
CDP CPU profile around the load. This never stops inference or patches the SDK.
|
||||
Ordinary format acceptance runs without profiling. Final load snapshots also
|
||||
record pending/reserved row IDs to distinguish missed sampling from placement
|
||||
backlog. Use `ARTPLAYER_DANMUKU_ARTIFACT` with `ARTPLAYER_MASK_ARTIFACT` to bind both
|
||||
candidate plugins to the exact main/legacy files being tested.
|
||||
`danmuku-dpip.spec.js` also includes two-window load cases and both plugin versions.
|
||||
Frozen-plugin retained DOM, candidate cleanup and unsupported API outcomes remain
|
||||
distinct; the old plugin's observed loss is not complete-delivery acceptance.
|
||||
|
||||
@@ -19,8 +19,8 @@ test.beforeAll(async () => {
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
for (const implementation of ['published', 'candidate']) {
|
||||
for (const mode of [0, 1]) {
|
||||
test(`${core} core / ${implementation} plugin / mode ${mode}: Worker waiting does not consume visible lifetime`, async ({ page }, testInfo) => {
|
||||
for (const [mode, antiOverlap] of [0, 1].flatMap(mode => [true, false].map(antiOverlap => [mode, antiOverlap]))) {
|
||||
test(`${core} core / ${implementation} plugin / mode ${mode} / antiOverlap ${antiOverlap}: placement preserves visible lifetime`, async ({ page }, testInfo) => {
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
await page.evaluate(() => {
|
||||
const evidence = window.lifetimeEvidence = { posts: [], replies: [], visible: [], recycled: [] }
|
||||
@@ -50,11 +50,11 @@ for (const core of ['published', 'candidate']) {
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
|
||||
// Install after native playing, then start once. The published duplicate
|
||||
// play/playing loop defect is covered elsewhere and is not this timing case.
|
||||
await page.evaluate(async (mode) => {
|
||||
await page.evaluate(async ({ mode, antiOverlap }) => {
|
||||
const art = window.art
|
||||
const evidence = window.lifetimeEvidence
|
||||
art.on('artplayerPluginDanmuku:visible', row => evidence.visible.push({ wall: performance.now(), clock: Date.now(), startedAt: row.$lastStartTime, rest: row.$restTime, mode: row.mode, time: art.currentTime, transition: row.$ref.style.transition }))
|
||||
art.plugins.add(window.artplayerPluginDanmuku({ danmuku: [], speed: 1, antiOverlap: false, heatmap: false, emitter: false }))
|
||||
art.plugins.add(window.artplayerPluginDanmuku({ danmuku: [], speed: 1, antiOverlap, heatmap: false, emitter: false }))
|
||||
const plugin = art.plugins.artplayerPluginDanmuku
|
||||
const owner = plugin.config({})
|
||||
const makeWait = owner.makeWait
|
||||
@@ -65,16 +65,18 @@ for (const core of ['published', 'candidate']) {
|
||||
}
|
||||
await plugin.load([{ id: 'delayed', text: 'Full visible lifetime', time: art.currentTime + 0.4, mode }])
|
||||
owner.start()
|
||||
}, mode)
|
||||
}, { mode, antiOverlap })
|
||||
await expect.poll(() => page.evaluate(() => window.lifetimeEvidence.recycled.length)).toBe(1)
|
||||
await page.click('#pause')
|
||||
const evidence = await page.evaluate(() => ({ ...window.lifetimeEvidence, video: { width: window.art.video.videoWidth, time: window.art.currentTime } }))
|
||||
await testInfo.attach('danmuku-lifetime', { contentType: 'application/json', body: JSON.stringify({ core, implementation, mode, sha256: hash(code), evidence, scope: 'Native video and real Worker geometry with controlled 1500ms request delay; does not assert naturally occurring Worker starvation.' }) })
|
||||
expect(evidence.posts).toHaveLength(1)
|
||||
expect(evidence.replies).toHaveLength(1)
|
||||
const workerPlacement = implementation === 'published' || antiOverlap
|
||||
await testInfo.attach('danmuku-lifetime', { contentType: 'application/json', body: JSON.stringify({ core, implementation, mode, antiOverlap, workerPlacement, sha256: hash(code), evidence, scope: 'Native video; Worker path has a controlled 1500ms request delay, candidate relaxed path must bypass it. Both candidate paths require full visible lifetime. Does not assert naturally occurring Worker starvation.' }) })
|
||||
expect(evidence.posts).toHaveLength(workerPlacement ? 1 : 0)
|
||||
expect(evidence.replies).toHaveLength(workerPlacement ? 1 : 0)
|
||||
expect(evidence.visible).toHaveLength(1)
|
||||
expect(evidence.video.width).toBeGreaterThan(0)
|
||||
expect(evidence.replies[0].wall - evidence.posts[0].wall).toBeGreaterThanOrEqual(1400)
|
||||
if (workerPlacement)
|
||||
expect(evidence.replies[0].wall - evidence.posts[0].wall).toBeGreaterThanOrEqual(1400)
|
||||
const lifetime = evidence.recycled[0].wall - evidence.visible[0].wall
|
||||
if (implementation === 'published') {
|
||||
// Preserve the historical defect as explicit evidence, not the candidate contract.
|
||||
|
||||
@@ -265,7 +265,23 @@ for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
|
||||
expect(restored.mode).toBe('alpha')
|
||||
await testInfo.attach('actual-combination-page', { body: await page.screenshot(), contentType: 'image/png' })
|
||||
const outputsBeforeLoad = await page.evaluate(() => window.maskCombination.times.length)
|
||||
load = await observeDanmukuLoad(page, { label: 'Mask load', requireComplete: implementation === 'candidate' })
|
||||
let profiler
|
||||
if (process.env.ARTPLAYER_MASK_PROFILE === '1') {
|
||||
assert.equal(testInfo.project.name, 'chromium', 'CDP profiling requires the Chromium diagnostic subset')
|
||||
profiler = await page.context().newCDPSession(page)
|
||||
await profiler.send('Profiler.enable')
|
||||
await profiler.send('Profiler.start')
|
||||
}
|
||||
try {
|
||||
load = await observeDanmukuLoad(page, { label: 'Mask load', requireComplete: implementation === 'candidate' })
|
||||
}
|
||||
finally {
|
||||
if (profiler) {
|
||||
const { profile } = await profiler.send('Profiler.stop')
|
||||
await testInfo.attach('mask-cpu-profile', { contentType: 'application/json', body: JSON.stringify(profile) })
|
||||
await profiler.detach()
|
||||
}
|
||||
}
|
||||
load.modelOutputs = await page.evaluate(before => window.maskCombination.times.length - before, outputsBeforeLoad)
|
||||
expect(load.modelOutputs).toBeGreaterThan(1)
|
||||
await page.evaluate(() => window.art.destroy(false))
|
||||
|
||||
@@ -93,7 +93,7 @@ for (const implementation of ['published', 'candidate']) {
|
||||
const visible = new Set(evidence.visible.map(row => row.id))
|
||||
return { ...evidence, rows, missing: rows.filter(row => !visible.has(row.id)).map(row => row.id), media: { time: window.art.currentTime, width: window.art.video.videoWidth } }
|
||||
})
|
||||
await testInfo.attach('danmuku-timing-observation', { contentType: 'application/json', body: JSON.stringify({ implementation, scenario, sha256: hash(code), observation, scope: 'Diagnostic only: native timing with controlled load, original readys getter and unmodified queue states. Missing rows are reported, not waived as stability acceptance.' }) })
|
||||
await testInfo.attach('danmuku-timing-observation', { contentType: 'application/json', body: JSON.stringify({ implementation, scenario, sha256: hash(code), observation, scope: 'Native timing with controlled load, original readys getter and unmodified queue states. Candidate must recover both CPU and asynchronous gaps; published CPU loss is observed separately. This is not sustained-load acceptance.' }) })
|
||||
expect(observation.media.width).toBeGreaterThan(0)
|
||||
expect(observation.rows).toHaveLength(3)
|
||||
expect(observation.frames.length).toBeGreaterThan(2)
|
||||
@@ -111,6 +111,10 @@ for (const implementation of ['published', 'candidate']) {
|
||||
expect(observation.callbacks[index].start).toBeGreaterThanOrEqual(observation.callbacks[index - 1].end)
|
||||
}
|
||||
}
|
||||
if (implementation === 'candidate') {
|
||||
expect(observation.missing).toEqual([])
|
||||
expect(observation.visible.map(row => row.id)).toEqual(['first', 'middle', 'sentinel'])
|
||||
}
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -78,6 +78,149 @@ function geometry(mode = 1) {
|
||||
return { type: 'getDanmuTop', target: { mode, height: 20, speed: 148 }, visibles: [], antiOverlap: true, clientWidth: 640, clientHeight: 360, marginTop: 10, marginBottom: 90 }
|
||||
}
|
||||
|
||||
test(`${prefix}: non-overlap-disabled placement does not queue a Worker round trip and keeps callbacks serial`, async (t) => {
|
||||
const gate = deferred()
|
||||
const calls = []
|
||||
const { env, plugin, owner, worker } = await fixture({ antiOverlap: false, beforeVisible(row) {
|
||||
calls.push(row.text)
|
||||
return row.text === 'first' ? gate.promise : true
|
||||
} })
|
||||
t.after(() => {
|
||||
env.destroy()
|
||||
gate.resolve(false)
|
||||
})
|
||||
await plugin.load([{ text: 'first', time: 10, mode: 1 }, { text: 'second', time: 10, mode: 1 }])
|
||||
env.art.playing = true
|
||||
owner.start()
|
||||
env.frame()
|
||||
await env.flush()
|
||||
assert.deepEqual(calls, ['first'])
|
||||
gate.resolve(true)
|
||||
await env.flush()
|
||||
assert.deepEqual(calls, ['first', 'second'])
|
||||
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['first', 'second'])
|
||||
assert.equal(worker.messages.length, 0)
|
||||
assert.deepEqual([...owner.queue].map(row => row.$ref.offsetTop), [owner.marginTop, owner.marginTop + 20])
|
||||
})
|
||||
|
||||
for (const boundary of ['continuous', 'seek', 'stop', 'hide', 'visibility']) {
|
||||
test(`${prefix}: CPU gap recovery respects ${boundary} boundaries and keeps public readys unchanged`, async (t) => {
|
||||
const { env, plugin, owner } = await fixture({}, { automatic: true })
|
||||
t.after(() => env.destroy())
|
||||
const listeners = new Set()
|
||||
const doc = {
|
||||
visibilityState: 'visible',
|
||||
addEventListener(name, fn) {
|
||||
assert.equal(name, 'visibilitychange')
|
||||
listeners.add(fn)
|
||||
},
|
||||
removeEventListener(name, fn) {
|
||||
assert.equal(name, 'visibilitychange')
|
||||
listeners.delete(fn)
|
||||
},
|
||||
}
|
||||
owner.$player.ownerDocument = doc
|
||||
await plugin.load([{ text: 'gap', time: 10.4, mode: 1 }])
|
||||
env.art.playing = true
|
||||
owner.start()
|
||||
env.frame()
|
||||
await env.flush()
|
||||
env.tick(800)
|
||||
env.art.currentTime = 10.8
|
||||
if (boundary === 'seek')
|
||||
env.art.emit('video:seeking')
|
||||
if (boundary === 'stop') {
|
||||
owner.stop()
|
||||
owner.start()
|
||||
}
|
||||
if (boundary === 'hide') {
|
||||
owner.hide()
|
||||
owner.show()
|
||||
}
|
||||
if (boundary === 'visibility') {
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
assert.equal(owner.readys.length, 0)
|
||||
env.frame()
|
||||
await env.flush()
|
||||
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), boundary === 'continuous' ? ['gap'] : [])
|
||||
env.destroy()
|
||||
assert.equal(listeners.size, 0)
|
||||
})
|
||||
}
|
||||
|
||||
test(`${prefix}: document adoption drops the old catch-up window and transfers visibility ownership`, async (t) => {
|
||||
const { env, plugin, owner } = await fixture({}, { automatic: true })
|
||||
t.after(() => env.destroy())
|
||||
function document() {
|
||||
const listeners = new Set()
|
||||
return {
|
||||
visibilityState: 'visible',
|
||||
listeners,
|
||||
addEventListener: (_name, fn) => listeners.add(fn),
|
||||
removeEventListener: (_name, fn) => listeners.delete(fn),
|
||||
}
|
||||
}
|
||||
const original = document()
|
||||
const adopted = document()
|
||||
owner.$player.ownerDocument = original
|
||||
await plugin.load([{ text: 'before adoption', time: 10.4, mode: 1 }, { text: 'after adoption', time: 11.2, mode: 1 }])
|
||||
env.art.playing = true
|
||||
owner.start()
|
||||
await env.frame()
|
||||
assert.equal(original.listeners.size, 1)
|
||||
owner.$player.ownerDocument = adopted
|
||||
env.tick(800)
|
||||
env.art.currentTime = 10.8
|
||||
await env.frame()
|
||||
assert.equal(original.listeners.size, 0)
|
||||
assert.equal(adopted.listeners.size, 1)
|
||||
assert.equal(output(env, 'visible').length, 0, 'Do not replay rows crossed while adopting another document')
|
||||
env.tick(800)
|
||||
env.art.currentTime = 11.6
|
||||
await env.frame()
|
||||
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['after adoption'])
|
||||
env.destroy()
|
||||
assert.equal(adopted.listeners.size, 0)
|
||||
})
|
||||
|
||||
test(`${prefix}: CPU gap recovery ignores newly appended past rows and does not retry an already sampled false callback`, async (t) => {
|
||||
const calls = []
|
||||
const { env, plugin, owner } = await fixture({ beforeVisible(row) {
|
||||
calls.push(row.text)
|
||||
return row.text !== 'declined'
|
||||
} }, { automatic: true })
|
||||
t.after(() => env.destroy())
|
||||
await plugin.load([{ text: 'declined', time: 10, mode: 1 }, { text: 'gap', time: 10.4, mode: 1 }])
|
||||
env.art.playing = true
|
||||
owner.start()
|
||||
env.frame()
|
||||
await env.flush()
|
||||
env.tick(800)
|
||||
env.art.currentTime = 10.8
|
||||
await plugin.load([{ text: 'appended-past', time: 10.3, mode: 1 }])
|
||||
env.frame()
|
||||
await env.flush()
|
||||
assert.deepEqual(calls, ['declined', 'gap'])
|
||||
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['gap'])
|
||||
})
|
||||
|
||||
for (const boundary of ['stop', 'hide', 'reset', 'destroy']) {
|
||||
test(`${prefix}: inline relaxed placement honors ${boundary} from the first visible callback`, async (t) => {
|
||||
const { env, plugin, owner, worker } = await fixture({ antiOverlap: false })
|
||||
t.after(() => env.destroy())
|
||||
await plugin.load([{ text: 'first', time: 10, mode: 1 }, { text: 'cancelled', time: 10, mode: 1 }])
|
||||
env.art.on('artplayerPluginDanmuku:visible', () => owner[boundary]())
|
||||
env.art.playing = true
|
||||
owner.start()
|
||||
env.frame()
|
||||
await env.flush()
|
||||
assert.deepEqual(output(env, 'visible').map(event => event.args[0].text), ['first'])
|
||||
assert.equal(worker.messages.length, 0)
|
||||
assert.equal(owner.queue[1].$ref, null)
|
||||
})
|
||||
}
|
||||
|
||||
test(`${prefix}: repeated play/playing and direct starts preserve events and internal return with only one queued frame`, async () => {
|
||||
const { env, owner } = await fixture()
|
||||
env.art.playing = true
|
||||
|
||||
@@ -36,7 +36,8 @@ export async function observeDanmukuLoad(page, { label, requireComplete, rate =
|
||||
art.off('artplayerPluginDanmuku:visible', visible)
|
||||
art.off('artplayerPluginDanmuku:error', error)
|
||||
const loaded = owner.queue.filter(row => ids.has(row.id))
|
||||
const result = { ...state, endWall: host.performance.now(), time: art.currentTime, loaded: loaded.length, waiting: loaded.filter(row => row.$state === 'wait').length, referenced: loaded.filter(row => row.$ref).length, nodes: art.template.$danmuku.children.length, pooled: owner.$refs.length, uniquePool: new Set(owner.$refs).size, decodedWidth: art.video.videoWidth }
|
||||
const buffered = { pending: [...owner.scheduler?.buffer.pending || []].filter(row => ids.has(row.id)).map(row => row.id), reserved: [...owner.scheduler?.buffer.reserved || []].filter(row => ids.has(row.id)).map(row => row.id) }
|
||||
const result = { ...state, endWall: host.performance.now(), time: art.currentTime, loaded: loaded.length, waiting: loaded.filter(row => row.$state === 'wait').length, referenced: loaded.filter(row => row.$ref).length, nodes: art.template.$danmuku.children.length, pooled: owner.$refs.length, uniquePool: new Set(owner.$refs).size, decodedWidth: art.video.videoWidth, buffered }
|
||||
window.danmukuCombinationLoadResult = result
|
||||
return result
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user