mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-13 07:46:17 -08:00
refactor(ambilight): [PKG-AMBILIGHT-03] split typed lifecycle and recover native canvas sampling
This commit is contained in:
1 parent
43f0e9a8c6
commit
6caee44dbf
25 files changed
+1680
-306
No files matched your search
@@ -0,0 +1,294 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Candidate lifecycle assertions are also run against the frozen source to prove the fixes.
|
||||
import test from 'node:test'
|
||||
import { ambilightCandidate, ambilightEnvironment, ambilightHistorical } from './helpers/ambilight.js'
|
||||
|
||||
const implementation = await ambilightCandidate()
|
||||
|
||||
test('Ambilight candidate matches all historical normal grid, sample order, timing and public return observations', async () => {
|
||||
const observations = []
|
||||
for (const source of [...await ambilightHistorical(), implementation]) {
|
||||
const env = await ambilightEnvironment(source)
|
||||
const result = env.factory()(env.art)
|
||||
const grid = env.parent.children[0]
|
||||
env.art.emit('ready')
|
||||
env.art.playing = true
|
||||
env.frame(99)
|
||||
assert.equal(env.draws.length, 0)
|
||||
env.frame(100)
|
||||
assert.equal(env.draws.length, 9)
|
||||
env.frame(199)
|
||||
assert.equal(env.draws.length, 9)
|
||||
observations.push({ name: result.name, grid: { className: grid.className, style: grid.style }, items: grid.children.map(child => child.style), samples: env.draws.map(args => [...args.slice(1)]), start: result.start(), stop: result.stop(), pending: env.frames.size })
|
||||
}
|
||||
for (const observed of observations.slice(1))
|
||||
assert.deepEqual(observed, observations[0])
|
||||
})
|
||||
|
||||
test('Ambilight candidate tracks RAF zero without duplicate work and cancels it', async () => {
|
||||
const env = await ambilightEnvironment(implementation, { firstFrame: 0 })
|
||||
const result = env.factory()(env.art)
|
||||
result.start()
|
||||
result.start()
|
||||
assert.equal(env.frames.size, 1)
|
||||
result.stop()
|
||||
assert.equal(env.frames.size, 0)
|
||||
})
|
||||
|
||||
test('Ambilight candidate core destruction releases its view, canvas and listeners and makes escaped methods inert', async () => {
|
||||
const env = await ambilightEnvironment(implementation)
|
||||
const result = env.factory()(env.art)
|
||||
env.art.emit('ready')
|
||||
env.art.destroy()
|
||||
result.start()
|
||||
result.stop()
|
||||
env.art.emit('ready')
|
||||
env.art.destroy()
|
||||
assert.equal(env.frames.size, 0)
|
||||
assert.deepEqual(env.parent.children, [env.video])
|
||||
assert.equal(env.nodes.find(node => node.tag === 'canvas').width, 0)
|
||||
assert.equal(env.listeners.get('ready')?.size || 0, 0)
|
||||
assert.equal(env.listeners.get('destroy')?.size || 0, 0)
|
||||
})
|
||||
|
||||
test('Ambilight candidate recovers from canvas read/draw failure without wedging its loop', async () => {
|
||||
for (const kind of ['read', 'draw']) {
|
||||
const env = await ambilightEnvironment(implementation)
|
||||
const result = env.factory()(env.art)
|
||||
result.start()
|
||||
env.art.playing = true
|
||||
const failure = Object.assign(new Error(kind), { name: kind === 'read' ? 'SecurityError' : 'InvalidStateError' })
|
||||
if (kind === 'read')
|
||||
env.setReadError(failure)
|
||||
else env.setDrawError(failure)
|
||||
assert.doesNotThrow(() => env.frame(100))
|
||||
assert.equal(env.frames.size, 1)
|
||||
env.setReadError(null)
|
||||
env.setDrawError(null)
|
||||
env.frame(200)
|
||||
assert.equal(env.draws.length, 10)
|
||||
assert.equal(env.parent.children[0].children[8].style.backgroundColor, 'rgb(10, 20, 30)')
|
||||
env.art.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('Ambilight candidate skips unavailable contexts and invalid dimensions while retaining a recoverable loop', async () => {
|
||||
const missing = await ambilightEnvironment(implementation, { noContext: true })
|
||||
missing.art.playing = true
|
||||
missing.time(100)
|
||||
assert.doesNotThrow(() => missing.factory()(missing.art).start())
|
||||
assert.equal(missing.frames.size, 1)
|
||||
missing.art.destroy()
|
||||
for (const width of [0, -1, Number.NaN, Infinity]) {
|
||||
const env = await ambilightEnvironment(implementation)
|
||||
env.video.videoWidth = width
|
||||
env.art.playing = true
|
||||
env.time(100)
|
||||
const result = env.factory()(env.art)
|
||||
result.start()
|
||||
assert.equal(env.draws.length, 0)
|
||||
env.video.videoWidth = 300
|
||||
env.frame(200)
|
||||
assert.equal(env.draws.length, 9)
|
||||
env.art.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('Ambilight candidate stops in-progress sampling after reentrant stop or core destruction', async () => {
|
||||
for (const operation of ['stop', 'destroy']) {
|
||||
let result
|
||||
const env = await ambilightEnvironment(implementation, { onDraw() {
|
||||
if (operation === 'stop')
|
||||
result.stop()
|
||||
else env.art.destroy()
|
||||
} })
|
||||
result = env.factory()(env.art)
|
||||
const grid = env.parent.children[0]
|
||||
result.start()
|
||||
env.art.playing = true
|
||||
env.frame(100)
|
||||
assert.equal(env.draws.length, 1)
|
||||
assert.equal(grid.children[0].style.backgroundColor, undefined)
|
||||
assert.equal(env.frames.size, 0)
|
||||
env.art.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('Ambilight candidate stop/start reentrancy defers the replacement frame and discards obsolete colors', async () => {
|
||||
let result
|
||||
let once = true
|
||||
const env = await ambilightEnvironment(implementation, { onDraw() {
|
||||
if (!once)
|
||||
return
|
||||
once = false
|
||||
result.stop()
|
||||
result.start()
|
||||
} })
|
||||
result = env.factory()(env.art)
|
||||
result.start()
|
||||
env.art.playing = true
|
||||
env.frame(100)
|
||||
assert.equal(env.draws.length, 1)
|
||||
assert.equal(env.frames.size, 1)
|
||||
assert.equal(env.parent.children[0].children[0].style.backgroundColor, undefined)
|
||||
env.frame(200)
|
||||
assert.equal(env.draws.length, 10)
|
||||
env.art.destroy()
|
||||
})
|
||||
|
||||
test('Ambilight candidate rolls back inserted views when setup throws', async () => {
|
||||
const env = await ambilightEnvironment(implementation)
|
||||
const failure = new Error('style setup failed')
|
||||
env.art.constructor.utils.setStyles = () => {
|
||||
throw failure
|
||||
}
|
||||
assert.throws(() => env.factory()(env.art), error => error === failure)
|
||||
assert.deepEqual(env.parent.children, [env.video])
|
||||
assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 0)
|
||||
})
|
||||
|
||||
test('Ambilight candidate late attachment to a destroyed host allocates no view or canvas', async () => {
|
||||
const env = await ambilightEnvironment(implementation)
|
||||
env.art.destroy()
|
||||
const count = env.nodes.length
|
||||
const result = env.factory()(env.art)
|
||||
result.start()
|
||||
assert.equal(env.nodes.length, count)
|
||||
assert.equal(env.frames.size, 0)
|
||||
assert.equal(env.listeners.size, 0)
|
||||
})
|
||||
|
||||
test('Ambilight candidate releases canvas and view when context acquisition throws', async () => {
|
||||
const failure = new Error('context acquisition failed')
|
||||
const env = await ambilightEnvironment(implementation, { onContext() {
|
||||
throw failure
|
||||
} })
|
||||
assert.throws(() => env.factory()(env.art), error => error === failure)
|
||||
assert.deepEqual(env.parent.children, [env.video])
|
||||
const canvas = env.nodes.find(node => node.tag === 'canvas')
|
||||
assert.equal(canvas.width, 0)
|
||||
assert.equal(canvas.height, 0)
|
||||
assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 0)
|
||||
})
|
||||
|
||||
test('Ambilight candidate rolls back partial subscription while preserving the setup error', async () => {
|
||||
const env = await ambilightEnvironment(implementation)
|
||||
const failure = new Error('ready subscription failed')
|
||||
const on = env.art.on
|
||||
env.art.on = (name, callback) => {
|
||||
on(name, callback)
|
||||
if (name === 'ready')
|
||||
throw failure
|
||||
return env.art
|
||||
}
|
||||
assert.throws(() => env.factory()(env.art), error => error === failure)
|
||||
assert.deepEqual(env.parent.children, [env.video])
|
||||
assert.equal(env.nodes.find(node => node.tag === 'canvas').width, 0)
|
||||
assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 0)
|
||||
})
|
||||
|
||||
test('Ambilight candidate attempts every release even when host listener cleanup throws', async () => {
|
||||
const env = await ambilightEnvironment(implementation)
|
||||
const result = env.factory()(env.art)
|
||||
result.start()
|
||||
const failure = new Error('host off failed')
|
||||
const off = env.art.off
|
||||
env.art.off = (...args) => {
|
||||
off(...args)
|
||||
throw failure
|
||||
}
|
||||
assert.throws(() => env.art.destroy(), error => error === failure)
|
||||
result.start()
|
||||
assert.equal(env.frames.size, 0)
|
||||
assert.deepEqual(env.parent.children, [env.video])
|
||||
assert.equal(env.nodes.find(node => node.tag === 'canvas').width, 0)
|
||||
assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 0)
|
||||
})
|
||||
|
||||
test('Ambilight candidate ignores a cancelled stale callback without losing its replacement frame', async () => {
|
||||
const env = await ambilightEnvironment(implementation)
|
||||
const result = env.factory()(env.art)
|
||||
result.start()
|
||||
const stale = [...env.frames.values()][0]
|
||||
result.stop()
|
||||
result.start()
|
||||
const replacement = [...env.frames.keys()][0]
|
||||
stale(100)
|
||||
assert.deepEqual([...env.frames.keys()], [replacement])
|
||||
result.stop()
|
||||
assert.equal(env.frames.size, 0)
|
||||
env.art.destroy()
|
||||
})
|
||||
|
||||
test('Ambilight candidate construction-time destruction releases partially initialized resources', async () => {
|
||||
for (const boundary of ['style', 'context', 'element', 'subscription']) {
|
||||
const env = await ambilightEnvironment(implementation, { onContext() {
|
||||
if (boundary === 'context')
|
||||
env.art.destroy()
|
||||
} })
|
||||
if (boundary === 'style') {
|
||||
const original = env.art.constructor.utils.setStyles
|
||||
env.art.constructor.utils.setStyles = (...args) => {
|
||||
env.art.destroy()
|
||||
return original(...args)
|
||||
}
|
||||
}
|
||||
if (boundary === 'element') {
|
||||
const original = env.art.constructor.utils.createElement
|
||||
env.art.constructor.utils.createElement = (...args) => {
|
||||
env.art.destroy()
|
||||
return original(...args)
|
||||
}
|
||||
}
|
||||
if (boundary === 'subscription') {
|
||||
const original = env.art.on
|
||||
env.art.on = (...args) => {
|
||||
const result = original(...args)
|
||||
env.art.destroy()
|
||||
return result
|
||||
}
|
||||
}
|
||||
const result = env.factory()(env.art)
|
||||
result.start()
|
||||
assert.deepEqual(env.parent.children, [env.video], boundary)
|
||||
assert.equal(env.frames.size, 0, boundary)
|
||||
assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 0, boundary)
|
||||
for (const canvas of env.nodes.filter(node => node.tag === 'canvas')) assert.equal(canvas.width, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('Ambilight candidate observes destruction from option getters before allocating resources', async () => {
|
||||
const env = await ambilightEnvironment(implementation)
|
||||
const count = env.nodes.length
|
||||
const result = env.factory({ get blur() {
|
||||
env.art.destroy()
|
||||
return '50px'
|
||||
} })(env.art)
|
||||
result.start()
|
||||
assert.equal(env.nodes.length, count)
|
||||
assert.equal(env.frames.size, 0)
|
||||
assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 0)
|
||||
})
|
||||
|
||||
test('Ambilight candidate keeps normal styles, special frequency values, stop colors and synchronous return shape', async () => {
|
||||
for (const [frequency, draws] of [[0, 0], [-1, 9], [Infinity, 9], [Number.NaN, 9], ['2', 0]]) {
|
||||
const env = await ambilightEnvironment(implementation)
|
||||
const option = { frequency, zIndex: -10 }
|
||||
const attach = env.factory(option)
|
||||
option.opacity = 0.8
|
||||
const result = attach(env.art)
|
||||
env.art.playing = true
|
||||
env.time(100)
|
||||
assert.equal(result.start(), undefined)
|
||||
assert.equal(env.draws.length, draws)
|
||||
const grid = env.parent.children[0]
|
||||
assert.equal(grid.style.zIndex, 9)
|
||||
assert.equal(grid.children[0].style.opacity, 0.8)
|
||||
assert.equal(grid.children[0].style.filter, 'blur(50px)')
|
||||
const colors = grid.children.map(child => child.style.backgroundColor)
|
||||
assert.equal(result.stop(), undefined)
|
||||
assert.deepEqual(grid.children.map(child => child.style.backgroundColor), colors)
|
||||
assert.deepEqual(Object.keys(result), ['name', 'start', 'stop'])
|
||||
env.art.destroy()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { ambilightCandidate } from '../helpers/ambilight.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const implementation = await ambilightCandidate()
|
||||
|
||||
async function setup(page, core, testInfo, crossOrigin = false) {
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
// Lexical observers delegate to native RAF; core and test frames are not counted.
|
||||
await page.addScriptTag({ content: `(() => {
|
||||
const pending = new Set();
|
||||
const requestAnimationFrame = callback => {
|
||||
const id = window.requestAnimationFrame(time => { pending.delete(id); callback(time); });
|
||||
pending.add(id); return id;
|
||||
};
|
||||
const cancelAnimationFrame = id => { pending.delete(id); window.cancelAnimationFrame(id); };
|
||||
const module = { exports: {} }; const exports = module.exports;
|
||||
${implementation.source};
|
||||
window.ambilightFactory = module.exports.default || module.exports;
|
||||
window.ambilightPending = pending;
|
||||
})();` })
|
||||
await page.evaluate((crossOrigin) => {
|
||||
const url = new URL('/test/pattern.mp4', location.href)
|
||||
if (crossOrigin)
|
||||
url.hostname = 'localhost'
|
||||
window.createPlayer(url.href)
|
||||
window.art.plugins.add(window.ambilightFactory({ frequency: Infinity }))
|
||||
window.ambilight = window.art.plugins.artplayerPluginAmbilight
|
||||
}, crossOrigin)
|
||||
await expect.poll(() => page.evaluate(() => window.art.template.$video.videoWidth)).toBeGreaterThan(0)
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.art.playing && window.art.currentTime > 0)).toBe(true)
|
||||
await testInfo.attach('ambilight-candidate-inputs', { contentType: 'application/json', body: JSON.stringify({ core, implementation: implementation.name, sha256: hash(implementation.source), crossOrigin, canvas: 'native canvas and media, lexical native RAF observer' }) })
|
||||
}
|
||||
|
||||
const colors = page => page.evaluate(() => [...document.querySelectorAll('.artplayer-plugin-ambilight > div')].map(node => node.style.backgroundColor))
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
await testInfo.attach('ambilight-candidate-state', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => ({ version: window.Artplayer?.version, pending: window.ambilightPending?.size, grids: document.querySelectorAll('.artplayer-plugin-ambilight').length, source: window.art?.url }))) })
|
||||
await page.evaluate(() => {
|
||||
window.ambilight?.stop()
|
||||
if (window.art && !window.art.isDestroy)
|
||||
window.art.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
for (const core of ['published-5.1.7', 'published', 'candidate']) {
|
||||
test(`${core}: Ambilight ready starts native sampling and destruction releases only its frames and view`, async ({ page }, testInfo) => {
|
||||
await setup(page, core, testInfo)
|
||||
await expect.poll(async () => (await colors(page)).filter(color => /^rgb\(/.test(color)).length).toBe(9)
|
||||
expect(await page.evaluate(() => window.ambilightPending.size)).toBe(1)
|
||||
await page.click('#pause')
|
||||
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(false)
|
||||
const before = await colors(page)
|
||||
await page.evaluate(() => window.ambilight.stop())
|
||||
expect(await colors(page)).toEqual(before)
|
||||
expect(await page.evaluate(() => window.ambilightPending.size)).toBe(0)
|
||||
await page.evaluate(() => window.ambilight.start())
|
||||
expect(await page.evaluate(() => window.ambilightPending.size)).toBe(1)
|
||||
await page.evaluate(async () => {
|
||||
window.art.destroy(false)
|
||||
window.ambilight.start()
|
||||
window.ambilight.stop()
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
})
|
||||
expect(await page.locator('.artplayer-plugin-ambilight').count()).toBe(0)
|
||||
expect(await page.evaluate(() => window.ambilightPending.size)).toBe(0)
|
||||
})
|
||||
|
||||
test(`${core}: Ambilight recovers from native cross-origin taint after a same-origin source switch`, async ({ page }, testInfo) => {
|
||||
await setup(page, core, testInfo, true)
|
||||
const error = await page.evaluate(() => {
|
||||
const context = document.createElement('canvas').getContext('2d')
|
||||
window.ambilightProbe = context
|
||||
context.drawImage(window.art.template.$video, 0, 0, 1, 1)
|
||||
try {
|
||||
context.getImageData(0, 0, 1, 1)
|
||||
return null
|
||||
}
|
||||
catch (error) { return error.name }
|
||||
})
|
||||
expect(error).toBe('SecurityError')
|
||||
expect(await colors(page)).toEqual(Array.from({ length: 9 }, () => ''))
|
||||
expect(await page.evaluate(() => window.ambilightPending.size)).toBe(1)
|
||||
await page.evaluate(() => {
|
||||
window.ambilightOriginalGrid = document.querySelector('.artplayer-plugin-ambilight')
|
||||
return window.art.switchUrl('/test/pattern.mp4')
|
||||
})
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.art.playing && window.art.template.$video.currentSrc.startsWith(location.origin))).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const context = document.createElement('canvas').getContext('2d')
|
||||
try {
|
||||
context.drawImage(window.art.template.$video, 0, 0, 1, 1)
|
||||
context.getImageData(0, 0, 1, 1)
|
||||
return true
|
||||
}
|
||||
catch { return false }
|
||||
})).toBe(true)
|
||||
await testInfo.attach('native-canvas-reset-control', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => {
|
||||
const context = window.ambilightProbe
|
||||
context.canvas.width = 3
|
||||
try {
|
||||
context.drawImage(window.art.template.$video, 0, 0, 1, 1)
|
||||
context.getImageData(0, 0, 1, 1)
|
||||
return { resetReadable: true }
|
||||
}
|
||||
catch (error) { return { resetReadable: false, error: error.name } }
|
||||
})) })
|
||||
await expect.poll(async () => (await colors(page)).filter(color => /^rgb\(/.test(color)).length).toBe(9)
|
||||
expect(await page.evaluate(() => window.ambilightOriginalGrid === document.querySelector('.artplayer-plugin-ambilight'))).toBe(true)
|
||||
expect(await page.evaluate(() => window.ambilightPending.size)).toBe(1)
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
import assert from 'node:assert/strict'
|
||||
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 { verifyAmbilightContract } from '../../refactor/scripts/ambilight-contract.mjs'
|
||||
import { readMember } from '../../refactor/scripts/releases.mjs'
|
||||
import { getEntryFile } from '../../scripts/projects.js'
|
||||
|
||||
export async function ambilightCandidate() {
|
||||
if (process.env.ARTPLAYER_AMBILIGHT_BASELINE === '1')
|
||||
return (await ambilightHistorical()).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-plugin-ambilight'))], bundle: true, write: false, platform: 'browser', format: 'cjs', target: 'es2020' })
|
||||
return { name: 'candidate-source', source: result.outputFiles[0].text, format: 'artifact' }
|
||||
}
|
||||
|
||||
export async function ambilightHistorical() {
|
||||
const contract = await verifyAmbilightContract()
|
||||
@@ -64,7 +76,10 @@ export async function ambilightEnvironment(implementation, settings = {}) {
|
||||
return child
|
||||
},
|
||||
remove() { this.parentNode?.removeChild(this) },
|
||||
getContext: () => context,
|
||||
getContext() {
|
||||
settings.onContext?.()
|
||||
return context
|
||||
},
|
||||
}
|
||||
nodes.push(value)
|
||||
return value
|
||||
|
||||
Reference in new issue
Block a user