test(danmuku): [PKG-DANMUKU-08] cover old plugin combinations and expose model load failures

This commit is contained in:
Harvey Zhao committed 2026-09-14 21:32:10 +08:00
1 parent 30363a5346
commit 475d38043e
13 files changed
+11199 -282

No files matched your search

@@ -1,5 +1,12 @@
# Danmuku Mask maintenance map
The native combination suite includes both frozen/current Danmuku with three core
versions and a bounded 20 rows/s load after model readiness. Model output must
continue during that load. Current Chromium/WebKit delivery failures block the
combination gate under PKG-DANMUKU-MASK-LOAD-01; Firefox passes do not close them.
See `refactor/changes/2026-09-14-PKG-DANMUKU-08-combined-load.md` for exact source
inputs, historical observations and the still-unproven performance cause.
The compatibility baseline is the actual npm 1.1.0 package and the earlier
1.0.0 export shape. Sources and historical failure probes are recorded in
`refactor/baselines/danmuku-mask-contract.md`, `danmuku-mask-release.json` and
@@ -1,11 +1,14 @@
# Danmuku maintenance map
`test/browser/danmuku-dpip.spec.js` exercises the candidate with old/current cores
`test/browser/danmuku-dpip.spec.js` exercises frozen/current plugins with old/current cores
in two sequential native Document PiP windows. It checks timestamp-delivered text,
adopted layer and heatmap nodes, stylesheet layout, one surviving track Worker,
close/native-close/destroy cleanup, and no retained scheduling operation. Native
API absence is reported separately; it does not simulate a popup or count as
playback evidence. This is not a background-throttling or device benchmark.
playback evidence. Frozen 5.3.0 retains renderer/Setting nodes on destroy(false);
its explicit historical outcome is not candidate cleanup acceptance. The load
case runs 120 timestamped rows in each of two windows, observing native RAF in
the player's owning window. This is not a background-throttling or device benchmark.
Set `ARTPLAYER_DANMUKU_ARTIFACT` and `ARTPLAYER_DPIP_ARTIFACT` together to check
the corresponding built formats; both loaded hashes appear in each attachment.
@@ -15,6 +18,12 @@ both plugins from source by default. `ARTPLAYER_MASK_ARTIFACT` and
Do not substitute a stale dist file for source checks. Installation, remaining
old-plugin combinations, combined-load performance and devices remain task 08/09.
The actual Mask combination now also tests frozen 5.3.0 and a 20 rows/s post-model
load. `test/helpers/danmuku-combination-load.js` owns only test RAF/listeners and
retains observations after failed assertions. Candidate complete delivery remains
mandatory. Chromium/WebKit failures are tracked by DANMUKU-MASK-LOAD-01 and
PKG-DANMUKU-MASK-LOAD-01; ordinary playback or Firefox passes cannot close them.
`danmuku-fullscreen.spec.js` tests all old/current core and plugin pairs in real
document and CSS web fullscreen, with three fresh player lifetimes per case.
Each lifetime must show a timestamp-delivered row, restore layout and release its
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,77 @@
# PKG-DANMUKU-08 旧插件组合与真实组合负载
状态:仍 doing,存在未关闭失败。起点30363a5346e8d6b227651f224dd4d411289a9971。
本批仅扩展测试和证据,不修改生产实现、声明、默认配置或依赖。下一修复由独立
PKG-DANMUKU-MASK-LOAD-01负责。风险继续阻止弹幕08与Mask05终验;尚未启动的
弹幕09与Mask06显式依赖该修复,确保它不能被分发阶段绕过。
## 真实输入与新增覆盖
画中画及Mask用例现在都加载冻结npm弹幕5.3.0或当前源码,校验tarball成员SHA。
显式ARTPLAYER_DANMUKU_ARTIFACT仍只替换候选输入,不能覆盖冻结发布输入。
画中画使用当前Document PiP,核心为5.4.0/当前源码;Mask使用当前插件及固定
本地MediaPipe模型,另覆盖实际5.3.1-beta.1核心。没有修改模型或SDK字节。
`test/helpers/danmuku-combination-load.js`通过旧config/load调用设置speed=1、
antiOverlap=false,6秒内每秒20条,观察到媒体推进8秒后核验120条。真实RAF来自
播放器所属window,原生媒体时钟和Worker不被替换。临时可见/错误监听器及RAF在
finally释放;失败后仍保存load快照。候选要求每行恰好可见一次、最终wait、无引用、
节点池身份唯一;冻结旧插件仅观察其实际漏显,不能混称为完整交付通过。
Mask负载发生在真实模型就绪、pause/seek/网页全屏/hide-show验证之后,并检查
负载期间模型确实继续产生输出。新画中画load用例在两个真实PiP窗口各运行一轮,
单项45秒只为新增16秒媒体推进留预算;原有close/native-close/destroy超时不变。
这不是三小时耐久、后台节流保证、物理设备验收或私有GPU/WASM泄漏证明。
## 画中画结果与旧清理缺陷
Windows Chromium153.0.8010.12、Firefox155.0、WebKit26.6;Node24.21.0/Yarn1.22.22。
新增旧插件首轮18项为6个API不可用记录、12个原生退出后零DOM断言失败。
12个支持用例均已完成两次真实开窗、弹幕显示、热力图/设置迁移、关闭和Worker终止;
冻结5.3.0在destroy(false)后保留3个renderer节点及1个Setting,两个核心完全一致。
旧destroy实现不移除这些节点,候选在05已修复其所有权清理;参考
[资源修复](2026-09-13-PKG-DANMUKU-05-resources.md)。
因此旧分支单独断言该确定的历史残留,outcome明确为
native-playback-with-published-retained-dom;候选仍要求零DOM与空scheduler。
两行固定fixture精确断言3/1,load fixture按销毁前实际节点数验证旧保留行为。
保留原12个失败报告;这不要求永久保留旧插件缺陷,也不豁免候选清理。
最终普通PiP矩阵36项退出0:12候选原生通过、12旧插件原生播放及残留观察、
12 WebKit API不可用记录。新增load矩阵12项退出0:4候选原生、4旧插件原生、
4 API不可用;每个候选原生用例两个窗口分别120行完整显示/回收,最终Worker终止。
普通矩阵54.25秒、load矩阵94.81秒,均无重试。源码PiP测试不冒充最终安装分发。
## Mask负载失败:不能计入验收完成
18项组合为12 passed / 6 failed,运行退出1。12个通过中9项为旧插件观察、
3项为Firefox候选完整交付;不能称12项候选负载通过。
| 引擎 | 候选负载显示 | 旧插件负载显示 | 结论 |
| --- | --- | --- | --- |
| Chromium | 49/120(当前核心)、48/120(5.4.0) | 34–49/120 | 候选仍有活动/待放置工作,串行放置积压需分析 |
| Chromium / beta核心 | 未进入负载 | 45/120 | 模型就绪后Ready mask combination一行未显示,原7秒断言失败 |
| Firefox | 三核心各120/120 | 三核心各120/120 | 本负载完整显示/回收 |
| WebKit | 三核心109/109/116条,共需各120 | 104/111/113条 | 有未显示行,最终已无行节点引用;检查未采样时间窗口 |
本轮候选RAF帧间隔P95:Chromium约214–219ms,Firefox78–85ms,WebKit227–238ms。
这些数据表明与普通播放器相比主线程推进明显变慢,但尚不能将模型、PNG编码、
Worker往返或某个模块单独定为根因。旧插件也漏显;目前没有证据证明候选比旧插件
回归。候选目标仍不达标,不能降低20条/秒、移除完整性断言或只跑Firefox。
原始模型、源码/核心哈希、行与RAF/媒体时间快照均保留在报告中;机器摘要见
[组合负载证据](../baselines/danmuku-combined-load-validation.json)。
## 修复接续与重跑
PKG-DANMUKU-MASK-LOAD-01首先区分未采样行与已采样待放置行,分析实际模型各阶段
及Worker请求/回复成本,建立可控回归后修复。保持公开readys/回调串行顺序、
seek/pause/hide/reset取消和显示寿命契约;涉及真实兼容冲突时明确决策。
不得通过在调试夹具中停止模型、降低视频尺寸或改变媒体时钟作为正式通过结果。
若某条诊断改变负载输入,必须保持原始失败并明确其反事实用途。
重跑:`yarn test:browser:source test/browser/danmuku-dpip.spec.js --workers=2`,
只选新增负载加`--grep 'PiP load'`;Mask用
`yarn test:browser:source test/browser/danmuku-mask-native.spec.js
--grep 'actual model and Danmuku' --workers=2`。每次前归档前一份报告。
三个修改文件的只读ESLint通过。任务、风险和生成文档同步校验后独立本地提交,
再运行提交审计。没有push、部署或发布;回退本检查点只撤销覆盖,不修复生产问题。
+14 -7
View File
@@ -2,9 +2,9 @@
> 由 tasks.json 生成。请修改数据后运行 `node refactor/scripts/plan.mjs --write`,不要手改本表。
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 261 项,范围 22 个包及工作区/示例。
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 262 项,范围 22 个包及工作区/示例。
状态:todo 48 / doing 21 / blocked 0 / done 192 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 49 / doing 21 / blocked 0 / done 192 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -22,8 +22,8 @@
| artplayer-plugin-auto-thumbnail | 1.1.0 | PKG-AUTO-THUMB-01, PKG-AUTO-THUMB-02, PKG-AUTO-THUMB-03, PKG-AUTO-THUMB-04, PKG-AUTO-THUMB-05, PKG-AUTO-THUMB-06, PKG-AUTO-THUMB-07, PKG-AUTO-THUMB-08, PKG-AUTO-THUMB-09, PKG-AUTO-THUMB-10, PKG-AUTO-THUMB-11 |
| artplayer-plugin-chapter | 1.1.0 | PILOT-01, PKG-CHAPTER-01, PKG-CHAPTER-02, PKG-CHAPTER-03, PKG-CHAPTER-04, PKG-CHAPTER-05, PKG-CHAPTER-06, PKG-CHAPTER-HOVER-01 |
| artplayer-plugin-chromecast | 1.1.0 | PKG-CAST-01, PKG-CAST-02, PKG-CAST-03, PKG-CAST-04, PKG-CAST-05, PKG-CAST-06 |
| artplayer-plugin-danmuku | 5.3.0 | PKG-DANMUKU-01, PKG-DANMUKU-02, PKG-DANMUKU-03, PKG-DANMUKU-04, PKG-DANMUKU-05, PKG-DANMUKU-06, PKG-DANMUKU-07, PKG-DANMUKU-08, PKG-DANMUKU-09, PKG-DANMUKU-10, PKG-DANMUKU-11, PKG-DANMUKU-12 |
| artplayer-plugin-danmuku-mask | 1.1.0 | PKG-MASK-01, PKG-MASK-02, PKG-MASK-03, PKG-MASK-04, PKG-MASK-05, PKG-MASK-06, ENG-LINT-01 |
| artplayer-plugin-danmuku | 5.3.0 | PKG-DANMUKU-01, PKG-DANMUKU-02, PKG-DANMUKU-03, PKG-DANMUKU-04, PKG-DANMUKU-05, PKG-DANMUKU-06, PKG-DANMUKU-07, PKG-DANMUKU-08, PKG-DANMUKU-09, PKG-DANMUKU-10, PKG-DANMUKU-11, PKG-DANMUKU-12, PKG-DANMUKU-MASK-LOAD-01 |
| artplayer-plugin-danmuku-mask | 1.1.0 | PKG-MASK-01, PKG-MASK-02, PKG-MASK-03, PKG-MASK-04, PKG-MASK-05, PKG-MASK-06, ENG-LINT-01, PKG-DANMUKU-MASK-LOAD-01 |
| artplayer-plugin-dash-control | 1.1.0 | PKG-DASH-01, PKG-DASH-02, PKG-DASH-03, PKG-DASH-04, PKG-DASH-SEEK-01, PKG-DASH-MENU-01, PKG-DASH-05, PKG-DASH-06 |
| artplayer-plugin-document-pip | 1.1.0 | PKG-DPIP-01, PKG-DPIP-02, PKG-DPIP-03, PKG-DPIP-04, PKG-DPIP-05, PKG-DPIP-06 |
| artplayer-plugin-hls-control | 1.1.0 | PKG-HLS-01, PKG-HLS-02, PKG-HLS-03, PKG-HLS-04, PKG-HLS-SDK-01, PKG-HLS-05, PKG-HLS-06 |
@@ -263,7 +263,7 @@
| PKG-MASK-03 | artplayer-plugin-danmuku-mask<br>整理内部职责与资源 | PKG-MASK-02, CORE-18, PKG-DANMUKU-07 | 模型加载/推理/画布输出分离,阻止重叠推理与过期写入 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | done |
| PKG-MASK-04 | artplayer-plugin-danmuku-mask<br>迁移自有源码和公开类型 | PKG-MASK-03, ENG-04, ENG-06, CORE-07 | 模型 adapter、canvas 和选项的精确类型 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | done |
| PKG-MASK-05 | artplayer-plugin-danmuku-mask<br>验证新旧核心和组合 | PKG-MASK-04, CORE-22 | 真实模型和 danmuku/seek/全屏组合,GPU 资源释放 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | doing |
| PKG-MASK-06 | artplayer-plugin-danmuku-mask<br>验证分发并同步文档 | PKG-MASK-05, ENG-07 | danmuku.mask.js、资源版本/许可、CPU fallback 和包体积证据 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | H | todo |
| PKG-MASK-06 | artplayer-plugin-danmuku-mask<br>验证分发并同步文档 | PKG-MASK-05, ENG-07, PKG-DANMUKU-MASK-LOAD-01 | danmuku.mask.js、资源版本/许可、CPU fallback 和包体积证据 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | H | todo |
## 5 包迁移:artplayer-plugin-asr
@@ -336,7 +336,7 @@
| PKG-DANMUKU-06 | artplayer-plugin-danmuku<br>迁移 TS 与公开声明 | PKG-DANMUKU-05, ENG-04, ENG-06 | 自有模块、worker 消息、option/item/result 类型 | 旧参数、扩展字段和事件消费通过,vendored 边界清楚 | H | done |
| PKG-DANMUKU-07 | artplayer-plugin-danmuku<br>弹幕能力稳定性验收 | PKG-DANMUKU-06, PKG-DANMUKU-10, PKG-DANMUKU-11, PKG-DANMUKU-12 | 负载/渲染/资源对比及 mask 可依赖的稳定边界 | 在试点核心中旧插件 API 与关键帧路径通过 | H | done |
| PKG-DANMUKU-08 | artplayer-plugin-danmuku<br>完成新旧核心与组合验收 | PKG-DANMUKU-07, CORE-22 | 最终核心/旧核心、mask/fullscreen/PiP 组合报告 | 性能无未解释退化,反复装卸无累计资源 | H | doing |
| PKG-DANMUKU-09 | artplayer-plugin-danmuku<br>完成分发、示例与文档 | PKG-DANMUKU-08, ENG-07 | danmuku.js、README、声明、worker 与产物 | tarball 和静态 icons 等旧调用通过,有独立回退版本 | H | todo |
| PKG-DANMUKU-09 | artplayer-plugin-danmuku<br>完成分发、示例与文档 | PKG-DANMUKU-08, ENG-07, PKG-DANMUKU-MASK-LOAD-01 | danmuku.js、README、声明、worker 与产物 | tarball 和静态 icons 等旧调用通过,有独立回退版本 | H | todo |
| PKG-DANMUKU-10 | artplayer-plugin-danmuku<br>修复 issue958 密集热力图过高与曲线裁平 | PKG-DANMUKU-05 | 自动密度缩放与曲线边界、历史自定义坐标兼容及真实浏览器红绿证据 | 16000条三种分布在两核心三引擎和source/main/legacy不遮挡;显式轴和自定义points保持旧解释,有单独commit | M | done |
| PKG-DANMUKU-11 | artplayer-plugin-danmuku<br>修复 Worker 隐藏等待吞掉可见寿命 | PKG-DANMUKU-06 | 成功放置时的可见计时与延迟回复回归 | 真实 Worker 延迟下完整显示寿命;保留暂停/回收/取消和公开契约 | M | done |
| PKG-DANMUKU-12 | artplayer-plugin-danmuku<br>保持异步放置期间的原生帧采样与寿命维护 | PKG-DANMUKU-11 | 每帧采样与串行dispatcher、代际buffer及回归 | 异步等待不丢已到时采样、不延迟既有寿命维护;保留顺序、取消与公开readys窗口 | H | done |
@@ -463,6 +463,12 @@
| ENG-LINT-01 | artplayer-plugin-danmuku-mask, workspace<br>修复 Mask manifest 排序并恢复全仓 lint | PKG-MASK-04 | 仅调整 manifest 字段顺序,独立验证全仓只读 lint 与元数据语义不变 | 根 lint 通过;解析后的 manifest 深度相等;不改包版本、依赖或公开入口 | L | done |
| ENG-13 | workspace<br>保证性能资源探针的最小观察时长 | ENG-08 | 性能页面按实测时钟补足既有350ms观察窗口,保留冻结基线和原校验门槛;时钟边界测试和实际安装包配对证据 | 349ms仍被拒绝;早到定时器补足观察、正常超时保留实际值;冻结计时样本逻辑未变,三浏览器实际350ms观察及资源校验通过 | M | done |
## 5 包组合:Danmuku / Mask
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| PKG-DANMUKU-MASK-LOAD-01 | artplayer-plugin-danmuku, artplayer-plugin-danmuku-mask<br>修复真实Mask组合下的弹幕未采样与放置积压 | PKG-DANMUKU-12, PKG-MASK-04, CORE-22 | 分阶段性能/采样诊断、兼容实现修复及新旧核心/产物回归 | 关闭真实模型组合5项密集负载失败和1项负载前漏显,保持回调顺序、显示寿命与生命周期取消;不能以降低负载或关闭模型制造通过 | H | todo |
## 完成证据与阻塞
- DOC-01: [记录](progress.md)
@@ -611,7 +617,7 @@
- PKG-DANMUKU-05: [记录](changes/2026-09-13-PKG-DANMUKU-05-resources.md) [记录](baselines/danmuku-resources-validation.json)
- PKG-DANMUKU-06: [记录](changes/2026-09-13-PKG-DANMUKU-06-types.md) [记录](baselines/danmuku-types-validation.json)
- PKG-DANMUKU-07: [记录](changes/2026-09-13-PKG-DANMUKU-07-stability.md) [记录](baselines/danmuku-stability-validation.json) [记录](danmuku-stability.md)
- PKG-DANMUKU-08: [记录](changes/2026-09-14-PKG-DANMUKU-08-native-combinations.md) [记录](baselines/danmuku-combination-checkpoint.json) [记录](changes/2026-09-14-PKG-DANMUKU-08-fullscreen-lifetimes.md) [记录](baselines/danmuku-fullscreen-validation.json) [记录](baselines/danmuku-old-core-load-validation.json)
- PKG-DANMUKU-08: [记录](changes/2026-09-14-PKG-DANMUKU-08-native-combinations.md) [记录](baselines/danmuku-combination-checkpoint.json) [记录](changes/2026-09-14-PKG-DANMUKU-08-fullscreen-lifetimes.md) [记录](baselines/danmuku-fullscreen-validation.json) [记录](baselines/danmuku-old-core-load-validation.json) [记录](changes/2026-09-14-PKG-DANMUKU-08-combined-load.md) [记录](baselines/danmuku-combined-load-validation.json)
- PKG-CANVAS-01: [记录](changes/2026-09-12-PKG-CANVAS-01-contract.md) [记录](baselines/canvas-contract.md) [记录](baselines/canvas-release.json) [记录](canvas-validation.md) [记录](baselines/canvas-contract-validation.json)
- PKG-CANVAS-02: [记录](changes/2026-09-12-PKG-CANVAS-02-tests.md) [记录](baselines/canvas-behavior-validation.json) [记录](canvas-validation.md)
- PKG-CANVAS-03: [记录](changes/2026-09-12-PKG-CANVAS-03-lifecycle.md) [记录](baselines/canvas-lifecycle-validation.json) [记录](canvas-validation.md)
@@ -678,3 +684,4 @@
- CI-BROWSER-01: [记录](changes/2026-09-14-CI-BROWSER-01-scopes.md) [记录](baselines/browser-scope-validation.json)
- PKG-CHAPTER-HOVER-01: [记录](changes/2026-09-14-PKG-CHAPTER-HOVER-01-geometry.md) [记录](baselines/chapter-hover-validation.json)
- PKG-CANVAS-SUBTITLE-01: [记录](changes/2026-09-14-PKG-CANVAS-SUBTITLE-01-track.md) [记录](baselines/canvas-subtitles-validation.json)
- PKG-DANMUKU-MASK-LOAD-01: [记录](changes/2026-09-14-PKG-DANMUKU-08-combined-load.md) [记录](baselines/danmuku-combined-load-validation.json)
+21
View File
@@ -1,5 +1,26 @@
# 进度与证据
## PKG-DANMUKU-08 旧插件组合与真实负载:新增必须修复项
扩展PiP/Mask测试加载校验后的npm弹幕5.3.0及候选源码。普通PiP矩阵36项退出0:
24原生播放(12候选清理、12旧插件残留观察),12WebKit API不可用;新load矩阵
12项退出0:8原生(4候选/4旧插件)、4不可用。候选每个原生用例在两个窗口
各完整显示/回收120行,Worker终止。旧插件destroy(false)残留renderer/Setting的
12个首轮失败单独保留,并有明确历史outcome;候选零残留要求不变。
真实Mask模型保持运行并施加20条/秒负载,18项12通过6失败(退出1):候选
5项负载显示不完整,另1项Chromium beta核心负载前行未显示。Firefox候选3项
完整交付;旧插件在Chromium/WebKit也漏显,尚不能认定重构回归或单一根因。
完整的行、帧间隔、输入哈希和原失败报告见
[记录](changes/2026-09-14-PKG-DANMUKU-08-combined-load.md)及
[证据](baselines/danmuku-combined-load-validation.json)。没有降低负载、扩大等待或
关闭模型把该失败改为通过。原先只含稀疏行的Mask通过报告不能关闭新增负载要求。
新增PKG-DANMUKU-MASK-LOAD-01及DANMUKU-MASK-LOAD-01,接续区分未采样与已采样
放置积压,分析真实模型/绘制/Worker往返成本后兼容修复。08/Mask05保持doing,
09/Mask06显式依赖修复。262项:192 done、21 doing、49 todo。专项lint和任务/风险
校验通过;浏览器仍有上述6失败。本检查点独立本地提交并核验审计;无push/发布。
## PKG-DANMUKU-08 全屏生命周期与旧核心负载检查点(仍 doing)
三引擎/新旧核心/新旧弹幕的网页及原生document全屏源码24项通过,候选main与
+1
View File
@@ -275,3 +275,4 @@
| CANVAS-SUBTITLE-01 | resolved / 已复现 | Canvas底层video缺初始字幕轨道且普通Canvas挂载不能加载原生字幕 | PKG-CANVAS-SUBTITLE-01, PKG-CANVAS-05 |
| DPIP-INSTALLED-PROVENANCE-01 | resolved / 已复现 | Document PiP测试加载器忽略显式安装map并回退到源码 | PKG-CANVAS-05, CI-01 |
| DASH-MENU-01 | resolved / 已复现 | SDK 5.2.1 播放中设置质量选项在点击时变为不可见 | PKG-DASH-MENU-01, PKG-DASH-05, REVIEW-02 |
| DANMUKU-MASK-LOAD-01 | open / 已复现 | 真实Mask模型运行时弹幕存在未采样窗口及串行放置积压 | PKG-DANMUKU-MASK-LOAD-01, PKG-DANMUKU-08, PKG-MASK-05 |
+20
View File
@@ -6294,6 +6294,26 @@
"test/dash-events.test.js"
],
"resolutionRationale": "原trace显示设置仍打开但导航退回根面板;真实SDK刷新在四组核心/SDK组合复现相同失败。修复后源码92、main设置32、legacy完整候选92通过,新旧核心/两SDK/两引擎覆盖质量及音轨,原失败与未加force/延长超时的回归均保留。Node446验证三格式及重入/销毁边界;DASH其他SDK/设备/发布风险仍独立开放。"
},
{
"id": "DANMUKU-MASK-LOAD-01",
"title": "真实Mask模型运行时弹幕存在未采样窗口及串行放置积压",
"confirmation": "reproduced",
"status": "open",
"owners": [
"PKG-DANMUKU-MASK-LOAD-01",
"PKG-DANMUKU-08",
"PKG-MASK-05"
],
"evidence": [
"refactor/changes/2026-09-14-PKG-DANMUKU-08-combined-load.md",
"refactor/baselines/danmuku-combined-load-validation.json",
"test/browser/danmuku-mask-native.spec.js",
"test/helpers/danmuku-combination-load.js"
],
"compatibleResolution": "分析真实模型/绘制/Worker往返成本和未采样、待放置行,保持公共readys与回调顺序、显示寿命及生命周期取消;用独立回归验证兼容修复。",
"closureCriteria": "原始失败保留并有可解释修复;真实模型下120行完整交付、无活动引用积压,新旧核心和main/legacy以及暂停/seek/hide/destroy回归通过;不降低负载、停模型或扩大等待伪造结果。",
"workspaceState": "源码18项12通过6失败:5项候选密集负载不完整,1项Chromium beta核心负载前行未显示。旧插件同环境也漏显,尚未证明是重构回归或定位单一瓶颈。Firefox候选和原生PiP负载通过。"
}
]
}
+29 -3
View File
@@ -2764,7 +2764,8 @@
],
"dependsOn": [
"PKG-MASK-05",
"ENG-07"
"ENG-07",
"PKG-DANMUKU-MASK-LOAD-01"
],
"status": "todo",
"risk": "H",
@@ -3565,7 +3566,9 @@
"baselines/danmuku-combination-checkpoint.json",
"changes/2026-09-14-PKG-DANMUKU-08-fullscreen-lifetimes.md",
"baselines/danmuku-fullscreen-validation.json",
"baselines/danmuku-old-core-load-validation.json"
"baselines/danmuku-old-core-load-validation.json",
"changes/2026-09-14-PKG-DANMUKU-08-combined-load.md",
"baselines/danmuku-combined-load-validation.json"
]
},
{
@@ -3577,7 +3580,8 @@
],
"dependsOn": [
"PKG-DANMUKU-08",
"ENG-07"
"ENG-07",
"PKG-DANMUKU-MASK-LOAD-01"
],
"status": "todo",
"risk": "H",
@@ -5465,6 +5469,28 @@
"changes/2026-09-14-PKG-CANVAS-SUBTITLE-01-track.md",
"baselines/canvas-subtitles-validation.json"
]
},
{
"id": "PKG-DANMUKU-MASK-LOAD-01",
"phase": "5 包组合:Danmuku / Mask",
"title": "修复真实Mask组合下的弹幕未采样与放置积压",
"scope": [
"artplayer-plugin-danmuku",
"artplayer-plugin-danmuku-mask"
],
"dependsOn": [
"PKG-DANMUKU-12",
"PKG-MASK-04",
"CORE-22"
],
"status": "todo",
"risk": "H",
"deliverable": "分阶段性能/采样诊断、兼容实现修复及新旧核心/产物回归",
"acceptance": "关闭真实模型组合5项密集负载失败和1项负载前漏显,保持回调顺序、显示寿命与生命周期取消;不能以降低负载或关闭模型制造通过",
"evidence": [
"changes/2026-09-14-PKG-DANMUKU-08-combined-load.md",
"baselines/danmuku-combined-load-validation.json"
]
}
]
}
+8
View File
@@ -250,6 +250,14 @@ with a missed narrow timestamp window. A passing post-ready combination is not
proof of startup losslessness, private GPU/WASM closure, OS fullscreen or devices.
See refactor/changes/2026-09-14-PKG-MASK-05-native-checkpoint.md for exact limits.
Its combination cases now include frozen/current Danmuku and a bounded 120-row
load while the actual model continues. `danmuku-combination-load.js` observes
real media/RAF and cleans up its test listeners; it does not seed eligible rows.
Candidate completeness failures remain failing under PKG-DANMUKU-MASK-LOAD-01.
`danmuku-dpip.spec.js` also includes two-window load cases and both plugin versions.
Frozen-plugin retained DOM, candidate cleanup and unsupported API outcomes remain
distinct; the old plugin's observed loss is not complete-delivery acceptance.
`jassub-native.spec.js` runs actual published JASSUB 1.1.0, its native Worker,
fixed local WASM/font and authored ASS cues with offscreenRender=false. It checks
real canvas pixels before/after seek, CSS web fullscreen and normal destruction.
+167 -139
View File
@@ -1,161 +1,189 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { observeDanmukuLoad } from '../helpers/danmuku-combination-load.js'
import { dpipCandidate } from '../helpers/dpip.js'
import { compilePackage } from '../helpers/load.js'
import { expect, test } from './fixtures.js'
const dpip = await dpipCandidate()
let danmuku
const implementations = new Map()
test.beforeAll(async () => {
danmuku = process.env.ARTPLAYER_DANMUKU_ARTIFACT
const release = JSON.parse(fs.readFileSync('refactor/baselines/danmuku-release.json', 'utf8')).release
const member = 'package/dist/artplayer-plugin-danmuku.js'
const published = readMember(await ensureArchive(release), member)
assert.equal(hash(published), release.files[member])
implementations.set('published', { input: `npm ${release.version}`, code: published.toString() })
const candidate = process.env.ARTPLAYER_DANMUKU_ARTIFACT
? fs.readFileSync(process.env.ARTPLAYER_DANMUKU_ARTIFACT, 'utf8')
: await compilePackage('artplayer-plugin-danmuku', 'umd')
implementations.set('candidate', { input: process.env.ARTPLAYER_DANMUKU_ARTIFACT || 'source UMD', code: candidate })
})
for (const core of ['published', 'candidate']) {
for (const ending of ['close', 'native-close', 'destroy']) {
test(`${core}: Danmuku, heatmap and Worker survive native Document PiP ${ending}`, async ({ page, context }, testInfo) => {
await page.goto(`/test/player.html?core=${core}`)
const supported = await page.evaluate(() => typeof window.documentPictureInPicture?.requestWindow === 'function')
const evidence = { core, ending, supported, danmuku: { input: process.env.ARTPLAYER_DANMUKU_ARTIFACT || 'source UMD', sha256: hash(danmuku) }, dpip: { input: dpip.name, sha256: hash(dpip.code) }, outcome: 'incomplete', rounds: [] }
if (!supported) {
evidence.outcome = 'native-document-pip-unavailable'
await testInfo.attach('danmuku-dpip', { contentType: 'application/json', body: JSON.stringify(evidence) })
return
}
await page.evaluate(() => {
window.combinationWorkers = []
const NativeWorker = window.Worker
window.Worker = class extends NativeWorker {
constructor(...args) {
super(...args)
this.record = { terminated: 0, replies: 0 }
window.combinationWorkers.push(this.record)
this.addEventListener('message', () => this.record.replies++)
}
terminate() {
this.record.terminated++
return super.terminate()
}
for (const implementation of ['published', 'candidate']) {
for (const ending of ['close', 'native-close', 'destroy', 'load']) {
test(`${core}: ${implementation} Danmuku, heatmap and Worker survive native Document PiP ${ending}`, async ({ page, context }, testInfo) => {
if (ending === 'load')
test.setTimeout(45000) // Two native PiP loads add 16 seconds of actual media progression.
const input = implementations.get(implementation)
const danmuku = input.code
await page.goto(`/test/player.html?core=${core}`)
const supported = await page.evaluate(() => typeof window.documentPictureInPicture?.requestWindow === 'function')
const evidence = { core, implementation, ending, supported, danmuku: { input: input.input, sha256: hash(danmuku) }, dpip: { input: dpip.name, sha256: hash(dpip.code) }, outcome: 'incomplete', rounds: [], loads: [] }
if (!supported) {
evidence.outcome = 'native-document-pip-unavailable'
await testInfo.attach('danmuku-dpip', { contentType: 'application/json', body: JSON.stringify(evidence) })
return
}
})
await page.addScriptTag({ content: danmuku })
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${dpip.code}; window.dpipFactory = module.exports.default || module.exports; })();` })
await page.evaluate(() => {
window.combinationVisible = []
window.combinationErrors = []
window.combinationEvents = []
window.art = new window.Artplayer({
container: '.player',
url: '/assets/sample/video.mp4',
muted: true,
plugins: [window.artplayerPluginDanmuku({ danmuku: [], heatmap: true, emitter: true, speed: 4 }), window.dpipFactory({ fallbackToVideoPiP: false })],
})
window.danmuku = window.art.plugins.artplayerPluginDanmuku
window.danmukuOwner = window.danmuku.config({})
window.pip = window.art.plugins.artplayerPluginDocumentPip
window.combinationPlayer = window.art.template.$player
window.combinationLayer = window.art.template.$danmuku
window.combinationParent = window.combinationPlayer.parentNode
window.art.on('document-pip', value => window.combinationEvents.push(value))
window.art.on('artplayerPluginDanmuku:error', error => window.combinationErrors.push(String(error)))
window.art.on('artplayerPluginDanmuku:visible', row => window.combinationVisible.push({ text: row.text, ref: row.$ref, time: window.art.currentTime }))
document.querySelector('#play').onclick = () => window.art.play()
const open = document.createElement('button')
open.id = 'danmuku-open-pip'
open.textContent = 'Open Danmuku PiP'
open.onclick = () => {
window.openingDanmukuPip = window.pip.open()
}
document.body.append(open)
})
try {
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
for (let round = 0; round < 2; round++) {
const popupPromise = context.waitForEvent('page', { timeout: 8000 })
await page.click('#danmuku-open-pip')
const popup = await popupPromise
await page.evaluate(() => window.openingDanmukuPip)
await expect(popup.locator('video.art-video')).toBeVisible()
await expect(popup.locator('.artplayer-plugin-danmuku')).toHaveCount(1)
const text = `PiP row ${round}`
await page.evaluate(async (text) => {
await window.danmuku.load([{ text, time: window.art.currentTime + 0.5, mode: 1 }])
}, text)
await expect.poll(() => page.evaluate(text => window.combinationVisible.some(row => row.text === text), text)).toBe(true)
await expect(popup.getByText(text, { exact: true })).toBeVisible()
const snapshot = await page.evaluate((text) => {
const row = window.combinationVisible.find(row => row.text === text)
const rect = row.ref.getBoundingClientRect()
const popup = window.documentPictureInPicture.window
return {
time: window.art.currentTime,
rowTime: row.time,
width: rect.width,
height: rect.height,
adopted: row.ref.ownerDocument === popup.document && window.combinationLayer.ownerDocument === popup.document,
inLayer: window.combinationLayer.contains(row.ref),
position: popup.getComputedStyle(row.ref).position,
player: window.combinationPlayer.ownerDocument === popup.document,
heatmap: window.art.controls.heatmap.ownerDocument === popup.document && !!window.art.controls.heatmap.querySelector('svg'),
workers: window.combinationWorkers.map(worker => ({ ...worker })),
await page.evaluate(() => {
window.combinationWorkers = []
const NativeWorker = window.Worker
window.Worker = class extends NativeWorker {
constructor(...args) {
super(...args)
this.record = { terminated: 0, replies: 0 }
window.combinationWorkers.push(this.record)
this.addEventListener('message', () => this.record.replies++)
}
}, text)
evidence.rounds.push(snapshot)
expect(snapshot.adopted && snapshot.inLayer && snapshot.player && snapshot.heatmap).toBe(true)
expect(snapshot.width).toBeGreaterThan(0)
expect(snapshot.height).toBeGreaterThan(0)
expect(snapshot.position).toBe('absolute')
expect(snapshot.workers).toHaveLength(1)
expect(snapshot.workers[0].replies).toBeGreaterThan(0)
if (round === 0)
await testInfo.attach('native-danmuku-page', { contentType: 'image/png', body: await popup.screenshot() })
if (round === 1 && ending === 'destroy') {
terminate() {
this.record.terminated++
return super.terminate()
}
}
})
await page.addScriptTag({ content: danmuku })
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${dpip.code}; window.dpipFactory = module.exports.default || module.exports; })();` })
await page.evaluate(() => {
window.combinationVisible = []
window.combinationErrors = []
window.combinationEvents = []
window.art = new window.Artplayer({
container: '.player',
url: '/assets/sample/video.mp4',
muted: true,
plugins: [window.artplayerPluginDanmuku({ danmuku: [], heatmap: true, emitter: true, speed: 4 }), window.dpipFactory({ fallbackToVideoPiP: false })],
})
window.danmuku = window.art.plugins.artplayerPluginDanmuku
window.danmukuOwner = window.danmuku.config({})
window.pip = window.art.plugins.artplayerPluginDocumentPip
window.combinationPlayer = window.art.template.$player
window.combinationLayer = window.art.template.$danmuku
window.combinationParent = window.combinationPlayer.parentNode
window.art.on('document-pip', value => window.combinationEvents.push(value))
window.art.on('artplayerPluginDanmuku:error', error => window.combinationErrors.push(String(error)))
window.art.on('artplayerPluginDanmuku:visible', row => window.combinationVisible.push({ text: row.text, ref: row.$ref, time: window.art.currentTime }))
document.querySelector('#play').onclick = () => window.art.play()
const open = document.createElement('button')
open.id = 'danmuku-open-pip'
open.textContent = 'Open Danmuku PiP'
open.onclick = () => {
window.openingDanmukuPip = window.pip.open()
}
document.body.append(open)
})
try {
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
for (let round = 0; round < 2; round++) {
const popupPromise = context.waitForEvent('page', { timeout: 8000 })
await page.click('#danmuku-open-pip')
const popup = await popupPromise
await page.evaluate(() => window.openingDanmukuPip)
await expect(popup.locator('video.art-video')).toBeVisible()
await expect(popup.locator('.artplayer-plugin-danmuku')).toHaveCount(1)
const text = `PiP row ${round}`
await page.evaluate(async (text) => {
await window.danmuku.load([{ text, time: window.art.currentTime + 0.5, mode: 1 }])
}, text)
await expect.poll(() => page.evaluate(text => window.combinationVisible.some(row => row.text === text), text)).toBe(true)
await expect(popup.getByText(text, { exact: true }).and(popup.locator(':visible'))).toBeVisible()
const snapshot = await page.evaluate((text) => {
const row = window.combinationVisible.find(row => row.text === text)
const rect = row.ref.getBoundingClientRect()
const popup = window.documentPictureInPicture.window
return {
time: window.art.currentTime,
rowTime: row.time,
width: rect.width,
height: rect.height,
adopted: row.ref.ownerDocument === popup.document && window.combinationLayer.ownerDocument === popup.document,
inLayer: window.combinationLayer.contains(row.ref),
position: popup.getComputedStyle(row.ref).position,
player: window.combinationPlayer.ownerDocument === popup.document,
heatmap: window.art.controls.heatmap.ownerDocument === popup.document && !!window.art.controls.heatmap.querySelector('svg'),
workers: window.combinationWorkers.map(worker => ({ ...worker })),
}
}, text)
evidence.rounds.push(snapshot)
expect(snapshot.adopted && snapshot.inLayer && snapshot.player && snapshot.heatmap).toBe(true)
expect(snapshot.width).toBeGreaterThan(0)
expect(snapshot.height).toBeGreaterThan(0)
expect(snapshot.position).toBe('absolute')
expect(snapshot.workers).toHaveLength(1)
expect(snapshot.workers[0].replies).toBeGreaterThan(0)
if (round === 0)
await testInfo.attach('native-danmuku-page', { contentType: 'image/png', body: await popup.screenshot() })
if (ending === 'load')
evidence.loads.push(await observeDanmukuLoad(page, { label: `PiP ${round}`, requireComplete: implementation === 'candidate' }))
evidence.beforeDestroy = await page.evaluate(() => ({ nodes: window.combinationLayer.children.length }))
if (round === 1 && ending === 'destroy') {
await page.evaluate(() => window.art.destroy(false))
}
else {
await page.evaluate(ending => ending === 'native-close' ? window.documentPictureInPicture.window.close() : window.pip.close(), ending)
await expect.poll(() => page.evaluate(() => window.pip.isActive)).toBe(false)
expect(await page.evaluate(() => window.combinationPlayer.parentNode === window.combinationParent && window.combinationLayer.ownerDocument === document)).toBe(true)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(snapshot.time + 0.1)
}
}
if (ending !== 'destroy')
await page.evaluate(() => window.art.destroy(false))
await expect.poll(() => page.evaluate(() => !window.documentPictureInPicture.window || window.documentPictureInPicture.window.closed)).toBe(true)
evidence.final = await page.evaluate(() => ({
active: window.pip.isActive,
workers: window.combinationWorkers,
errors: window.combinationErrors,
events: window.combinationEvents,
frame: window.danmukuOwner.scheduler?.frame,
operation: window.danmukuOwner.scheduler?.operation,
nodes: window.combinationLayer.children.length,
settings: document.querySelectorAll('.artplayer-plugin-danmuku').length,
placeholders: document.querySelectorAll('.artplayer-document-pip-placeholder').length,
}))
expect(evidence.final.active).toBe(false)
expect(evidence.final.workers).toHaveLength(1)
expect(evidence.final.workers[0].terminated).toBe(1)
expect(evidence.final.errors).toEqual([])
// Destroy suppresses further public events, as in the existing native PiP contract.
expect(evidence.final.events).toEqual(ending === 'destroy' ? [true, false, true] : [true, false, true, false])
if (implementation === 'candidate') {
expect(evidence.final.frame).toBeNull()
expect(evidence.final.operation).toBeNull()
expect(evidence.final.nodes + evidence.final.settings + evidence.final.placeholders).toBe(0)
evidence.outcome = 'native-playback-danmuku-and-cleanup-passed'
}
else {
await page.evaluate(ending => ending === 'native-close' ? window.documentPictureInPicture.window.close() : window.pip.close(), ending)
await expect.poll(() => page.evaluate(() => window.pip.isActive)).toBe(false)
expect(await page.evaluate(() => window.combinationPlayer.parentNode === window.combinationParent && window.combinationLayer.ownerDocument === document)).toBe(true)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(snapshot.time + 0.1)
// Frozen npm 5.3.0 destroy(false) retains its renderer and Setting nodes.
expect(evidence.final.nodes).toBe(ending === 'load' ? evidence.beforeDestroy.nodes : 3)
expect(evidence.final.nodes).toBeGreaterThan(0)
expect(evidence.final.settings).toBe(1)
expect(evidence.final.placeholders).toBe(0)
evidence.outcome = 'native-playback-with-published-retained-dom'
}
}
if (ending !== 'destroy')
await page.evaluate(() => window.art.destroy(false))
await expect.poll(() => page.evaluate(() => !window.documentPictureInPicture.window || window.documentPictureInPicture.window.closed)).toBe(true)
evidence.final = await page.evaluate(() => ({
active: window.pip.isActive,
workers: window.combinationWorkers,
errors: window.combinationErrors,
events: window.combinationEvents,
frame: window.danmukuOwner.scheduler.frame,
operation: window.danmukuOwner.scheduler.operation,
nodes: window.combinationLayer.children.length,
settings: document.querySelectorAll('.artplayer-plugin-danmuku').length,
placeholders: document.querySelectorAll('.artplayer-document-pip-placeholder').length,
}))
expect(evidence.final.active).toBe(false)
expect(evidence.final.workers).toHaveLength(1)
expect(evidence.final.workers[0].terminated).toBe(1)
expect(evidence.final.errors).toEqual([])
// Destroy suppresses further public events, as in the existing native PiP contract.
expect(evidence.final.events).toEqual(ending === 'destroy' ? [true, false, true] : [true, false, true, false])
expect(evidence.final.frame).toBeNull()
expect(evidence.final.operation).toBeNull()
expect(evidence.final.nodes + evidence.final.settings + evidence.final.placeholders).toBe(0)
evidence.outcome = 'native-playback-danmuku-and-cleanup-passed'
}
finally {
await testInfo.attach('danmuku-dpip', { contentType: 'application/json', body: JSON.stringify(evidence) })
await page.evaluate(() => {
if (window.art && !window.art.isDestroy)
window.art.destroy(false)
})
}
})
finally {
evidence.incompleteLoad = await page.evaluate(() => window.danmukuCombinationLoadResult)
await testInfo.attach('danmuku-dpip', { contentType: 'application/json', body: JSON.stringify(evidence) })
await page.evaluate(() => {
if (window.art && !window.art.isDestroy)
window.art.destroy(false)
})
}
})
}
}
}
+147 -131
View File
@@ -1,7 +1,8 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { observeDanmukuLoad } from '../helpers/danmuku-combination-load.js'
import { compilePackage } from '../helpers/load.js'
import { expect, test } from './fixtures.js'
@@ -20,11 +21,18 @@ const assets = baseline.sdk.assets.map(({ file, sha256 }) => {
const media = 'docs/assets/sample/steve-jobs.mp4'
const danmukuFile = process.env.ARTPLAYER_DANMUKU_ARTIFACT || 'source UMD'
let danmukuCode
const danmukuImplementations = new Map()
test.beforeAll(async () => {
;[code, danmukuCode] = await Promise.all([
process.env.ARTPLAYER_MASK_ARTIFACT ? fs.readFileSync(artifact, 'utf8') : compilePackage('artplayer-plugin-danmuku-mask', 'umd'),
process.env.ARTPLAYER_DANMUKU_ARTIFACT ? fs.readFileSync(danmukuFile, 'utf8') : compilePackage('artplayer-plugin-danmuku', 'umd'),
])
const release = JSON.parse(fs.readFileSync('refactor/baselines/danmuku-release.json', 'utf8')).release
const member = 'package/dist/artplayer-plugin-danmuku.js'
const published = readMember(await ensureArchive(release), member)
assert.equal(hash(published), release.files[member])
danmukuImplementations.set('published', { file: `npm ${release.version}`, code: published.toString() })
danmukuImplementations.set('candidate', { file: danmukuFile, code: danmukuCode })
})
for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
@@ -132,138 +140,146 @@ for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
}
})
test(`Mask actual model and Danmuku share the layer through pause, seek and web fullscreen with ${core} core`, async ({ page }, testInfo) => {
test.setTimeout(90000)
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: danmukuCode })
await page.addScriptTag({ content: code })
const unexpected = []
const origin = new URL(testInfo.project.use.baseURL).origin
await page.route('**/*', (route) => {
if (new URL(route.request().url()).origin !== origin) {
unexpected.push(route.request().url())
return route.abort()
}
return route.continue()
})
await page.evaluate(() => {
window.maskCombination = { times: [], visible: [], snapshots: [], samples: [] }
const encode = HTMLCanvasElement.prototype.toDataURL
HTMLCanvasElement.prototype.toDataURL = function (...args) {
const result = encode.apply(this, args)
window.maskCombination.times.push(window.art?.currentTime)
return result
}
window.art = new window.Artplayer({
container: '.player',
url: '/assets/sample/steve-jobs.mp4',
muted: true,
fullscreenWeb: true,
plugins: [window.artplayerPluginDanmuku({ danmuku: [{ text: 'Native mask combination', time: 6, mode: 1 }], heatmap: false }), window.artplayerPluginDanmukuMask({ solutionPath: '/assets/@mediapipe/selfie_segmentation' })],
for (const implementation of ['published', 'candidate']) {
test(`Mask actual model and Danmuku share the layer through pause, seek and web fullscreen with ${core} core / ${implementation} plugin`, async ({ page }, testInfo) => {
const danmuku = danmukuImplementations.get(implementation)
test.setTimeout(90000)
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: danmuku.code })
await page.addScriptTag({ content: code })
const unexpected = []
let load
const origin = new URL(testInfo.project.use.baseURL).origin
await page.route('**/*', (route) => {
if (new URL(route.request().url()).origin !== origin) {
unexpected.push(route.request().url())
return route.abort()
}
return route.continue()
})
window.maskCombination.layer = window.art.template.$danmuku
const owner = window.art.plugins.artplayerPluginDanmuku.config({})
const ready = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(owner), 'readys').get
Object.defineProperty(owner, 'readys', { configurable: true, get() {
const rows = ready.call(this)
window.maskCombination.samples.push({ time: window.art.currentTime, wall: performance.now(), rows: rows.map(row => ({ time: row.time, text: row.text })) })
return rows
} })
window.art.on('artplayerPluginDanmuku:visible', (row) => {
const node = row.$ref
const rect = node?.getBoundingClientRect()
window.maskCombination.visible.push({ text: row.text, connected: node?.isConnected, inLayer: node ? window.maskCombination.layer.contains(node) : false, width: rect?.width, height: rect?.height, display: node ? getComputedStyle(node).display : null })
await page.evaluate(() => {
window.maskCombination = { times: [], visible: [], snapshots: [], samples: [] }
const encode = HTMLCanvasElement.prototype.toDataURL
HTMLCanvasElement.prototype.toDataURL = function (...args) {
const result = encode.apply(this, args)
window.maskCombination.times.push(window.art?.currentTime)
return result
}
window.art = new window.Artplayer({
container: '.player',
url: '/assets/sample/steve-jobs.mp4',
muted: true,
fullscreenWeb: true,
plugins: [window.artplayerPluginDanmuku({ danmuku: [{ text: 'Native mask combination', time: 6, mode: 1 }], heatmap: false }), window.artplayerPluginDanmukuMask({ solutionPath: '/assets/@mediapipe/selfie_segmentation' })],
})
window.maskCombination.layer = window.art.template.$danmuku
const owner = window.art.plugins.artplayerPluginDanmuku.config({})
const ready = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(owner), 'readys').get
Object.defineProperty(owner, 'readys', { configurable: true, get() {
const rows = ready.call(this)
window.maskCombination.samples.push({ time: window.art.currentTime, wall: performance.now(), rows: rows.map(row => ({ time: row.time, text: row.text })) })
return rows
} })
window.art.on('artplayerPluginDanmuku:visible', (row) => {
const node = row.$ref
const rect = node?.getBoundingClientRect()
window.maskCombination.visible.push({ text: row.text, connected: node?.isConnected, inLayer: node ? window.maskCombination.layer.contains(node) : false, width: rect?.width, height: rect?.height, display: node ? getComputedStyle(node).display : null })
})
document.querySelector('#play').onclick = () => window.art.play()
document.querySelector('#pause').onclick = () => window.art.pause()
window.maskSnapshot = (phase) => {
const layer = window.art.template.$danmuku
const rect = layer.getBoundingClientRect()
const snapshot = { phase, time: window.art.currentTime, outputs: window.maskCombination.times.length, sameLayer: layer === window.maskCombination.layer, mask: layer.style.maskImage.startsWith('url('), mode: getComputedStyle(layer).maskMode, width: rect.width, height: rect.height, fullscreenWeb: window.art.fullscreenWeb }
window.maskCombination.snapshots.push(snapshot)
return snapshot
}
})
document.querySelector('#play').onclick = () => window.art.play()
document.querySelector('#pause').onclick = () => window.art.pause()
window.maskSnapshot = (phase) => {
const layer = window.art.template.$danmuku
const rect = layer.getBoundingClientRect()
const snapshot = { phase, time: window.art.currentTime, outputs: window.maskCombination.times.length, sameLayer: layer === window.maskCombination.layer, mask: layer.style.maskImage.startsWith('url('), mode: getComputedStyle(layer).maskMode, width: rect.width, height: rect.height, fullscreenWeb: window.art.fullscreenWeb }
window.maskCombination.snapshots.push(snapshot)
return snapshot
try {
await expect.poll(() => page.evaluate(() => window.art.duration)).toBeGreaterThan(20)
await page.evaluate(() => {
window.art.currentTime = 5
})
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.maskCombination.times.length), { timeout: 30000 }).toBeGreaterThan(2)
// Keep the startup row and sampling history as diagnostics: native model
// initialization may block its narrow existing timestamp window. Exercise
// the ongoing combination after the real model has become operational.
await page.evaluate(async () => {
const time = window.art.currentTime + 1
window.maskCombination.scheduled = time
await window.art.plugins.artplayerPluginDanmuku.load([{ text: 'Ready mask combination', time, mode: 1 }])
})
await expect.poll(() => page.evaluate(() => window.maskCombination.visible.map(row => row.text))).toContain('Ready mask combination')
const row = await page.evaluate(() => window.maskCombination.visible.find(row => row.text === 'Ready mask combination'))
expect(row.connected && row.inLayer).toBe(true)
expect(row.width).toBeGreaterThan(0)
expect(row.height).toBeGreaterThan(0)
expect(row.display).not.toBe('none')
const initial = await page.evaluate(() => window.maskSnapshot('playing'))
expect(initial.mask).toBe(true)
expect(initial.sameLayer).toBe(true)
await page.locator('#pause').click()
await expect.poll(() => page.evaluate(() => window.art.video.paused)).toBe(true)
// Wait native frames for the already-running SDK frame to finish. Compare
// its media timestamp, rather than forbidding one legitimate late encode.
const paused = await page.evaluate(async () => {
const time = window.art.currentTime
const beforeOutputs = window.maskCombination.times.length
await new Promise(resolve => setTimeout(resolve, 500))
return { before: time, after: window.art.currentTime, extraOutputs: window.maskCombination.times.length - beforeOutputs, snapshot: window.maskSnapshot('paused') }
})
expect(Math.abs(paused.after - paused.before)).toBeLessThan(0.01)
expect(paused.extraOutputs).toBeLessThanOrEqual(1)
await page.evaluate(async () => {
const seeked = new Promise(resolve => window.art.video.addEventListener('seeked', resolve, { once: true }))
window.art.currentTime = 20
window.art.fullscreenWeb = true
await seeked
window.maskCombination.seeked = { time: window.art.currentTime, outputs: window.maskCombination.times.length }
})
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThanOrEqual(19.9)
const full = await page.evaluate(() => window.maskSnapshot('fullscreen-paused'))
expect(full.sameLayer).toBe(true)
expect(full.fullscreenWeb).toBe(true)
expect(full.width).toBeGreaterThan(initial.width)
await page.evaluate(() => window.art.play())
await expect.poll(() => page.evaluate(() => window.maskCombination.times.length - window.maskCombination.seeked.outputs), { timeout: 30000 }).toBeGreaterThanOrEqual(2)
expect(await page.evaluate(() => window.maskCombination.times.at(-1))).toBeGreaterThan(20)
await page.evaluate(async () => {
window.art.fullscreenWeb = false
window.art.plugins.artplayerPluginDanmuku.hide()
window.art.plugins.artplayerPluginDanmuku.show()
await window.art.plugins.artplayerPluginDanmuku.load([{ text: 'After seek and visibility', time: window.art.currentTime + 1, mode: 1 }])
})
await expect.poll(() => page.evaluate(() => window.maskCombination.visible.map(row => row.text))).toContain('After seek and visibility')
const afterRow = await page.evaluate(() => window.maskCombination.visible.find(row => row.text === 'After seek and visibility'))
expect(afterRow.connected && afterRow.inLayer).toBe(true)
expect(afterRow.width).toBeGreaterThan(0)
expect(afterRow.height).toBeGreaterThan(0)
expect(afterRow.display).not.toBe('none')
const restored = await page.evaluate(() => window.maskSnapshot('restored'))
expect(restored.sameLayer).toBe(true)
expect(restored.mask).toBe(true)
expect(restored.width).toBe(initial.width)
expect(restored.mode).toBe('alpha')
await testInfo.attach('actual-combination-page', { body: await page.screenshot(), contentType: 'image/png' })
const outputsBeforeLoad = await page.evaluate(() => window.maskCombination.times.length)
load = await observeDanmukuLoad(page, { label: 'Mask load', requireComplete: implementation === 'candidate' })
load.modelOutputs = await page.evaluate(before => window.maskCombination.times.length - before, outputsBeforeLoad)
expect(load.modelOutputs).toBeGreaterThan(1)
await page.evaluate(() => window.art.destroy(false))
expect(await page.evaluate(() => window.maskCombination.layer.style.maskImage)).toBe('none')
expect(unexpected).toEqual([])
}
finally {
const state = await page.evaluate(() => ({ times: window.maskCombination.times, visible: window.maskCombination.visible, snapshots: window.maskCombination.snapshots, scheduled: window.maskCombination.scheduled, samples: window.maskCombination.samples, seeked: window.maskCombination.seeked, incompleteLoad: window.danmukuCombinationLoadResult }))
await testInfo.attach('actual-combination-evidence', { body: JSON.stringify({ core, implementation, mask: { file: artifact, sha256: hash(code) }, danmuku: { file: danmuku.file, sha256: hash(danmuku.code) }, unexpected, state, load, limitation: 'Actual model, native media, timestamp-delivered Danmuku and CSS web fullscreen. Bounded 20 rows/s after model readiness. No OS fullscreen, GPU completion or device coverage claim.' }, null, 2), contentType: 'application/json' })
await page.evaluate(() => {
if (window.art && !window.art.isDestroy)
window.art.destroy()
})
}
})
try {
await expect.poll(() => page.evaluate(() => window.art.duration)).toBeGreaterThan(20)
await page.evaluate(() => {
window.art.currentTime = 5
})
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.maskCombination.times.length), { timeout: 30000 }).toBeGreaterThan(2)
// Keep the startup row and sampling history as diagnostics: native model
// initialization may block its narrow existing timestamp window. Exercise
// the ongoing combination after the real model has become operational.
await page.evaluate(async () => {
const time = window.art.currentTime + 1
window.maskCombination.scheduled = time
await window.art.plugins.artplayerPluginDanmuku.load([{ text: 'Ready mask combination', time, mode: 1 }])
})
await expect.poll(() => page.evaluate(() => window.maskCombination.visible.map(row => row.text))).toContain('Ready mask combination')
const row = await page.evaluate(() => window.maskCombination.visible.find(row => row.text === 'Ready mask combination'))
expect(row.connected && row.inLayer).toBe(true)
expect(row.width).toBeGreaterThan(0)
expect(row.height).toBeGreaterThan(0)
expect(row.display).not.toBe('none')
const initial = await page.evaluate(() => window.maskSnapshot('playing'))
expect(initial.mask).toBe(true)
expect(initial.sameLayer).toBe(true)
await page.locator('#pause').click()
await expect.poll(() => page.evaluate(() => window.art.video.paused)).toBe(true)
// Wait native frames for the already-running SDK frame to finish. Compare
// its media timestamp, rather than forbidding one legitimate late encode.
const paused = await page.evaluate(async () => {
const time = window.art.currentTime
const beforeOutputs = window.maskCombination.times.length
await new Promise(resolve => setTimeout(resolve, 500))
return { before: time, after: window.art.currentTime, extraOutputs: window.maskCombination.times.length - beforeOutputs, snapshot: window.maskSnapshot('paused') }
})
expect(Math.abs(paused.after - paused.before)).toBeLessThan(0.01)
expect(paused.extraOutputs).toBeLessThanOrEqual(1)
await page.evaluate(async () => {
const seeked = new Promise(resolve => window.art.video.addEventListener('seeked', resolve, { once: true }))
window.art.currentTime = 20
window.art.fullscreenWeb = true
await seeked
window.maskCombination.seeked = { time: window.art.currentTime, outputs: window.maskCombination.times.length }
})
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThanOrEqual(19.9)
const full = await page.evaluate(() => window.maskSnapshot('fullscreen-paused'))
expect(full.sameLayer).toBe(true)
expect(full.fullscreenWeb).toBe(true)
expect(full.width).toBeGreaterThan(initial.width)
await page.evaluate(() => window.art.play())
await expect.poll(() => page.evaluate(() => window.maskCombination.times.length - window.maskCombination.seeked.outputs), { timeout: 30000 }).toBeGreaterThanOrEqual(2)
expect(await page.evaluate(() => window.maskCombination.times.at(-1))).toBeGreaterThan(20)
await page.evaluate(async () => {
window.art.fullscreenWeb = false
window.art.plugins.artplayerPluginDanmuku.hide()
window.art.plugins.artplayerPluginDanmuku.show()
await window.art.plugins.artplayerPluginDanmuku.load([{ text: 'After seek and visibility', time: window.art.currentTime + 1, mode: 1 }])
})
await expect.poll(() => page.evaluate(() => window.maskCombination.visible.map(row => row.text))).toContain('After seek and visibility')
const afterRow = await page.evaluate(() => window.maskCombination.visible.find(row => row.text === 'After seek and visibility'))
expect(afterRow.connected && afterRow.inLayer).toBe(true)
expect(afterRow.width).toBeGreaterThan(0)
expect(afterRow.height).toBeGreaterThan(0)
expect(afterRow.display).not.toBe('none')
const restored = await page.evaluate(() => window.maskSnapshot('restored'))
expect(restored.sameLayer).toBe(true)
expect(restored.mask).toBe(true)
expect(restored.width).toBe(initial.width)
expect(restored.mode).toBe('alpha')
await testInfo.attach('actual-combination-page', { body: await page.screenshot(), contentType: 'image/png' })
await page.evaluate(() => window.art.destroy(false))
expect(await page.evaluate(() => window.maskCombination.layer.style.maskImage)).toBe('none')
expect(unexpected).toEqual([])
}
finally {
const state = await page.evaluate(() => ({ times: window.maskCombination.times, visible: window.maskCombination.visible, snapshots: window.maskCombination.snapshots, scheduled: window.maskCombination.scheduled, samples: window.maskCombination.samples, seeked: window.maskCombination.seeked }))
await testInfo.attach('actual-combination-evidence', { body: JSON.stringify({ core, mask: { file: artifact, sha256: hash(code) }, danmuku: { file: danmukuFile, sha256: hash(danmukuCode) }, unexpected, state, limitation: 'Actual model, native media, timestamp-delivered Danmuku and CSS web fullscreen. No OS fullscreen, GPU completion or device coverage claim.' }, null, 2), contentType: 'application/json' })
await page.evaluate(() => {
if (window.art && !window.art.isDestroy)
window.art.destroy()
})
}
})
}
}
+60
View File
@@ -0,0 +1,60 @@
import { expect } from '@playwright/test'
// Observe real media/RAF delivery after a companion plugin has become operational.
export async function observeDanmukuLoad(page, { label, requireComplete, rate = 20, seconds = 6 }) {
let result
try {
await page.evaluate(async ({ label, rate, seconds }) => {
const art = window.art
const plugin = art.plugins.artplayerPluginDanmuku
const owner = plugin.config({ speed: 1, antiOverlap: false })
const host = art.template.$player.ownerDocument.defaultView
const base = art.currentTime
const rows = Array.from({ length: rate * seconds }, (_, index) => ({
id: `${label}-${index}`,
text: `${label} ${index}`,
time: base + 0.5 + index / rate,
mode: index % 3,
}))
const ids = new Set(rows.map(row => row.id))
const state = window.danmukuCombinationLoad = { base, end: base + seconds + 2, rows, visible: [], frames: [], errors: [], startWall: host.performance.now() }
const visible = (row) => {
if (ids.has(row.id))
state.visible.push({ id: row.id, time: art.currentTime, wall: host.performance.now() })
}
const error = error => state.errors.push(String(error))
let frame
const sample = () => {
state.frames.push({ time: art.currentTime, wall: host.performance.now() })
frame = host.requestAnimationFrame(sample)
}
frame = host.requestAnimationFrame(sample)
art.on('artplayerPluginDanmuku:visible', visible)
art.on('artplayerPluginDanmuku:error', error)
window.finishDanmukuCombinationLoad = () => {
host.cancelAnimationFrame(frame)
art.off('artplayerPluginDanmuku:visible', visible)
art.off('artplayerPluginDanmuku:error', error)
const loaded = owner.queue.filter(row => ids.has(row.id))
const result = { ...state, endWall: host.performance.now(), time: art.currentTime, loaded: loaded.length, waiting: loaded.filter(row => row.$state === 'wait').length, referenced: loaded.filter(row => row.$ref).length, nodes: art.template.$danmuku.children.length, pooled: owner.$refs.length, uniquePool: new Set(owner.$refs).size, decodedWidth: art.video.videoWidth }
window.danmukuCombinationLoadResult = result
return result
}
await plugin.load(rows)
}, { label, rate, seconds })
await expect.poll(() => page.evaluate(() => window.art.currentTime >= window.danmukuCombinationLoad.end), { timeout: 16000 }).toBe(true)
}
finally {
result = await page.evaluate(() => window.finishDanmukuCombinationLoad?.())
}
expect(result.decodedWidth).toBeGreaterThan(0)
expect(result.loaded).toBe(rate * seconds)
expect(result.errors).toEqual([])
if (requireComplete) {
expect(result.visible.map(row => row.id).sort()).toEqual(result.rows.map(row => row.id).sort())
expect(result.waiting).toBe(rate * seconds)
expect(result.referenced).toBe(0)
expect(result.uniquePool).toBe(result.pooled)
}
return { ...result, requireComplete, rate, seconds }
}