mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-16 09:36:16 -08:00
refactor(canvas): [PKG-CANVAS-03] own media and frame lifecycle in strict TypeScript
This commit is contained in:
1 parent
e931520e66
commit
2b319109cd
29 files changed
+2015
-362
No files matched your search
@@ -1,12 +1,11 @@
|
||||
import fs from 'node:fs'
|
||||
import { transform } from 'esbuild'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { ambilightCandidate } from '../helpers/ambilight.js'
|
||||
import { canvasCandidate } from '../helpers/canvas.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const implementation = await ambilightCandidate()
|
||||
const proxySource = fs.readFileSync('packages/artplayer-proxy-canvas/src/index.js', 'utf8')
|
||||
const proxy = (await transform(proxySource, { format: 'cjs', target: 'es2020' })).code
|
||||
const proxySource = (await canvasCandidate()).source
|
||||
const proxy = proxySource
|
||||
|
||||
for (const core of ['published-5.1.7', 'published', 'candidate']) {
|
||||
const scenario = core === 'published-5.1.7' ? 'historical core has no proxy option and retains native Ambilight playback' : 'Ambilight samples all nine canvas proxy regions after output resizing'
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { canvasCandidate } from '../helpers/canvas.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const candidate = await canvasCandidate()
|
||||
|
||||
async function setup(page, core, testInfo) {
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
await page.addScriptTag({ content: `(() => {
|
||||
const pending = new Set();
|
||||
const state = { requested: 0, fired: 0, cancelled: 0 };
|
||||
const requestAnimationFrame = callback => {
|
||||
const id = window.requestAnimationFrame(time => { pending.delete(id); state.fired++; callback(time); });
|
||||
pending.add(id); state.requested++; return id;
|
||||
};
|
||||
const cancelAnimationFrame = id => { pending.delete(id); state.cancelled++; window.cancelAnimationFrame(id); };
|
||||
const module = { exports: {} }; const exports = module.exports;
|
||||
${candidate.source};
|
||||
window.canvasFactory = module.exports.default || module.exports;
|
||||
window.canvasFrames = { pending, state };
|
||||
})();` })
|
||||
await page.evaluate(() => {
|
||||
window.proxyDraws = 0
|
||||
window.proxyErrors = []
|
||||
window.proxyErrorStates = []
|
||||
window.proxyEvents = []
|
||||
window.art = new window.Artplayer({
|
||||
container: '.player',
|
||||
url: '/test/pattern.mp4',
|
||||
muted: true,
|
||||
loop: true,
|
||||
proxy: window.canvasFactory((context, video) => {
|
||||
window.proxyContext = context
|
||||
window.proxyVideo = video
|
||||
window.proxyDraws++
|
||||
if (window.destroyNextDraw) {
|
||||
window.destroyNextDraw = false
|
||||
window.art.destroy(false)
|
||||
window.destroyedInCallback = true
|
||||
}
|
||||
}),
|
||||
})
|
||||
for (const name of ['video:loadedmetadata', 'video:loadeddata', 'video:canplay', 'video:seeked', 'artplayerProxyCanvas:draw', 'destroy'])
|
||||
window.art.on(name, () => window.proxyEvents.push(name))
|
||||
window.art.on('artplayerProxyCanvas:error', (error) => {
|
||||
window.proxyErrors.push({ name: error.name, message: error.message })
|
||||
const video = window.proxyVideo || window.art?.template.$player.querySelector('video')
|
||||
let direct
|
||||
if (video) {
|
||||
const canvas = document.createElement('canvas')
|
||||
try {
|
||||
const context = canvas.getContext('2d')
|
||||
context.drawImage(video, 0, 0, 300, 150)
|
||||
direct = [...context.getImageData(0, 0, 1, 1).data]
|
||||
}
|
||||
catch (failure) { direct = failure.name }
|
||||
}
|
||||
window.proxyErrorStates.push({ readyState: video?.readyState, seeking: video?.seeking, paused: video?.paused, currentTime: video?.currentTime, source: video?.currentSrc, dimensions: [video?.videoWidth, video?.videoHeight], frames: video?.getVideoPlaybackQuality?.().totalVideoFrames, direct })
|
||||
})
|
||||
document.querySelector('#play').onclick = () => window.art.play()
|
||||
document.querySelector('#pause').onclick = () => window.art.pause()
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.proxyDraws)).toBeGreaterThan(1)
|
||||
await expect.poll(() => page.evaluate(() => window.proxyVideo.currentTime)).toBeGreaterThan(0)
|
||||
await testInfo.attach('canvas-candidate-input', { contentType: 'application/json', body: JSON.stringify({ core, sha256: hash(candidate.source), scope: 'actual candidate source bundle, native media/Canvas/bitmap; lexical RAF observer delegates native scheduling' }) })
|
||||
}
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
await testInfo.attach('canvas-candidate-error-states', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => window.proxyErrorStates)) })
|
||||
await testInfo.attach('canvas-candidate-state', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => ({ version: window.Artplayer?.version, destroyed: window.art?.isDestroy, connected: window.proxyVideo?.isConnected, currentSrc: window.proxyVideo?.currentSrc, currentTime: window.proxyVideo?.currentTime, frames: window.canvasFrames?.state, pending: window.canvasFrames?.pending.size, draws: window.proxyDraws, errors: window.proxyErrors, events: window.proxyEvents }))) })
|
||||
await page.evaluate(() => {
|
||||
if (window.art && !window.art.isDestroy)
|
||||
window.art.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: candidate canvas paints native pixels, resizes and switches media`, async ({ page }, testInfo) => {
|
||||
await setup(page, core, testInfo)
|
||||
expect(await page.evaluate(() => window.art.template.$video.nodeName)).toBe('CANVAS')
|
||||
expect(await page.evaluate(() => window.proxyVideo.isConnected)).toBe(true)
|
||||
expect(await page.evaluate(() => [window.proxyVideo.videoWidth, window.proxyVideo.videoHeight])).toEqual([320, 180])
|
||||
await expect.poll(() => page.evaluate(() => window.proxyContext.getImageData(0, 0, 1, 1).data[3])).toBe(255)
|
||||
expect(await page.evaluate(() => {
|
||||
const { canvas } = window.proxyContext
|
||||
const values = [0.2, 0.5, 0.8].map(ratio => [...window.proxyContext.getImageData(Math.floor(canvas.width * ratio), Math.floor(canvas.height / 2), 1, 1).data].join(','))
|
||||
return new Set(values).size
|
||||
})).toBeGreaterThan(1)
|
||||
await page.evaluate(() => {
|
||||
window.art.template.$player.style.width = '320px'
|
||||
window.art.template.$player.style.height = '480px'
|
||||
window.art.emit('resize')
|
||||
})
|
||||
await testInfo.attach('canvas-resize-dimensions', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => ({ media: [window.proxyVideo.videoWidth, window.proxyVideo.videoHeight], css: [window.proxyVideo.clientWidth, window.proxyVideo.clientHeight], player: [window.art.template.$player.clientWidth, window.art.template.$player.clientHeight], canvas: [window.proxyContext.canvas.width, window.proxyContext.canvas.height], padding: window.proxyContext.canvas.style.padding }))) })
|
||||
expect(await page.evaluate(() => window.art.template.$video.style.padding)).toBe('150px 0px')
|
||||
await expect.poll(() => page.evaluate(() => window.proxyContext.getImageData(0, 0, 1, 1).data[3])).toBe(255)
|
||||
await page.evaluate(() => window.art.switchUrl('/assets/sample/video.mp4'))
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.proxyVideo.currentSrc.endsWith('/assets/sample/video.mp4') && window.proxyVideo.currentTime > 0)).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.proxyContext.getImageData(0, 0, 1, 1).data[3])).toBe(255)
|
||||
expect(await page.evaluate(() => window.proxyErrors)).toEqual([])
|
||||
})
|
||||
|
||||
test(`${core}: candidate paused seek draws the new video frame without restarting RAF`, async ({ page }, testInfo) => {
|
||||
await setup(page, core, testInfo)
|
||||
await page.click('#pause')
|
||||
await expect.poll(() => page.evaluate(() => window.canvasFrames.pending.size)).toBe(0)
|
||||
const count = await page.evaluate(() => window.proxyDraws)
|
||||
await page.evaluate(() => window.art.currentTime = 0.5)
|
||||
await expect.poll(() => page.evaluate(() => window.proxyDraws)).toBeGreaterThan(count)
|
||||
await expect.poll(() => page.evaluate(() => window.proxyContext.getImageData(0, 0, 1, 1).data[3])).toBe(255)
|
||||
expect(await page.evaluate(() => ({ paused: window.proxyVideo.paused, pending: window.canvasFrames.pending.size }))).toEqual({ paused: true, pending: 0 })
|
||||
})
|
||||
|
||||
test(`${core}: candidate destroy inside callback leaves no media or native RAF and escaped play is inert`, async ({ page }, testInfo) => {
|
||||
await setup(page, core, testInfo)
|
||||
await page.evaluate(() => {
|
||||
window.escapedCanvas = window.art.template.$video
|
||||
window.escapedPlay = window.escapedCanvas.play
|
||||
window.destroyNextDraw = true
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.destroyedInCallback && window.art.isDestroy)).toBe(true)
|
||||
expect(await page.evaluate(() => window.canvasFrames.pending.size)).toBe(0)
|
||||
const before = await page.evaluate(() => window.canvasFrames.state.fired)
|
||||
await page.evaluate(async () => {
|
||||
window.escapedCanvas.src = '/assets/sample/video.mp4'
|
||||
await window.escapedPlay()
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
})
|
||||
expect(await page.evaluate(() => ({ connected: window.proxyVideo.isConnected, paused: window.proxyVideo.paused, source: window.proxyVideo.getAttribute('src'), pending: window.canvasFrames.pending.size, fired: window.canvasFrames.state.fired, last: window.proxyEvents.at(-1) }))).toEqual({ connected: false, paused: true, source: null, pending: 0, fired: before, last: 'destroy' })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Run the same candidate assertions against frozen source to establish failures.
|
||||
import test from 'node:test'
|
||||
import { canvasCandidate, canvasEnvironment, canvasHistorical } from './helpers/canvas.js'
|
||||
|
||||
const implementation = await canvasCandidate()
|
||||
const listenerCount = env => [...env.listeners.values()].reduce((count, set) => count + set.size, 0)
|
||||
function deferred() {
|
||||
let resolve, reject
|
||||
const promise = new Promise((yes, no) => {
|
||||
resolve = yes
|
||||
reject = no
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
test('Canvas candidate preserves native canvas methods, live video forwarding and normal resize/callback/event order', async () => {
|
||||
const observations = []
|
||||
for (const source of [...await canvasHistorical(), implementation]) {
|
||||
const env = await canvasEnvironment(source)
|
||||
const calls = []
|
||||
const canvas = env.factory((ctx, video) => calls.push(ctx === env.context && video === env.video))(env.art)
|
||||
canvas.src = 'movie.mp4'
|
||||
await canvas.play()
|
||||
env.art.emit('video:loadedmetadata')
|
||||
env.art.emit('resize')
|
||||
await env.flush()
|
||||
observations.push({ same: canvas === env.canvas, url: env.video.src, paused: env.video.paused, native: canvas.toDataURL(), callbacks: calls, size: [canvas.width, canvas.height], padding: canvas.style.padding, events: env.emitted.map(event => event.name) })
|
||||
}
|
||||
for (const value of observations.slice(1)) assert.deepEqual(value, observations[0])
|
||||
})
|
||||
|
||||
for (const operation of ['pause', 'destroy', 'source']) {
|
||||
test(`Canvas candidate discards and closes bitmap that resolves after ${operation}`, async () => {
|
||||
const pending = deferred()
|
||||
let closed = 0
|
||||
const env = await canvasEnvironment(implementation, { createImageBitmap: () => pending.promise })
|
||||
const canvas = env.factory()(env.art)
|
||||
env.art.emit('video:play')
|
||||
if (operation === 'pause')
|
||||
env.art.emit('video:pause')
|
||||
else if (operation === 'destroy')
|
||||
env.art.destroy()
|
||||
else canvas.src = 'replacement.mp4'
|
||||
pending.resolve({ close() {
|
||||
closed++
|
||||
} })
|
||||
await env.flush()
|
||||
assert.equal(closed, 1)
|
||||
assert.equal(env.draws.length, 0)
|
||||
assert.equal(env.frames.size, 0)
|
||||
assert.equal(env.emitted.filter(event => event.name === 'artplayerProxyCanvas:draw').length, 0)
|
||||
})
|
||||
}
|
||||
|
||||
test('Canvas candidate serializes repeated play and resize while discarding outdated frame results', async () => {
|
||||
const pending = [deferred(), deferred()]
|
||||
let count = 0
|
||||
let closed = 0
|
||||
const env = await canvasEnvironment(implementation, { createImageBitmap: () => pending[count++].promise })
|
||||
env.factory()(env.art)
|
||||
env.art.emit('video:play')
|
||||
env.art.emit('video:play')
|
||||
env.art.emit('resize')
|
||||
assert.equal(count, 1)
|
||||
pending[0].resolve({ close() {
|
||||
closed++
|
||||
} })
|
||||
await env.flush()
|
||||
assert.equal(env.draws.length, 0)
|
||||
assert.equal(count, 2)
|
||||
const latest = { close() {
|
||||
closed++
|
||||
} }
|
||||
pending[1].resolve(latest)
|
||||
await env.flush()
|
||||
assert.deepEqual(env.draws.map(args => args[0]), [latest])
|
||||
assert.equal(closed, 2)
|
||||
assert.equal(env.frames.size, 1)
|
||||
env.art.destroy()
|
||||
assert.equal(env.frames.size, 0)
|
||||
})
|
||||
|
||||
test('Canvas candidate closes bitmap on draw error and preserves the original error before retry', async () => {
|
||||
const failure = new Error('draw failed')
|
||||
let closed = 0
|
||||
const env = await canvasEnvironment(implementation, { createImageBitmap: () => Promise.resolve({ close() {
|
||||
closed++
|
||||
} }), onDraw() {
|
||||
throw failure
|
||||
} })
|
||||
env.factory()(env.art)
|
||||
env.art.emit('video:play')
|
||||
await env.flush()
|
||||
assert.equal(closed, 1)
|
||||
assert.equal(env.emitted.at(-1).args[0], failure)
|
||||
assert.equal(env.frames.size, 1)
|
||||
env.art.destroy()
|
||||
})
|
||||
|
||||
test('Canvas candidate contains callback exceptions and stops reentrant destroy before draw event or RAF', async () => {
|
||||
for (const throws of [false, true]) {
|
||||
const env = await canvasEnvironment(implementation)
|
||||
env.factory(() => {
|
||||
env.art.destroy()
|
||||
if (throws)
|
||||
throw new Error('callback destroyed then failed')
|
||||
})(env.art)
|
||||
env.art.emit('video:play')
|
||||
await env.flush()
|
||||
assert.equal(env.frames.size, 0)
|
||||
assert.equal(env.emitted.filter(event => event.name.startsWith('artplayerProxyCanvas:')).length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('Canvas candidate destroy before deferred setup removes every host handler and timer', async () => {
|
||||
const env = await canvasEnvironment(implementation)
|
||||
const canvas = env.factory()(env.art)
|
||||
const play = canvas.play
|
||||
env.art.destroy()
|
||||
canvas.src = 'late.mp4'
|
||||
await play()
|
||||
env.flushTimers()
|
||||
assert.equal(env.proxies.length, 0)
|
||||
assert.equal(env.timers.size, 0)
|
||||
assert.equal(listenerCount(env), 0)
|
||||
assert.notEqual(env.video.src, 'late.mp4')
|
||||
assert.equal(env.video.paused, true)
|
||||
})
|
||||
|
||||
test('Canvas candidate mounts and owns backing media, releases subscriptions and media on destroy', async () => {
|
||||
const env = await canvasEnvironment(implementation)
|
||||
env.factory()(env.art)
|
||||
env.flushTimers()
|
||||
assert.deepEqual(env.art.template.$player.children, [env.video])
|
||||
assert.equal(env.proxies.length, 5)
|
||||
env.art.destroy()
|
||||
assert.deepEqual(env.art.template.$player.children, [])
|
||||
assert.equal(env.proxies.length, 0)
|
||||
assert.equal(listenerCount(env), 0)
|
||||
assert.equal(env.video.paused, true)
|
||||
assert.equal(env.calls.filter(call => call.name === 'load').length, 1)
|
||||
})
|
||||
|
||||
test('Canvas candidate late registration returns inert canvas without resources', async () => {
|
||||
const env = await canvasEnvironment(implementation)
|
||||
env.art.destroy()
|
||||
assert.equal(env.factory()(env.art), env.canvas)
|
||||
env.flushTimers()
|
||||
assert.equal(env.timers.size, 0)
|
||||
assert.equal(listenerCount(env), 0)
|
||||
assert.equal(env.proxies.length, 0)
|
||||
})
|
||||
|
||||
test('Canvas candidate setup rollback preserves failure and removes partially registered handler', async () => {
|
||||
const env = await canvasEnvironment(implementation)
|
||||
const on = env.art.on
|
||||
const failure = new Error('registration failed')
|
||||
env.art.on = (...args) => {
|
||||
on(...args)
|
||||
throw failure
|
||||
}
|
||||
assert.throws(() => env.factory()(env.art), error => error === failure)
|
||||
assert.equal(listenerCount(env), 0)
|
||||
assert.equal(env.timers.size, 0)
|
||||
})
|
||||
|
||||
test('Canvas candidate zero dimensions defer drawing and keep finite geometry until metadata', async () => {
|
||||
const env = await canvasEnvironment(implementation)
|
||||
env.video.videoWidth = 0
|
||||
env.video.videoHeight = 0
|
||||
env.factory()(env.art)
|
||||
env.art.emit('resize')
|
||||
await env.flush()
|
||||
assert.equal(env.draws.length, 0)
|
||||
assert(Number.isFinite(env.canvas.width) && Number.isFinite(env.canvas.height))
|
||||
env.video.videoWidth = 320
|
||||
env.video.videoHeight = 180
|
||||
env.art.emit('video:loadedmetadata')
|
||||
env.art.emit('resize')
|
||||
await env.flush()
|
||||
assert.equal(env.draws.length, 1)
|
||||
})
|
||||
|
||||
test('Canvas candidate missing 2D context reports a capability error and does not spin a frame loop', async () => {
|
||||
const env = await canvasEnvironment(implementation, { noContext: true })
|
||||
env.factory()(env.art)
|
||||
env.art.emit('video:play')
|
||||
await env.flush()
|
||||
assert.equal(env.emitted.filter(event => event.name === 'artplayerProxyCanvas:error').length, 1)
|
||||
assert.equal(env.frames.size, 0)
|
||||
})
|
||||
|
||||
test('Canvas candidate RAF zero is cancelled and paused seek draws once without restarting playback', async () => {
|
||||
const env = await canvasEnvironment(implementation, { firstFrame: 0 })
|
||||
env.factory()(env.art)
|
||||
env.art.emit('video:play')
|
||||
await env.flush()
|
||||
assert(env.frames.has(0))
|
||||
env.art.emit('video:pause')
|
||||
assert.equal(env.frames.size, 0)
|
||||
const before = env.draws.length
|
||||
env.art.emit('video:seeked')
|
||||
await env.flush()
|
||||
assert.equal(env.draws.length, before + 1)
|
||||
assert.equal(env.frames.size, 0)
|
||||
})
|
||||
|
||||
test('Canvas candidate waits for a decoded frame before requesting bitmap and then resumes drawing', async () => {
|
||||
let allocated = 0
|
||||
const env = await canvasEnvironment(implementation, { createImageBitmap: () => {
|
||||
allocated++
|
||||
return Promise.resolve({ close() {} })
|
||||
} })
|
||||
env.video.readyState = 1
|
||||
env.factory()(env.art)
|
||||
env.art.emit('video:play')
|
||||
await env.flush()
|
||||
assert.equal(allocated, 0)
|
||||
env.video.readyState = 4
|
||||
env.art.emit('resize')
|
||||
await env.flush()
|
||||
assert.equal(allocated, 1)
|
||||
env.art.destroy()
|
||||
})
|
||||
|
||||
test('Canvas candidate ignores late bitmap rejection after terminal disposal', async () => {
|
||||
const pending = deferred()
|
||||
const env = await canvasEnvironment(implementation, { createImageBitmap: () => pending.promise })
|
||||
env.factory()(env.art)
|
||||
env.art.emit('video:play')
|
||||
env.art.destroy()
|
||||
pending.reject(new Error('late failure'))
|
||||
await env.flush()
|
||||
assert.equal(env.emitted.filter(event => event.name === 'artplayerProxyCanvas:error').length, 0)
|
||||
assert.equal(env.frames.size, 0)
|
||||
})
|
||||
|
||||
test('Canvas candidate preserves active callback error identity and closes bitmap before invoking it', async () => {
|
||||
let closed = false
|
||||
const failure = new Error('callback failure')
|
||||
const env = await canvasEnvironment(implementation, { createImageBitmap: () => Promise.resolve({ close() {
|
||||
closed = true
|
||||
} }) })
|
||||
env.factory(() => {
|
||||
assert.equal(closed, true)
|
||||
throw failure
|
||||
})(env.art)
|
||||
env.art.emit('resize')
|
||||
await env.flush()
|
||||
assert.equal(env.emitted.at(-1).args[0], failure)
|
||||
assert.equal(env.frames.size, 0)
|
||||
})
|
||||
|
||||
test('Canvas candidate deferred partial media registration failure rolls back native and host subscriptions', async () => {
|
||||
const env = await canvasEnvironment(implementation)
|
||||
const proxy = env.art.proxy
|
||||
const failure = new Error('partial media registration')
|
||||
env.art.proxy = (...args) => {
|
||||
proxy(...args)
|
||||
throw failure
|
||||
}
|
||||
env.factory()(env.art)
|
||||
env.flushTimers()
|
||||
assert.equal(env.proxies.length, 0)
|
||||
assert.equal(listenerCount(env), 0)
|
||||
assert.equal(env.video.parentNode, null)
|
||||
assert.equal(env.emitted.at(-1).args[0], failure)
|
||||
})
|
||||
|
||||
test('Canvas candidate handles destruction reentered before host registration returns', async () => {
|
||||
const env = await canvasEnvironment(implementation)
|
||||
const on = env.art.on
|
||||
env.art.on = (name, handler) => {
|
||||
if (name === 'destroy') {
|
||||
env.art.isDestroy = true
|
||||
handler()
|
||||
}
|
||||
return on(name, handler)
|
||||
}
|
||||
env.factory()(env.art)
|
||||
assert.equal(listenerCount(env), 0)
|
||||
assert.equal(env.timers.size, 0)
|
||||
})
|
||||
|
||||
test('Canvas candidate attempts remaining cleanup when a host unsubscribe throws', async () => {
|
||||
const env = await canvasEnvironment(implementation)
|
||||
env.factory()(env.art)
|
||||
env.flushTimers()
|
||||
const off = env.art.off
|
||||
const failure = new Error('unsubscribe failure')
|
||||
env.art.off = (...args) => {
|
||||
off(...args)
|
||||
throw failure
|
||||
}
|
||||
assert.throws(() => env.art.destroy(), error => error === failure)
|
||||
assert.equal(listenerCount(env), 0)
|
||||
assert.equal(env.proxies.length, 0)
|
||||
assert.equal(env.video.parentNode, null)
|
||||
assert.equal(env.canvas.width, 0)
|
||||
})
|
||||
|
||||
test('Canvas candidate drops bitmap errors from frames invalidated by a native seeking transition', async () => {
|
||||
const pending = deferred()
|
||||
const env = await canvasEnvironment(implementation, { createImageBitmap: () => pending.promise })
|
||||
env.factory()(env.art)
|
||||
env.art.emit('video:play')
|
||||
env.video.readyState = 1
|
||||
env.video.seeking = true
|
||||
pending.reject(new Error('old native frame unavailable'))
|
||||
await env.flush()
|
||||
assert.equal(env.emitted.filter(event => event.name === 'artplayerProxyCanvas:error').length, 0)
|
||||
env.art.destroy()
|
||||
})
|
||||
|
||||
test('Canvas candidate defers only initial unavailable bitmap errors, without trusting zero frame counters as a global capability gate', async () => {
|
||||
let frames = 0
|
||||
const failure = Object.assign(new Error('first frame unavailable'), { name: 'InvalidStateError' })
|
||||
const env = await canvasEnvironment(implementation, { createImageBitmap: () => Promise.reject(failure) })
|
||||
env.video.getVideoPlaybackQuality = () => ({ totalVideoFrames: frames })
|
||||
env.factory()(env.art)
|
||||
env.art.emit('resize')
|
||||
await env.flush()
|
||||
assert.equal(env.draws.length, 0)
|
||||
assert.equal(env.emitted.filter(event => event.name === 'artplayerProxyCanvas:error').length, 0)
|
||||
frames = 1
|
||||
env.art.emit('resize')
|
||||
await env.flush()
|
||||
assert.equal(env.emitted.at(-1).args[0], failure)
|
||||
const direct = await canvasEnvironment(implementation)
|
||||
direct.video.getVideoPlaybackQuality = () => ({ totalVideoFrames: 0 })
|
||||
direct.factory()(direct.art)
|
||||
direct.art.emit('resize')
|
||||
await direct.flush()
|
||||
assert.equal(direct.draws.length, 1)
|
||||
})
|
||||
|
||||
test('Canvas candidate never classifies a user callback InvalidStateError as a missing native first frame', async () => {
|
||||
const failure = Object.assign(new Error('callback invalid state'), { name: 'InvalidStateError' })
|
||||
const env = await canvasEnvironment(implementation, { createImageBitmap: () => Promise.resolve({ close() {} }) })
|
||||
env.video.getVideoPlaybackQuality = () => ({ totalVideoFrames: 0 })
|
||||
env.factory(() => {
|
||||
throw failure
|
||||
})(env.art)
|
||||
env.art.emit('resize')
|
||||
await env.flush()
|
||||
assert.equal(env.emitted.at(-1).args[0], failure)
|
||||
})
|
||||
|
||||
test('Canvas candidate preserves historical JavaScript calls with a falsy callback', async () => {
|
||||
for (const source of [...await canvasHistorical(), implementation]) {
|
||||
for (const option of [undefined, null, false, 0, '']) {
|
||||
const env = await canvasEnvironment(source)
|
||||
env.factory(option)(env.art)
|
||||
env.art.emit('resize')
|
||||
await env.flush()
|
||||
assert.equal(env.emitted.filter(event => event.name === 'artplayerProxyCanvas:error').length, 0, `${source.name}: ${String(option)}`)
|
||||
assert.equal(env.emitted.at(-1).name, 'artplayerProxyCanvas:draw')
|
||||
}
|
||||
}
|
||||
})
|
||||
+39
-2
@@ -1,7 +1,19 @@
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import vm from 'node:vm'
|
||||
import { transform } from 'esbuild'
|
||||
import { build, transform } from 'esbuild'
|
||||
import { verifyCanvasContract } from '../../refactor/scripts/canvas-contract.mjs'
|
||||
import { readMember } from '../../refactor/scripts/releases.mjs'
|
||||
import { getEntryFile } from '../../scripts/projects.js'
|
||||
|
||||
export async function canvasCandidate() {
|
||||
if (process.env.ARTPLAYER_CANVAS_BASELINE === '1')
|
||||
return (await canvasHistorical()).find(item => item.name === 'frozen-workspace')
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url))
|
||||
const result = await build({ entryPoints: [getEntryFile(path.join(root, 'packages/artplayer-proxy-canvas'))], bundle: true, write: false, platform: 'browser', format: 'cjs', target: 'es2020' })
|
||||
return { name: 'candidate-source', source: result.outputFiles[0].text, format: 'artifact' }
|
||||
}
|
||||
|
||||
export async function canvasHistorical() {
|
||||
const contract = await verifyCanvasContract()
|
||||
@@ -50,6 +62,7 @@ export async function canvasEnvironment(implementation, settings = {}) {
|
||||
title: 'video-title',
|
||||
src: '',
|
||||
currentTime: 0,
|
||||
readyState: 4,
|
||||
paused: true,
|
||||
muted: false,
|
||||
style: {},
|
||||
@@ -63,11 +76,35 @@ export async function canvasEnvironment(implementation, settings = {}) {
|
||||
this.paused = true
|
||||
},
|
||||
addEventListener(...args) { calls.push({ name: 'video-listener', receiver: this, args }) },
|
||||
removeEventListener(name, callback) {
|
||||
for (let index = proxies.length - 1; index >= 0; index--) {
|
||||
if (proxies[index].target === this && proxies[index].name === name && proxies[index].callback === callback)
|
||||
proxies.splice(index, 1)
|
||||
}
|
||||
},
|
||||
setAttribute(name, value) { this[name] = value },
|
||||
removeAttribute(name) { delete this[name] },
|
||||
remove() { this.parentNode?.removeChild(this) },
|
||||
load() { calls.push({ name: 'load', receiver: this }) },
|
||||
}
|
||||
const player = {
|
||||
clientWidth: 640,
|
||||
clientHeight: 480,
|
||||
children: [],
|
||||
appendChild(node) {
|
||||
this.children.push(node)
|
||||
node.parentNode = this
|
||||
return node
|
||||
},
|
||||
removeChild(node) {
|
||||
this.children.splice(this.children.indexOf(node), 1)
|
||||
node.parentNode = null
|
||||
},
|
||||
}
|
||||
const art = {
|
||||
constructor: { config: { events: ['loadedmetadata', 'play', 'pause', 'seeked', 'error'] }, utils: { createElement: tag => tag === 'canvas' ? canvas : video } },
|
||||
option: { autoSize: false },
|
||||
template: { $player: { clientWidth: 640, clientHeight: 480 } },
|
||||
template: { $player: player },
|
||||
isDestroy: false,
|
||||
on(name, callback) {
|
||||
if (!listeners.has(name))
|
||||
|
||||
Reference in new issue
Block a user