mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-17 18:11:20 -08:00
refactor(auto-thumbnail): [PKG-AUTO-THUMB-03] own hidden renderers and reject stale seeks
This commit is contained in:
1 parent
84721f19ed
commit
a128706fe7
23 files changed
+12210
-38
No files matched your search
@@ -13,6 +13,7 @@ async function setup(option = { width: 80, number: 2 }) {
|
||||
return env
|
||||
}
|
||||
function assertClosed(env, video = env.videos[0]) {
|
||||
assert.equal(env.attached.has(video), false)
|
||||
assert.equal(video.src, undefined)
|
||||
for (const key of ['onloadedmetadata', 'onseeked', 'onerror']) assert.equal(video[key], null)
|
||||
assert(env.operations.some(item => item.name === 'pause' && item.video === video))
|
||||
@@ -329,3 +330,119 @@ test('Auto-thumbnail candidate keeps the metadata duration snapshot for frame ti
|
||||
assert.deepEqual(env.operations.filter(item => item.name === 'seek').map(item => item.value), [0, 60])
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate hides an attached decoder at intrinsic dimensions without playback', async () => {
|
||||
const env = await setup()
|
||||
const video = env.videos[0]
|
||||
assert.equal(env.attached.has(video), true)
|
||||
assert.equal(video['aria-hidden'], 'true')
|
||||
assert.equal(video.tabIndex, -1)
|
||||
assert.equal(video.muted, true)
|
||||
assert.match(video.style.cssText, /visibility:hidden/)
|
||||
assert.doesNotMatch(video.style.cssText, /display:none|width:1px|height:1px/)
|
||||
env.metadata()
|
||||
assert.equal(video.width, 1920)
|
||||
assert.equal(video.height, 1080)
|
||||
assert.equal(video.style.width, '1920px')
|
||||
assert.equal(video.style.height, '1080px')
|
||||
env.art.emit('destroy')
|
||||
assertClosed(env)
|
||||
})
|
||||
|
||||
for (const phase of ['beforeAppend', 'afterAppend']) {
|
||||
test(`Auto-thumbnail candidate ${phase} reentry cannot leave an attached decoder`, async () => {
|
||||
const env = autoThumbnailEnvironment(implementation)
|
||||
await env.factory({})(env.art)
|
||||
env.controls[phase] = () => env.art.emit('destroy')
|
||||
env.art.emit('video:loadedmetadata')
|
||||
assert.equal(env.attached.size, 0)
|
||||
assert.equal(env.videos[0].src, undefined)
|
||||
assertClosed(env)
|
||||
assert.equal(env.warnings.length, 0)
|
||||
})
|
||||
}
|
||||
|
||||
test('Auto-thumbnail candidate failed insertion removes a partially attached decoder', async () => {
|
||||
const env = autoThumbnailEnvironment(implementation)
|
||||
const failure = new Error('Insertion observer failed')
|
||||
env.controls.afterAppend = () => {
|
||||
throw failure
|
||||
}
|
||||
await env.factory({})(env.art)
|
||||
env.art.emit('video:loadedmetadata')
|
||||
assert.equal(env.attached.size, 0)
|
||||
assert.equal(env.videos[0].src, undefined)
|
||||
assert.equal(env.warnings[0][1], failure)
|
||||
assertClosed(env)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate decoder removal still runs after load throws', async () => {
|
||||
const env = await setup()
|
||||
const failure = new Error('Decoder reset failed')
|
||||
env.videos[0].load = () => {
|
||||
throw failure
|
||||
}
|
||||
env.art.emit('destroy')
|
||||
assert.equal(env.attached.size, 0)
|
||||
assert.equal(env.videos[0].src, undefined)
|
||||
assert.equal(env.warnings[0][1], failure)
|
||||
assert([...env.listeners.values()].every(set => set.size === 0))
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate ignores a stale seeked event while a new seek is in progress', async () => {
|
||||
const env = await setup()
|
||||
env.metadata()
|
||||
env.videos[0].seeking = true
|
||||
env.seeked()
|
||||
assert.equal(env.blobs.length, 0)
|
||||
env.videos[0].seeking = false
|
||||
env.seeked()
|
||||
assert.equal(env.blobs.length, 1)
|
||||
env.art.emit('destroy')
|
||||
assertClosed(env)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate retries a completed seek at the wrong time before drawing', async () => {
|
||||
const env = await setup()
|
||||
env.metadata()
|
||||
env.videos[0].timeOffset = 1
|
||||
env.seeked()
|
||||
assert.equal(env.blobs.length, 0)
|
||||
assert.deepEqual(env.operations.filter(item => item.name === 'seek').map(item => item.value), [0, 0])
|
||||
env.videos[0].timeOffset = 0
|
||||
env.seeked()
|
||||
assert.equal(env.blobs.length, 1)
|
||||
env.art.emit('destroy')
|
||||
assertClosed(env)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate bounds incorrect seek retries and preserves its last usable preview', async () => {
|
||||
const env = await setup()
|
||||
env.metadata()
|
||||
env.seeked()
|
||||
env.finish()
|
||||
const previous = env.art.thumbnails
|
||||
env.videos[0].timeOffset = -60
|
||||
for (let index = 0; index < 5; index++) env.seeked()
|
||||
assert.equal(env.blobs.length, 1)
|
||||
assert.equal(env.art.thumbnails, previous)
|
||||
assert.equal(env.urls.size, 1)
|
||||
assert.equal(env.operations.filter(item => item.name === 'seek').length, 5)
|
||||
assert.equal(env.warnings.length, 1)
|
||||
assertClosed(env)
|
||||
env.art.emit('destroy')
|
||||
assert.equal(env.urls.size, 0)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate seek-time getters cannot draw after synchronous destruction', async () => {
|
||||
const env = await setup()
|
||||
env.metadata()
|
||||
Object.defineProperty(env.videos[0], 'timeOffset', { get() {
|
||||
env.art.emit('destroy')
|
||||
return 0
|
||||
} })
|
||||
env.seeked()
|
||||
assert.equal(env.blobs.length, 0)
|
||||
assertClosed(env)
|
||||
})
|
||||
@@ -75,7 +75,7 @@ for (const scenario of ['destroy', 'restart', 'complete']) {
|
||||
}
|
||||
const after = { urls: probe.urls.size, updates: probe.updates.length }
|
||||
const decoder = probe.videos[0]
|
||||
const video = { src: decoder.getAttribute('src'), readyState: decoder.readyState, paused: decoder.paused, handlers: ['onloadedmetadata', 'onseeked', 'onerror'].map(key => decoder[key] === null) }
|
||||
const video = { connected: decoder.isConnected, src: decoder.getAttribute('src'), readyState: decoder.readyState, paused: decoder.paused, handlers: ['onloadedmetadata', 'onseeked', 'onerror'].map(key => decoder[key] === null) }
|
||||
probe.art.emit('destroy')
|
||||
probe.art.emit('destroy')
|
||||
const final = { urls: probe.urls.size, listeners: [...probe.listeners.values()].reduce((sum, set) => sum + set.size, 0), updates: probe.updates.length }
|
||||
@@ -87,7 +87,7 @@ for (const scenario of ['destroy', 'restart', 'complete']) {
|
||||
expect(state.result).toEqual({ name: 'artplayerPluginAutoThumbnail' })
|
||||
expect(state.before).toEqual(scenario === 'complete' ? { urls: 1, updates: 2 } : { urls: 0, updates: 0 })
|
||||
expect(state.after).toEqual(state.before)
|
||||
expect(state.video).toEqual({ src: null, readyState: 0, paused: true, handlers: [true, true, true] })
|
||||
expect(state.video).toEqual({ connected: false, src: null, readyState: 0, paused: true, handlers: [true, true, true] })
|
||||
expect(state.final).toEqual({ urls: 0, listeners: 0, updates: scenario === 'complete' ? 2 : 0 })
|
||||
expect(state.blobs.every(blob => blob.type === 'image/jpeg' && blob.bytes > 0)).toBe(true)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { autoThumbnailCandidate } from '../helpers/auto-thumbnail.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const implementation = await autoThumbnailCandidate()
|
||||
|
||||
for (const media of ['auto-thumbnail-timeline', 'pattern']) {
|
||||
test(`Auto-thumbnail candidate retains cells 2-4 ${media} pixels in a hidden decoder`, async ({ page, browserName }, testInfo) => {
|
||||
await page.goto('/test/player.html?core=published')
|
||||
await page.setContent('<!doctype html><style>video { width: 1px; height: 1px; max-width: 1px; max-height: 1px; display: none }</style>')
|
||||
await page.addScriptTag({ content: implementation.code })
|
||||
await page.evaluate(async (media) => {
|
||||
const createElement = document.createElement.bind(document)
|
||||
const drawImage = CanvasRenderingContext2D.prototype.drawImage
|
||||
const probe = window.probe = { videos: [], updates: [], draws: [], playing: 0, warnings: [] }
|
||||
const warn = console.warn
|
||||
console.warn = (...args) => probe.warnings.push(args.map(String))
|
||||
document.createElement = function (tag, ...args) {
|
||||
const element = createElement(tag, ...args)
|
||||
if (tag === 'video') {
|
||||
probe.videos.push(element)
|
||||
element.addEventListener('playing', () => probe.playing++)
|
||||
}
|
||||
return element
|
||||
}
|
||||
CanvasRenderingContext2D.prototype.drawImage = function (source, ...args) {
|
||||
const result = drawImage.call(this, source, ...args)
|
||||
if (source instanceof HTMLVideoElement) {
|
||||
const [x, y, width, height] = args
|
||||
const style = getComputedStyle(source)
|
||||
const box = source.getBoundingClientRect()
|
||||
probe.draws.push({
|
||||
time: source.currentTime,
|
||||
duration: source.duration,
|
||||
connected: source.isConnected,
|
||||
visibility: style.visibility,
|
||||
display: style.display,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
raw: [...this.getImageData(x + Math.floor(width / 2), y + Math.floor(height / 2), 1, 1).data],
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
const listeners = new Map()
|
||||
probe.art = {
|
||||
option: { url: `/test/${media}.mp4` },
|
||||
on(name, callback) {
|
||||
if (!listeners.has(name))
|
||||
listeners.set(name, new Set())
|
||||
listeners.get(name).add(callback)
|
||||
},
|
||||
off(name, callback) { listeners.get(name)?.delete(callback) },
|
||||
emit(name) { for (const callback of [...(listeners.get(name) || [])]) callback() },
|
||||
get thumbnails() { return probe.updates.at(-1) },
|
||||
set thumbnails(value) { probe.updates.push(value) },
|
||||
}
|
||||
probe.restore = () => {
|
||||
document.createElement = createElement
|
||||
CanvasRenderingContext2D.prototype.drawImage = drawImage
|
||||
console.warn = warn
|
||||
}
|
||||
await window.artplayerPluginAutoThumbnail({ width: 80, number: 5 })(probe.art)
|
||||
probe.art.emit('video:loadedmetadata')
|
||||
}, media)
|
||||
await expect.poll(() => page.evaluate(() => window.probe.updates.length)).toBe(5)
|
||||
const state = await page.evaluate(async () => {
|
||||
const probe = window.probe
|
||||
const image = document.createElement('img')
|
||||
image.src = probe.updates.at(-1).url
|
||||
await image.decode()
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = image.naturalWidth
|
||||
canvas.height = image.naturalHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.drawImage(image, 0, 0)
|
||||
const cells = Array.from({ length: 5 }, (_, index) => [...ctx.getImageData(index * 80 + 40, 22, 1, 1).data])
|
||||
const spatial = [10, 20, 40, 60, 70].map(x => [...ctx.getImageData(160 + x, 22, 1, 1).data])
|
||||
const final = probe.videos.map(video => ({ connected: video.isConnected, src: video.getAttribute('src'), paused: video.paused }))
|
||||
probe.art.emit('destroy')
|
||||
probe.restore()
|
||||
return { cells, spatial, draws: probe.draws, final, playing: probe.playing, warnings: probe.warnings }
|
||||
})
|
||||
await testInfo.attach('auto-thumbnail-candidate-hidden-pixels', { contentType: 'application/json', body: JSON.stringify({
|
||||
sha256: hash(implementation.code),
|
||||
media,
|
||||
scope: 'Native decoding and JPEG pixels for cells 2-4, hidden intrinsic-sized rendering and cleanup. Cells 0-1 are diagnostic only: initial drawing/readiness/timestamp behavior remains open under AUTO-THUMB-PIXEL-01. Stub player host, not a core integration.',
|
||||
...state,
|
||||
}) })
|
||||
testInfo.annotations.push({ type: 'remaining-initial-frame-risk', description: `${browserName}: cells 0-1 are evidence only; this test cannot close AUTO-THUMB-PIXEL-01 or PKG-AUTO-THUMB-03.` })
|
||||
expect(state.warnings).toEqual([])
|
||||
expect(state.playing).toBe(0)
|
||||
expect(state.final).toEqual([{ connected: false, src: null, paused: true }])
|
||||
expect(state.draws).toHaveLength(5)
|
||||
for (const [index, draw] of state.draws.entries()) {
|
||||
expect(draw.connected).toBe(true)
|
||||
expect(draw.visibility).toBe('hidden')
|
||||
expect(draw.display).toBe('block')
|
||||
expect(draw.width).toBeGreaterThanOrEqual(80)
|
||||
expect(draw.height).toBeGreaterThanOrEqual(45)
|
||||
if (index > 1) {
|
||||
expect(Math.abs(draw.time - draw.duration * index / 5)).toBeLessThan(0.05)
|
||||
expect(draw.raw[3]).toBe(255)
|
||||
}
|
||||
}
|
||||
if (media === 'auto-thumbnail-timeline') {
|
||||
const [black, blue, yellow] = state.cells.slice(2)
|
||||
expect(Math.max(...black.slice(0, 3))).toBeLessThan(30)
|
||||
expect(blue[2]).toBeGreaterThan(200)
|
||||
expect(Math.max(blue[0], blue[1])).toBeLessThan(40)
|
||||
expect(Math.min(yellow[0], yellow[1])).toBeGreaterThan(200)
|
||||
expect(yellow[2]).toBeLessThan(40)
|
||||
}
|
||||
else {
|
||||
for (const pixel of state.cells.slice(2, 4)) {
|
||||
expect(pixel[2] - Math.max(pixel[0], pixel[1])).toBeGreaterThan(80)
|
||||
}
|
||||
expect(state.cells[4][0] - Math.max(state.cells[4][1], state.cells[4][2])).toBeGreaterThan(80)
|
||||
expect(new Set(state.spatial.map(pixel => pixel.slice(0, 3).join(','))).size).toBeGreaterThan(2)
|
||||
}
|
||||
})
|
||||
}
|
||||
Binary file not shown.
@@ -76,6 +76,7 @@ assert.equal(hash(iframeCoreBytes), iframeCore.files[iframeCoreMember], 'Histori
|
||||
add('/published-4.5.9/artplayer.js', iframeCoreBytes, { kind: 'npm-release', version: iframeCore.version, integrity: iframeCore.integrity, member: iframeCoreMember })
|
||||
add('/test/audio-tone.m4a', fs.readFileSync(path.join(workspace, 'test/browser/media/audio-tone.m4a')), { kind: 'generated-media', file: 'test/browser/media/audio-tone.m4a' })
|
||||
add('/test/thumbnail-pattern.mp4', fs.readFileSync(path.join(workspace, 'test/browser/media/thumbnail-pattern.mp4')), { kind: 'generated-media', file: 'test/browser/media/thumbnail-pattern.mp4' })
|
||||
add('/test/auto-thumbnail-timeline.mp4', fs.readFileSync(path.join(workspace, 'test/browser/media/auto-thumbnail-timeline.mp4')), { kind: 'generated-media', file: 'test/browser/media/auto-thumbnail-timeline.mp4' })
|
||||
|
||||
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.mp4': 'video/mp4', '.m4a': 'audio/mp4', '.webm': 'video/webm', '.vtt': 'text/vtt', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg' }
|
||||
add('/test/declaration-cues.vtt', fs.readFileSync(path.join(workspace, 'test/browser/media/declaration-cues.vtt')), { kind: 'test-subtitles', file: 'test/browser/media/declaration-cues.vtt' })
|
||||
|
||||
@@ -42,12 +42,29 @@ export function autoThumbnailEnvironment(implementation, { script = false } = {}
|
||||
const updates = []
|
||||
const warnings = []
|
||||
let nextUrl = 0
|
||||
const controls = { nullContext: false, drawError: null, encodeError: null, updateError: null, onUpdate: null }
|
||||
const controls = { nullContext: false, drawError: null, encodeError: null, updateError: null, onUpdate: null, beforeAppend: null, afterAppend: null }
|
||||
const attached = new Set()
|
||||
const document = {
|
||||
documentElement: {
|
||||
appendChild(video) {
|
||||
controls.beforeAppend?.(video)
|
||||
attached.add(video)
|
||||
controls.afterAppend?.(video)
|
||||
return video
|
||||
},
|
||||
},
|
||||
createElement(tag) {
|
||||
if (tag === 'video') {
|
||||
const video = {
|
||||
style: {},
|
||||
setAttribute(name, value) { this[name] = value },
|
||||
remove() {
|
||||
attached.delete(this)
|
||||
operations.push({ name: 'remove', video: this })
|
||||
},
|
||||
duration: 120,
|
||||
timeOffset: 0,
|
||||
seeking: false,
|
||||
videoWidth: 1920,
|
||||
videoHeight: 1080,
|
||||
onloadedmetadata: null,
|
||||
@@ -62,7 +79,7 @@ export function autoThumbnailEnvironment(implementation, { script = false } = {}
|
||||
}
|
||||
let time = 0
|
||||
Object.defineProperty(video, 'currentTime', {
|
||||
get: () => time,
|
||||
get: () => time + video.timeOffset,
|
||||
set(value) {
|
||||
time = value
|
||||
operations.push({ name: 'seek', value, handlerInstalled: typeof video.onseeked === 'function', video })
|
||||
@@ -158,6 +175,7 @@ export function autoThumbnailEnvironment(implementation, { script = false } = {}
|
||||
updates,
|
||||
warnings,
|
||||
controls,
|
||||
attached,
|
||||
metadata(video = videos.at(-1)) { video.onloadedmetadata?.() },
|
||||
seeked(video = videos.at(-1)) { video.onseeked?.() },
|
||||
finish(index = 0, blob = { type: 'image/jpeg' }) { blobs[index].callback(blob) },
|
||||
|
||||
Reference in new issue
Block a user