diff --git a/package.json b/package.json
index 75f14f389..4cca802c8 100644
--- a/package.json
+++ b/package.json
@@ -51,7 +51,7 @@
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
- "test:unit": "node --test test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
+ "test:unit": "node --test test/ambilight.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
@@ -69,7 +69,8 @@
"check:commits": "node refactor/scripts/commit-audit.mjs",
"check:impact": "node refactor/scripts/impact.mjs",
"check:contracts": "node refactor/scripts/contracts.mjs",
- "test:contracts": "node refactor/scripts/run-contracts.mjs"
+ "test:contracts": "node refactor/scripts/run-contracts.mjs",
+ "test:ambilight": "node --test test/ambilight.test.js"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
diff --git a/refactor/ambilight-validation.md b/refactor/ambilight-validation.md
index 248993bd3..6d688eaf3 100644
--- a/refactor/ambilight-validation.md
+++ b/refactor/ambilight-validation.md
@@ -11,3 +11,16 @@
复现问题;04兼容类型及旧CJS.default;05/06做真实媒体、代理、浏览器和安装包验证。
stop保留最后画面,核心destroy终止资源。zIndex仍忽略,参数省略及两版声明差异分别
处理;Git关联不构成连续核心支持范围。
+
+## 02历史回归
+
+`yarn test:ambilight`运行30项Node回归并接入test:unit;正常参数/频率/暂停与已确认的
+历史问题分别断言。`yarn test:browser test/browser/ambilight.spec.js`运行三实现/三核心/
+三引擎真实媒体;核心标签published是实际npm5.4.0,published-5.1.7是实际npm5.1.7,
+candidate是当前源码;以browser-evidence的manifest和实际version为准。
+跨origin使用同一服务的localhost与127.0.0.1,读像素抛原生SecurityError;换回同源
+后持续错误是待03修复的历史污染。Node零尺寸记录只证明未保护,未模拟为浏览器结论。
+
+02最终30项Node、54项三引擎/三核心浏览器通过,未skip;新建Canvas能读取已恢复的
+同源视频,而旧插件Canvas仍抛SecurityError。主CI955项通过(另44项重复观察)。
+完整报告与输入来源见baselines/ambilight-behavior-validation.json。03开始修复这些问题。
diff --git a/refactor/baselines/ambilight-behavior-validation.json b/refactor/baselines/ambilight-behavior-validation.json
new file mode 100644
index 000000000..9ae36cc96
--- /dev/null
+++ b/refactor/baselines/ambilight-behavior-validation.json
@@ -0,0 +1,109 @@
+{
+ "schemaVersion": 1,
+ "task": "PKG-AMBILIGHT-02",
+ "status": "done",
+ "sourceCommit": "06032a6906aa2a5c59e421cd8076d70daf5f4801",
+ "checks": [
+ {
+ "command": "node --test test/ambilight.test.js",
+ "result": "pass",
+ "tests": 30,
+ "scope": "Three frozen implementations; normal control/parameter semantics and historical failure assertions",
+ "log": "refactor/.cache/ambilight02-tests.log"
+ },
+ {
+ "command": "playwright test test/browser/ambilight.spec.js",
+ "result": "pass",
+ "tests": 54,
+ "projects": [
+ "chromium",
+ "firefox",
+ "webkit"
+ ],
+ "coreSources": [
+ "npm 5.1.7",
+ "npm 5.4.0",
+ "candidate source"
+ ],
+ "skipped": 0,
+ "scope": "Native video/Canvas/RAF, local source switch, retained DOM and actual cross-origin taint; fresh native canvas reads recovered video while old plugin canvas still fails",
+ "report": "refactor/.cache/ambilight02-final-results/report.json",
+ "archivedResults": "refactor/.cache/ambilight02-final-results/results",
+ "originalResultsPrefix": "refactor/.cache/browser/results",
+ "stats": {
+ "startTime": "2026-09-12T03:03:56.604Z",
+ "duration": 68280.7,
+ "expected": 54,
+ "skipped": 0,
+ "unexpected": 0,
+ "flaky": 0
+ }
+ },
+ {
+ "command": "eslint test/ambilight.test.js test/browser/ambilight.spec.js --no-fix",
+ "result": "pass"
+ },
+ {
+ "command": "yarn ci:check",
+ "result": "pass",
+ "tests": {
+ "unit": 862,
+ "engineering": 14,
+ "baseline": 79,
+ "mainTotal": 955,
+ "repeatedContractExecutions": 44
+ },
+ "log": "refactor/.cache/ambilight02-ci.log"
+ }
+ ],
+ "inputs": [
+ {
+ "file": "test/ambilight.test.js",
+ "sha256LF": "886a617d1ddb800c9fd639e3a125b9d1788e67a287900b7d6fbf8f6324774c47"
+ },
+ {
+ "file": "test/browser/ambilight.spec.js",
+ "sha256LF": "5035a3a38460d56747b3786299672e4dbde6543bd384fb3c7112e522dd2add7f"
+ },
+ {
+ "file": "test/helpers/ambilight.js",
+ "sha256LF": "7775f147355d2aa08615a82079760924ae151beb7d9bdc94cccfd8195f5b19d8"
+ },
+ {
+ "file": "package.json",
+ "sha256LF": "0d2d5c253b72588e48fe6e68ad7069b70945d8a6bcd6866bbca4516e8299721a"
+ },
+ {
+ "file": "test/browser/server.mjs",
+ "sha256LF": "af513e1e64f875668b6a7cbd060276d85d930e0cc1715577e97907cc26c3b58d"
+ },
+ {
+ "file": "refactor/baselines/ambilight-release.json",
+ "sha256LF": "6acbea40a26397ffa36549fbecb1a12c057cfb3fc932f7cb83181ad5fa37bd2f"
+ }
+ ],
+ "logs": [
+ {
+ "file": "refactor/.cache/ambilight02-tests.log",
+ "sha256": "f90963c42d07326e72767e81a9a446891b234ce8e6295192a7bef57ca3be03bf"
+ },
+ {
+ "file": "refactor/.cache/ambilight02-browser-final.log",
+ "sha256": "4f1f4ea230fc667f48a1de5a0890abd2f4945342f2760952e1f50d3847e1083b"
+ },
+ {
+ "file": "refactor/.cache/ambilight02-lint-final.log",
+ "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ },
+ {
+ "file": "refactor/.cache/ambilight02-ci.log",
+ "sha256": "b265745fbf3347d3c31c94e4a48a2e6006027afeb2239758f68a6976c553d8bd"
+ }
+ ],
+ "browserReportSha256": "7bd351759c2003cdc6c4d52dee7bdb4ec24f647ae19a91fa4e6f974b5edcd1c7",
+ "limitations": [
+ "These are historical observations; candidate fixes remain PKG-AMBILIGHT-03",
+ "Controlled zero dimensions prove missing guards, not a specific native exception",
+ "No physical devices, canvas proxy support claim, full consumer type or final packed plugin acceptance"
+ ]
+}
diff --git a/refactor/changes/2026-09-12-PKG-AMBILIGHT-02-tests.md b/refactor/changes/2026-09-12-PKG-AMBILIGHT-02-tests.md
new file mode 100644
index 000000000..f7436df37
--- /dev/null
+++ b/refactor/changes/2026-09-12-PKG-AMBILIGHT-02-tests.md
@@ -0,0 +1,19 @@
+# PKG-AMBILIGHT-02 历史行为与失败复现
+
+沿用01两个实际发布及冻结工作区,30项Node测试分别验证频率/暂停/重复start-stop和
+历史异常路径。对id=0、销毁后逃逸start、取色异常卡住旧handle、空context/零尺寸、
+取色中stop/destroy重入、设置样式失败和已销毁host晚注册建立可重跑断言。
+
+浏览器使用原生video、Canvas和RAF;三个核心(实际npm5.1.7、npm5.4.0及候选)、
+三插件实现、三引擎验证9格取色、暂停/停止保留颜色、切源和destroy(false)残留视图。
+127.0.0.1页面加载localhost同端口视频构成真实跨origin;手动start捕获原生SecurityError,
+切回同源且媒体播放后仍复现同一错误,证明历史Canvas污染持续存在。没有拦截网络或
+模拟像素/原生异常;主动catch用于避免将预期旧缺陷变成浏览器未处理错误。
+
+Node模拟器的零尺寸被允许,只证明缺少尺寸保护,不声称浏览器一定抛某种异常;真实
+浏览器结果与源文件/样本/引擎/Core版本由附件保存。默认全局CI仍执行全部必要门槛。
+专项、三引擎和完整CI结果见 [验证记录](../baselines/ambilight-behavior-validation.json)。
+
+这些断言固定的是历史问题,03需另加候选应当修复的断言,不能改旧快照让缺陷消失。
+下一步拆分视图、取色和RAF/销毁归属,保留正常stop/配置/样式/返回行为;类型和最终
+分发仍归04/06,实际设备与代理能力归05。无生产源码变更、推送或发布。
diff --git a/refactor/plan.md b/refactor/plan.md
index 017ce354a..21a37bb50 100644
--- a/refactor/plan.md
+++ b/refactor/plan.md
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 220 项,范围 22 个包及工作区/示例。
-状态:todo 129 / doing 6 / blocked 0 / done 85 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
+状态:todo 128 / doing 6 / blocked 0 / done 86 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -149,7 +149,7 @@
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| PKG-AMBILIGHT-01 | artplayer-plugin-ambilight
核对包契约与历史用法 | BASE-05 | blur/opacity/frequency/duration、start/stop 与挂载样式 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | M | done |
-| PKG-AMBILIGHT-02 | artplayer-plugin-ambilight
建立特有行为与错误测试 | PKG-AMBILIGHT-01, ENG-03, ENG-05 | 跨域 canvas 失败、零尺寸、暂停、重复 start/stop 和 destroy | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | M | todo |
+| PKG-AMBILIGHT-02 | artplayer-plugin-ambilight
建立特有行为与错误测试 | PKG-AMBILIGHT-01, ENG-03, ENG-05 | 跨域 canvas 失败、零尺寸、暂停、重复 start/stop 和 destroy | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | M | done |
| PKG-AMBILIGHT-03 | artplayer-plugin-ambilight
整理内部职责与资源 | PKG-AMBILIGHT-02, CORE-03, CORE-12 | 取色计算与绘帧分离,统一 RAF 和 DOM 清理 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | M | todo |
| PKG-AMBILIGHT-04 | artplayer-plugin-ambilight
迁移自有源码和公开类型 | PKG-AMBILIGHT-03, ENG-04, ENG-06, CORE-07 | canvas 上下文、参数及 start/stop 的明确类型 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | M | todo |
| PKG-AMBILIGHT-05 | artplayer-plugin-ambilight
验证新旧核心和组合 | PKG-AMBILIGHT-04, CORE-22 | 原生 video、canvas proxy 的能力边界及销毁无帧循环 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | M | todo |
@@ -477,6 +477,7 @@
- PKG-CHAPTER-04: [记录](changes/2026-09-10-PKG-CHAPTER-04-public-types.md) [记录](baselines/chapter-types-validation.json)
- PKG-CHAPTER-05: [记录](changes/2026-09-12-PKG-CHAPTER-05-combinations.md) [记录](baselines/chapter-combinations-checkpoint.json)
- PKG-AMBILIGHT-01: [记录](changes/2026-09-12-PKG-AMBILIGHT-01-contract.md) [记录](baselines/ambilight-contract.md) [记录](baselines/ambilight-release.json) [记录](baselines/ambilight-contract-validation.json)
+- PKG-AMBILIGHT-02: [记录](changes/2026-09-12-PKG-AMBILIGHT-02-tests.md) [记录](baselines/ambilight-behavior-validation.json) [记录](ambilight-validation.md)
- PKG-AUDIO-01: [记录](changes/2026-09-12-PKG-AUDIO-01-contract.md) [记录](baselines/audio-track-contract.md) [记录](baselines/audio-track-release.json)
- PKG-AUDIO-02: [记录](changes/2026-09-12-PKG-AUDIO-02-tests.md) [记录](audio-validation.md) [记录](baselines/audio-validation.json)
- PKG-AUDIO-03: [记录](changes/2026-09-12-PKG-AUDIO-03-lifecycle.md) [记录](baselines/audio-lifecycle-validation.json)
diff --git a/refactor/progress.md b/refactor/progress.md
index c8cf35433..f9f68e894 100644
--- a/refactor/progress.md
+++ b/refactor/progress.md
@@ -1,5 +1,14 @@
# 进度与证据
+## 最新完成:Ambilight-02 行为与失败回归
+
+30项Node复现RAF id零、异常后卡住、销毁/重入/晚注册和初始化回滚问题。54项三引擎/
+三核心真实媒体验证通过;原生跨域Canvas污染在同源恢复后仍持续,新Canvas能取色的
+独立对照排除媒体未恢复误判。核心来源为实际npm5.1.7/5.4.0及候选;无skip。主CI955项
+通过,另44项重复观察;03将拆TS并修复,尚未改生产源码。当前220项:86 done、
+6 doing、128 todo。见 [实施记录](changes/2026-09-12-PKG-AMBILIGHT-02-tests.md)
+和 [验证证据](baselines/ambilight-behavior-validation.json)。
+
## 最新完成:Ambilight-01 实际发布契约
冻结npm1.0.0/1.1.0共12成员及工作区源码/声明/demo;真实执行旧CJS.default、新CJS/
diff --git a/refactor/risk-table.md b/refactor/risk-table.md
index bd262b399..ad5b1bdab 100644
--- a/refactor/risk-table.md
+++ b/refactor/risk-table.md
@@ -151,6 +151,6 @@
| VAST-TYPE-01 | open / 已复现 | VAST declares an async factory as synchronous and imports an unavailable SDK type dependency | PKG-VAST-04, PKG-VAST-06 |
| VAST-LIFE-01 | open / 已复现 | VAST SDK and container lack core destruction ownership and pending work cancellation | PKG-VAST-02, PKG-VAST-03, PKG-VAST-05 |
| VAST-DIST-01 | open / 已复现 | Published VAST CommonJS default namespace differs from the current callable distribution | PKG-VAST-04, PKG-VAST-06 |
-| AMBILIGHT-LIFE-01 | open / 源码/产物事实 | Ambilight sampling and RAF/DOM ownership lack terminal/error handling | PKG-AMBILIGHT-02, PKG-AMBILIGHT-03, PKG-AMBILIGHT-05 |
+| AMBILIGHT-LIFE-01 | open / 已复现 | Ambilight sampling and RAF/DOM ownership lack terminal/error handling | PKG-AMBILIGHT-02, PKG-AMBILIGHT-03, PKG-AMBILIGHT-05 |
| AMBILIGHT-TYPE-01 | open / 源码/产物事实 | Ambilight declarations disagree with optional factory argument and differ across published generations | PKG-AMBILIGHT-04, PKG-AMBILIGHT-06 |
| AMBILIGHT-DIST-01 | open / 已复现 | Ambilight 1.0.0 CommonJS default namespace differs from callable 1.1.0 | PKG-AMBILIGHT-04, PKG-AMBILIGHT-06 |
diff --git a/refactor/risks.json b/refactor/risks.json
index 062174d09..363d0aedf 100644
--- a/refactor/risks.json
+++ b/refactor/risks.json
@@ -3255,7 +3255,7 @@
{
"id": "AMBILIGHT-LIFE-01",
"title": "Ambilight sampling and RAF/DOM ownership lack terminal/error handling",
- "confirmation": "source-observed",
+ "confirmation": "reproduced",
"owners": [
"PKG-AMBILIGHT-02",
"PKG-AMBILIGHT-03",
@@ -3267,8 +3267,12 @@
"evidence": [
"refactor/baselines/ambilight-contract.md",
"refactor/baselines/ambilight-release.json",
- "refactor/changes/2026-09-12-PKG-AMBILIGHT-01-contract.md"
- ]
+ "refactor/changes/2026-09-12-PKG-AMBILIGHT-01-contract.md",
+ "refactor/changes/2026-09-12-PKG-AMBILIGHT-02-tests.md",
+ "refactor/baselines/ambilight-behavior-validation.json",
+ "refactor/ambilight-validation.md"
+ ],
+ "workspaceState": "30 Node historical tests reproduce RAF id zero, terminal/escaped method, sampling/context, reentrant stop/destroy and setup rollback failures. Native browser sampling of a cross-origin video throws SecurityError and stays tainted after same-origin source recovery. No production fixes in this task."
},
{
"id": "AMBILIGHT-TYPE-01",
diff --git a/refactor/tasks.json b/refactor/tasks.json
index 7c31c358b..a93502c18 100644
--- a/refactor/tasks.json
+++ b/refactor/tasks.json
@@ -1481,11 +1481,15 @@
"ENG-03",
"ENG-05"
],
- "status": "todo",
+ "status": "done",
"risk": "M",
"deliverable": "跨域 canvas 失败、零尺寸、暂停、重复 start/stop 和 destroy",
"acceptance": "旧版本行为可重跑,成功/失败/切源/销毁有必要断言",
- "evidence": []
+ "evidence": [
+ "changes/2026-09-12-PKG-AMBILIGHT-02-tests.md",
+ "baselines/ambilight-behavior-validation.json",
+ "ambilight-validation.md"
+ ]
},
{
"id": "PKG-AMBILIGHT-03",
diff --git a/test/ambilight.test.js b/test/ambilight.test.js
new file mode 100644
index 000000000..099a143a4
--- /dev/null
+++ b/test/ambilight.test.js
@@ -0,0 +1,168 @@
+import assert from 'node:assert/strict'
+// eslint-disable-next-line test/no-import-node-test -- Preserve historical RAF/canvas behavior separately from candidate fixes.
+import test from 'node:test'
+import { ambilightEnvironment, ambilightHistorical } from './helpers/ambilight.js'
+
+for (const implementation of await ambilightHistorical()) {
+ const label = implementation.name
+
+ test(`Ambilight ${label}: frequency throttles samples and pause/stop preserve last colors`, async () => {
+ const env = await ambilightEnvironment(implementation)
+ const result = env.factory({ frequency: 2 })(env.art)
+ env.art.emit('ready')
+ env.art.playing = true
+ env.frame(499)
+ assert.equal(env.draws.length, 0)
+ env.frame(500)
+ assert.equal(env.draws.length, 9)
+ const colors = env.parent.children[0].children.map(child => child.style.backgroundColor)
+ env.art.playing = false
+ env.frame(1000)
+ assert.equal(env.draws.length, 9)
+ assert.equal(env.frames.size, 1)
+ result.stop()
+ assert.deepEqual(env.parent.children[0].children.map(child => child.style.backgroundColor), colors)
+ env.art.playing = true
+ result.start()
+ assert.equal(env.draws.length, 18)
+ assert.equal(env.frames.size, 1)
+ result.stop()
+ })
+
+ test(`Ambilight ${label}: ordinary start/stop is repeatable and ready can restart an earlier stop`, async () => {
+ const env = await ambilightEnvironment(implementation)
+ const result = env.factory()(env.art)
+ result.stop()
+ env.art.emit('ready')
+ result.start()
+ result.start()
+ assert.equal(env.frames.size, 1)
+ result.stop()
+ result.stop()
+ assert.equal(env.frames.size, 0)
+ result.start()
+ assert.equal(env.frames.size, 1)
+ result.stop()
+ })
+
+ test(`Ambilight ${label}: special frequency values preserve historical JavaScript division semantics`, async () => {
+ for (const [frequency, samples] of [[0, 0], [-1, 9], [Infinity, 9], [Number.NaN, 9], ['2', 0]]) {
+ const env = await ambilightEnvironment(implementation)
+ const result = env.factory({ frequency })(env.art)
+ env.art.playing = true
+ env.time(100)
+ result.start()
+ assert.equal(env.draws.length, samples, String(frequency))
+ assert.equal(env.frames.size, 1)
+ result.stop()
+ }
+ })
+
+ test(`Ambilight ${label}: historical RAF id zero creates duplicate work and escapes stop`, async () => {
+ const env = await ambilightEnvironment(implementation, { firstFrame: 0 })
+ const result = env.factory()(env.art)
+ result.start()
+ result.start()
+ assert.equal(env.frames.size, 2)
+ result.stop()
+ assert.deepEqual([...env.frames.keys()], [0])
+ })
+
+ test(`Ambilight ${label}: historical destroy leaves its view and escaped start can restart frames`, async () => {
+ const env = await ambilightEnvironment(implementation)
+ const result = env.factory()(env.art)
+ env.art.emit('ready')
+ env.art.destroy()
+ assert.equal(env.frames.size, 0)
+ assert.equal(env.parent.children.length, 2)
+ result.start()
+ assert.equal(env.frames.size, 1)
+ result.stop()
+ })
+
+ test(`Ambilight ${label}: historical frame exceptions wedge start until an explicit stop`, async () => {
+ for (const errorType of ['draw', 'read']) {
+ const env = await ambilightEnvironment(implementation)
+ const result = env.factory()(env.art)
+ const error = Object.assign(new Error(errorType), { name: errorType === 'read' ? 'SecurityError' : 'InvalidStateError' })
+ result.start()
+ env.art.playing = true
+ if (errorType === 'read')
+ env.setReadError(error)
+ else env.setDrawError(error)
+ assert.throws(() => env.frame(100), value => value === error)
+ assert.equal(env.frames.size, 0)
+ env.setReadError(null)
+ env.setDrawError(null)
+ result.start()
+ assert.equal(env.frames.size, 0, 'Stale nonzero handle still blocks restart')
+ result.stop()
+ env.time(200)
+ result.start()
+ assert.equal(env.frames.size, 1)
+ result.stop()
+ }
+ })
+
+ test(`Ambilight ${label}: historical null contexts and zero dimensions reach unguarded sampling`, async () => {
+ const missing = await ambilightEnvironment(implementation, { noContext: true })
+ const result = missing.factory()(missing.art)
+ missing.art.playing = true
+ missing.time(100)
+ assert.throws(result.start, error => error.name === 'TypeError')
+ assert.equal(missing.parent.children.length, 2)
+ const zero = await ambilightEnvironment(implementation)
+ zero.video.videoWidth = zero.video.videoHeight = 0
+ zero.art.playing = true
+ zero.time(100)
+ const zeroResult = zero.factory()(zero.art)
+ zeroResult.start()
+ assert.equal(zero.draws.length, 9, 'Recorder accepts zero area; this proves missing guard, not browser acceptance')
+ assert.equal(zero.draws[0][3], 0)
+ assert.equal(zero.draws[0][4], 0)
+ zeroResult.stop()
+ })
+
+ test(`Ambilight ${label}: historical stop/destroy during sampling queues a new frame after cleanup`, async () => {
+ for (const operation of ['stop', 'destroy']) {
+ let result
+ let first = true
+ const env = await ambilightEnvironment(implementation, { onDraw() {
+ if (!first)
+ return
+ first = false
+ if (operation === 'stop')
+ result.stop()
+ else env.art.destroy()
+ } })
+ result = env.factory()(env.art)
+ result.start()
+ env.art.playing = true
+ env.frame(100)
+ assert.equal(env.draws.length, 9)
+ assert.equal(env.frames.size, 1)
+ result.stop()
+ }
+ })
+
+ test(`Ambilight ${label}: historical setup failure retains an inserted view without a cleanup handle`, 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.equal(env.parent.children.length, 2)
+ assert.equal(env.listeners.size, 0)
+ })
+
+ test(`Ambilight ${label}: historical late attachment to a destroyed host still allocates and starts`, async () => {
+ const env = await ambilightEnvironment(implementation)
+ env.art.destroy()
+ const result = env.factory()(env.art)
+ assert.equal(env.parent.children.length, 2)
+ result.start()
+ assert.equal(env.frames.size, 1)
+ result.stop()
+ })
+}
diff --git a/test/browser/ambilight.spec.js b/test/browser/ambilight.spec.js
new file mode 100644
index 000000000..7ece89fac
--- /dev/null
+++ b/test/browser/ambilight.spec.js
@@ -0,0 +1,97 @@
+import { transform } from 'esbuild'
+import { hash } from '../../refactor/scripts/releases.mjs'
+import { ambilightHistorical } from '../helpers/ambilight.js'
+import { expect, test } from './fixtures.js'
+
+const implementations = await Promise.all((await ambilightHistorical()).map(async item => ({ ...item, code: item.format === 'source' ? (await transform(item.source, { format: 'cjs', target: 'es2020' })).code : item.source })))
+
+async function setup(page, core, implementation, testInfo, crossOrigin = false) {
+ await page.goto(`/test/player.html?core=${core}`)
+ await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${implementation.code}; window.ambilightFactory = module.exports.default || module.exports; })();` })
+ await page.evaluate((crossOrigin) => {
+ const url = new URL('/test/pattern.mp4', location.href)
+ if (crossOrigin)
+ url.hostname = 'localhost'
+ window.createPlayer(url.href)
+ }, 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 page.evaluate(() => {
+ window.art.plugins.add(window.ambilightFactory({ frequency: Infinity }))
+ window.ambilight = window.art.plugins.artplayerPluginAmbilight
+ })
+ await testInfo.attach('ambilight-inputs', { contentType: 'application/json', body: JSON.stringify({ core, implementation: implementation.name, sha256: hash(implementation.code), crossOrigin, media: '/test/pattern.mp4', canvas: 'native, no pixel or RAF stubs' }) })
+}
+
+test.afterEach(async ({ page }, testInfo) => {
+ await testInfo.attach('ambilight-state', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => ({ version: window.Artplayer?.version, source: window.art?.url, width: window.art?.template.$video.videoWidth, currentTime: window.art?.currentTime, errors: window.ambilightErrors, grids: document.querySelectorAll('.artplayer-plugin-ambilight').length }))) })
+ await page.evaluate(() => {
+ // Historical escaped methods intentionally restart work; cleanup follows assertions.
+ window.ambilight?.stop()
+ if (window.art && !window.art.isDestroy)
+ window.art.destroy()
+ })
+})
+
+for (const core of ['published-5.1.7', 'published', 'candidate']) {
+ for (const implementation of implementations) {
+ const label = `${core} / ${implementation.name}`
+ test(`${label}: native canvas colors, pause, source switch and historical retained destroy view`, async ({ page }, testInfo) => {
+ await setup(page, core, implementation, testInfo)
+ await page.evaluate(() => window.ambilight.start())
+ await expect.poll(() => page.evaluate(() => [...document.querySelectorAll('.artplayer-plugin-ambilight > div')].filter(node => /^rgb\(/.test(node.style.backgroundColor)).length)).toBe(9)
+ const layout = await page.evaluate(() => {
+ const grid = document.querySelector('.artplayer-plugin-ambilight')
+ return { parentMatches: grid.parentNode === window.art.template.$video.parentNode, nextMatches: grid.nextSibling === window.art.template.$video, columns: getComputedStyle(grid).gridTemplateColumns.split(' ').length, zIndex: getComputedStyle(grid).zIndex }
+ })
+ expect(layout).toEqual({ parentMatches: true, nextMatches: true, columns: 3, zIndex: '9' })
+ await page.click('#pause')
+ await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(false)
+ const before = await page.evaluate(() => [...document.querySelectorAll('.artplayer-plugin-ambilight > div')].map(node => node.style.backgroundColor))
+ const stopped = await page.evaluate(() => {
+ const returned = window.ambilight.stop()
+ return { returned: returned === undefined, colors: [...document.querySelectorAll('.artplayer-plugin-ambilight > div')].map(node => node.style.backgroundColor) }
+ })
+ expect(stopped).toEqual({ returned: true, colors: before })
+ await page.evaluate(() => window.art.switchUrl('/assets/sample/video.mp4'))
+ await page.click('#play')
+ await expect.poll(() => page.evaluate(() => window.art.playing && window.art.template.$video.videoWidth > 0)).toBe(true)
+ await page.evaluate(() => window.ambilight.start())
+ expect(await page.evaluate(() => window.art.template.$video.currentSrc.endsWith('/assets/sample/video.mp4'))).toBe(true)
+ await page.evaluate(() => window.art.destroy(false))
+ expect(await page.locator('.artplayer-plugin-ambilight').count()).toBe(1)
+ })
+
+ test(`${label}: native cross-origin SecurityError persists after same-origin recovery in historical canvas`, async ({ page }, testInfo) => {
+ await setup(page, core, implementation, testInfo, true)
+ const capture = () => page.evaluate(() => {
+ window.ambilight.stop()
+ try {
+ window.ambilight.start()
+ return null
+ }
+ catch (error) {
+ window.ambilightErrors = [...(window.ambilightErrors || []), error.name]
+ return error.name
+ }
+ })
+ expect(await capture()).toBe('SecurityError')
+ await page.evaluate(() => 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 canvas = document.createElement('canvas')
+ const context = 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)
+ expect(await capture()).toBe('SecurityError')
+ expect(await page.evaluate(() => window.art.currentTime)).toBeGreaterThanOrEqual(0)
+ })
+ }
+}