Files
ArtPlayer/test/browser/thumbnail-core.spec.js
T

173 lines
9.6 KiB
JavaScript

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' }) })
})
}
}