diff --git a/package.json b/package.json
index 4b24682da..eec8a294f 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 test/asr-distribution.test.js",
"typecheck": "node scripts/typecheck.mjs",
- "test:unit": "node --test test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.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/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js",
+ "test:unit": "node --test test/danmuku-mask-failures.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.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/asr-fallback-routing.test.js test/asr-local-example.test.js test/asr-explicit-capture.test.js test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.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",
@@ -98,7 +98,8 @@
"test:asr-types-package": "node refactor/scripts/asr-package-types.mjs",
"test:asr-demo": "node refactor/scripts/asr-local-demo.mjs",
"test:chromecast": "node --test test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js",
- "test:chromecast-types-package": "node refactor/scripts/chromecast-package-types.mjs"
+ "test:chromecast-types-package": "node refactor/scripts/chromecast-package-types.mjs",
+ "test:danmuku-mask": "node --test test/danmuku-mask-failures.test.js refactor/scripts/danmuku-mask-contract.test.mjs"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
diff --git a/refactor/baselines/danmuku-mask-failures-validation.json b/refactor/baselines/danmuku-mask-failures-validation.json
new file mode 100644
index 000000000..8a66f8510
--- /dev/null
+++ b/refactor/baselines/danmuku-mask-failures-validation.json
@@ -0,0 +1,326 @@
+{
+ "task": "PKG-MASK-02",
+ "sourceCommit": "b0cfbfe3a09a84a295a257e45e4577847588d1cb",
+ "node": "v24.21.0",
+ "inputs": [
+ {
+ "name": "frozen-source",
+ "inputSha256": "2397900688f86bc74bc86a4e870f69478a651bd8df02bba543cdab832773fd6e",
+ "transformedSha256": "5baf9875cfebd4bafad8091471f663f9a914e8ba208420e87b8005874f837228",
+ "source": true,
+ "replacements": []
+ },
+ {
+ "name": "published-1.1.0-main",
+ "archiveSha256": "96682e46cb140a84b164818dd118480d7be42b24afaab390027a25e52e8ae31c",
+ "member": "package/dist/artplayer-plugin-danmuku-mask.js",
+ "inputSha256": "07cebbfbf77f8ec4b2df6a77d7d7300345b86f2993e220ff14e1aae530e4fb31",
+ "transformedSha256": "07cebbfbf77f8ec4b2df6a77d7d7300345b86f2993e220ff14e1aae530e4fb31",
+ "probeSha256": "472b81399c806d2fbfc4abe40e3955017bec498ad1e3966fd894c4776150015f",
+ "counts": {
+ "setBackend": 2,
+ "createSegmenter": 1,
+ "toBinaryMask": 1,
+ "drawMask": 1
+ },
+ "replacements": [
+ {
+ "start": 901335,
+ "end": 901337,
+ "target": "setBackend",
+ "originalSha256": "3c42d9ac9807bbf91745b921d4887371b41b4a52e4e78f0dd127358e967dd4e0"
+ },
+ {
+ "start": 901437,
+ "end": 901439,
+ "target": "setBackend",
+ "originalSha256": "3c42d9ac9807bbf91745b921d4887371b41b4a52e4e78f0dd127358e967dd4e0"
+ },
+ {
+ "start": 901764,
+ "end": 902205,
+ "target": "createSegmenter",
+ "originalSha256": "3d8107811f464ec31d18f7b813a77e95492db7c324f5be67634fc550a4dc9fff"
+ },
+ {
+ "start": 902561,
+ "end": 903452,
+ "target": "toBinaryMask",
+ "originalSha256": "1fa52455c5c212f2b17b7de7b35bec4296492ba60b447d24ec8c7aef3e6734a7"
+ },
+ {
+ "start": 903502,
+ "end": 903504,
+ "target": "drawMask",
+ "originalSha256": "d1ee0b45da3fe74f3ebe628dc12fbd399eda5f374bd184104528a95e0ab5a757"
+ }
+ ]
+ },
+ {
+ "name": "published-1.1.0-legacy",
+ "archiveSha256": "96682e46cb140a84b164818dd118480d7be42b24afaab390027a25e52e8ae31c",
+ "member": "package/dist/artplayer-plugin-danmuku-mask.legacy.js",
+ "inputSha256": "a678a3c0f6d3920bee3b66af9943b3612935028bff61c96677b8eaf8ef7cc8f0",
+ "transformedSha256": "a678a3c0f6d3920bee3b66af9943b3612935028bff61c96677b8eaf8ef7cc8f0",
+ "probeSha256": "c2fef32f4c07c8c52382b638f15b4539ee4d13b425734042b98881d5fe4ab1c9",
+ "counts": {
+ "setBackend": 2,
+ "createSegmenter": 1,
+ "toBinaryMask": 1,
+ "drawMask": 1
+ },
+ "replacements": [
+ {
+ "start": 904198,
+ "end": 904200,
+ "target": "setBackend",
+ "originalSha256": "0041fea20826cea952ac6411b2584c7d09c42dee7be4631e9fe6eadba3963b64"
+ },
+ {
+ "start": 904300,
+ "end": 904302,
+ "target": "setBackend",
+ "originalSha256": "0041fea20826cea952ac6411b2584c7d09c42dee7be4631e9fe6eadba3963b64"
+ },
+ {
+ "start": 904629,
+ "end": 905070,
+ "target": "createSegmenter",
+ "originalSha256": "d98df21622f139a06fde9e766a6bb24dd2919b03e2d4df908b42cedf302289ad"
+ },
+ {
+ "start": 905453,
+ "end": 906344,
+ "target": "toBinaryMask",
+ "originalSha256": "f77e943d57ef12cabd9b2c4cfefb2f88be20fb2169d865d6a1bf81b6ab04720a"
+ },
+ {
+ "start": 906394,
+ "end": 906396,
+ "target": "drawMask",
+ "originalSha256": "69d61997a241e97931db9dd1cfcef218041a752485f5f7956b09766287682da3"
+ }
+ ]
+ },
+ {
+ "name": "published-1.1.0-module",
+ "archiveSha256": "96682e46cb140a84b164818dd118480d7be42b24afaab390027a25e52e8ae31c",
+ "member": "package/dist/artplayer-plugin-danmuku-mask.mjs",
+ "inputSha256": "48f7a449dc9555ab4060a578902b5431701f81b2f093a7cb0526042a8c417400",
+ "transformedSha256": "01b79edcfc8abf85006acb0e1b642636ce9845313fde1d8fb9d39c62a918939f",
+ "probeSha256": "67d5c6dd94e27ce752b772481b66cb2bb0f275630a4ed2ca4642f1f1fde095f2",
+ "counts": {
+ "setBackend": 2,
+ "createSegmenter": 1,
+ "toBinaryMask": 1,
+ "drawMask": 1
+ },
+ "replacements": [
+ {
+ "start": 1725698,
+ "end": 1725708,
+ "target": "setBackend",
+ "originalSha256": "eeb426cebcc7b4b1377849d09fd1d50b3c61d85684675799a258f4e3aae8823a"
+ },
+ {
+ "start": 1725846,
+ "end": 1725856,
+ "target": "setBackend",
+ "originalSha256": "eeb426cebcc7b4b1377849d09fd1d50b3c61d85684675799a258f4e3aae8823a"
+ },
+ {
+ "start": 1726444,
+ "end": 1726446,
+ "target": "createSegmenter",
+ "originalSha256": "8e5202705183bd3a20a29e224499b0f77a8273ee33cd93cca71043c57ad4bdfc"
+ },
+ {
+ "start": 1727942,
+ "end": 1727944,
+ "target": "toBinaryMask",
+ "originalSha256": "799267762dffea91ee7fb9d426a552458f46166208b09d5e8d82b9c318a05d2f"
+ },
+ {
+ "start": 1728116,
+ "end": 1728118,
+ "target": "drawMask",
+ "originalSha256": "38cebf4836a918cb5a42873deff4065fe36689cbfd0a3e862ede3ebff766807e"
+ }
+ ]
+ },
+ {
+ "name": "published-1.0.0-main",
+ "archiveSha256": "8a88f52ae843bed1be01a2e60b82cf886d33582f990a5016d49b1409c3493c4b",
+ "member": "package/dist/artplayer-plugin-danmuku-mask.js",
+ "inputSha256": "940cc240fcdb208427cb25d54180f9b84e0bfa96b5a4d3ba68fa9fb2ad8ffa16",
+ "transformedSha256": "940cc240fcdb208427cb25d54180f9b84e0bfa96b5a4d3ba68fa9fb2ad8ffa16",
+ "probeSha256": "93edf238803b68dc3b3192fdf9c85c12ad13224892e7aaca364394064b1fcc57",
+ "counts": {
+ "setBackend": 2,
+ "createSegmenter": 1,
+ "toBinaryMask": 1,
+ "drawMask": 1
+ },
+ "replacements": [
+ {
+ "start": 2702,
+ "end": 2714,
+ "target": "setBackend",
+ "originalSha256": "7dc39bab39429669a8e1ca50452330036048b7797dd1c10191c52e01b82c788e"
+ },
+ {
+ "start": 2814,
+ "end": 2826,
+ "target": "setBackend",
+ "originalSha256": "7dc39bab39429669a8e1ca50452330036048b7797dd1c10191c52e01b82c788e"
+ },
+ {
+ "start": 3190,
+ "end": 3207,
+ "target": "createSegmenter",
+ "originalSha256": "6b1027a187ae2bfb9960a09621abdf3133be7b2c94244f9b8913c149e5ca6e50"
+ },
+ {
+ "start": 3507,
+ "end": 3521,
+ "target": "toBinaryMask",
+ "originalSha256": "1aa8e213bdfcac558beb18b6c619a17d310ca974e61c97de388317a74f2f64d1"
+ },
+ {
+ "start": 3613,
+ "end": 3623,
+ "target": "drawMask",
+ "originalSha256": "588b57876050ec1c883c40e546f66bf01a3116a3081053d961a09a37fd6da20b"
+ }
+ ]
+ },
+ {
+ "name": "published-1.0.0-legacy",
+ "archiveSha256": "8a88f52ae843bed1be01a2e60b82cf886d33582f990a5016d49b1409c3493c4b",
+ "member": "package/dist/artplayer-plugin-danmuku-mask.legacy.js",
+ "inputSha256": "49da39c368e036f894e623277ce837efa9b7524a040d8ae01e2245b3459c3c9f",
+ "transformedSha256": "49da39c368e036f894e623277ce837efa9b7524a040d8ae01e2245b3459c3c9f",
+ "probeSha256": "fa9ef8f14ce6bc0ec13557f3cc8ccb9cedf4b58a0b93db4983bdbc2106852791",
+ "counts": {
+ "setBackend": 2,
+ "createSegmenter": 1,
+ "toBinaryMask": 1,
+ "drawMask": 1
+ },
+ "replacements": [
+ {
+ "start": 3164,
+ "end": 3176,
+ "target": "setBackend",
+ "originalSha256": "ff79b48a47dfadebba910487f17a3ff7e32347f91af5e66b4f9360227ecc996b"
+ },
+ {
+ "start": 3322,
+ "end": 3334,
+ "target": "setBackend",
+ "originalSha256": "ff79b48a47dfadebba910487f17a3ff7e32347f91af5e66b4f9360227ecc996b"
+ },
+ {
+ "start": 3743,
+ "end": 3760,
+ "target": "createSegmenter",
+ "originalSha256": "d509804e369c2ff85b0463b30708c279397274ebaa58438e1275b9e98ec0a8dc"
+ },
+ {
+ "start": 4567,
+ "end": 4581,
+ "target": "toBinaryMask",
+ "originalSha256": "a4d2ab45b3dc07af8a0474981caa8dd3932885b722e467b18c55647c93602b92"
+ },
+ {
+ "start": 4654,
+ "end": 4664,
+ "target": "drawMask",
+ "originalSha256": "fe3835d5b191fc5d5efb0f08289855ac690bbc17f1553848c1aab292c3e4274c"
+ }
+ ]
+ },
+ {
+ "name": "published-1.0.0-module",
+ "archiveSha256": "8a88f52ae843bed1be01a2e60b82cf886d33582f990a5016d49b1409c3493c4b",
+ "member": "package/dist/artplayer-plugin-danmuku-mask.mjs",
+ "inputSha256": "92ae895c45c3dd39110c3513eb5cb6d99b9249d96b37b7fd1a6437e97febdc8f",
+ "transformedSha256": "dade379e7fe355271e74904022b95f6c8965e33228921224f50eb232a651fec8",
+ "probeSha256": "9b3bacea7a45879c1ba841becf25bf8fea264cfaa2637506eb323048942cf4a7",
+ "counts": {
+ "setBackend": 2,
+ "createSegmenter": 1,
+ "toBinaryMask": 1,
+ "drawMask": 1
+ },
+ "replacements": [
+ {
+ "start": 4608,
+ "end": 4620,
+ "target": "setBackend",
+ "originalSha256": "7dc39bab39429669a8e1ca50452330036048b7797dd1c10191c52e01b82c788e"
+ },
+ {
+ "start": 4748,
+ "end": 4760,
+ "target": "setBackend",
+ "originalSha256": "7dc39bab39429669a8e1ca50452330036048b7797dd1c10191c52e01b82c788e"
+ },
+ {
+ "start": 5215,
+ "end": 5232,
+ "target": "createSegmenter",
+ "originalSha256": "6b1027a187ae2bfb9960a09621abdf3133be7b2c94244f9b8913c149e5ca6e50"
+ },
+ {
+ "start": 5768,
+ "end": 5782,
+ "target": "toBinaryMask",
+ "originalSha256": "1aa8e213bdfcac558beb18b6c619a17d310ca974e61c97de388317a74f2f64d1"
+ },
+ {
+ "start": 5908,
+ "end": 5918,
+ "target": "drawMask",
+ "originalSha256": "588b57876050ec1c883c40e546f66bf01a3116a3081053d961a09a37fd6da20b"
+ }
+ ]
+ }
+ ],
+ "scope": "Frozen owned source and actual archive-derived lifecycle probes. Archive call sites are replaced only at five controlled SDK callee boundaries; module bytes first transformed to CJS. This is not unmodified bundle/model or native ESM/browser/device acceptance.",
+ "candidateAcceptance": false,
+ "expectedTests": 54,
+ "isolatedFatalChildren": 4,
+ "checks": {
+ "nodeTests": {
+ "passed": 54,
+ "failed": 0,
+ "skipped": 0
+ },
+ "lint": "passed",
+ "isolatedFatalChildren": {
+ "count": 4,
+ "expectedExit": 1,
+ "mode": "--unhandled-rejections=strict",
+ "parentGlobalRejectionHandler": false
+ }
+ },
+ "evidence": {
+ "file": "refactor/.cache/danmuku-mask-failures-final.log",
+ "sha256": "c47c1a61a0c802aaa795b5a9fd0c4a84e38e5dbaa69243e4e684b41ed21fbf40"
+ },
+ "toolVersions": {
+ "typescript": "5.9.3",
+ "esbuild": "0.27.7"
+ },
+ "testFiles": [
+ {
+ "file": "test/helpers/danmuku-mask.js",
+ "sha256": "82a5d6990af9bf5910b073b65ab73fdc60a2071e25d2d5f059b1ba35d0b3cd00"
+ },
+ {
+ "file": "test/danmuku-mask-failures.test.js",
+ "sha256": "035c3123b6f01c99e6bd41b825bdd4ed5aa7a5ce7c94285e734ece6c23710bfe"
+ }
+ ]
+}
diff --git a/refactor/changes/2026-09-13-PKG-MASK-02-failures.md b/refactor/changes/2026-09-13-PKG-MASK-02-failures.md
new file mode 100644
index 000000000..a210290c7
--- /dev/null
+++ b/refactor/changes/2026-09-13-PKG-MASK-02-failures.md
@@ -0,0 +1,82 @@
+# PKG-MASK-02 历史错误与生命周期观察
+
+本任务新增历史测试、专属 helper 和证据,并接入根测试脚本;未修改生产或生成产物,未执行模型,
+没有浏览器服务器、推送或发布。PKG-MASK-03 仍依赖 PKG-DANMUKU-07;该依赖尚未
+满足,生产重构按任务依赖继续推进。本任务不代表 SDK-08 完成。
+
+## 输入和可重放接缝
+
+源码来自 `b0cfbfe3a09a84a295a257e45e4577847588d1cb`;真实 npm 1.0.0/1.1.0
+tarball 来自 MASK-01 的 SHA512/SHA256 冻结。源文件与六个 main/legacy/module
+成员共七个输入,标识见 [机器报告](../baselines/danmuku-mask-failures-validation.json)。
+
+两种探针必须区分:
+
+- MASK-01 已用**未改写的归档字节**验证 CommonJS/script 导出、注册和 stop,未启动模型。
+- MASK-02 的历史生命周期探针对归档导出工厂内部 **五个 SDK 调用表达式的 callee**
+ 做受控替换:setBackend(webgl/cpu) 两处、createSegmenter/toBinaryMask/drawMask
+ 各一处。inline SDK 函数表达式作为整体替换,原插件异步流程、生命周期、错误处理、
+ canvas 操作与旧版 async helper 保留。module 先以 esbuild 转为 CJS,因此不算
+ 原生 ESM 验收。冻结源码仅转换模块形式,通过 require seam 注入 SDK 替身。
+
+helper 首先校验真实归档,再从实际导出函数定位唯一原文字段,用 TypeScript AST
+寻找接缝。固定替换数量不是模糊匹配后继续运行:任何数量不符直接失败。报告保存
+原成员哈希、转 CJS 后哈希、探针哈希、每个 callee 的范围/旧表达式哈希。首项测试
+逐项比较报告,变化必须显式审查。实际 SDK 顶层注册可执行,但没有模型初始化、
+模型网络、GPU 或媒体推理。探针 fetch 被拒绝;错误探针不得以网络错误替代目标错误。
+
+## 实际观察
+
+七个输入都复现以下三个历史缺陷,共 21 项:
+
+| 触发 | 历史结果 |
+| --- | --- |
+| 初始化 pending → stop → 完成初始化 | stop 的 maskImage=none 被后续 mask 写入覆盖,重新安排 RAF |
+| 推理 pending → destroy → 推理完成 | 销毁后仍画 mask/排 RAF;segmenter 未 dispose,ready/destroy listener 留存 |
+| 初始化完成后再次 start → stop | 两条 RAF 链,stop 只取消最后记录的一条;剩余链下一轮重新写 mask |
+
+详细错误矩阵集中在冻结源码与 npm 1.1.0 main,各 16 项,避免为所有格式机械重复:
+
+| 触发 | 历史结果 / 关联风险 |
+| --- | --- |
+| 初始化 pending 时 stop 或 destroy | 后续模型仍存活,不 dispose;MASK-LIFETIME-01 |
+| 推理 pending 时 stop | 晚到结果覆盖 none 并排新 RAF;MASK-LIFETIME-01 |
+| 同时两次 start | 创建两个模型、两条 RAF,destroy 后仍剩一条,两个模型均未 dispose;MASK-START-01 |
+| segmentPeople rejection | 每帧记一次错误并重试;改为成功后能再画 mask;MASK-BACKEND-01 |
+| setBackend(webgl) fulfilled false | 不尝试 CPU,继续初始化;MASK-BACKEND-01 |
+| WebGL rejection、CPU fulfilled true/false | 均尝试 CPU、记录一次 warning、继续初始化,忽略返回 boolean;不是 MediaPipe CPU 推理证据 |
+| CPU rejection,经显式 start 调用 | Promise 以同一错误拒绝,尚未创建模型或排 RAF |
+| createSegmenter rejection | 记录错误,但 start resolve;以后 RAF 只轮询,既不推理也不自动重新初始化 |
+| 缺少 $danmuku | start 在模型创建后以 TypeError 拒绝;stop 也抛错,模型未 dispose;MASK-DOM-01 |
+| getContext 返回 null | start resolve;读图像时报 TypeError 并排下一帧,旧 mask 不被清除 |
+| createElement(canvas) 抛错 | start 以同一错误拒绝;已初始化模型在 destroy 后仍未 dispose |
+| getImageData 抛受控 SecurityError | 错误被记录并重试,既有 mask 留存;这不是原生 CORS 复现 |
+| RAF 返回 0;destroy 后再次 ready | truthiness 判断漏取消 0,保留 ready listener 可再启动一条链 |
+| ready 内 CPU rejection | 被事件调度丢弃的 start Promise 产生未处理拒绝 |
+| 缺少 $video | segmentBody 在 try 之外读 paused,产生未处理拒绝 |
+
+加上接缝身份验证,共 **54 项历史测试**。未处理拒绝的四项各启独立 Node 子进程,
+使用 `--unhandled-rejections=strict`,验证子进程 exit=1 和指定错误;父进程没有安装
+unhandledRejection handler,子进程也没有吞错 handler。子进程只能以指定错误失败,
+AssertionError 或禁止网络错误均不作为通过。测试进程本身没有实际 RAF 定时器;
+故意留存的历史帧只在该测试的 Map 内,不会泄漏到真实 UI。
+
+## 验证及后续约束
+
+固定 Node 24.21.0:
+
+```sh
+node --test test/danmuku-mask-failures.test.js
+node node_modules/eslint/bin/eslint.js test/helpers/danmuku-mask.js test/danmuku-mask-failures.test.js
+yarn test:danmuku-mask
+```
+
+54 项通过、0 fail、0 skip;专属 lint 通过。日志
+`refactor/.cache/danmuku-mask-failures-final.log`,机器报告记录其哈希和输入身份。
+这里只证明旧缺陷可重跑,**这些结果不能变成候选验收期望**。后续候选测试应独立
+验证异步失效、模型单一归属、RAF 单链、适当错误传播/事件处理和销毁清理;不要靠
+将本套测试切换到候选 source 来保留已知问题。SDK-08、真实模型/设备/视频/浏览器
+与新旧核心组合仍由 05 验收,通知/资源最终分发由 06 核实。
+
+父代理独立复跑54项通过,加入现有test:unit使CI自动执行;专属
+test:danmuku-mask同时覆盖01的六项契约。未安装依赖或更改Yarn锁文件。
diff --git a/refactor/plan.md b/refactor/plan.md
index 6dba667db..71e1b59dc 100644
--- a/refactor/plan.md
+++ b/refactor/plan.md
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 228 项,范围 22 个包及工作区/示例。
-状态:todo 73 / doing 16 / blocked 0 / done 139 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
+状态:todo 73 / doing 15 / blocked 0 / done 140 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -241,7 +241,7 @@
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| PKG-MASK-01 | artplayer-plugin-danmuku-mask
核对包契约与历史用法 | BASE-05 | 模型参数、start/stop、默认下载路径及 mask 样式 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结;接续 SDK-08,分别核实 Yarn 解析版本、未固定版本的模型 solutionPath 和资源来源 | H | done |
-| PKG-MASK-02 | artplayer-plugin-danmuku-mask
建立特有行为与错误测试 | PKG-MASK-01, ENG-03, ENG-05 | 加载期间停止/销毁、重复启动、推理失败、WebGL/CPU 边界 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | doing |
+| PKG-MASK-02 | artplayer-plugin-danmuku-mask
建立特有行为与错误测试 | PKG-MASK-01, ENG-03, ENG-05 | 加载期间停止/销毁、重复启动、推理失败、WebGL/CPU 边界 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done |
| PKG-MASK-03 | artplayer-plugin-danmuku-mask
整理内部职责与资源 | PKG-MASK-02, CORE-18, PKG-DANMUKU-07 | 模型加载/推理/画布输出分离,阻止重叠推理与过期写入 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo |
| PKG-MASK-04 | artplayer-plugin-danmuku-mask
迁移自有源码和公开类型 | PKG-MASK-03, ENG-04, ENG-06, CORE-07 | 模型 adapter、canvas 和选项的精确类型 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo |
| PKG-MASK-05 | artplayer-plugin-danmuku-mask
验证新旧核心和组合 | PKG-MASK-04, CORE-22 | 真实模型和 danmuku/seek/全屏组合,GPU 资源释放 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
@@ -530,6 +530,7 @@
- PKG-MULTI-SUB-07: [记录](baselines/multiple-subtitles-timestamps.json) [记录](changes/2026-09-13-PKG-MULTI-SUB-07-timestamps.md)
- PKG-JASSUB-01: [记录](baselines/jassub-release.json) [记录](baselines/jassub-vendor.json) [记录](baselines/jassub-font-metadata.json) [记录](baselines/jassub-contract.md) [记录](baselines/jassub-contract-validation.json) [记录](changes/2026-09-13-PKG-JASSUB-01-baseline.md)
- PKG-MASK-01: [记录](baselines/danmuku-mask-release.json) [记录](baselines/danmuku-mask-registry.json) [记录](baselines/danmuku-mask-contract.md) [记录](baselines/danmuku-mask-contract-validation.json) [记录](changes/2026-09-13-PKG-MASK-01-contract.md)
+- PKG-MASK-02: [记录](changes/2026-09-13-PKG-MASK-02-failures.md) [记录](baselines/danmuku-mask-failures-validation.json)
- PKG-ASR-01: [记录](changes/2026-09-13-PKG-ASR-01-contracts.md) [记录](baselines/asr-contract.md) [记录](baselines/asr-release.json)
- PKG-ASR-02: [记录](changes/2026-09-13-PKG-ASR-02-audio-baseline.md) [记录](baselines/asr-audio-validation.json)
- PKG-ASR-03: [记录](changes/2026-09-13-PKG-ASR-03-audio-ownership.md) [记录](baselines/asr-ownership-validation.json)
diff --git a/refactor/progress.md b/refactor/progress.md
index f7dbc70be..261b9e994 100644
--- a/refactor/progress.md
+++ b/refactor/progress.md
@@ -1,5 +1,16 @@
# 进度与证据
+## PKG-MASK-02 生命周期与错误基线完成
+
+54项冻结历史测试及父代理复核通过,覆盖源码和两旧版本三格式的关键生命周期;
+完整失败矩阵聚焦源码/最新main,四个未处理拒绝在独立进程精确取证。归档SDK接缝
+五处callee转换哈希可重放,不能当作真实模型通过。新测试接入test:unit和专属
+yarn test:danmuku-mask;四类风险由源码观察提升为已复现,保持开放待修复。
+见[变更](changes/2026-09-13-PKG-MASK-02-failures.md)与[证据](baselines/danmuku-mask-failures-validation.json)。
+228项:140 done、15 doing、73 todo。Mask03仍等待Danmuku07
+稳定依赖;Danmuku02受控失败和真实浏览器基线继续并行。没有推送或发布。
+
+
## PKG-DANMUKU-01 弹幕契约与归档完成
冻结81稳定版目录、15真实归档和28源码/文档/图标输入。44项测试及父代理复核通过;
diff --git a/refactor/risk-table.md b/refactor/risk-table.md
index 8c0f36606..880d884e2 100644
--- a/refactor/risk-table.md
+++ b/refactor/risk-table.md
@@ -221,11 +221,11 @@
| CAST-LIFE-01 | resolved / 已复现 | Cast listeners and continuations survive player destruction and update another player icon | PKG-CAST-03, PKG-CAST-05 |
| CAST-STATE-01 | resolved / 已复现 | Cast terminal session states leave the normalized callback and icon connected | PKG-CAST-03, PKG-CAST-05 |
| CAST-ERROR-01 | resolved / 已复现 | Cast media loading rejection escapes on a detached Promise while the click has already fulfilled | PKG-CAST-03, PKG-CAST-05 |
-| MASK-LIFETIME-01 | open / 源码/产物事实 | Mask asynchronous initialization and inference continue after stop or destroy | PKG-MASK-02, PKG-MASK-03 |
-| MASK-START-01 | open / 源码/产物事实 | Concurrent Mask start calls can initialize multiple models and create independent RAF chains | PKG-MASK-02, PKG-MASK-03 |
+| MASK-LIFETIME-01 | open / 已复现 | Mask asynchronous initialization and inference continue after stop or destroy | PKG-MASK-02, PKG-MASK-03 |
+| MASK-START-01 | open / 已复现 | Concurrent Mask start calls can initialize multiple models and create independent RAF chains | PKG-MASK-02, PKG-MASK-03 |
| MASK-MODEL-01 | open / 源码/产物事实 | Published Mask options and installed MediaPipe adapter consume different model settings | PKG-MASK-02, PKG-MASK-04, PKG-MASK-05 |
-| MASK-BACKEND-01 | open / 源码/产物事实 | Mask TensorFlow backend fallback does not establish MediaPipe fallback and failed initialization keeps scheduling | PKG-MASK-02, PKG-MASK-03, PKG-MASK-05 |
-| MASK-DOM-01 | open / 源码/产物事实 | Mask registration and frame processing assume Danmuku DOM, readable frames and a 2D context | PKG-MASK-02, PKG-MASK-03, PKG-MASK-05 |
+| MASK-BACKEND-01 | open / 已复现 | Mask TensorFlow backend fallback does not establish MediaPipe fallback and failed initialization keeps scheduling | PKG-MASK-02, PKG-MASK-03, PKG-MASK-05 |
+| MASK-DOM-01 | open / 已复现 | Mask registration and frame processing assume Danmuku DOM, readable frames and a 2D context | PKG-MASK-02, PKG-MASK-03, PKG-MASK-05 |
| MASK-NOTICE-01 | open / 源码/产物事实 | Published Mask bundles lack a separate third-party notice despite bundled SDK code | PKG-MASK-06 |
| DANMUKU-TYPE-01 | open / 已复现 | Danmuku historical declared results, optional mount and initial Promise input disagree with runtime | PKG-DANMUKU-03, PKG-DANMUKU-06 |
| DANMUKU-INPUT-01 | open / 已复现 | Danmuku replaces time zero with current time plus 0.5 and mutates supplied input objects | PKG-DANMUKU-02, PKG-DANMUKU-03 |
diff --git a/refactor/risks.json b/refactor/risks.json
index e52598747..c317b2d61 100644
--- a/refactor/risks.json
+++ b/refactor/risks.json
@@ -4906,7 +4906,7 @@
{
"id": "MASK-LIFETIME-01",
"title": "Mask asynchronous initialization and inference continue after stop or destroy",
- "confirmation": "source-observed",
+ "confirmation": "reproduced",
"status": "open",
"owners": [
"PKG-MASK-02",
@@ -4915,7 +4915,10 @@
"evidence": [
"refactor/baselines/danmuku-mask-contract.md",
"refactor/baselines/danmuku-mask-release.json",
- "refactor/baselines/danmuku-mask-contract-validation.json"
+ "refactor/baselines/danmuku-mask-contract-validation.json",
+ "refactor/changes/2026-09-13-PKG-MASK-02-failures.md",
+ "refactor/baselines/danmuku-mask-failures-validation.json",
+ "test/danmuku-mask-failures.test.js"
],
"compatibleResolution": "Invalidate stale work while keeping restartable stop distinct from final destroy; release model and subscriptions.",
"closureCriteria": "Pending initialization/inference cannot write masks or schedule work after stop/destroy; errors settle and owned resources are released."
@@ -4923,7 +4926,7 @@
{
"id": "MASK-START-01",
"title": "Concurrent Mask start calls can initialize multiple models and create independent RAF chains",
- "confirmation": "source-observed",
+ "confirmation": "reproduced",
"status": "open",
"owners": [
"PKG-MASK-02",
@@ -4932,7 +4935,10 @@
"evidence": [
"refactor/baselines/danmuku-mask-contract.md",
"refactor/baselines/danmuku-mask-release.json",
- "refactor/baselines/danmuku-mask-contract-validation.json"
+ "refactor/baselines/danmuku-mask-contract-validation.json",
+ "refactor/changes/2026-09-13-PKG-MASK-02-failures.md",
+ "refactor/baselines/danmuku-mask-failures-validation.json",
+ "test/danmuku-mask-failures.test.js"
],
"compatibleResolution": "Share pending initialization and own at most one inference loop per live registration.",
"closureCriteria": "Repeated and concurrent starts, stop then start, failures and multiple instances have deterministic resource and result assertions."
@@ -4958,7 +4964,7 @@
{
"id": "MASK-BACKEND-01",
"title": "Mask TensorFlow backend fallback does not establish MediaPipe fallback and failed initialization keeps scheduling",
- "confirmation": "source-observed",
+ "confirmation": "reproduced",
"status": "open",
"owners": [
"PKG-MASK-02",
@@ -4968,7 +4974,10 @@
"evidence": [
"refactor/baselines/danmuku-mask-contract.md",
"refactor/baselines/danmuku-mask-release.json",
- "refactor/baselines/danmuku-mask-contract-validation.json"
+ "refactor/baselines/danmuku-mask-contract-validation.json",
+ "refactor/changes/2026-09-13-PKG-MASK-02-failures.md",
+ "refactor/baselines/danmuku-mask-failures-validation.json",
+ "test/danmuku-mask-failures.test.js"
],
"compatibleResolution": "Model backend outcomes explicitly, settle initialization failures and document the actual MediaPipe capability boundary.",
"closureCriteria": "Fulfilled false, rejection, CPU failure, createSegmenter failure and later retry are tested; real inference fallback is separately evidenced."
@@ -4976,7 +4985,7 @@
{
"id": "MASK-DOM-01",
"title": "Mask registration and frame processing assume Danmuku DOM, readable frames and a 2D context",
- "confirmation": "source-observed",
+ "confirmation": "reproduced",
"status": "open",
"owners": [
"PKG-MASK-02",
@@ -4986,7 +4995,10 @@
"evidence": [
"refactor/baselines/danmuku-mask-contract.md",
"refactor/baselines/danmuku-mask-release.json",
- "refactor/baselines/danmuku-mask-contract-validation.json"
+ "refactor/baselines/danmuku-mask-contract-validation.json",
+ "refactor/changes/2026-09-13-PKG-MASK-02-failures.md",
+ "refactor/baselines/danmuku-mask-failures-validation.json",
+ "test/danmuku-mask-failures.test.js"
],
"compatibleResolution": "Handle missing dependencies and frame/context failures without stale writes or uncontrolled loops while preserving plugin ordering.",
"closureCriteria": "Missing Danmuku/context, CORS failure, source changes and ready/destroy timing have controlled and real browser evidence."
diff --git a/refactor/tasks.json b/refactor/tasks.json
index 95a9fb384..9e9ec450d 100644
--- a/refactor/tasks.json
+++ b/refactor/tasks.json
@@ -2542,11 +2542,14 @@
"ENG-03",
"ENG-05"
],
- "status": "doing",
+ "status": "done",
"risk": "H",
"deliverable": "加载期间停止/销毁、重复启动、推理失败、WebGL/CPU 边界",
"acceptance": "旧版本行为可重跑,成功/失败/切源/销毁有必要断言",
- "evidence": []
+ "evidence": [
+ "changes/2026-09-13-PKG-MASK-02-failures.md",
+ "baselines/danmuku-mask-failures-validation.json"
+ ]
},
{
"id": "PKG-MASK-03",
diff --git a/test/danmuku-mask-failures.test.js b/test/danmuku-mask-failures.test.js
new file mode 100644
index 000000000..9d016c9ec
--- /dev/null
+++ b/test/danmuku-mask-failures.test.js
@@ -0,0 +1,259 @@
+import assert from 'node:assert/strict'
+import { spawnSync } from 'node:child_process'
+import fs from 'node:fs'
+import process from 'node:process'
+// eslint-disable-next-line test/no-import-node-test -- Historical runner includes fatal isolated Node rejection probes.
+import test from 'node:test'
+import { danmukuMaskEnvironment, danmukuMaskHistorical, flushMask } from './helpers/danmuku-mask.js'
+
+// These assertions describe frozen historical defects, never candidate acceptance.
+const implementations = await danmukuMaskHistorical()
+const detailed = implementations.filter(item => ['frozen-source', 'published-1.1.0-main'].includes(item.name))
+
+test('Historical SDK probes replace exactly five call sites and retain verified input identities', () => {
+ const expected = JSON.parse(fs.readFileSync('refactor/baselines/danmuku-mask-failures-validation.json', 'utf8'))
+ const actual = implementations.map(({ code: _code, ...identity }) => identity)
+ assert.deepEqual(actual, expected.inputs, 'SDK seam transformations must remain exactly reproducible')
+ assert.equal(implementations.length, 7)
+ for (const item of implementations) {
+ assert.match(item.inputSha256, /^[a-f0-9]{64}$/u)
+ assert.match(item.transformedSha256, /^[a-f0-9]{64}$/u)
+ if (!item.source) {
+ assert.match(item.archiveSha256, /^[a-f0-9]{64}$/u)
+ assert.match(item.probeSha256, /^[a-f0-9]{64}$/u)
+ assert.deepEqual(item.counts, { setBackend: 2, createSegmenter: 1, toBinaryMask: 1, drawMask: 1 })
+ assert.equal(item.replacements.length, 5)
+ }
+ }
+})
+
+for (const implementation of implementations) {
+ test(`${implementation.name}: historical stop during initialization is undone by late initialization`, async () => {
+ const env = danmukuMaskEnvironment(implementation, { pendingInit: true })
+ const pending = env.result.start()
+ await flushMask()
+ assert.equal(env.initGates.length, 1)
+ env.result.stop()
+ assert.equal(env.style.maskImage, 'none')
+ env.initGates[0].resolve()
+ await pending
+ await flushMask()
+ assert.equal(env.draws.length, 1)
+ assert.equal(env.style.maskImage, 'url(data:image/png;base64,historical-mask)')
+ assert.equal(env.frames.size, 1)
+ env.result.stop()
+ })
+
+ test(`${implementation.name}: historical inference completion after destroy writes a mask and revives RAF`, async () => {
+ const env = danmukuMaskEnvironment(implementation, { pendingSegment: true })
+ await env.result.start()
+ await flushMask()
+ assert.equal(env.segmentGates.length, 1)
+ env.destroy()
+ env.segmentGates[0].resolve([{}])
+ await flushMask()
+ assert.equal(env.draws.length, 1)
+ assert.equal(env.style.maskImage, 'url(data:image/png;base64,historical-mask)')
+ assert.equal(env.frames.size, 1)
+ assert.equal(env.created[0].disposed, 0)
+ assert.deepEqual([...env.listeners].map(([name, callbacks]) => [name, callbacks.size]), [['ready', 1], ['destroy', 1]])
+ env.result.stop()
+ })
+
+ test(`${implementation.name}: historical repeated start leaves a second RAF chain after stop`, async () => {
+ const env = danmukuMaskEnvironment(implementation)
+ await env.result.start()
+ await flushMask()
+ await env.result.start()
+ await flushMask()
+ assert.equal(env.created.length, 1)
+ assert.equal(env.frames.size, 2)
+ env.result.stop()
+ assert.equal(env.frames.size, 1)
+ await env.frame()
+ assert.equal(env.frames.size, 1)
+ assert.equal(env.style.maskImage, 'url(data:image/png;base64,historical-mask)')
+ env.result.stop()
+ })
+}
+
+for (const implementation of detailed) {
+ for (const action of ['stop', 'destroy']) {
+ test(`${implementation.name}: historical ${action} during initialization does not dispose the late model`, async () => {
+ const env = danmukuMaskEnvironment(implementation, { pendingInit: true })
+ const pending = env.result.start()
+ await flushMask()
+ if (action === 'stop')
+ env.result.stop()
+ else env.destroy()
+ env.initGates[0].resolve()
+ await pending
+ await flushMask()
+ assert.equal(env.created[0].disposed, 0)
+ assert.equal(env.frames.size, 1)
+ assert.equal(env.style.maskMode, 'alpha')
+ env.result.stop()
+ })
+ }
+
+ test(`${implementation.name}: historical stop during inference cannot prevent its pending write`, async () => {
+ const env = danmukuMaskEnvironment(implementation, { pendingSegment: true })
+ await env.result.start()
+ env.result.stop()
+ assert.deepEqual(env.writes, ['none'])
+ env.segmentGates[0].resolve([{}])
+ await flushMask()
+ assert.deepEqual(env.writes, ['none', 'url(data:image/png;base64,historical-mask)'])
+ assert.equal(env.frames.size, 1)
+ env.result.stop()
+ })
+
+ test(`${implementation.name}: historical concurrent starts initialize two models and lose disposal ownership`, async () => {
+ const env = danmukuMaskEnvironment(implementation, { pendingInit: true })
+ const first = env.result.start()
+ const second = env.result.start()
+ await flushMask()
+ assert.equal(env.created.length, 2)
+ for (const gate of env.initGates) gate.resolve()
+ await Promise.all([first, second])
+ await flushMask()
+ assert.equal(env.frames.size, 2)
+ env.destroy()
+ assert.equal(env.frames.size, 1)
+ assert.deepEqual(env.created.map(instance => instance.disposed), [0, 0])
+ })
+
+ test(`${implementation.name}: historical inference rejection logs once per frame and keeps retrying`, async () => {
+ const error = new Error('controlled inference failure')
+ const env = danmukuMaskEnvironment(implementation, { segmentError: error })
+ await env.result.start()
+ await flushMask()
+ assert.equal(env.frames.size, 1)
+ assert.equal(env.logs.length, 1)
+ assert.deepEqual(env.logs[0], ['error', 'Error in segmentBody:', error])
+ assert.equal(env.draws.length, 0)
+ await env.frame()
+ assert.equal(env.logs.length, 2)
+ assert.equal(env.frames.size, 1)
+ env.settings.segmentError = null
+ await env.frame()
+ assert.equal(env.draws.length, 1)
+ env.result.stop()
+ })
+
+ test(`${implementation.name}: historical fulfilled false WebGL selection does not trigger CPU fallback`, async () => {
+ const env = danmukuMaskEnvironment(implementation, { backendResult: { webgl: false } })
+ await env.result.start()
+ await flushMask()
+ assert.deepEqual(env.backends, ['webgl'])
+ assert.equal(env.created.length, 1)
+ assert.equal(env.logs.length, 0)
+ env.result.stop()
+ })
+
+ test(`${implementation.name}: historical rejected WebGL selection tries CPU and ignores its fulfilled boolean`, async () => {
+ for (const cpu of [true, false]) {
+ const env = danmukuMaskEnvironment(implementation, { backendError: { webgl: new Error('no WebGL') }, backendResult: { cpu } })
+ await env.result.start()
+ await flushMask()
+ assert.deepEqual(env.backends, ['webgl', 'cpu'])
+ assert.deepEqual(env.logs, [['warn', 'WebGL backend not available, falling back to CPU', 'no WebGL']])
+ assert.equal(env.created.length, 1)
+ env.result.stop()
+ }
+ })
+
+ test(`${implementation.name}: historical CPU rejection rejects explicit start before creating a model`, async () => {
+ const error = new Error('controlled CPU failure')
+ const env = danmukuMaskEnvironment(implementation, { backendError: { webgl: new Error('no WebGL'), cpu: error } })
+ await assert.rejects(env.result.start(), failure => failure === error)
+ assert.deepEqual(env.backends, ['webgl', 'cpu'])
+ assert.equal(env.created.length, 0)
+ assert.equal(env.frames.size, 0)
+ })
+
+ test(`${implementation.name}: historical model initialization rejection resolves start and polls without a model`, async () => {
+ const error = new Error('controlled model init failure')
+ const env = danmukuMaskEnvironment(implementation, { initError: error })
+ assert.equal(await env.result.start(), undefined)
+ assert.deepEqual(env.logs, [['error', 'Error initializing segmenter:', error]])
+ assert.equal(env.frames.size, 1)
+ await env.frame()
+ assert.equal(env.created.length, 1)
+ assert.equal(env.inferences.length, 0)
+ env.result.stop()
+ })
+
+ test(`${implementation.name}: historical missing danmuku rejects start after allocating the model and makes stop throw`, async () => {
+ const env = danmukuMaskEnvironment(implementation, { noDanmuku: true })
+ await assert.rejects(env.result.start(), error => error.name === 'TypeError' && error.message.includes('style'))
+ assert.throws(() => env.result.stop(), error => error.name === 'TypeError' && error.message.includes('style'))
+ assert.equal(env.created.length, 1)
+ assert.equal(env.created[0].disposed, 0)
+ })
+
+ test(`${implementation.name}: historical unavailable 2D context logs read failures and keeps the frame loop`, async () => {
+ const env = danmukuMaskEnvironment(implementation, { noContext: true })
+ await env.result.start()
+ await flushMask()
+ assert.equal(env.logs.length, 1)
+ assert.equal(env.logs[0][2].name, 'TypeError')
+ assert.match(env.logs[0][2].message, /getImageData/u)
+ assert.equal(env.frames.size, 1)
+ assert.equal(env.writes.length, 0)
+ env.result.stop()
+ })
+
+ test(`${implementation.name}: historical canvas allocation failure rejects start but leaves the initialized model owned`, async () => {
+ const error = new Error('controlled canvas allocation failure')
+ const env = danmukuMaskEnvironment(implementation, { canvasError: error })
+ await assert.rejects(env.result.start(), failure => failure === error)
+ assert.equal(env.created.length, 1)
+ assert.equal(env.created[0].disposed, 0)
+ assert.equal(env.frames.size, 0)
+ env.destroy()
+ assert.equal(env.created[0].disposed, 0)
+ })
+
+ test(`${implementation.name}: historical canvas read security failure is logged and retried without clearing the previous mask`, async () => {
+ const error = new Error('controlled canvas taint')
+ error.name = 'SecurityError'
+ const env = danmukuMaskEnvironment(implementation, { readError: error })
+ await env.result.start()
+ await flushMask()
+ assert.equal(env.logs[0][2], error)
+ assert.equal(env.style.maskImage, 'url(prior-mask)')
+ await env.frame()
+ assert.equal(env.logs.length, 2)
+ env.result.stop()
+ })
+
+ test(`${implementation.name}: historical zero RAF id survives stop and retained ready listeners restart after destroy`, async () => {
+ const env = danmukuMaskEnvironment(implementation, { firstFrame: 0, paused: true })
+ await env.result.start()
+ assert.deepEqual([...env.frames.keys()], [0])
+ env.destroy()
+ assert.deepEqual([...env.frames.keys()], [0])
+ assert.deepEqual(env.cancelled, [])
+ env.emit('ready')
+ await flushMask()
+ assert.equal(env.frames.size, 2)
+ assert.equal(env.created[0].disposed, 0)
+ })
+
+ for (const scenario of ['ready-backend', 'missing-video']) {
+ test(`${implementation.name}: historical ${scenario} unhandled rejection is observed only in a fatal isolated child`, () => {
+ const script = `import { danmukuMaskHistorical, danmukuMaskEnvironment, flushMask } from './test/helpers/danmuku-mask.js';
+const implementation = (await danmukuMaskHistorical()).find(item => item.name === ${JSON.stringify(implementation.name)});
+const settings = ${scenario === 'ready-backend' ? '{backendError: {webgl: new Error("no WebGL"), cpu: new Error("MASK02_READY_BACKEND_REJECTION")}}' : '{noVideo: true}'};
+const env = danmukuMaskEnvironment(implementation, settings);
+${scenario === 'ready-backend' ? 'env.emit("ready");' : 'await env.result.start();'}
+await flushMask();`
+ const child = spawnSync(process.execPath, ['--unhandled-rejections=strict', '--input-type=module', '-e', script], { encoding: 'utf8', timeout: 15000 })
+ assert.equal(child.error, undefined)
+ assert.equal(child.status, 1)
+ assert.match(child.stderr, scenario === 'ready-backend' ? /MASK02_READY_BACKEND_REJECTION/u : /Cannot read properties of undefined \(reading 'paused'\)/u)
+ assert.doesNotMatch(child.stderr, /AssertionError|Model network access is forbidden/u)
+ })
+ }
+}
diff --git a/test/helpers/danmuku-mask.js b/test/helpers/danmuku-mask.js
new file mode 100644
index 000000000..40402b856
--- /dev/null
+++ b/test/helpers/danmuku-mask.js
@@ -0,0 +1,239 @@
+import assert from 'node:assert/strict'
+import vm from 'node:vm'
+import { transform } from 'esbuild'
+import ts from 'typescript'
+import { verifyDanmukuMaskContract } from '../../refactor/scripts/danmuku-mask-contract.mjs'
+import { hash, readMember } from '../../refactor/scripts/releases.mjs'
+
+const sourceFile = 'packages/artplayer-plugin-danmuku-mask/src/index.js'
+let historical
+export function danmukuMaskHistorical() {
+ historical ||= loadHistorical()
+ return historical
+}
+
+function evaluate(code, additions = {}) {
+ const module = { exports: {} }
+ const context = {
+ module,
+ exports: module.exports,
+ console: { log() {}, warn() {}, error() {} },
+ fetch() { throw new Error('Model network access is forbidden in historical probes') },
+ ...additions,
+ }
+ context.global = context
+ context.self = context
+ vm.runInNewContext(code, context, { timeout: 5000 })
+ return module.exports.default || module.exports
+}
+
+function instrument(code, factory, label) {
+ const text = factory.toString()
+ const start = code.indexOf(text)
+ assert(start >= 0 && !code.includes(text, start + 1), 'Exported factory must map uniquely to the evaluated bytes')
+ const prefix = 'const factory = ('
+ const parsed = ts.createSourceFile('factory.js', `${prefix}${text});`, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
+ assert.equal(parsed.parseDiagnostics.length, 0)
+ const replacements = []
+ const counts = { setBackend: 0, createSegmenter: 0, toBinaryMask: 0, drawMask: 0 }
+ const configs = new Set()
+ function findConfigs(node) {
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isObjectLiteralExpression(node.initializer)) {
+ const properties = node.initializer.properties.map(property => property.name?.getText(parsed))
+ if (properties.includes('runtime') && properties.includes('modelType'))
+ configs.add(node.name.text)
+ }
+ ts.forEachChild(node, findConfigs)
+ }
+ findConfigs(parsed)
+ function visit(node) {
+ if (ts.isCallExpression(node)) {
+ const args = node.arguments
+ const callee = node.expression.getText(parsed)
+ let target
+ if (args.length === 1 && ts.isStringLiteral(args[0]) && ['webgl', 'cpu'].includes(args[0].text))
+ target = 'setBackend'
+ else if (callee === 'createSegmenter' || callee.endsWith('.createSegmenter') || callee.includes('is not a supported model name.') || (args.length === 2 && ts.isIdentifier(args[1]) && configs.has(args[1].text)))
+ target = 'createSegmenter'
+ else if (callee === 'toBinaryMask' || callee.endsWith('.toBinaryMask') || callee.includes('mask.toImageData') || (args.length === 5 && args[3].getText(parsed).endsWith('.drawContour') && args[4].getText(parsed).endsWith('.foregroundThreshold')))
+ target = 'toBinaryMask'
+ else if (args.length === 5 && args[3].getText(parsed).endsWith('.opacity') && args[4].getText(parsed).endsWith('.maskBlurAmount'))
+ target = 'drawMask'
+ if (target) {
+ counts[target]++
+ replacements.push({ start: start + node.expression.getStart(parsed) - prefix.length, end: start + node.expression.end - prefix.length, target, original: callee })
+ // Inline SDK bodies are replaced as one expression, never traversed as owned lifecycle code.
+ return
+ }
+ }
+ ts.forEachChild(node, visit)
+ }
+ visit(parsed)
+ assert.deepEqual(counts, { setBackend: 2, createSegmenter: 1, toBinaryMask: 1, drawMask: 1 }, label)
+ let result = code
+ for (const replacement of [...replacements].sort((a, b) => b.start - a.start))
+ result = `${result.slice(0, replacement.start)}globalThis.__maskProbe.${replacement.target}${result.slice(replacement.end)}`
+ return { code: result, counts, replacements: replacements.map(({ start, end, target, original }) => ({ start, end, target, originalSha256: hash(original) })) }
+}
+
+async function loadHistorical() {
+ const contract = await verifyDanmukuMaskContract()
+ const implementations = []
+ const source = contract.sources.get(sourceFile)
+ const sourceCode = (await transform(source, { format: 'cjs', target: 'es2020' })).code
+ implementations.push({ name: 'frozen-source', code: sourceCode, inputSha256: hash(source), transformedSha256: hash(sourceCode), source: true, replacements: [] })
+ for (const release of [contract.baseline.release, ...contract.baseline.previous]) {
+ const archive = contract.archives.get(release.version)
+ for (const entry of ['main', 'legacy', 'module']) {
+ const member = `package/${release.manifest[entry].replace(/^\.\//u, '')}`
+ const input = readMember(archive, member).toString()
+ const code = entry === 'module' ? (await transform(input, { format: 'cjs', target: 'es2020' })).code : input
+ const modified = instrument(code, evaluate(code), `${release.version}/${entry}`)
+ implementations.push({ name: `published-${release.version}-${entry}`, archiveSha256: release.sha256, member, inputSha256: hash(input), transformedSha256: hash(code), probeSha256: hash(modified.code), ...modified })
+ }
+ }
+ return implementations
+}
+
+export const flushMask = () => new Promise(resolve => setImmediate(resolve))
+function deferred() {
+ let resolve
+ let reject
+ const promise = new Promise((accept, fail) => {
+ resolve = accept
+ reject = fail
+ })
+ return { promise, resolve, reject }
+}
+
+export function danmukuMaskEnvironment(implementation, settings = {}) {
+ const env = {
+ frames: new Map(),
+ listeners: new Map(),
+ logs: [],
+ backends: [],
+ created: [],
+ inferences: [],
+ masks: [],
+ draws: [],
+ initGates: [],
+ segmentGates: [],
+ writes: [],
+ cancelled: [],
+ settings,
+ }
+ let nextFrame = settings.firstFrame ?? 1
+ const style = { maskMode: 'luminance', maskImage: 'url(prior-mask)' }
+ Object.defineProperty(style, 'maskImage', {
+ configurable: true,
+ enumerable: true,
+ get: () => env.writes.at(-1) ?? 'url(prior-mask)',
+ set: value => env.writes.push(value),
+ })
+ const context = {
+ getImageData() {
+ if (settings.readError)
+ throw settings.readError
+ return { data: new Uint8ClampedArray([255, 255, 255, 255]) }
+ },
+ putImageData() {},
+ }
+ env.canvas = {
+ width: 0,
+ height: 0,
+ getContext: () => settings.noContext ? null : context,
+ toDataURL: () => 'data:image/png;base64,historical-mask',
+ }
+ env.video = settings.noVideo ? undefined : { paused: settings.paused ?? false, ended: false, videoWidth: 2, videoHeight: 1 }
+ env.style = style
+ env.art = {
+ isDestroy: false,
+ template: { $video: env.video, $danmuku: settings.noDanmuku ? undefined : { style } },
+ on(name, callback) {
+ if (!env.listeners.has(name))
+ env.listeners.set(name, new Set())
+ env.listeners.get(name).add(callback)
+ },
+ off(name, callback) { env.listeners.get(name)?.delete(callback) },
+ }
+ const probe = {
+ SupportedModels: { MediaPipeSelfieSegmentation: 'selfie' },
+ async setBackend(name) {
+ env.backends.push(name)
+ if (settings.backendError?.[name])
+ throw settings.backendError[name]
+ return settings.backendResult?.[name] ?? true
+ },
+ async createSegmenter(model, config) {
+ const instance = {
+ model,
+ config,
+ disposed: 0,
+ dispose() { instance.disposed++ },
+ async segmentPeople(video) {
+ env.inferences.push({ instance, video })
+ if (settings.segmentError)
+ throw settings.segmentError
+ if (settings.pendingSegment) {
+ const gate = deferred()
+ env.segmentGates.push(gate)
+ return gate.promise
+ }
+ return settings.empty ? [] : [{}]
+ },
+ }
+ env.created.push(instance)
+ if (settings.initError)
+ throw settings.initError
+ if (settings.pendingInit) {
+ const gate = deferred()
+ env.initGates.push(gate)
+ await gate.promise
+ }
+ return instance
+ },
+ async toBinaryMask(...args) {
+ env.masks.push(args)
+ return {}
+ },
+ async drawMask(...args) { env.draws.push(args) },
+ }
+ const factory = evaluate(implementation.code, {
+ __maskProbe: probe,
+ require(name) {
+ assert(['@tensorflow-models/body-segmentation', '@tensorflow/tfjs-core', '@tensorflow/tfjs-backend-webgl', '@tensorflow/tfjs-backend-cpu'].includes(name))
+ return probe
+ },
+ console: { log() {}, warn: (...args) => env.logs.push(['warn', ...args]), error: (...args) => env.logs.push(['error', ...args]) },
+ document: { createElement(tag) {
+ assert.equal(tag, 'canvas')
+ if (settings.canvasError)
+ throw settings.canvasError
+ return env.canvas
+ } },
+ requestAnimationFrame(callback) {
+ const id = nextFrame++
+ env.frames.set(id, callback)
+ return id
+ },
+ cancelAnimationFrame(id) {
+ env.cancelled.push(id)
+ env.frames.delete(id)
+ },
+ })
+ env.result = factory(settings.option)(env.art)
+ env.emit = (name) => {
+ for (const callback of [...(env.listeners.get(name) || [])]) callback()
+ }
+ env.destroy = () => {
+ env.art.isDestroy = true
+ env.emit('destroy')
+ }
+ env.frame = async () => {
+ const frames = [...env.frames.values()]
+ env.frames.clear()
+ for (const callback of frames) await callback()
+ await flushMask()
+ }
+ return env
+}