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