refactor(ambilight): [PKG-AMBILIGHT-03] split typed lifecycle and recover native canvas sampling

This commit is contained in:
Harvey Zhao committed 2026-09-12 11:35:13 +08:00
1 parent 43f0e9a8c6
commit 6caee44dbf
25 files changed
+1680 -306

No files matched your search

+294
View File
@@ -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()
}
})
+114
View File
@@ -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)
})
}
+17 -2
View File
@@ -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