diff --git a/package.json b/package.json
index 148dac69c..f6d663662 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/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.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:unit": "node --test test/canvas.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.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",
@@ -71,7 +71,8 @@
"check:contracts": "node refactor/scripts/contracts.mjs",
"test:contracts": "node refactor/scripts/run-contracts.mjs",
"test:ambilight": "node --test test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js",
- "test:ambilight-types-package": "node refactor/scripts/ambilight-package-types.mjs"
+ "test:ambilight-types-package": "node refactor/scripts/ambilight-package-types.mjs",
+ "test:canvas": "node --test test/canvas.test.js"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
diff --git a/refactor/baselines/canvas-behavior-validation.json b/refactor/baselines/canvas-behavior-validation.json
new file mode 100644
index 000000000..af0caf857
--- /dev/null
+++ b/refactor/baselines/canvas-behavior-validation.json
@@ -0,0 +1,206 @@
+{
+ "schemaVersion": 1,
+ "task": "PKG-CANVAS-02",
+ "status": "done",
+ "checks": [
+ {
+ "command": "node --test test/canvas.test.js",
+ "result": "pass",
+ "tests": 33,
+ "scope": "11 historical normal/error/ownership categories across three frozen implementations"
+ },
+ {
+ "command": "playwright test test/browser/canvas.spec.js",
+ "result": "pass",
+ "tests": 42,
+ "skipped": 0,
+ "browsers": [
+ {
+ "project": "chromium",
+ "version": "153.0.8010.12",
+ "platform": "win32"
+ },
+ {
+ "project": "firefox",
+ "version": "155.0",
+ "platform": "win32"
+ },
+ {
+ "project": "webkit",
+ "version": "26.6",
+ "platform": "win32"
+ }
+ ],
+ "scope": "36 archived/frozen proxy checks plus 6 independent native attached/detached controls; expected historical WebKit transparency and post-destroy RAF remain defects"
+ },
+ {
+ "command": "focused eslint --no-fix",
+ "result": "pass"
+ },
+ {
+ "command": "yarn ci:check",
+ "result": "pass",
+ "tests": {
+ "unit": 914,
+ "engineering": 14,
+ "baseline": 96,
+ "mainTotal": 1024,
+ "repeatedContractExecutions": 44
+ },
+ "strictProductionTypeScriptFiles": 278
+ }
+ ],
+ "limitations": [
+ "Historical failure assertions are not candidate fixes or rendering acceptance.",
+ "WebKit detached-video transparency is scoped to recorded Windows Playwright browser and media inputs, not all Safari devices.",
+ "Controlled Node DOM and bitmap promises do not prove native pixel, codec or device behavior.",
+ "No VAST VPN exception or browser skip used."
+ ],
+ "sourceCommit": "5e26d2298d4fc7568b208962d2aa069b788a8a78",
+ "preservedBrowserAttempts": [
+ {
+ "file": "refactor/.cache/canvas02-first-results/report.json",
+ "sha256": "de5c0195c7de8a1551b064fd4213c089cfe20d6aecc5bdba4558938ac7b2758e",
+ "stats": {
+ "startTime": "2026-09-12T04:24:44.219Z",
+ "duration": 29128.137000000002,
+ "expected": 30,
+ "skipped": 0,
+ "unexpected": 6,
+ "flaky": 0
+ },
+ "resultsDirectory": "refactor/.cache/canvas02-first-results/results"
+ },
+ {
+ "file": "refactor/.cache/canvas02-webkit-wait-results/report.json",
+ "sha256": "43a7b4497d89213244201ecfd052b0fa0dedabea71d88239756df12998e3039e",
+ "stats": {
+ "startTime": "2026-09-12T04:26:11.586Z",
+ "duration": 22140.993,
+ "expected": 0,
+ "skipped": 0,
+ "unexpected": 6,
+ "flaky": 0
+ },
+ "resultsDirectory": "refactor/.cache/canvas02-webkit-wait-results/results"
+ },
+ {
+ "file": "refactor/.cache/canvas02-pixel-probe-results/report.json",
+ "sha256": "dd441cc8e223fdaeecdb39dac5297c5f5cb1008a2c396d3b9caac5fa3a43ed76",
+ "stats": {
+ "startTime": "2026-09-12T04:27:46.306Z",
+ "duration": 11620.739,
+ "expected": 0,
+ "skipped": 0,
+ "unexpected": 1,
+ "flaky": 0
+ },
+ "resultsDirectory": "refactor/.cache/canvas02-pixel-probe-results/results"
+ },
+ {
+ "file": "refactor/.cache/canvas02-attach-probe-results/report.json",
+ "sha256": "5f12808970c4cb65ffed6cb265c3d3b595bdea0eb1ea79fa3e524a77a22070b7",
+ "stats": {
+ "startTime": "2026-09-12T04:29:43.968Z",
+ "duration": 11748.322,
+ "expected": 0,
+ "skipped": 0,
+ "unexpected": 1,
+ "flaky": 0
+ },
+ "resultsDirectory": "refactor/.cache/canvas02-attach-probe-results/results"
+ },
+ {
+ "file": "refactor/.cache/canvas02-native-control-results/report.json",
+ "sha256": "655cd06111a6f9389bee5b7bd56b1f50b81482635bb69ad58462051a721235a5",
+ "stats": {
+ "startTime": "2026-09-12T04:34:13.618Z",
+ "duration": 6111.419,
+ "expected": 3,
+ "skipped": 0,
+ "unexpected": 0,
+ "flaky": 0
+ },
+ "resultsDirectory": "refactor/.cache/canvas02-native-control-results/results"
+ },
+ {
+ "file": "refactor/.cache/canvas02-native-detached-results/report.json",
+ "sha256": "1db5932b7979980177dc0d158090cec3d967cce33c2e4f9a4d2f1c7d6ca94f95",
+ "stats": {
+ "startTime": "2026-09-12T04:35:12.367Z",
+ "duration": 11861.622,
+ "expected": 0,
+ "skipped": 0,
+ "unexpected": 1,
+ "flaky": 0
+ },
+ "resultsDirectory": "refactor/.cache/canvas02-native-detached-results/results"
+ },
+ {
+ "file": "refactor/.cache/canvas02-pixel-boundary-results/report.json",
+ "sha256": "ac1768227986cb716d40231a856bab8ae9dcb35ee0161516a42cb47e5df1e687",
+ "stats": {
+ "startTime": "2026-09-12T04:36:16.598Z",
+ "duration": 30244.336,
+ "expected": 42,
+ "skipped": 0,
+ "unexpected": 0,
+ "flaky": 0
+ },
+ "resultsDirectory": "refactor/.cache/canvas02-pixel-boundary-results/results"
+ },
+ {
+ "file": "refactor/.cache/canvas02-final-results/report.json",
+ "sha256": "28886ac131df9f5f871fb9c66b0985a73cd9e84dd5542d028db68e28f374b649",
+ "stats": {
+ "startTime": "2026-09-12T04:37:05.676Z",
+ "duration": 28495.888,
+ "expected": 42,
+ "skipped": 0,
+ "unexpected": 0,
+ "flaky": 0
+ },
+ "resultsDirectory": "refactor/.cache/canvas02-final-results/results"
+ }
+ ],
+ "inputs": [
+ {
+ "file": "test/canvas.test.js",
+ "sha256LF": "df1b1aaee15f18b6b9808f48e759ef2ef22912e7a20a0b831e51bee4d84ee436"
+ },
+ {
+ "file": "test/browser/canvas.spec.js",
+ "sha256LF": "e34a4463574f09057af7f307e8564cbcd8f03415a270f362b6c83de69359f652"
+ },
+ {
+ "file": "test/helpers/canvas.js",
+ "sha256LF": "cc617b6d1053a9ed9caa5dad03088abaaff4344f078e23d007ee1a935f2a6c3c"
+ },
+ {
+ "file": "refactor/baselines/canvas-release.json",
+ "sha256LF": "7f7b229a5c0350a5d02be101d0095548c7ef1e76ce95c3b7152b415b0117398c"
+ }
+ ],
+ "logs": [
+ {
+ "file": "refactor/.cache/canvas02-node.log",
+ "sha256": "7558fb6922ba2e2490c586051081375f98069048d234f4cd7fda22b85b746bfd"
+ },
+ {
+ "file": "refactor/.cache/canvas02-lint-final.log",
+ "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ },
+ {
+ "file": "refactor/.cache/canvas02-browser-readiness.log",
+ "sha256": "ba10158100c211a7f5ee5e05e917f7ffc20c8968e8372b7d99f668e16e4b7970"
+ },
+ {
+ "file": "refactor/.cache/canvas02-ci-before-risk-index.log",
+ "sha256": "0a0452a062448c3a2092c4d62ed84b5a255f9016cf4ef86c108825952d45a45e"
+ },
+ {
+ "file": "refactor/.cache/canvas02-ci.log",
+ "sha256": "e4137711c5902d943ddfdae260f895959223e9ef1040e464197f8f59209d403f"
+ }
+ ]
+}
diff --git a/refactor/canvas-validation.md b/refactor/canvas-validation.md
index b7d74cc53..a7db14c81 100644
--- a/refactor/canvas-validation.md
+++ b/refactor/canvas-validation.md
@@ -15,9 +15,27 @@ play/pause、destroy前后订阅、缺context和尺寸异常;保留历史失
当前已有的Ambilight代理颜色测试会直接加载proxy的src/index.js;代理迁移文件入口
或模块拆分时必须同步改为实际候选入口加载,不能用旧dist掩盖改名或遗漏模块。
-Canvas本身的正式历史浏览器矩阵尚未建立;没有在01使用新浏览器结果作包整体验收。
+Canvas正式历史浏览器矩阵在02建立;01没有使用新浏览器结果作包整体验收。
01最终13项Canvas专项、7项契约索引检查与991项完整CI通过;证据见
[发布契约验证](baselines/canvas-contract-validation.json) 和
[实施记录](changes/2026-09-12-PKG-CANVAS-01-contract.md)。三个新增风险继续open,
-生命周期仅source-observed,02需先复现再推进03源码修复。
+01时生命周期仅source-observed,02复现以后推进03源码修复。
+
+## 02 历史失败与真实浏览器
+
+`yarn test:canvas`运行33项历史Node检查,已接入test:unit。运行
+`yarn test:browser test/browser/canvas.spec.js`核验42项:三实现×两种实际支持proxy
+的核心×三引擎×两场景=36,加三个引擎分别挂载/未挂载原生video的6项独立对照。
+就绪事件顺序、暂停/seek/resize/切源及回调内destroy后继续RAF均有断言。历史npm
+5.1.7不支持proxy,不假装把它计入此矩阵。
+
+WebKit历史代理输出透明是明确的失败基线;测试成功表示重现了该失败。原生video
+播放前挂载可读到像素,未挂载对照为透明;播放后才挂载旧代理的video未恢复。
+没有替换像素、bitmap或媒体来绕过它,03需以候选真实像素关闭CANVAS-PIXEL-01。
+既有Ambilight组合测试由回调填充九格调色板,只证明输出buffer几何,不证明代理
+已从视频画出有效帧。Windows Playwright WebKit证据不能直接等同真实Safari设备。
+
+失败尝试与最终报告均保留report.json和完整results,哈希与范围见
+[02验证证据](baselines/canvas-behavior-validation.json);问题处理细节见
+[02实施记录](changes/2026-09-12-PKG-CANVAS-02-tests.md)。本任务不修改生产源码。
diff --git a/refactor/changes/2026-09-12-PKG-CANVAS-02-tests.md b/refactor/changes/2026-09-12-PKG-CANVAS-02-tests.md
new file mode 100644
index 000000000..f5d330622
--- /dev/null
+++ b/refactor/changes/2026-09-12-PKG-CANVAS-02-tests.md
@@ -0,0 +1,32 @@
+# PKG-CANVAS-02 历史行为与错误回归
+
+对实际npm1.0.0、1.1.0和01冻结工作区运行33项Node测试,覆盖挂起bitmap在pause/
+destroy之后完成、重复play产生并发帧、旧帧覆盖新帧、drawImage异常未close、bitmap
+拒绝、用户回调异常、回调中同步销毁、延迟订阅在销毁后安装、注册失败未回滚、已销毁
+宿主、零尺寸和缺失2D context。正常回调错误语义与资源缺陷分别记录;假Canvas的
+NaN仅描述输入赋值,不声称真实Canvas能存储NaN尺寸。
+
+真实浏览器直接执行两个归档及冻结JS,组合实际npm核心5.4.0/候选核心和Chromium/
+Firefox/WebKit,36项代理检查加6项独立原生对照。代理检查包含loadedmetadata→
+loadeddata→canplay顺序、播放/暂停/seek、resize、切源,以及回调中destroy以后
+原生RAF仍被调用。RAF观察器只包装代理的词法引用,仍委托浏览器调度;媒体、Canvas、
+bitmap、回调和事件均为实际执行。
+
+首次矩阵30通过/6失败:WebKit的六个正常代理场景透明,视频时间仍推进。等待更多帧、
+对同一视频直接drawImage、将其在播放后挂载到页面均未恢复。独立原生video播放前
+挂载则三个引擎都读到alpha255;未挂载的WebKit对照为alpha0,另两个引擎仍255。
+这将问题收窄到当前WebKit环境的未挂载媒体路径,没有据此宣称所有Safari设备相同。
+最终测试明确断言历史透明结果;它们证明旧失败可复现,不是WebKit渲染成功或skip。
+03必须以候选实际像素验证修复,不能把原生对照当作代理修复证据。
+
+新增test:canvas并接入test:unit,源码、类型、依赖和构建产物保持本任务开始时状态。
+验证结果及保留的失败/最终报告见[证据](../baselines/canvas-behavior-validation.json)。
+生命周期风险升级为reproduced,新增未挂载视频像素风险,均继续open交给03~05。
+
+VAST脚本VPN例外继续只适用于VAST;本任务没有使用例外,也没有跳过浏览器场景。
+完成检查后本任务独立本地提交,不推送/发布。
+
+首次完整CI在风险索引一致性检查失败:新增风险的JSON台账已更新,生成的risk-table.md
+尚未同步。使用risk-register.mjs --write重新生成并重跑CI,保留首次失败日志。
+
+最终专项lint、33项历史Node、42项浏览器及完整CI1024项通过(914单元+14工程+96基线),另44项重复契约观察不计为新增。278个生产TS文件严格检查;状态done,准备独立提交。
diff --git a/refactor/plan.md b/refactor/plan.md
index c8667160b..5f0132fc5 100644
--- a/refactor/plan.md
+++ b/refactor/plan.md
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 221 项,范围 22 个包及工作区/示例。
-状态:todo 125 / doing 6 / blocked 0 / done 90 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
+状态:todo 124 / doing 6 / blocked 0 / done 91 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -319,7 +319,7 @@
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| PKG-CANVAS-01 | artplayer-proxy-canvas
核对包契约与历史用法 | BASE-05 | callback、canvas 原方法、转发 media 属性/方法/事件 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done |
-| PKG-CANVAS-02 | artplayer-proxy-canvas
建立特有行为与错误测试 | PKG-CANVAS-01, ENG-03, ENG-05 | ready/loadeddata/canplay、play/seek、回调、resize 与销毁 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | todo |
+| PKG-CANVAS-02 | artplayer-proxy-canvas
建立特有行为与错误测试 | PKG-CANVAS-01, ENG-03, ENG-05 | ready/loadeddata/canplay、play/seek、回调、resize 与销毁 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done |
| PKG-CANVAS-03 | artplayer-proxy-canvas
整理内部职责与资源 | PKG-CANVAS-02, CORE-06, CORE-11, CORE-16 | video adapter/原 canvas 方法/RAF 绘制/事件订阅分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo |
| PKG-CANVAS-04 | artplayer-proxy-canvas
迁移自有源码和公开类型 | PKG-CANVAS-03, ENG-04, ENG-06, CORE-07 | canvas 与媒体能力的精确组合类型,保持 Result | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo |
| PKG-CANVAS-05 | artplayer-proxy-canvas
验证新旧核心和组合 | PKG-CANVAS-04, CORE-22 | 真实 video/canvas 绘制、字幕和 document PiP 恢复 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
@@ -507,3 +507,4 @@
- PKG-VAST-02: [记录](changes/2026-09-12-PKG-VAST-02-tests.md) [记录](baselines/vast-behavior-validation.json) [记录](baselines/vast-core.json) [记录](vast-validation.md)
- PKG-VAST-03: [记录](changes/2026-09-12-PKG-VAST-03-lifecycle.md) [记录](baselines/vast-lifecycle-validation.json) [记录](vast-compatibility-decision.md)
- PKG-CANVAS-01: [记录](changes/2026-09-12-PKG-CANVAS-01-contract.md) [记录](baselines/canvas-contract.md) [记录](baselines/canvas-release.json) [记录](canvas-validation.md) [记录](baselines/canvas-contract-validation.json)
+- PKG-CANVAS-02: [记录](changes/2026-09-12-PKG-CANVAS-02-tests.md) [记录](baselines/canvas-behavior-validation.json) [记录](canvas-validation.md)
diff --git a/refactor/progress.md b/refactor/progress.md
index 896c099f5..70f816489 100644
--- a/refactor/progress.md
+++ b/refactor/progress.md
@@ -1,5 +1,16 @@
# 进度与证据
+## 最新完成:Canvas-02 历史错误与原生浏览器对照
+
+33项Node回归与42项真实浏览器检查通过,就绪事件顺序、播放/暂停/seek/resize/切源
+及销毁后RAF均有断言。WebKit六个旧代理场景首次透明失败;独立原生对照表明播放前
+挂载可读到像素、未挂载透明。最终矩阵明确重现历史失败,不把它当作渲染成功。
+完整CI1024项通过,另44项重复观察;未修改生产源码。CANVAS-LIFE-01升级reproduced,
+新增CANVAS-PIXEL-01待03修复。当前221项:91 done、6 doing、124 todo。下一步03
+拆分严格TS职责并以候选回归关闭生命周期/像素问题。见
+[本步记录](changes/2026-09-12-PKG-CANVAS-02-tests.md) 和
+[验证证据](baselines/canvas-behavior-validation.json)。独立本地提交,无推送/发布。
+
## 最新完成:Canvas-01 发布契约与正常基线
冻结npm1.0.0/1.1.0共12成员及工作区6个Git输入,运行三实现的同步返回、媒体属性/
diff --git a/refactor/risk-table.md b/refactor/risk-table.md
index ea7dcc982..42b9dccbe 100644
--- a/refactor/risk-table.md
+++ b/refactor/risk-table.md
@@ -155,6 +155,7 @@
| AMBILIGHT-TYPE-01 | resolved / 已复现 | Ambilight declarations disagree with optional factory argument and differ across published generations | PKG-AMBILIGHT-04, PKG-AMBILIGHT-06 |
| AMBILIGHT-DIST-01 | resolved / 已复现 | Ambilight 1.0.0 CommonJS default namespace differs from callable 1.1.0 | PKG-AMBILIGHT-04, PKG-AMBILIGHT-06 |
| AMBILIGHT-PROXY-01 | resolved / 已复现 | Canvas proxy output pixels differ from forwarded video dimensions | PKG-AMBILIGHT-PROXY-01, PKG-AMBILIGHT-05 |
-| CANVAS-LIFE-01 | open / 源码/产物事实 | Canvas proxy pending draw, bitmap and delayed subscriptions lack terminal ownership | PKG-CANVAS-02, PKG-CANVAS-03, PKG-CANVAS-05 |
+| CANVAS-LIFE-01 | open / 已复现 | Canvas proxy pending draw, bitmap and delayed subscriptions lack terminal ownership | PKG-CANVAS-02, PKG-CANVAS-03, PKG-CANVAS-05 |
| CANVAS-TYPE-01 | open / 源码/产物事实 | Canvas proxy declarations change required callback across generations and omit forwarded media capabilities | PKG-CANVAS-04, PKG-CANVAS-06 |
| CANVAS-DIST-01 | open / 已复现 | Canvas proxy published CommonJS namespace differs from current callable export | PKG-CANVAS-04, PKG-CANVAS-06 |
+| CANVAS-PIXEL-01 | open / 已复现 | Historical Canvas proxy produces transparent frames with detached native video in tested WebKit | PKG-CANVAS-03, PKG-CANVAS-05 |
diff --git a/refactor/risks.json b/refactor/risks.json
index 3f3248f47..2b2115536 100644
--- a/refactor/risks.json
+++ b/refactor/risks.json
@@ -3343,7 +3343,7 @@
{
"id": "CANVAS-LIFE-01",
"title": "Canvas proxy pending draw, bitmap and delayed subscriptions lack terminal ownership",
- "confirmation": "source-observed",
+ "confirmation": "reproduced",
"owners": [
"PKG-CANVAS-02",
"PKG-CANVAS-03",
@@ -3355,8 +3355,11 @@
"evidence": [
"refactor/baselines/canvas-contract.md",
"refactor/baselines/canvas-release.json",
- "refactor/changes/2026-09-12-PKG-CANVAS-01-contract.md"
- ]
+ "refactor/changes/2026-09-12-PKG-CANVAS-01-contract.md",
+ "refactor/changes/2026-09-12-PKG-CANVAS-02-tests.md",
+ "refactor/baselines/canvas-behavior-validation.json"
+ ],
+ "workspaceState": "33 historical Node checks reproduce pending bitmap, concurrent RAF, close omission, reentrant destroy and delayed/partial registration leaks; 18 real browser core/proxy combinations observe RAF continuing after destroy inside callback. Candidate fixes pending task 03."
},
{
"id": "CANVAS-TYPE-01",
@@ -3391,6 +3394,23 @@
"refactor/baselines/canvas-release.json",
"refactor/changes/2026-09-12-PKG-CANVAS-01-contract.md"
]
+ },
+ {
+ "id": "CANVAS-PIXEL-01",
+ "title": "Historical Canvas proxy produces transparent frames with detached native video in tested WebKit",
+ "status": "open",
+ "confirmation": "reproduced",
+ "owners": [
+ "PKG-CANVAS-03",
+ "PKG-CANVAS-05"
+ ],
+ "compatibleResolution": "Verify actual pixel recovery with explicit backing media ownership and stable public canvas surface; do not infer that attaching after playback fixes it.",
+ "closureCriteria": "Candidate native proxy pixels, pause/seek/source switch and terminal cleanup pass all three engines; device limitations remain explicit.",
+ "evidence": [
+ "refactor/changes/2026-09-12-PKG-CANVAS-02-tests.md",
+ "refactor/baselines/canvas-behavior-validation.json"
+ ],
+ "workspaceState": "All six historical WebKit proxy combinations produce alpha0 while time advances. Separate native video mounted before play yields alpha255; detached native control alpha0. Chromium/Firefox both paths yield255. These historical observations do not prove candidate fix or all Safari device behavior."
}
]
}
diff --git a/refactor/tasks.json b/refactor/tasks.json
index 9491dbade..e082119b7 100644
--- a/refactor/tasks.json
+++ b/refactor/tasks.json
@@ -3268,11 +3268,15 @@
"ENG-03",
"ENG-05"
],
- "status": "todo",
+ "status": "done",
"risk": "H",
"deliverable": "ready/loadeddata/canplay、play/seek、回调、resize 与销毁",
"acceptance": "旧版本行为可重跑,成功/失败/切源/销毁有必要断言",
- "evidence": []
+ "evidence": [
+ "changes/2026-09-12-PKG-CANVAS-02-tests.md",
+ "baselines/canvas-behavior-validation.json",
+ "canvas-validation.md"
+ ]
},
{
"id": "PKG-CANVAS-03",
diff --git a/test/browser/canvas.spec.js b/test/browser/canvas.spec.js
new file mode 100644
index 000000000..33779dc7e
--- /dev/null
+++ b/test/browser/canvas.spec.js
@@ -0,0 +1,163 @@
+import { transform } from 'esbuild'
+import { hash } from '../../refactor/scripts/releases.mjs'
+import { canvasHistorical } from '../helpers/canvas.js'
+import { expect, test } from './fixtures.js'
+
+const implementations = await Promise.all((await canvasHistorical()).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) {
+ 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;
+ ${implementation.code};
+ window.canvasFactory = module.exports.default || module.exports;
+ window.canvasFrames = { state, pending, stopAll() { for (const id of pending) window.cancelAnimationFrame(id); pending.clear(); } };
+ })();` })
+ await page.evaluate(() => {
+ window.proxyDraws = 0
+ window.proxyEvents = []
+ window.proxyErrors = []
+ 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.destroyInNextCallback) {
+ window.destroyInNextCallback = false
+ window.art.destroy(false)
+ window.destroyedInCallback = true
+ }
+ }),
+ })
+ for (const name of ['video:loadedmetadata', 'video:loadeddata', 'video:canplay', 'video:play', 'video:pause', '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 }))
+ 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-historical-inputs', { contentType: 'application/json', body: JSON.stringify({ core, implementation: implementation.name, sha256: hash(implementation.code), nativeBitmapAPI: await page.evaluate(() => typeof createImageBitmap), scope: 'actual archived proxy or frozen workspace, native video/Canvas/bitmap and lexical native RAF observer' }) })
+}
+
+test.afterEach(async ({ page }, testInfo) => {
+ await testInfo.attach('canvas-native-pixel-control', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(async () => {
+ const video = window.proxyVideo
+ const output = window.proxyContext
+ if (!video || !output)
+ return null
+ const pixel = (context, x, y) => [...context.getImageData(x, y, 1, 1).data]
+ const probe = document.createElement('canvas')
+ probe.width = video.videoWidth || 320
+ probe.height = video.videoHeight || 180
+ const context = probe.getContext('2d')
+ const state = { output: { width: output.canvas.width, height: output.canvas.height, corner: pixel(output, 0, 0), center: pixel(output, Math.floor(output.canvas.width / 2), Math.floor(output.canvas.height / 2)) } }
+ try {
+ context.drawImage(video, 0, 0, probe.width, probe.height)
+ state.direct = { corner: pixel(context, 0, 0), center: pixel(context, Math.floor(probe.width / 2), Math.floor(probe.height / 2)) }
+ if (typeof createImageBitmap === 'function') {
+ const bitmap = await createImageBitmap(video)
+ try {
+ context.clearRect(0, 0, probe.width, probe.height)
+ context.drawImage(bitmap, 0, 0, probe.width, probe.height)
+ state.bitmap = { width: bitmap.width, height: bitmap.height, corner: pixel(context, 0, 0), center: pixel(context, Math.floor(probe.width / 2), Math.floor(probe.height / 2)) }
+ }
+ finally { bitmap.close() }
+ }
+ }
+ catch (error) { state.error = { name: error.name, message: error.message } }
+ return state
+ })) })
+ await testInfo.attach('canvas-historical-state', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => ({ version: window.Artplayer?.version, host: window.art?.template.$video.nodeName, source: window.proxyVideo?.currentSrc, width: window.proxyVideo?.videoWidth, currentTime: window.proxyVideo?.currentTime, destroyed: window.art?.isDestroy, frames: window.canvasFrames?.state, pending: window.canvasFrames?.pending.size, draws: window.proxyDraws, errors: window.proxyErrors, events: window.proxyEvents }))) })
+ await page.evaluate(() => {
+ window.proxyVideo?.pause()
+ if (window.art && !window.art.isDestroy)
+ window.art.destroy()
+ window.canvasFrames?.stopAll()
+ })
+})
+
+for (const core of ['published', 'candidate']) {
+ for (const implementation of implementations) {
+ const label = `${core} / ${implementation.name}`
+ test(`${label}: native proxy control flow and detached-video pixel boundary`, async ({ page, browserName }, testInfo) => {
+ await setup(page, core, implementation, testInfo)
+ expect(await page.evaluate(() => window.art.template.$video.nodeName)).toBe('CANVAS')
+ await expect.poll(() => page.evaluate(() => window.proxyEvents.includes('video:canplay'))).toBe(true)
+ const readiness = await page.evaluate(() => ['video:loadedmetadata', 'video:loadeddata', 'video:canplay'].map(name => window.proxyEvents.indexOf(name)))
+ expect(readiness[0]).toBeGreaterThanOrEqual(0)
+ expect(readiness[1]).toBeGreaterThan(readiness[0])
+ expect(readiness[2]).toBeGreaterThan(readiness[1])
+ if (browserName === 'webkit') {
+ const detached = await page.evaluate(() => ({ connected: window.proxyVideo.isConnected, alpha: window.proxyContext.getImageData(0, 0, 1, 1).data[3], currentTime: window.proxyVideo.currentTime }))
+ expect(detached.connected).toBe(false)
+ expect(detached.alpha).toBe(0)
+ await testInfo.attach('webkit-historical-pixel-failure', { contentType: 'application/json', body: JSON.stringify({ state: detached, scope: 'expected historical transparent output, not successful rendering; independent attached and detached native controls run separately' }) })
+ }
+ else {
+ await expect.poll(() => page.evaluate(() => window.proxyContext.getImageData(0, 0, 1, 1).data[3])).toBe(255)
+ }
+ await page.click('#pause')
+ await expect.poll(() => page.evaluate(() => window.proxyVideo.paused)).toBe(true)
+ await page.evaluate(() => window.art.currentTime = 0.5)
+ await expect.poll(() => page.evaluate(() => window.proxyEvents.filter(name => name === 'video:seeked').length)).toBeGreaterThan(0)
+ await page.evaluate(() => {
+ const player = window.art.template.$player
+ player.style.width = '320px'
+ player.style.height = '480px'
+ window.art.emit('resize')
+ })
+ await expect.poll(() => page.evaluate(() => window.art.template.$video.width)).toBe(320)
+ expect(await page.evaluate(() => window.art.template.$video.style.padding)).toBe('150px 0px')
+ 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.paused && window.proxyVideo.currentTime > 0)).toBe(true)
+ expect(await page.evaluate(() => window.proxyEvents.filter(name => name === 'video:loadedmetadata').length)).toBeGreaterThan(1)
+ })
+
+ test(`${label}: historical destroy inside the real draw callback leaves native RAF work running`, async ({ page }, testInfo) => {
+ await setup(page, core, implementation, testInfo)
+ await page.evaluate(() => window.destroyInNextCallback = true)
+ await expect.poll(() => page.evaluate(() => window.destroyedInCallback && window.art.isDestroy)).toBe(true)
+ await expect.poll(() => page.evaluate(() => window.canvasFrames.pending.size)).toBeGreaterThan(0)
+ const fired = await page.evaluate(() => window.canvasFrames.state.fired)
+ await expect.poll(() => page.evaluate(() => window.canvasFrames.state.fired)).toBeGreaterThan(fired)
+ })
+ }
+}
+
+for (const attached of [true, false]) {
+ test(`native video pixel control without proxy: attached=${attached}`, async ({ page, browserName }, testInfo) => {
+ await page.goto('/test/player.html')
+ await page.evaluate(async (attached) => {
+ const video = document.createElement('video')
+ video.src = '/test/pattern.mp4'
+ video.muted = true
+ video.loop = true
+ if (attached)
+ document.body.appendChild(video)
+ window.proxyVideo = video
+ window.proxyContext = document.createElement('canvas').getContext('2d')
+ await video.play()
+ }, attached)
+ await expect.poll(() => page.evaluate(() => window.proxyVideo.currentTime)).toBeGreaterThan(0)
+ await expect.poll(() => page.evaluate(() => {
+ window.proxyContext.drawImage(window.proxyVideo, 0, 0, 300, 150)
+ return window.proxyContext.getImageData(0, 0, 1, 1).data[3]
+ })).toBe(browserName === 'webkit' && !attached ? 0 : 255)
+ await testInfo.attach('native-control', { contentType: 'application/json', body: JSON.stringify({ attached, scope: 'native video, direct drawImage, no proxy or bitmap before assertion; detached WebKit transparency is a historical environment observation, not rendering acceptance' }) })
+ })
+}
diff --git a/test/canvas.test.js b/test/canvas.test.js
new file mode 100644
index 000000000..02d2735e5
--- /dev/null
+++ b/test/canvas.test.js
@@ -0,0 +1,173 @@
+import assert from 'node:assert/strict'
+// eslint-disable-next-line test/no-import-node-test -- Node historical regression runner.
+import test from 'node:test'
+import { canvasEnvironment, canvasHistorical } from './helpers/canvas.js'
+
+function deferred() {
+ let resolve, reject
+ const promise = new Promise((yes, no) => {
+ resolve = yes
+ reject = no
+ })
+ return { promise, resolve, reject }
+}
+
+for (const implementation of await canvasHistorical()) {
+ test(`Canvas ${implementation.name}: historical pending bitmap draws and reschedules after pause or destroy`, async () => {
+ for (const operation of ['video:pause', 'destroy']) {
+ const pending = deferred()
+ let closed = 0
+ const bitmap = { close() {
+ closed++
+ } }
+ const env = await canvasEnvironment(implementation, { createImageBitmap: () => pending.promise })
+ env.factory()(env.art)
+ env.art.emit('video:play')
+ env.art.emit(operation)
+ assert.equal(env.frames.size, 0)
+ pending.resolve(bitmap)
+ await env.flush()
+ assert.equal(env.draws.length, 1)
+ assert.equal(closed, 1)
+ assert.equal(env.frames.size, 1)
+ assert.equal(env.emitted.at(-1).name, 'artplayerProxyCanvas:draw')
+ }
+ })
+
+ test(`Canvas ${implementation.name}: historical repeated play creates concurrent stale bitmap loops`, async () => {
+ const pending = [deferred(), deferred()]
+ let index = 0
+ const env = await canvasEnvironment(implementation, { createImageBitmap: () => pending[index++].promise })
+ env.factory()(env.art)
+ env.art.emit('video:play')
+ env.art.emit('video:play')
+ const newer = { close() {} }
+ const older = { close() {} }
+ pending[1].resolve(newer)
+ await env.flush()
+ pending[0].resolve(older)
+ await env.flush()
+ assert.deepEqual(env.draws.map(args => args[0]), [newer, older])
+ assert.equal(env.frames.size, 2)
+ env.art.destroy()
+ assert.equal(env.frames.size, 1, 'Only the latest stored RAF handle is cancelled')
+ })
+
+ test(`Canvas ${implementation.name}: historical draw failure emits its error but leaks the acquired bitmap`, async () => {
+ const failure = new Error('draw failed')
+ let closed = 0
+ const bitmap = { close() {
+ closed++
+ } }
+ const env = await canvasEnvironment(implementation, { createImageBitmap: () => Promise.resolve(bitmap), onDraw() {
+ throw failure
+ } })
+ env.factory()(env.art)
+ env.art.emit('video:play')
+ await env.flush()
+ assert.equal(closed, 0)
+ assert.equal(env.emitted.at(-1).name, 'artplayerProxyCanvas:error')
+ assert.equal(env.emitted.at(-1).args[0], failure)
+ assert.equal(env.frames.size, 1)
+ })
+
+ test(`Canvas ${implementation.name}: historical bitmap rejection emits the original error without direct-video fallback`, async () => {
+ const failure = new Error('bitmap rejected')
+ const env = await canvasEnvironment(implementation, { createImageBitmap: () => Promise.reject(failure) })
+ env.factory()(env.art)
+ env.art.emit('video:play')
+ await env.flush()
+ assert.equal(env.draws.length, 0)
+ assert.equal(env.emitted.at(-1).name, 'artplayerProxyCanvas:error')
+ assert.equal(env.emitted.at(-1).args[0], failure)
+ assert.equal(env.frames.size, 1)
+ })
+
+ test(`Canvas ${implementation.name}: callback errors occur after bitmap close and replace the draw event with an error`, async () => {
+ const failure = new Error('callback failed')
+ let closed = 0
+ const env = await canvasEnvironment(implementation, { createImageBitmap: () => Promise.resolve({ close() {
+ closed++
+ } }) })
+ env.factory(() => {
+ throw failure
+ })(env.art)
+ env.art.emit('resize')
+ await env.flush()
+ assert.equal(closed, 1)
+ assert.equal(env.emitted.filter(event => event.name === 'artplayerProxyCanvas:draw').length, 0)
+ assert.equal(env.emitted.at(-1).args[0], failure)
+ })
+
+ test(`Canvas ${implementation.name}: historical callback destruction still emits draw and queues a new frame`, async () => {
+ const env = await canvasEnvironment(implementation)
+ env.factory(() => env.art.destroy())(env.art)
+ env.art.emit('video:play')
+ await env.flush()
+ assert.deepEqual(env.emitted.map(event => event.name), ['video:play', 'destroy', 'artplayerProxyCanvas:draw'])
+ assert.equal(env.frames.size, 1)
+ })
+
+ test(`Canvas ${implementation.name}: historical destroy before setup timeout retains host handlers and attaches media listeners later`, async () => {
+ const env = await canvasEnvironment(implementation)
+ env.factory()(env.art)
+ env.art.destroy()
+ assert.equal(env.timers.size, 1)
+ assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 5)
+ env.flushTimers()
+ assert.equal(env.proxies.length, 5)
+ env.emitVideo({ type: 'play' })
+ await env.flush()
+ assert.equal(env.frames.size, 1)
+ })
+
+ test(`Canvas ${implementation.name}: historical late attachment to a destroyed host still registers and draws`, async () => {
+ const env = await canvasEnvironment(implementation)
+ env.art.destroy()
+ const result = env.factory()(env.art)
+ assert.equal(result, env.canvas)
+ env.flushTimers()
+ env.emitVideo({ type: 'play' })
+ await env.flush()
+ assert.equal(env.proxies.length, 5)
+ assert.equal(env.draws.length, 1)
+ assert.equal(env.frames.size, 1)
+ })
+
+ test(`Canvas ${implementation.name}: historical setup subscription failure leaves deferred work and the partial host registration`, async () => {
+ const env = await canvasEnvironment(implementation)
+ const on = env.art.on
+ const failure = new Error('subscription failed')
+ env.art.on = (...args) => {
+ on(...args)
+ throw failure
+ }
+ assert.throws(() => env.factory()(env.art), error => error === failure)
+ assert.equal(env.timers.size, 1)
+ assert.equal([...env.listeners.values()].reduce((count, set) => count + set.size, 0), 1)
+ env.flushTimers()
+ assert.equal(env.proxies.length, 5)
+ })
+
+ test(`Canvas ${implementation.name}: historical zero media dimensions reach unguarded resize and drawing`, 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(Number.isNaN(env.canvas.height), 'This controlled element does not apply native unsigned dimension coercion')
+ assert.equal(env.canvas.style.padding, 'NaNpx 0px')
+ assert.equal(env.draws.length, 1)
+ })
+
+ test(`Canvas ${implementation.name}: historical missing 2D context repeats a TypeError path with no capability guard`, async () => {
+ const env = await canvasEnvironment(implementation, { noContext: true })
+ env.factory()(env.art)
+ env.art.emit('video:play')
+ await env.flush()
+ assert.equal(env.emitted.at(-1).name, 'artplayerProxyCanvas:error')
+ assert.equal(env.emitted.at(-1).args[0].name, 'TypeError')
+ assert.equal(env.frames.size, 1)
+ })
+}