test(thumbnail): [PKG-TOOL-THUMB-05] verify generated sheets across core versions

This commit is contained in:
Harvey Zhao committed 2026-09-16 10:29:20 +08:00
1 parent fb78328c43
commit 8aa99ef03f
13 files changed
+15502 -9

No files matched your search

+12
View File
@@ -535,6 +535,18 @@ The first immediate-pixel WebKit failure remains in the refactor evidence.
This is native desktop media playback, not physical audibility or full devices.
`document-site.spec.js` also checks generated guide links and Run Code parameters.
## Standalone thumbnail sheets
The standalone tool's `thumbnail-core.spec.js` generates actual PNG sheets from
a selected local MP4 and displays them in core 3.5.31, 5.4.0 and the candidate.
It checks two generations, real hover, unobscured screenshot pixels and exact
Blob URL ownership. Historical grid defects are asserted separately from the
correct candidate layout. Tool main can be selected with
`ARTPLAYER_THUMBNAIL_ARTIFACT`. Windows WebKit's HTTP-success/Blob-error-4 branch
is explicitly a capability control, not successful extraction. Full evidence:
`refactor/baselines/thumbnail-core-validation.json`; run
`yarn test:browser:source thumbnail-core.spec.js --workers=1`.
## Auto-thumbnail delayed registration
Auto-thumbnail's `auto-thumbnail-registration.spec.js` separately checks direct
+6 -1
View File
@@ -95,6 +95,11 @@ const vttCoreMember = `package/${vttCore.manifest.main.replace(/^\.\//, '')}`
const vttCoreBytes = readMember(await ensureArchive(vttCore), vttCoreMember)
assert.equal(hash(vttCoreBytes), vttCore.files[vttCoreMember], 'Historical VTT core differs from frozen archive')
add('/published-5.1.6/artplayer.js', vttCoreBytes, { kind: 'npm-release', version: vttCore.version, integrity: vttCore.integrity, member: vttCoreMember })
const thumbnailCore = JSON.parse(fs.readFileSync(path.join(workspace, 'refactor/baselines/thumbnail-core.json'), 'utf8')).release
const thumbnailCoreMember = `package/${thumbnailCore.manifest.main}`
const thumbnailCoreBytes = readMember(await ensureArchive(thumbnailCore), thumbnailCoreMember)
assert.equal(hash(thumbnailCoreBytes), thumbnailCore.files[thumbnailCoreMember], 'Historical Thumbnail core differs from frozen archive')
add('/published-3.5.31/artplayer.js', thumbnailCoreBytes, { kind: 'npm-release', version: thumbnailCore.version, integrity: thumbnailCore.integrity, member: thumbnailCoreMember })
for (const release of JSON.parse(fs.readFileSync(path.join(workspace, 'refactor/baselines/multiple-subtitles-cores.json'), 'utf8')).releases) {
const member = `package/${release.manifest.main.replace(/^\.\//, '')}`
const bytes = readMember(await ensureArchive(release), member)
@@ -199,7 +204,7 @@ const server = http.createServer((req, res) => {
if (url.pathname === '/test/player.html') {
const core = url.searchParams.get('core') || 'candidate'
const chapter = url.searchParams.get('chapter') || 'candidate'
assert(['candidate', 'published', 'published-4.5.5', 'published-4.5.9', 'published-5.1.2', 'published-5.1.6', 'published-5.1.7', 'published-5.3.0', 'published-5.3.1-beta.1'].includes(core) && ['candidate', 'published'].includes(chapter), 'Invalid test combination')
assert(['candidate', 'published', 'published-3.5.31', 'published-4.5.5', 'published-4.5.9', 'published-5.1.2', 'published-5.1.6', 'published-5.1.7', 'published-5.3.0', 'published-5.3.1-beta.1'].includes(core) && ['candidate', 'published'].includes(chapter), 'Invalid test combination')
const html = fs.readFileSync(path.join(workspace, 'test/browser/player.html'), 'utf8')
.replace('__CORE__', core)
.replace('__CHAPTER__', chapter)
+172
View File
@@ -0,0 +1,172 @@
import { Buffer } from 'node:buffer'
import fs from 'node:fs'
import { fileURLToPath } from 'node:url'
import { PNG } from 'pngjs'
import { hash } from '../../refactor/scripts/releases.mjs'
import { thumbnailCandidate, thumbnailHistorical } from '../helpers/thumbnail.js'
import { expect, test } from './fixtures.js'
const sample = fileURLToPath(new URL('./media/thumbnail-pattern.mp4', import.meta.url))
const candidate = await thumbnailCandidate()
const implementations = [
...thumbnailHistorical().filter(item => item.name !== 'workspace.legacy.js'),
{ ...candidate, lifecycle: true },
{ ...candidate, name: `${candidate.name}-workspace-policy`, compatibility: 'workspace-4.4', lifecycle: true },
]
for (const core of ['published-3.5.31', 'published', 'candidate']) {
for (const implementation of implementations) {
test(`Thumbnail ${implementation.name} sheets display and refresh in ${core} core`, async ({ page, browserName }, testInfo) => {
test.setTimeout(45000)
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: implementation.code })
const native = await page.evaluate(async () => {
const bytes = await (await fetch('/test/thumbnail-pattern.mp4')).blob()
const source = URL.createObjectURL(bytes)
const result = {}
for (const [transport, url] of [['http', '/test/thumbnail-pattern.mp4'], ['blob', source]]) {
const video = document.createElement('video')
video.muted = true
document.body.append(video)
result[transport] = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Native media control did not settle')), 5000)
video.onloadeddata = () => {
clearTimeout(timer)
resolve({ loaded: true, width: video.videoWidth })
}
video.onerror = () => {
clearTimeout(timer)
resolve({ loaded: false, code: video.error.code })
}
video.src = url
})
video.removeAttribute('src')
video.load()
video.remove()
}
URL.revokeObjectURL(source)
return result
})
const delay = implementation.legacy ? 300 : 20
await page.evaluate(({ compatibility, delay }) => {
const input = document.createElement('input')
input.id = 'thumbnail-file'
input.type = 'file'
document.body.append(input)
window.createdThumbnailUrls = []
window.revokedThumbnailUrls = []
const create = URL.createObjectURL.bind(URL)
const revoke = URL.revokeObjectURL.bind(URL)
URL.createObjectURL = (blob) => {
const url = create(blob)
window.createdThumbnailUrls.push(url)
return url
}
URL.revokeObjectURL = (url) => {
window.revokedThumbnailUrls.push(url)
revoke(url)
}
window.tool = new window.ArtplayerToolThumbnail({ fileInput: input, number: 12, column: 4, width: 160, height: 90, delay, compatibility })
window.toolErrors = []
window.tool.on('error', error => window.toolErrors.push(String(error)))
}, { compatibility: implementation.compatibility, delay })
await page.locator('#thumbnail-file').setInputFiles(sample)
await testInfo.attach('thumbnail-core-inputs', { contentType: 'application/json', body: JSON.stringify({ core, implementation: implementation.name, delay, toolSha256: hash(implementation.code), mediaSha256: hash(fs.readFileSync(sample)), native, originalToolArchiveAvailable: false }) })
if (!native.blob.loaded) {
expect(browserName).toBe('webkit')
expect(native).toEqual({ http: { loaded: true, width: 320 }, blob: { loaded: false, code: 4 } })
await expect.poll(() => page.evaluate(() => window.tool.video.error?.code)).toBe(4)
// Native error state can be set before the queued error event is dispatched.
await expect.poll(() => page.evaluate(() => window.toolErrors.length)).toBe(implementation.lifecycle ? 1 : 0)
await page.evaluate(() => window.tool.destroy())
testInfo.annotations.push({ type: 'capability-control', description: 'Native HTTP works; native Blob and tool Blob fail with media error 4. This case does not validate extraction or core thumbnail display.' })
return
}
await expect.poll(() => page.evaluate(() => window.tool.video.readyState)).toBeGreaterThanOrEqual(2)
const generations = []
for (let generation = 0; generation < 2; generation++) {
const sheet = await page.evaluate(async () => {
await window.tool.start()
const image = new Image()
image.src = window.tool.thumbnailUrl
await image.decode()
const canvas = document.createElement('canvas')
canvas.width = image.width
canvas.height = image.height
const context = canvas.getContext('2d')
context.drawImage(image, 0, 0)
const cells = Array.from({ length: 12 }, (_, index) => [...context.getImageData(index % 4 * 160 + 80, Math.floor(index / 4) * 90 + 45, 1, 1).data])
window.art = new window.Artplayer({
container: '.player',
url: window.tool.videoUrl,
muted: true,
thumbnails: { url: window.tool.thumbnailUrl, number: 12, column: 4, width: 160, height: 90, scale: 1 },
})
return { url: window.tool.thumbnailUrl, width: image.width, height: image.height, cells, png: canvas.toDataURL('image/png').split(',')[1] }
})
const sheetPng = PNG.sync.read(Buffer.from(sheet.png, 'base64'))
await testInfo.attach(`sheet-${generation}`, { contentType: 'image/png', body: Buffer.from(sheet.png, 'base64') })
delete sheet.png
expect([sheet.width, sheet.height]).toEqual([640, 300])
// Independent FFmpeg decoding confirms all twelve sample centers are blue.
for (const pixel of sheet.cells)
expect(pixel[2] - Math.max(pixel[0], pixel[1])).toBeGreaterThan(80)
await expect.poll(() => page.evaluate(() => window.art.template.$video.readyState)).toBeGreaterThanOrEqual(2)
const progress = page.locator('.art-control-progress')
const preview = page.locator('.art-control-thumbnails')
const bounds = await progress.boundingBox()
const observed = []
// The old core's first-cell/row-boundary defect is independently characterized.
for (const [index, oldIndex] of [[1, 1], [4, 3], [5, 5], [0, 5], [11, 11]]) {
const x = bounds.x + bounds.width * (index + 0.5) / 12
const y = bounds.y + bounds.height / 2
await page.mouse.move(x, y)
await expect.poll(async () => {
await page.mouse.move(x, y)
return preview.evaluate(element => element.style.backgroundImage)
}).toContain('blob:')
const cell = core === 'candidate' ? index : oldIndex
await expect(preview).toHaveCSS('background-position', `${-(cell % 4) * 160}px ${-Math.floor(cell / 4) * 90}px`)
await expect(preview).toBeVisible()
let screenshot
await expect.poll(async () => {
screenshot = await preview.screenshot()
const png = PNG.sync.read(screenshot)
expect([png.width, png.height]).toEqual([160, 90])
let difference = 0
let channels = 0
// The progress time tooltip covers the lower part of the preview.
// Compare the unobscured interior, excluding rounded border pixels.
for (let py = 8; py < 60; py++) {
for (let px = 8; px < 152; px++) {
const actual = (py * png.width + px) * 4
const expected = ((Math.floor(cell / 4) * 90 + py) * sheetPng.width + cell % 4 * 160 + px) * 4
for (let channel = 0; channel < 3; channel++) {
difference += Math.abs(png.data[actual + channel] - sheetPng.data[expected + channel])
channels++
}
}
}
return difference / channels
}).toBeLessThanOrEqual(3)
observed.push({ requestedIndex: index, displayedIndex: cell })
await testInfo.attach(`sheet-${generation}-cell-${index}`, { contentType: 'image/png', body: screenshot })
}
const beforeDestroy = await page.evaluate(() => window.revokedThumbnailUrls.slice())
await page.evaluate(() => window.art.destroy(true))
expect(await page.evaluate(() => window.revokedThumbnailUrls)).toEqual(beforeDestroy)
expect(await page.evaluate(() => window.tool.video.isConnected)).toBe(true)
generations.push({ ...sheet, observed })
}
expect(generations[0].url).not.toBe(generations[1].url)
const cleanup = await page.evaluate(() => {
window.tool.destroy()
return { created: window.createdThumbnailUrls, revoked: window.revokedThumbnailUrls, videoConnected: window.tool.video.isConnected, players: window.Artplayer.instances.length, errors: window.toolErrors }
})
expect(cleanup.revoked.filter(url => url === '')).toHaveLength(core === 'published' ? 2 : 0)
expect([...cleanup.created].sort()).toEqual(cleanup.revoked.filter(url => url !== '').sort())
expect(cleanup).toMatchObject({ videoConnected: false, players: 0, errors: [] })
await testInfo.attach('thumbnail-core-results', { contentType: 'application/json', body: JSON.stringify({ core, implementation: implementation.name, generations, cleanup, scope: 'Native local file extraction, real hover and screenshot pixels on two generations; historical core defects asserted separately; no physical device claim' }) })
})
}
}