fix(danmuku): [PKG-DANMUKU-10] bound dense heatmaps for issue 958

This commit is contained in:
Harvey Zhao committed 2026-09-13 21:20:37 +08:00
1 parent 053d211fb0
commit abc8008da7
18 files changed
+4841 -21

No files matched your search

@@ -0,0 +1,82 @@
import fs from 'node:fs'
import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { compilePackage } from '../helpers/load.js'
import { expect, test } from './fixtures.js'
let candidate
test.beforeAll(async () => {
candidate = process.env.ARTPLAYER_DANMUKU_ARTIFACT
? fs.readFileSync(process.env.ARTPLAYER_DANMUKU_ARTIFACT, 'utf8')
: await compilePackage('artplayer-plugin-danmuku', 'umd')
})
for (const core of ['published', 'candidate']) {
for (const distribution of ['uniform', 'clustered', 'mixed']) {
test(`${core}: #958 ${distribution} 16000-row heatmap remains below 25px during playback and resize`, async ({ page }, testInfo) => {
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: candidate })
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.video.currentTime)).toBeGreaterThan(0.1)
await page.click('#pause')
await page.evaluate(async (distribution) => {
const art = window.art
const rows = Array.from({ length: 16000 }, (_, index) => ({
text: `density-${index}`,
time: distribution === 'clustered' ? art.duration / 2 : distribution === 'mixed' && index < 8000 ? art.duration / 2 : (index + 0.5) * art.duration / 16000,
}))
art.plugins.add(window.artplayerPluginDanmuku({ danmuku: [], heatmap: true, visible: false, emitter: false }))
window.densityPlugin = art.plugins.artplayerPluginDanmuku
window.densityOwner = await window.densityPlugin.load(rows)
art.controls.show = true
}, distribution)
const before = await page.evaluate(() => ({
time: window.art.video.currentTime,
offset: window.art.controls.heatmap.querySelector('#heatmap-start').getAttribute('offset'),
}))
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.video.currentTime)).toBeGreaterThan(before.time + 0.1)
await expect.poll(() => page.evaluate(() => window.art.controls.heatmap.querySelector('#heatmap-start').getAttribute('offset'))).not.toBe(before.offset)
await page.click('#pause')
const observations = []
for (const width of [640, 400]) {
const observation = await page.evaluate((width) => {
const art = window.art
art.template.$container.style.width = `${width}px`
art.emit('resize')
art.emit('setBar', 'played', 0.4)
const control = art.controls.heatmap
const path = control.querySelector('path')
const box = path.getBBox()
const pixels = path.getBoundingClientRect()
return {
width: control.offsetWidth,
height: control.offsetHeight,
box: { x: box.x, y: box.y, width: box.width, height: box.height },
pixels: { x: pixels.x, y: pixels.y, width: pixels.width, height: pixels.height },
path: path.getAttribute('d'),
progress: control.querySelector('#heatmap-start').getAttribute('offset'),
rows: window.densityOwner.queue.length,
decoded: { width: art.video.videoWidth, time: art.video.currentTime },
}
}, width)
observations.push(observation)
expect(observation.rows).toBe(16000)
expect(observation.height).toBe(100)
expect(observation.box.y).toBeGreaterThanOrEqual(75 - 0.001)
expect(observation.box.height).toBeLessThanOrEqual(25 + 0.001)
expect(observation.pixels.height).toBeGreaterThan(0)
expect(observation.pixels.width).toBeGreaterThan(0)
expect(observation.pixels.height).toBeLessThanOrEqual(25 + 0.01)
expect(observation.box.y + observation.box.height).toBeLessThanOrEqual(100 + 0.001)
expect(observation.progress).toBe('40%')
expect(observation.decoded.width).toBeGreaterThan(0)
}
await testInfo.attach('heatmap-density-evidence', { contentType: 'application/json', body: JSON.stringify({ core, distribution, candidateSha256: hash(candidate), observations }) })
await testInfo.attach('heatmap-density-page', { contentType: 'image/png', body: await page.screenshot() })
await page.evaluate(() => window.art.destroy(false))
await expect(page.locator('.art-control-heatmap')).toHaveCount(0)
})
}
}
+72
View File
@@ -84,6 +84,78 @@ function listenerCount(env) {
return [...env.listeners.values()].reduce((sum, list) => sum + list.length, 0)
}
function verticalCoordinates(path) {
const values = path.match(/-?\d+(?:\.\d+)?(?:e[+-]?\d+)?/giu).map(Number)
return values.filter((_, index) => index % 2 === 1)
}
test('heatmap #958: 16000 uniform, clustered and mixed rows stay in a 25px band without losing peaks', () => {
for (const distribution of ['uniform', 'clustered', 'mixed']) {
const queue = Array.from({ length: 16000 }, (_, index) => ({
time: distribution === 'clustered' ? 30 : distribution === 'mixed' && index < 8000 ? 30 : (index + 0.5) * 60 / 16000,
}))
for (const width of [400, 640, 1920]) {
const shape = heatmapGeometry({ width, height: 100, duration: 60, queue, option: true })
const coordinates = verticalCoordinates(shape.path)
assert(Math.min(...coordinates) >= 75, `${distribution}/${width}: top ${Math.min(...coordinates)}`)
assert(Math.max(...coordinates) <= 100, `${distribution}/${width}: bottom ${Math.max(...coordinates)}`)
if (distribution === 'mixed') {
const endpoints = [...shape.path.matchAll(/C [^Cz]+/gu)].map(segment => verticalCoordinates(segment[0]).at(-1))
assert(new Set(endpoints.map(value => value.toFixed(3))).size > 2, 'Dense peaks must remain distinct from the background and zero bin')
}
}
}
})
test('heatmap #958: automatic scaling bounds Bezier controls around adjacent peaks', () => {
const queue = Array.from({ length: 172 }, (_, index) => ({ time: index < 86 ? 0.9 : 1.5 }))
const shape = heatmapGeometry({ width: 400, height: 100, duration: 60, queue, option: true })
const coordinates = verticalCoordinates(shape.path)
assert(Math.min(...coordinates) >= 75)
assert(Math.max(...coordinates) <= 100)
})
test('heatmap #958: axis overrides read own enumerable getters once and ignore inherited fields', () => {
const queue = Array.from({ length: 16000 }, () => ({ time: 30 }))
const geometry = option => heatmapGeometry({ width: 400, height: 100, duration: 60, queue, option })
let reads = 0
const option = {
get yMax() {
assert.equal(++reads, 1)
return 128
},
}
assert.equal(geometry(option).path, geometry({ yMax: 128 }).path)
assert.equal(reads, 1)
assert.equal(geometry(Object.create({ yMax: 500 })).path, geometry(true).path)
assert.equal(geometry(Object.defineProperty({}, 'yMax', { value: 500 })).path, geometry(true).path)
})
test('heatmap #958: non-array points retain queue fallback and density fitting', () => {
const queue = Array.from({ length: 16000 }, () => ({ time: 30 }))
const geometry = points => heatmapGeometry({ width: 400, height: 100, duration: 60, queue, option: true, points })
for (const points of [null, false, 'points', { length: 1 }, {}, []])
assert.equal(geometry(points).path, geometry(undefined).path)
})
test('heatmap #958: explicit axes retain the historical dense-data mapping', async () => {
const queue = Array.from({ length: 16000 }, (_, index) => ({ time: (index + 0.5) * 60 / 16000 }))
for (const option of [{ yMax: 128 }, { yMin: 0 }, { yMin: -10, yMax: 500, scale: 0.4 }]) {
const candidate = fixture({ queue })
candidate.install(option)
candidate.art.emit('ready')
for (const implementation of historical) {
const env = danmukuEnvironment(implementation, { heatmapWidth: 400 })
const plugin = env.factory({ danmuku: [], heatmap: option })(env.art)
await env.flush()
plugin.show().queue = queue
env.art.emit('ready')
assert.equal(pathOf(candidate.element), pathOf(env.controls[0].element), `${implementation.name} / ${JSON.stringify(option)}`)
env.destroy()
}
}
})
test('heatmap: sampled SVG path matches frozen source and actual 5.3.0 main/legacy for legal options', async () => {
const queue = [0, 0.6, 1, 2, 2, 2.1, 8, 15, 28, 59.9, 60, Number.NaN, Infinity, -Infinity].map(time => ({ time }))
for (const option of [true, { sampling: 2.5, smoothing: 0.3, flattening: 0.1, scale: 0.4, opacity: 0.7, minHeight: 2, xMin: -10, xMax: 450, yMin: -1, yMax: 40 }]) {