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) + }) +}