test(danmuku-mask): [PKG-MASK-02] reproduce historical lifecycle and failure paths

This commit is contained in:
Harvey Zhao committed 2026-09-13 17:43:25 +08:00
1 parent d81b881085
commit ccf3d6ed14
10 files changed
+952 -18

No files matched your search

+3 -2
View File
@@ -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": {
@@ -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"
}
]
}
@@ -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锁文件。
+3 -2
View File
@@ -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<br>核对包契约与历史用法 | BASE-05 | 模型参数、start/stop、默认下载路径及 mask 样式 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结;接续 SDK-08,分别核实 Yarn 解析版本、未固定版本的模型 solutionPath 和资源来源 | H | done |
| PKG-MASK-02 | artplayer-plugin-danmuku-mask<br>建立特有行为与错误测试 | PKG-MASK-01, ENG-03, ENG-05 | 加载期间停止/销毁、重复启动、推理失败、WebGL/CPU 边界 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | doing |
| PKG-MASK-02 | artplayer-plugin-danmuku-mask<br>建立特有行为与错误测试 | PKG-MASK-01, ENG-03, ENG-05 | 加载期间停止/销毁、重复启动、推理失败、WebGL/CPU 边界 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done |
| PKG-MASK-03 | artplayer-plugin-danmuku-mask<br>整理内部职责与资源 | PKG-MASK-02, CORE-18, PKG-DANMUKU-07 | 模型加载/推理/画布输出分离,阻止重叠推理与过期写入 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo |
| PKG-MASK-04 | artplayer-plugin-danmuku-mask<br>迁移自有源码和公开类型 | PKG-MASK-03, ENG-04, ENG-06, CORE-07 | 模型 adapter、canvas 和选项的精确类型 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo |
| PKG-MASK-05 | artplayer-plugin-danmuku-mask<br>验证新旧核心和组合 | 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)
+11
View File
@@ -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项测试及父代理复核通过;
+4 -4
View File
@@ -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 |
+20 -8
View File
@@ -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."
+5 -2
View File
@@ -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",
+259
View File
@@ -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)
})
}
}
+239
View File
@@ -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
}