mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-24 06:31:20 -08:00
refactor(auto-thumbnail): [PKG-AUTO-THUMB-03] own extraction jobs and encoded URLs
This commit is contained in:
1 parent
9cd6659ab4
commit
84721f19ed
21 files changed
+2641
-154
No files matched your search
@@ -0,0 +1,331 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Repository Node lifecycle regression runner.
|
||||
import test from 'node:test'
|
||||
import { autoThumbnailCandidate, autoThumbnailEnvironment } from './helpers/auto-thumbnail.js'
|
||||
|
||||
const implementation = await autoThumbnailCandidate()
|
||||
async function setup(option = { width: 80, number: 2 }) {
|
||||
const env = autoThumbnailEnvironment(implementation)
|
||||
const result = env.factory(option)(env.art)
|
||||
assert.equal(typeof result.then, 'function')
|
||||
assert.deepEqual({ ...await result }, { name: 'artplayerPluginAutoThumbnail' })
|
||||
env.art.emit('video:loadedmetadata')
|
||||
return env
|
||||
}
|
||||
function assertClosed(env, video = env.videos[0]) {
|
||||
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))
|
||||
assert(env.operations.some(item => item.name === 'load' && item.video === video))
|
||||
}
|
||||
|
||||
test('Auto-thumbnail candidate serializes valid JPEG updates and retains only the final URL until destroy', async () => {
|
||||
const env = await setup({ width: 80, number: 2, scale: 0.5, height: 999 })
|
||||
env.metadata()
|
||||
assert.equal(env.blobs.length, 0, 'No empty preview is encoded')
|
||||
assert.equal(env.operations.find(item => item.name === 'seek').handlerInstalled, true)
|
||||
env.seeked()
|
||||
assert.equal(env.blobs.length, 1)
|
||||
assert.equal(env.blobs[0].draws.length, 1)
|
||||
env.seeked()
|
||||
assert.equal(env.blobs.length, 1, 'No next frame starts while encoding is pending')
|
||||
env.finish()
|
||||
env.seeked()
|
||||
env.finish(1)
|
||||
assert.deepEqual(env.operations.filter(item => item.name === 'seek').map(item => item.value), [0, 60])
|
||||
assert.deepEqual(env.canvases[0].draws.map(args => args.slice(1)), [[0, 0, 80, 45], [80, 0, 80, 45]])
|
||||
assert.equal(env.updates.length, 2)
|
||||
assert.deepEqual({ ...env.updates[1] }, { url: 'blob:auto-thumbnail-2', height: 45, column: 10, number: 2, width: 80, scale: 0.5 })
|
||||
assert.equal(env.urls.size, 1)
|
||||
assertClosed(env)
|
||||
env.art.emit('destroy')
|
||||
assert.equal(env.urls.size, 0)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate destroys pending metadata without creating later canvases', async () => {
|
||||
const env = await setup()
|
||||
const callback = env.videos[0].onloadedmetadata
|
||||
env.art.emit('destroy')
|
||||
callback()
|
||||
assert.equal(env.canvases.length, 0)
|
||||
assertClosed(env)
|
||||
assert([...env.listeners.values()].every(set => set.size === 0))
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate ignores Blob delivery after destroy and repeated destruction', async () => {
|
||||
const env = await setup()
|
||||
env.metadata()
|
||||
env.seeked()
|
||||
env.art.emit('destroy')
|
||||
env.finish()
|
||||
env.art.emit('destroy')
|
||||
assert.equal(env.updates.length, 0)
|
||||
assert.equal(env.urls.size, 0)
|
||||
assertClosed(env)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate prevents old metadata from replacing the new source', async () => {
|
||||
const options = { width: 80, number: 1 }
|
||||
const env = await setup(options)
|
||||
const oldVideo = env.videos[0]
|
||||
const oldMetadata = oldVideo.onloadedmetadata
|
||||
env.art.option.url = 'new.mp4'
|
||||
options.width = 160
|
||||
env.art.emit('video:loadedmetadata')
|
||||
oldMetadata()
|
||||
assert.equal(env.canvases.length, 0)
|
||||
env.metadata()
|
||||
env.seeked()
|
||||
env.finish()
|
||||
assert.equal(env.updates.length, 1)
|
||||
assert.equal(env.updates[0].width, 160)
|
||||
assertClosed(env, oldVideo)
|
||||
env.art.emit('destroy')
|
||||
assert.equal(env.urls.size, 0)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate restart cancels encoding before replacement metadata', async () => {
|
||||
const env = await setup()
|
||||
env.metadata()
|
||||
env.seeked()
|
||||
env.art.emit('restart')
|
||||
env.finish()
|
||||
assert.equal(env.updates.length, 0)
|
||||
assert.equal(env.urls.size, 0)
|
||||
assertClosed(env)
|
||||
env.art.emit('video:loadedmetadata')
|
||||
assert.equal(env.videos.length, 2)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate delivers each Blob callback once', async () => {
|
||||
const env = await setup()
|
||||
env.metadata()
|
||||
env.seeked()
|
||||
env.finish()
|
||||
env.finish()
|
||||
assert.equal(env.updates.length, 1)
|
||||
assert.equal(env.operations.filter(item => item.name === 'seek').length, 2)
|
||||
env.art.emit('destroy')
|
||||
assert.equal(env.urls.size, 0)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate null Blob preserves the previous valid preview and closes the decoder', async () => {
|
||||
const env = await setup()
|
||||
env.metadata()
|
||||
env.seeked()
|
||||
env.finish()
|
||||
const previous = env.art.thumbnails
|
||||
env.seeked()
|
||||
assert.doesNotThrow(() => env.finish(1, null))
|
||||
assert.equal(env.urls.has(previous.url), true)
|
||||
assert.equal(env.art.thumbnails, previous)
|
||||
assert.equal(env.warnings.length, 1)
|
||||
assertClosed(env)
|
||||
env.art.emit('destroy')
|
||||
assert.equal(env.urls.size, 0)
|
||||
})
|
||||
|
||||
for (const phase of ['draw', 'encode', 'context', 'media', 'setter']) {
|
||||
test(`Auto-thumbnail candidate ${phase} failure has an owner and releases decoder resources`, async () => {
|
||||
const env = await setup()
|
||||
const failure = new Error(`${phase} failed`)
|
||||
if (phase === 'context')
|
||||
env.controls.nullContext = true
|
||||
if (phase === 'draw')
|
||||
env.controls.drawError = failure
|
||||
if (phase === 'encode')
|
||||
env.controls.encodeError = failure
|
||||
if (phase === 'setter')
|
||||
env.controls.updateError = failure
|
||||
if (phase === 'media') {
|
||||
env.videos[0].error = failure
|
||||
assert.doesNotThrow(() => env.videos[0].onerror?.())
|
||||
}
|
||||
else {
|
||||
assert.doesNotThrow(() => env.metadata())
|
||||
assert.doesNotThrow(() => env.seeked())
|
||||
if (phase === 'setter')
|
||||
assert.doesNotThrow(() => env.finish())
|
||||
}
|
||||
assert.equal(env.warnings.length, 1)
|
||||
if (phase !== 'context')
|
||||
assert.equal(env.warnings[0][1], failure)
|
||||
assert.equal(env.urls.size, 0)
|
||||
assertClosed(env)
|
||||
})
|
||||
}
|
||||
|
||||
test('Auto-thumbnail candidate cleanup continues after pause throws', async () => {
|
||||
const env = await setup()
|
||||
const failure = new Error('pause failed')
|
||||
env.videos[0].pause = () => {
|
||||
throw failure
|
||||
}
|
||||
env.art.emit('destroy')
|
||||
assert.equal(env.videos[0].src, undefined)
|
||||
assert.equal(env.videos[0].onloadedmetadata, null)
|
||||
assert(env.operations.some(item => item.name === 'load'))
|
||||
assert([...env.listeners.values()].every(set => set.size === 0))
|
||||
assert.equal(env.warnings[0][1], failure)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate returns registration failures and rolls back listeners', async () => {
|
||||
const env = autoThumbnailEnvironment(implementation)
|
||||
const on = env.art.on
|
||||
const failure = new Error('listen failed')
|
||||
env.art.on = function (name, callback) {
|
||||
on.call(this, name, callback)
|
||||
if (name === 'video:loadedmetadata')
|
||||
throw failure
|
||||
return this
|
||||
}
|
||||
await assert.rejects(env.factory({})(env.art), error => error === failure)
|
||||
assert([...env.listeners.values()].every(set => set.size === 0))
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate rejects nonfinite input and live durations without creating a canvas', async () => {
|
||||
for (const option of [{ width: Infinity }, { number: Infinity }, { width: -1 }, { number: -2 }]) {
|
||||
const env = await setup(option)
|
||||
assert.equal(env.videos.length, 0)
|
||||
assert.equal(env.warnings.length, 1)
|
||||
env.art.emit('destroy')
|
||||
}
|
||||
const env = await setup()
|
||||
env.videos[0].duration = Infinity
|
||||
env.metadata()
|
||||
assert.equal(env.canvases.length, 0)
|
||||
assertClosed(env)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate does not revoke a foreign public URL on destroy', async () => {
|
||||
const env = await setup({ number: 1 })
|
||||
env.metadata()
|
||||
env.seeked()
|
||||
env.finish()
|
||||
env.art.thumbnails = { url: 'blob:foreign' }
|
||||
env.art.emit('destroy')
|
||||
assert.equal(env.operations.some(item => item.name === 'revokeURL' && item.url === 'blob:foreign'), false)
|
||||
assert.equal(env.urls.size, 0)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate recovers a URL created across reentrant destroy', async () => {
|
||||
const env = await setup()
|
||||
env.metadata()
|
||||
env.seeked()
|
||||
const create = env.box.URL.createObjectURL
|
||||
env.box.URL.createObjectURL = (blob) => {
|
||||
env.art.emit('destroy')
|
||||
return create(blob)
|
||||
}
|
||||
env.finish()
|
||||
assert.equal(env.updates.length, 0)
|
||||
assert.equal(env.urls.size, 0)
|
||||
assertClosed(env)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate replacement during URL creation cannot resume the old decoder', async () => {
|
||||
const env = await setup()
|
||||
env.metadata()
|
||||
env.seeked()
|
||||
const create = env.box.URL.createObjectURL
|
||||
env.box.URL.createObjectURL = (blob) => {
|
||||
const url = create(blob)
|
||||
env.art.emit('video:loadedmetadata')
|
||||
return url
|
||||
}
|
||||
env.finish()
|
||||
assert.equal(env.videos.length, 2)
|
||||
assert.equal(env.updates.length, 0)
|
||||
assert.equal(env.urls.size, 0)
|
||||
assertClosed(env, env.videos[0])
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate replacement from the host setter preserves the new job and its preview', async () => {
|
||||
const env = await setup({ number: 1 })
|
||||
env.metadata()
|
||||
env.seeked()
|
||||
env.controls.onUpdate = () => {
|
||||
env.controls.onUpdate = null
|
||||
env.art.emit('video:loadedmetadata')
|
||||
}
|
||||
env.finish()
|
||||
assert.equal(env.videos.length, 2)
|
||||
assert.equal(env.urls.size, 1)
|
||||
assertClosed(env, env.videos[0])
|
||||
env.metadata()
|
||||
env.seeked()
|
||||
env.finish(1)
|
||||
assert.equal(env.updates.length, 2)
|
||||
assert.equal(env.urls.size, 1)
|
||||
assert.equal(env.urls.has(env.updates[0].url), false)
|
||||
env.art.emit('destroy')
|
||||
assert.equal(env.urls.size, 0)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate retains coercible numeric strings and fractional frame-count behavior', async () => {
|
||||
for (const option of [{ width: '80', number: '2', scale: '0.5' }, { width: 20.5, number: 2.5, scale: 1 }]) {
|
||||
const env = await setup(option)
|
||||
env.metadata()
|
||||
for (let index = 0; index < Math.ceil(Number(option.number)); index++) {
|
||||
env.seeked()
|
||||
env.finish(index)
|
||||
}
|
||||
assert.equal(env.updates.length, Math.ceil(Number(option.number)))
|
||||
assert.equal(env.updates[0].width, option.width)
|
||||
assert.equal(env.updates[0].number, option.number)
|
||||
assert.equal(env.updates[0].scale, option.scale)
|
||||
assert.equal(env.canvases[0].width, Math.trunc(Number(option.width) * 10))
|
||||
assertClosed(env)
|
||||
env.art.emit('destroy')
|
||||
assert.equal(env.urls.size, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate parameter getters cannot start extraction after destroying the host', async () => {
|
||||
const env = autoThumbnailEnvironment(implementation)
|
||||
const option = { get width() {
|
||||
env.art.emit('destroy')
|
||||
return 80
|
||||
} }
|
||||
await env.factory(option)(env.art)
|
||||
env.art.emit('video:loadedmetadata')
|
||||
assert.equal(env.videos.length, 0)
|
||||
assert.equal(env.canvases.length, 0)
|
||||
assert([...env.listeners.values()].every(set => set.size === 0))
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate media dimension getters cannot allocate after cancellation', async () => {
|
||||
const env = await setup()
|
||||
Object.defineProperty(env.videos[0], 'videoWidth', { get() {
|
||||
env.art.emit('destroy')
|
||||
return 1920
|
||||
} })
|
||||
env.metadata()
|
||||
assert.equal(env.canvases.length, 0)
|
||||
assertClosed(env)
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate explicit URL does not read the fallback options getter', async () => {
|
||||
const env = autoThumbnailEnvironment(implementation)
|
||||
Object.defineProperty(env.art, 'option', { get() {
|
||||
throw new Error('Explicit URL must not read fallback options')
|
||||
} })
|
||||
await env.factory({ url: 'explicit.mp4', number: 1 })(env.art)
|
||||
env.art.emit('video:loadedmetadata')
|
||||
assert.equal(env.videos.length, 1)
|
||||
assert.equal(env.videos[0].src, 'explicit.mp4')
|
||||
assert.equal(env.warnings.length, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('Auto-thumbnail candidate keeps the metadata duration snapshot for frame times', async () => {
|
||||
const env = await setup({ width: 80, number: 2 })
|
||||
env.metadata()
|
||||
env.videos[0].duration = 240
|
||||
env.seeked()
|
||||
env.finish()
|
||||
assert.deepEqual(env.operations.filter(item => item.name === 'seek').map(item => item.value), [0, 60])
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
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 scenario of ['destroy', 'restart', 'complete']) {
|
||||
test(`Auto-thumbnail candidate native ${scenario} owns decoder and encoded URLs`, async ({ page }, testInfo) => {
|
||||
await page.goto('/test/player.html?core=published')
|
||||
await page.setContent('<!doctype html><div></div>')
|
||||
await page.addScriptTag({ content: implementation.code })
|
||||
await page.evaluate(async (hold) => {
|
||||
const probe = window.probe = { videos: [], updates: [], urls: new Set(), blobs: [], listeners: new Map(), hold }
|
||||
const createElement = document.createElement.bind(document)
|
||||
const createUrl = URL.createObjectURL.bind(URL)
|
||||
const revokeUrl = URL.revokeObjectURL.bind(URL)
|
||||
const toBlob = HTMLCanvasElement.prototype.toBlob
|
||||
document.createElement = function (name, ...args) {
|
||||
const element = createElement(name, ...args)
|
||||
if (name === 'video')
|
||||
probe.videos.push(element)
|
||||
return element
|
||||
}
|
||||
HTMLCanvasElement.prototype.toBlob = function (callback, type) {
|
||||
return toBlob.call(this, (blob) => {
|
||||
const record = { blob, callback }
|
||||
probe.blobs.push(record)
|
||||
if (!probe.hold)
|
||||
callback(blob)
|
||||
}, type)
|
||||
}
|
||||
URL.createObjectURL = (blob) => {
|
||||
const url = createUrl(blob)
|
||||
probe.urls.add(url)
|
||||
return url
|
||||
}
|
||||
URL.revokeObjectURL = (url) => {
|
||||
probe.urls.delete(url)
|
||||
revokeUrl(url)
|
||||
}
|
||||
probe.restore = () => {
|
||||
document.createElement = createElement
|
||||
HTMLCanvasElement.prototype.toBlob = toBlob
|
||||
URL.createObjectURL = createUrl
|
||||
URL.revokeObjectURL = revokeUrl
|
||||
}
|
||||
probe.art = {
|
||||
option: { url: '/test/pattern.mp4' },
|
||||
on(name, callback) {
|
||||
if (!probe.listeners.has(name))
|
||||
probe.listeners.set(name, new Set())
|
||||
probe.listeners.get(name).add(callback)
|
||||
return this
|
||||
},
|
||||
off(name, callback) {
|
||||
probe.listeners.get(name)?.delete(callback)
|
||||
return this
|
||||
},
|
||||
emit(name) {
|
||||
for (const callback of [...(probe.listeners.get(name) || [])]) callback()
|
||||
},
|
||||
get thumbnails() { return probe.updates.at(-1) },
|
||||
set thumbnails(value) { probe.updates.push(value) },
|
||||
}
|
||||
probe.result = await window.artplayerPluginAutoThumbnail({ width: 80, number: 2 })(probe.art)
|
||||
probe.art.emit('video:loadedmetadata')
|
||||
}, scenario !== 'complete')
|
||||
await expect.poll(() => page.evaluate(() => window.probe.blobs.length)).toBe(scenario === 'complete' ? 2 : 1)
|
||||
const state = await page.evaluate((scenario) => {
|
||||
const probe = window.probe
|
||||
const before = { urls: probe.urls.size, updates: probe.updates.length }
|
||||
if (scenario !== 'complete') {
|
||||
probe.art.emit(scenario)
|
||||
probe.blobs[0].callback(probe.blobs[0].blob)
|
||||
probe.blobs[0].callback(probe.blobs[0].blob)
|
||||
}
|
||||
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) }
|
||||
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 }
|
||||
const blobs = probe.blobs.map(({ blob }) => ({ type: blob.type, bytes: blob.size }))
|
||||
probe.restore()
|
||||
return { before, after, video, final, blobs, result: probe.result }
|
||||
}, scenario)
|
||||
await testInfo.attach('auto-thumbnail-candidate-native-lifecycle', { contentType: 'application/json', body: JSON.stringify({ scenario, sha256: hash(implementation.code), scope: 'Actual HTTP decoding/seek/JPEG lifecycle with a stub player host. This checkpoint does not establish accurate extracted pixels; AUTO-THUMB-PIXEL-01 remains open.', ...state }) })
|
||||
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.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)
|
||||
})
|
||||
}
|
||||
@@ -40,8 +40,9 @@ export function autoThumbnailEnvironment(implementation, { script = false } = {}
|
||||
const urls = new Map()
|
||||
const listeners = new Map()
|
||||
const updates = []
|
||||
const warnings = []
|
||||
let nextUrl = 0
|
||||
const controls = { nullContext: false, drawError: null, encodeError: null, updateError: null }
|
||||
const controls = { nullContext: false, drawError: null, encodeError: null, updateError: null, onUpdate: null }
|
||||
const document = {
|
||||
createElement(tag) {
|
||||
if (tag === 'video') {
|
||||
@@ -111,7 +112,7 @@ export function autoThumbnailEnvironment(implementation, { script = false } = {}
|
||||
urls.delete(url)
|
||||
operations.push({ name: 'revokeURL', url })
|
||||
},
|
||||
}, window: {} }
|
||||
}, window: {}, console: { warn: (...args) => warnings.push(args) } }
|
||||
if (!script)
|
||||
Object.assign(box, { module, exports: module.exports })
|
||||
vm.runInNewContext(implementation.code, box)
|
||||
@@ -140,6 +141,7 @@ export function autoThumbnailEnvironment(implementation, { script = false } = {}
|
||||
if (controls.updateError)
|
||||
throw controls.updateError
|
||||
updates.push(value)
|
||||
controls.onUpdate?.(value)
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -154,6 +156,7 @@ export function autoThumbnailEnvironment(implementation, { script = false } = {}
|
||||
urls,
|
||||
listeners,
|
||||
updates,
|
||||
warnings,
|
||||
controls,
|
||||
metadata(video = videos.at(-1)) { video.onloadedmetadata?.() },
|
||||
seeked(video = videos.at(-1)) { video.onseeked?.() },
|
||||
|
||||
Reference in new issue
Block a user