test(danmuku): [PKG-DANMUKU-07] verify native load and shared layer stability

This commit is contained in:
Harvey Zhao committed 2026-09-13 23:16:04 +08:00
1 parent 4f559cb31d
commit 00e81309ca
14 files changed
+6724 -11

No files matched your search

@@ -136,6 +136,14 @@ legacy build targets remain es2020 and es2015; syntax lowering does not polyfill
## Scheduling ownership
The root `art.template.$danmuku` belongs to the core template, not this plugin.
The Mask plugin captures this layer and the core video, and listens to core
ready/destroy; it does not read Danmuku queues, owners or plugin events. Renderer
cleanup owns individual comment nodes. Reset/replacement must preserve the root
identity and external mask styles; hide/show owns only its visibility opacity.
CSS-mask boundary tests do not establish model inference, SDK cleanup or complete
Mask integration. Those remain responsibilities of the Mask migration tasks.
Keep at most one pending RAF and one asynchronous preparation batch. Native `play`
and `playing` both invoke the existing start path and retain its events, but
must not create competing loops. Ready items precede wait items; wait selection
+4 -1
View File
@@ -53,7 +53,10 @@
`setBackend` fulfilled false 视为已执行 fallback。
registrar 捕获 `art.template.$video/$danmuku`,订阅 ready→start、destroy→stop。
必须在 Danmuku 插件建立 `$danmuku` 后注册;demo 正是先 Danmuku 再 Mask。
需要核心模板已经提供 `$danmuku/$video`;demo 使用先 Danmuku 再 Mask 的顺序。
2026-09-13(PKG-DANMUKU-07)纠正初始记录:`$danmuku` 由核心模板创建,
不是 Danmuku 插件创建,因此 demo 顺序本身不构成 Mask 对 Danmuku 注册的硬依赖。
Mask 源码不读取 Danmuku 队列、owner 或插件事件;完整模型组合仍待 Mask 验收。
start 建立 2D canvas 并设置 `$danmuku.style`:maskMode=alpha、maskSize=contain、
maskRepeat=no-repeat、backgroundSize=contain、backgroundRepeat=no-repeat。
每轮以视频固有尺寸推理,白色前景/黑色背景生成 binary mask,drawMask 后将 RGB
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,66 @@
# PKG-DANMUKU-07 弹幕稳定性与共享层边界
已完成本任务限定的验收,任务及提交状态以tasks.json和Git为准。此项对12后的生产
实现增加原生负载和资源验收,没有改变运行时、声明、依赖、版本或构建内容。
## 验收范围
- 候选核心中比较固定npm5.3.0插件与候选源码,三引擎,每秒2和20条,各三轮
14秒真实媒体播放。每轮追加12秒的时间戳序列,三轮总计72/720条;其余时间
让弹幕自然显示和到期。保留真实RAF、视频时钟、Worker与原始readys getter。
- 候选逐项检查采样/显示无丢失,三轮总显示和回收均恰好一次;队列追加语义、
每条对象只属于一个对应状态池、空闲池唯一性、根层节点和空闲池一致均受断言保护。
包装原始节点getter只记录身份,确认实际重复使用旧节点,并且有空闲节点时
不绕过池分配新节点;Worker必须存在且唯一,正常请求全部回复,销毁恰好一次。
- 旧插件结果保留为对照观察,没有把其可能的历史缺陷豁免用于候选断言。
每轮帧间隔单独统计;包含暂停/加载转换的总采样间隔另列,不能当纯播放卡顿。
可选performance.memory是未强制GC的读数,不能证明泄漏或作跨引擎内存排名。
- 两核心、三引擎运行已有资源用例:共享挂载、初始化回滚、发送取消/锁、原始
错误、destroy(false)、替换加载移除被移动的节点、热图实例隔离。
- 新增两核心的CSS mask边界用例:注册前就取得核心根层,以固定内联SVG mask
验证emit/reset/无参替换load/hide/show/destroy(false)中的层引用和样式保持,
销毁后两帧没有晚请求和新子节点。此用例设置资源测试的ready前置状态、采用
原生0.25播放速率,不能当时间戳负载证据;负载用例没有这些设置。
## 问题处置与兼容性
早期原生时间窗口诊断区分了两个来源:800ms主线程完全阻塞,及600ms用户异步
回调使旧调度停止采样。11独立修复隐藏Worker等待吞掉显示寿命;12独立修复异步
放置期间的采样和到期维护。07继续检查这些修复后的实际负载及资源状态。
没有把公开±0.1秒窗口改成无限追赶,因此完全阻塞时未被采样的时间点仍可错过。
本轮首个测试版本错误地把`load(rows)`当作替换,第二轮队列断言收到48/480而
错误预期24/240。核实原实现后修正累计队列预期,当轮显示数仍精确24/240;没有
修改生产语义。初步12项通过后,经独立审查补足Worker非空、节点身份复用、
逐条状态归属和跨轮恰好一次的断言,最终需按补强后的文件重新验证。
Mask仅依赖核心提供的video/弹幕根层及ready/destroy,不读取弹幕队列、Owner
或插件事件。旧契约文档误称根层由Danmuku建立,已依据核心模板纠正并注明日期。
固定发布归档和生产Mask源码未改。Mask自身晚推理复活RAF、模型dispose、并发
start、模型和完整组合问题仍由Mask迁移任务处理,不能从本CSS测试判定通过。
## 维护与重跑
```sh
yarn test:browser test/browser/danmuku-stability.spec.js test/browser/danmuku-mask-boundary.spec.js test/browser/danmuku-resources.spec.js --workers=2
```
负载测试的90秒单例deadline来自三轮42秒媒体预算及设置/销毁开销;没有扩大
已有短用例超时。每次运行必须等待进程终态,归档report/results,再运行另一格式。
正常来源是当前源码UMD;两个新用例均支持ARTPLAYER_DANMUKU_ARTIFACT。
包内ARCHITECTURE.md与refactor/danmuku-stability.md记录根层归属、诊断及限制。
07未修改生产,因此复用12已记录的264项源码/136项产物单测、严格类型/构建,及
其异步/几何/生命周期验证;不声称这些由本次持续负载套件重新完成。
08完整组合、09最终分发、真实Mask模型/设备以及全项目发布复盘继续保留。
回退本任务只撤销测试和文档,不应回退11/12已交付修复。
## 最终结果
补强后的66项浏览器检查全部通过(12项负载、6项CSS边界、48项资源),0重试、
0跳过。每个负载用例完成三轮42秒原生媒体推进,候选两档各72/720条均完整且
只显示/回收一次。精确节点复用、堆读数和帧间隔见
[机器证据](../baselines/danmuku-stability-validation.json)。
新测试lint通过;影响分析检查通过,保留Danmuku/Mask组合验证边,仅纠正其
“Danmuku创建根层”的错误原因,不降低CI范围。测试由现有Playwright目录自动发现。
未运行远端CI、未推送或发布。
+70
View File
@@ -0,0 +1,70 @@
# PKG-DANMUKU-07 稳定性工作记录
本任务已在12后的运行时完成下述限定范围验收,任务与提交状态以tasks.json及Git为准。
06类型检查、11/12修复与本任务证据分别维护;真实模型、完整组合和发布验收仍待后续任务。
## 已执行的时间窗口诊断
`test/browser/danmuku-timing-diagnostic.spec.js`在候选核心中比较实际npm5.3.0插件与
06候选源码,使用本地90秒视频的短片段、原生RAF/媒体时钟和真实Worker。独立RAF
观察与实际readys getter采样分别记录;getter只包装读取,不修改返回数组或状态。
每例3条弹幕,后置sentinel证明间隔后仍能正常发送。12项诊断通过意味着取证完成,
不代表漏掉弹幕的结果可以作为稳定性通过。
| 场景 | Chromium | Firefox | Windows WebKit | 新旧共同结果 |
| --- | --- | --- | --- | --- |
| 注入800ms主线程工作 | 最大媒体帧间隔约800ms | 约777–809ms | 约838–856ms | 前两条未进入任何readys采样,sentinel显示 |
| beforeVisible等待600ms | 原生帧间隔约19–21ms,采样间隔616–618ms | 帧44ms,采样583–609ms | 帧63–64ms,采样622–647ms | 第一条与sentinel显示,中间条未采样 |
这解释了两种不同来源:前者浏览器主线程无法及时调度,后者浏览器仍在出帧,但
插件等待当前异步准备完成而不再读取新的候选窗口。±0.1秒和串行回调都有明确
历史语义;后续方案必须比较seek、hide/show、stop/start和false重试边界,不能
直接改成无限追赶或并行回调。当前不据此声称全部真实负载问题已解决。
报告位于refactor/.cache/danmuku07-timing-diagnostic.json,日志及附件保留同名前缀。
候选是11计时修复之前的06源码,具体构建SHA在每项danmuku-timing-observation中;
后续变更需说明哪些观察仍适用,不把旧报告冒充新提交的验证。
## 当前下一步
以下为本轮执行入口的历史清单,现已通过最终66项原生用例落实本任务范围。
下一实施阶段为08组合或其他已就绪包任务;09分发和真实Mask模型/设备仍不能据此关闭。
1. 11计时修复和12采样修复已分别提交并核验;当前HEAD为
`4f559cb31d6b69a37439c6db2d32ca28ae5c78f3`,恢复本任务。
12源码/main/legacy各102项浏览器通过,慢回调中间行已恢复;CPU完全阻塞
的未采样窗口仍保留。见[12证据](baselines/danmuku-frame-sampling-validation.json)。
2. 对实际持续播放记录scheduled、readys、callback、placed、visible、recycled及
原生RAF/媒体间隔,区分false、拒绝、轨道满、取消和错过采样。
3. 比较正常密度、密集队列和连续生命周期后的节点池、Worker、监听器与计时器。
已有受控三小时快进只证明离散状态,不是真实三小时播放或内存测量。
4. 根据证据决定是否需要兼容内部修复,并为每个独立修复保留任务和commit。
5. 明确Mask可依赖的当前队列/DOM/事件边界,再交付07验收与后续08组合矩阵。
不增加任意弹幕上限,不通过修改媒体时钟、预置ready或放宽断言伪造负载通过。
## 本轮新增的持续播放与边界取证
`test/browser/danmuku-stability.spec.js` 使用候选核心、实际npm5.3.0/候选插件、
原生视频/RAF/Worker,每秒2或20条,各三轮14秒实际媒体推进。每轮追加12秒
的时间戳序列,余下时间用于自然显示和回收;逐条记录采样/显示/回收,同时记录
状态池、节点/空闲池、真实帧和可选未强制GC的堆内存读数。不是三小时耐久测试,
也不把不同浏览器的performance.memory当作可比的泄漏证明。
首轮Chromium两个用例因测试误把`load(rows)`当作替换,在第二轮队列断言失败:
收到48/480,错误预期24/240。核对运行实现后按既有追加契约修正为累计队列数,
当轮显示数量仍严格断言24/240;没有改变生产API。失败报告与trace保留在
`refactor/.cache/danmuku07-stability-first.json`及同前缀results中。
Mask静态审查已确认其直接依赖只有核心video/root层/ready/destroy,未读取弹幕
队列或Owner。已纠正旧契约对根层创建归属的错误描述;核心模板及节点绑定为依据。
后续CSS mask边界测试明确不加载真实模型,不代替Mask03/05的候选生命周期与组合验收。
最终补强了非空Worker、实际节点复用、每条状态归属和跨轮显示/回收恰好一次检查。
12负载、6共享mask层、48资源测试共66项在三引擎全部通过,0重试/跳过。
见[变更记录](changes/2026-09-13-PKG-DANMUKU-07-stability.md)和
[机器证据](baselines/danmuku-stability-validation.json)。
本次候选每秒2条用例三轮72条只分配3个节点、实际复用69次;每秒20条三轮720条
分配21–22个节点、复用698–699次。旧版对应分配6及24–25个节点。两者该正常
同步回调负载下都完整显示;差异是本次原生样本观察,不是通用吞吐或长期内存承诺。
+1 -1
View File
@@ -121,7 +121,7 @@
{
"dependency": "artplayer-plugin-danmuku",
"consumer": "artplayer-plugin-danmuku-mask",
"reason": "Mask renders into the $danmuku element supplied by the danmuku plugin",
"reason": "Mask and Danmuku share the core-owned $danmuku layer; retain combination validation for root identity, styles and lifecycle",
"evidence": [
"packages/artplayer-plugin-danmuku-mask/src/index.js",
"docs/assets/example/danmuku.mask.js"
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 231 项,范围 22 个包及工作区/示例。
状态:todo 68 / doing 15 / blocked 0 / done 148 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 68 / doing 14 / blocked 0 / done 149 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -315,7 +315,7 @@
| PKG-DANMUKU-04 | artplayer-plugin-danmuku<br>整理时钟、队列与轨道调度 | PKG-DANMUKU-03, CORE-10 | danmuku 调度器和确定性时钟测试 | seek/暂停/倍率/长时间运行无顺序和碰撞回归 | H | done |
| PKG-DANMUKU-05 | artplayer-plugin-danmuku<br>整理 DOM 渲染、设置、热力图与 worker | PKG-DANMUKU-04, CORE-14, CORE-18 | renderer/setting/heatmap/worker 职责及资源归属 | mount/icons/设置和 worker 协议保持,销毁无后台工作 | H | done |
| 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 | doing |
| 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 | todo |
| PKG-DANMUKU-09 | artplayer-plugin-danmuku<br>完成分发、示例与文档 | PKG-DANMUKU-08, ENG-07 | 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 |
@@ -562,6 +562,7 @@
- PKG-DANMUKU-04: [记录](changes/2026-09-13-PKG-DANMUKU-04-scheduler.md) [记录](baselines/danmuku-scheduler-validation.json)
- 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-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)
+14
View File
@@ -1,5 +1,19 @@
# 进度与证据
## PKG-DANMUKU-07 弹幕稳定性阶段验收完成
12后的源码在三引擎完成新旧插件两档负载对照,每例三轮42秒原生播放;候选
72/720条全部采样、显示并回收一次,真实节点复用、状态池唯一归属和Worker清理通过。
两核心共享CSS mask根层与已有资源回归通过;最终12+6+48共66项,0重试/跳过。
首轮错误测试把load(rows)当替换,修正为历史追加契约并保留失败报告;独立审查
补足复用与跨轮重复断言。Mask不依赖队列,根层属于核心;纠正文档和影响映射原因,
保留组合CI范围。lint及影响检查通过,没有生产/声明/依赖变化,复用12的相关验证。
见[变更](changes/2026-09-13-PKG-DANMUKU-07-stability.md)和
[证据](baselines/danmuku-stability-validation.json)。DANMUKU-LOAD-01按已解释来源及
11/12独立修复闭环;CPU阻塞下旧窗口不追赶的边界明确保留,不声称无条件无漏弹幕。
231项:149 done、14 doing、68 todo。08完整组合、09分发、真实Mask模型和设备/
发布复盘继续;独立本地提交,不推送、不发布。
## PKG-DANMUKU-12 持续采样与串行放置完成
异步beforeVisible/Worker等待期间继续原生RAF采样和可见寿命维护,以内部身份
+1 -1
View File
@@ -237,7 +237,7 @@
| DANMUKU-SOURCE-01 | open / 已复现 | Danmuku registry Git associations differ from releases and static icon provenance remains incomplete | PKG-DANMUKU-09 |
| DANMUKU-TRACK-01 | resolved / 已复现 | Native simultaneous fixed Danmuku comments can overlap with duplicate start loops and repeated Worker IDs | PKG-DANMUKU-04, PKG-DANMUKU-05, PKG-DANMUKU-07 |
| DANMUKU-INPUT-02 | resolved / 已复现 | Intermediate explicit-zero correction regressed accepted NaN timestamp fallback | PKG-DANMUKU-04 |
| DANMUKU-LOAD-01 | open / 待取证 | Native lifecycle fixtures sometimes miss timestamp eligibility during concurrent workload; real frame/window behavior needs load evidence | PKG-DANMUKU-07 |
| DANMUKU-LOAD-01 | resolved / 已复现 | Native lifecycle fixtures sometimes miss timestamp eligibility during concurrent workload; real frame/window behavior needs load evidence | PKG-DANMUKU-07 |
| DANMUKU-HEATMAP-02 | resolved / 已复现 | Issue958 dense automatic heatmaps exceed the fixed Y domain and flatten across video | PKG-DANMUKU-10 |
| DANMUKU-TIMING-01 | resolved / 已复现 | Hidden Worker wait is deducted from visible lifetime and can recycle a comment immediately after visible | PKG-DANMUKU-11 |
| DANMUKU-SAMPLING-01 | resolved / 已复现 | Serial asynchronous placement stops native frame eligibility sampling and delays existing visible expiry | PKG-DANMUKU-12 |
+13 -4
View File
@@ -5290,18 +5290,27 @@
{
"id": "DANMUKU-LOAD-01",
"title": "Native lifecycle fixtures sometimes miss timestamp eligibility during concurrent workload; real frame/window behavior needs load evidence",
"confirmation": "unverified",
"status": "open",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"PKG-DANMUKU-07"
],
"evidence": [
"refactor/changes/2026-09-13-PKG-DANMUKU-05-resources.md",
"refactor/baselines/danmuku-resources-validation.json",
"test/browser/danmuku-scheduler.spec.js"
"test/browser/danmuku-scheduler.spec.js",
"refactor/baselines/danmuku-stability-validation.json",
"refactor/baselines/danmuku-frame-sampling-validation.json"
],
"compatibleResolution": "Capture native RAF/media-time gaps and scheduled/eligible/visible item counts under realistic load before deciding whether the old plus/minus 0.1 second eligibility rule needs a compatible correction.",
"closureCriteria": "Real load runs explain missing scheduled items with frame and media evidence, and any confirmed implementation defect has a regression and compatible fix; seeding ready in lifecycle fixtures is not closure evidence."
"closureCriteria": "Real load runs explain missing scheduled items with frame and media evidence, and any confirmed implementation defect has a regression and compatible fix; seeding ready in lifecycle fixtures is not closure evidence.",
"resolutionEvidence": [
"refactor/baselines/danmuku-stability-validation.json",
"refactor/changes/2026-09-13-PKG-DANMUKU-07-stability.md",
"refactor/baselines/danmuku-frame-sampling-validation.json",
"refactor/baselines/danmuku-lifetime-validation.json"
],
"resolutionRationale": "Native timing evidence distinguishes unobserved CPU-blocked windows from asynchronous scheduler starvation. Tasks 11/12 fix the reproduced implementation defects. Final three-engine real-load runs keep candidate scheduled rows observed/displayed/recycled exactly once, with actual node reuse and state/Worker ownership; unchanged historical CPU-gap semantics and full model/device/release scope remain explicit."
},
{
"id": "DANMUKU-HEATMAP-02",
+6 -2
View File
@@ -3384,11 +3384,15 @@
"PKG-DANMUKU-11",
"PKG-DANMUKU-12"
],
"status": "doing",
"status": "done",
"risk": "H",
"deliverable": "负载/渲染/资源对比及 mask 可依赖的稳定边界",
"acceptance": "在试点核心中旧插件 API 与关键帧路径通过",
"evidence": []
"evidence": [
"changes/2026-09-13-PKG-DANMUKU-07-stability.md",
"baselines/danmuku-stability-validation.json",
"danmuku-stability.md"
]
},
{
"id": "PKG-DANMUKU-08",
+11
View File
@@ -147,3 +147,14 @@ rows and serial callbacks; the fixed published baseline retains its missing midd
row. CPU-block observations remain diagnostic, not no-loss acceptance. Both it and
`danmuku-lifetime.spec.js` accept the same artifact override. The latter verifies
the full visible lifetime after a controlled delay to an actual Worker request.
`danmuku-stability.spec.js` runs three native 14-second media cycles at 2 or 20
rows per second, appending through the public load(rows) contract. The candidate
must deliver and recycle every row once, preserve state membership and reuse
actual node identities. Its 90-second per-case deadline budgets the 42 seconds
of media plus setup/teardown; it is not a relaxation of other tests. Heap readings
remain observations. No media/RAF clock is replaced or ready state seeded here.
`danmuku-mask-boundary.spec.js` verifies the core-owned root and external CSS mask
across commands and destroy(false), with actual video and placement Worker. It
uses explicit ready rows for resource setup and a fixed SVG mask; it does not
load the Mask SDK/model or provide timestamp-delivery evidence.
+176
View File
@@ -0,0 +1,176 @@
import fs from 'node:fs'
import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { compilePackage } from '../helpers/load.js'
import { expect, test } from './fixtures.js'
let candidate
test.beforeAll(async () => {
candidate = process.env.ARTPLAYER_DANMUKU_ARTIFACT
? fs.readFileSync(process.env.ARTPLAYER_DANMUKU_ARTIFACT, 'utf8')
: await compilePackage('artplayer-plugin-danmuku', 'umd')
})
async function setup(page, core, testInfo) {
const external = []
const origin = new URL(testInfo.project.use.baseURL).origin
await page.route('**/*', (route) => {
if (new URL(route.request().url()).origin !== origin) {
external.push(route.request().url())
return route.abort()
}
return route.continue()
})
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: candidate })
await page.evaluate(() => {
window.maskBoundaryEvidence = { snapshots: [], workers: [], visible: [], errors: [] }
const NativeWorker = window.Worker
window.Worker = class extends NativeWorker {
constructor(url, options) {
super(url, options)
this.observation = { requests: [], replies: [], terminated: 0 }
window.maskBoundaryEvidence.workers.push(this.observation)
this.addEventListener('message', ({ data }) => this.observation.replies.push({ id: data.id, result: data.result ?? null }))
}
postMessage(message, ...args) {
this.observation.requests.push({ type: message.type, id: message.id })
return super.postMessage(message, ...args)
}
terminate() {
this.observation.terminated++
return super.terminate()
}
}
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true })
window.maskBoundaryLayer = window.art.template.$danmuku
const layer = window.maskBoundaryLayer
// Fixed CSS input tests Danmuku's shared-layer contract. No Mask plugin,
// model, TensorFlow backend or segmentation output is loaded or simulated.
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360"><rect width="320" height="360" fill="white"/></svg>'
layer.style.maskImage = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
Object.assign(layer.style, { maskMode: 'alpha', maskSize: 'contain', maskRepeat: 'no-repeat', backgroundSize: 'contain', backgroundRepeat: 'no-repeat' })
window.maskBoundarySnapshot = (label) => {
const properties = ['mask-image', 'mask-mode', 'mask-size', 'mask-repeat', 'background-size', 'background-repeat']
const computed = getComputedStyle(layer)
const snapshot = {
label,
sameLayer: window.art.template.$danmuku === layer,
connected: layer.isConnected,
styles: properties.map(name => ({ name, value: layer.style.getPropertyValue(name), priority: layer.style.getPropertyPriority(name), computed: computed.getPropertyValue(name) })),
children: layer.children.length,
opacity: layer.style.opacity,
time: window.art.currentTime,
}
window.maskBoundaryEvidence.snapshots.push(snapshot)
return snapshot
}
window.maskBoundaryBase = window.maskBoundarySnapshot('before-registration')
window.art.on('artplayerPluginDanmuku:visible', item => window.maskBoundaryEvidence.visible.push(item.id))
window.art.on('artplayerPluginDanmuku:error', error => window.maskBoundaryEvidence.errors.push(String(error)))
window.art.plugins.add(window.artplayerPluginDanmuku({ danmuku: [], heatmap: false, speed: 8, margin: [10, 10], fontSize: 20 }))
window.maskBoundaryPlugin = window.art.plugins.artplayerPluginDanmuku
document.querySelector('#play').onclick = () => window.art.play()
})
await expect.poll(() => page.evaluate(() => window.art.isReady && window.art.template.$video.videoWidth > 0)).toBe(true)
// Real slow playback leaves room for lifecycle checks on the finite fixture.
await page.evaluate(() => window.art.playbackRate = 0.25)
await testInfo.attach('danmuku-mask-boundary-inputs', {
contentType: 'application/json',
body: JSON.stringify({ core, artifact: process.env.ARTPLAYER_DANMUKU_ARTIFACT || 'source UMD', sha256: hash(candidate), media: '/test/pattern.mp4', scope: 'Shared root-layer identity and CSS preservation with fixed inline SVG mask; native video, DOM, RAF and placement Worker. No Mask SDK/model/end-to-end acceptance. Eligible rows are explicit resource-test preconditions, not timestamp-delivery evidence.' }),
})
return external
}
async function assertLayer(page, label) {
const { baseline, snapshot } = await page.evaluate(label => ({ baseline: window.maskBoundaryBase, snapshot: window.maskBoundarySnapshot(label) }), label)
expect(snapshot.sameLayer, label).toBe(true)
expect(snapshot.connected, label).toBe(true)
expect(snapshot.styles, label).toEqual(baseline.styles)
return snapshot
}
async function renderRow(page, id) {
await page.evaluate(async (id) => {
const owner = await window.maskBoundaryPlugin.emit({ id, text: id, mode: 1, time: 0 })
// Establish eligibility, then require actual placement Worker and visible DOM.
owner.setState(owner.queue[owner.queue.length - 1], 'ready')
window.maskBoundaryOwner = owner
}, id)
await page.click('#play')
await expect.poll(() => page.evaluate(id => window.maskBoundaryEvidence.visible.includes(id), id)).toBe(true)
await expect.poll(() => page.evaluate((id) => {
const node = [...window.maskBoundaryLayer.children].find(node => node.dataset.id === id)
if (!node)
return false
const rect = node.getBoundingClientRect()
return node.dataset.state === 'emit' && getComputedStyle(node).visibility === 'visible' && rect.width > 0 && rect.height > 0
}, id)).toBe(true)
}
test.afterEach(async ({ page }, testInfo) => {
if (page.isClosed())
return
const evidence = await page.evaluate(() => {
if (window.maskBoundaryEvidence && window.art && !window.art.isDestroy)
window.art.destroy(false)
return window.maskBoundaryEvidence ?? null
})
await testInfo.attach('danmuku-mask-boundary-evidence', { contentType: 'application/json', body: JSON.stringify(evidence) })
})
for (const core of ['published', 'candidate']) {
test(`${core}: Danmuku retains the core layer and external CSS mask across commands and retained-HTML destroy`, async ({ page }, testInfo) => {
const external = await setup(page, core, testInfo)
const initial = await assertLayer(page, 'after-registration')
const image = initial.styles.find(item => item.name === 'mask-image')
expect(image.value).toContain('data:image/svg+xml,')
expect(image.computed).toContain('data:image/svg+xml,')
await renderRow(page, 'MASK-BOUNDARY-EMIT')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0)
await assertLayer(page, 'after-native-emit')
await testInfo.attach('danmuku-mask-boundary-playing', { contentType: 'image/png', body: await page.screenshot() })
const reset = await page.evaluate(() => {
const owner = window.maskBoundaryPlugin.reset()
return { sameOwner: owner === window.maskBoundaryOwner, states: owner.queue.map(item => item.$state) }
})
expect(reset).toEqual({ sameOwner: true, states: ['wait'] })
await assertLayer(page, 'after-reset')
const replacement = await page.evaluate(async () => {
window.maskBoundaryPlugin.config({ danmuku: [{ id: 'CONFIGURED-REPLACEMENT', text: 'CONFIGURED-REPLACEMENT', time: 100 }] })
const owner = await window.maskBoundaryPlugin.load()
return { sameOwner: owner === window.maskBoundaryOwner, ids: owner.queue.map(item => item.id) }
})
expect(replacement).toEqual({ sameOwner: true, ids: ['CONFIGURED-REPLACEMENT'] })
await assertLayer(page, 'after-no-argument-load')
await renderRow(page, 'MASK-BOUNDARY-AFTER-LOAD')
await assertLayer(page, 'after-replacement-native-emit')
await page.evaluate(() => window.maskBoundaryPlugin.hide())
expect((await assertLayer(page, 'after-hide')).opacity).toBe('0')
await page.evaluate(() => window.maskBoundaryPlugin.show())
expect((await assertLayer(page, 'after-show')).opacity).toBe('1')
const beforeDestroy = await page.evaluate(() => ({ workers: window.maskBoundaryEvidence.workers, errors: window.maskBoundaryEvidence.errors }))
expect(beforeDestroy.errors).toEqual([])
expect(beforeDestroy.workers).toHaveLength(1)
expect(beforeDestroy.workers[0].requests.length).toBeGreaterThanOrEqual(2)
expect(beforeDestroy.workers[0].replies.length).toBeGreaterThanOrEqual(2)
await page.evaluate(() => window.art.destroy(false))
const destroyed = await assertLayer(page, 'after-destroy-false')
expect(destroyed.children).toBe(0)
// Use the native frame clock to detect immediate late plugin writes after cleanup.
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
const settled = await assertLayer(page, 'after-destroy-native-frames')
expect(settled.children).toBe(0)
const terminal = await page.evaluate(() => ({ workers: window.maskBoundaryEvidence.workers, errors: window.maskBoundaryEvidence.errors }))
expect(terminal.errors).toEqual([])
expect(terminal.workers[0].terminated).toBe(1)
expect(terminal.workers[0].requests).toEqual(beforeDestroy.workers[0].requests)
expect(external).toEqual([])
})
}
+194
View File
@@ -0,0 +1,194 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import process from 'node:process'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { compilePackage } from '../helpers/load.js'
import { expect, test } from './fixtures.js'
const implementations = new Map()
test.beforeAll(async () => {
const release = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/danmuku-release.json', import.meta.url), 'utf8')).release
const member = `package/${release.manifest.main.replace(/^\.\//u, '')}`
const published = readMember(await ensureArchive(release), member)
assert.equal(hash(published), release.files[member])
implementations.set('published', published.toString())
implementations.set('candidate', process.env.ARTPLAYER_DANMUKU_ARTIFACT
? fs.readFileSync(process.env.ARTPLAYER_DANMUKU_ARTIFACT, 'utf8')
: await compilePackage('artplayer-plugin-danmuku', 'umd'))
})
for (const implementation of ['published', 'candidate']) {
for (const rate of [2, 20]) {
test(`${implementation}: ${rate} rows per second across three native playback cycles`, async ({ page }, testInfo) => {
// Three 14-second media cycles plus setup/teardown; this is a bounded soak,
// not the ordinary short browser test deadline or a synthetic clock jump.
test.setTimeout(90000)
await page.goto('/test/player.html?core=candidate')
const code = implementations.get(implementation)
await page.addScriptTag({ content: code })
await page.evaluate(() => window.createPlayer('/assets/sample/video.mp4'))
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.click('#pause')
await page.evaluate(() => {
const art = window.art
const evidence = window.stabilityEvidence = { workers: [], frames: [], samples: [], visible: [], recycled: [], callbacks: [], cycles: [], errors: [], allocations: 0, reuses: 0, bypassedPool: 0 }
const NativeWorker = window.Worker
window.Worker = class extends NativeWorker {
constructor(...args) {
super(...args)
this.record = { posts: 0, replies: 0, terminated: 0, errors: [] }
evidence.workers.push(this.record)
this.addEventListener('message', () => this.record.replies++)
this.addEventListener('error', event => this.record.errors.push(event.message))
}
postMessage(...args) {
this.record.posts++
return super.postMessage(...args)
}
terminate() {
this.record.terminated++
return super.terminate()
}
}
art.on('artplayerPluginDanmuku:error', error => evidence.errors.push(String(error)))
art.on('artplayerPluginDanmuku:visible', row => evidence.visible.push({ id: row.id, time: art.currentTime, wall: performance.now() }))
art.plugins.add(window.artplayerPluginDanmuku({
danmuku: [],
heatmap: false,
emitter: false,
speed: 1,
antiOverlap: false,
beforeVisible(row) {
evidence.callbacks.push({ id: row.id, time: art.currentTime })
return true
},
}))
const owner = window.stabilityOwner = art.plugins.artplayerPluginDanmuku.config({})
const acquire = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(owner), '$ref').get
const acquired = new WeakSet()
Object.defineProperty(owner, '$ref', { configurable: true, get() {
const available = new Set(this.$refs)
const ref = acquire.call(this)
if (available.size && !available.has(ref))
evidence.bypassedPool++
if (acquired.has(ref))
evidence.reuses++
else evidence.allocations++
acquired.add(ref)
return ref
} })
const getter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(owner), 'readys').get
Object.defineProperty(owner, 'readys', { configurable: true, get() {
const rows = getter.call(this)
evidence.samples.push({ time: art.currentTime, wall: performance.now(), ids: rows.map(row => row.id) })
return rows
} })
const makeWait = owner.makeWait.bind(owner)
owner.makeWait = (row) => {
if (row.$state === 'emit')
evidence.recycled.push({ id: row.id, time: art.currentTime })
return makeWait(row)
}
const frame = (wall) => {
if (art.playing)
evidence.frames.push({ wall, time: art.currentTime })
window.stabilityFrame = requestAnimationFrame(frame)
}
window.stabilityFrame = requestAnimationFrame(frame)
})
try {
for (let cycle = 0; cycle < 3; cycle++) {
await page.evaluate(async ({ cycle, rate }) => {
const art = window.art
const base = art.currentTime
const rows = Array.from({ length: 12 * rate }, (_, index) => ({ id: `${cycle}-${index}`, text: `Row ${index}`, time: base + 0.5 + index / rate, mode: index % 3 }))
await art.plugins.artplayerPluginDanmuku.load(rows)
window.stabilityEnd = base + 14
window.stabilityEvidence.cycles.push({ cycle, base, rows: rows.map(row => ({ id: row.id, time: row.time })), startWall: performance.now() })
}, { cycle, rate })
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.currentTime >= window.stabilityEnd), { timeout: 22000 }).toBe(true)
await page.click('#pause')
const snapshot = await page.evaluate(() => {
const owner = window.stabilityOwner
const record = window.stabilityEvidence.cycles.at(-1)
const ids = new Set(record.rows.map(row => row.id))
const visible = window.stabilityEvidence.visible.filter(row => ids.has(row.id))
const sampled = new Set(window.stabilityEvidence.samples.flatMap(sample => sample.ids).filter(id => ids.has(id)))
const shown = new Set(visible.map(row => row.id))
const refs = owner.queue.map(row => row.$ref).filter(Boolean)
const queue = new Set(owner.queue)
const members = Object.values(owner.states).flat()
const memory = performance.memory
Object.assign(record, {
endWall: performance.now(),
time: window.art.currentTime,
decodedWidth: window.art.video.videoWidth,
visibleCount: visible.length,
uniqueVisible: shown.size,
missedSampling: record.rows.filter(row => !sampled.has(row.id)).map(row => row.id),
sampledNotVisible: record.rows.filter(row => sampled.has(row.id) && !shown.has(row.id)).map(row => row.id),
queue: owner.queue.length,
states: Object.fromEntries(Object.entries(owner.states).map(([state, rows]) => [state, rows.length])),
nodes: owner.$danmuku.children.length,
pooled: owner.$refs.length,
uniquePool: new Set(owner.$refs).size,
referenced: refs.length,
distinctReferences: new Set(refs).size,
uniqueStateMembers: new Set(members).size,
validStateMembers: Object.entries(owner.states).every(([state, rows]) => rows.every(row => queue.has(row) && row.$state === state)),
heap: memory ? { used: memory.usedJSHeapSize, total: memory.totalJSHeapSize } : null,
})
return record
})
expect(snapshot.decodedWidth).toBeGreaterThan(0)
expect(snapshot.time - snapshot.base).toBeGreaterThanOrEqual(14)
// load(rows) appends by public contract; old rows stay in the queue.
expect(snapshot.queue).toBe((cycle + 1) * 12 * rate)
expect(Object.values(snapshot.states).reduce((sum, count) => sum + count, 0)).toBe(snapshot.queue)
if (implementation === 'candidate') {
expect(snapshot.missedSampling).toEqual([])
expect(snapshot.sampledNotVisible).toEqual([])
expect(snapshot.visibleCount).toBe(12 * rate)
expect(snapshot.uniqueVisible).toBe(12 * rate)
expect(snapshot.referenced).toBe(0)
expect(snapshot.uniquePool).toBe(snapshot.pooled)
expect(snapshot.nodes).toBe(snapshot.pooled)
expect(snapshot.uniqueStateMembers).toBe(snapshot.queue)
expect(snapshot.validStateMembers).toBe(true)
}
}
}
finally {
const evidence = await page.evaluate(() => {
cancelAnimationFrame(window.stabilityFrame)
window.art.destroy()
return { ...window.stabilityEvidence, destroyed: window.art.isDestroy, retainedPlayerRoots: document.querySelectorAll('.art-video-player').length }
})
await testInfo.attach('danmuku-stability-observation', { contentType: 'application/json', body: JSON.stringify({ implementation, rate, sha256: hash(code), evidence, scope: 'Three bounded real media cycles; published defects are observed, candidate delivery and resource ownership are asserted. Optional unforced heap readings are not leak proof or comparable across engines.' }) })
expect(evidence.destroyed).toBe(true)
expect(evidence.retainedPlayerRoots).toBe(0)
expect(evidence.errors).toEqual([])
expect(evidence.workers).toHaveLength(1)
if (implementation === 'candidate' && evidence.cycles.length === 3) {
const expected = evidence.cycles.flatMap(cycle => cycle.rows.map(row => row.id)).sort()
expect(evidence.visible.map(row => row.id).sort()).toEqual(expected)
expect(evidence.recycled.map(row => row.id).sort()).toEqual(expected)
expect(evidence.bypassedPool).toBe(0)
expect(evidence.reuses).toBeGreaterThan(0)
expect(evidence.allocations + evidence.reuses).toBe(expected.length)
expect(evidence.cycles.at(-1).nodes).toBe(evidence.allocations)
}
for (const worker of evidence.workers) {
expect(worker.terminated).toBe(1)
expect(worker.errors).toEqual([])
if (implementation === 'candidate')
expect(worker.posts).toBe(worker.replies)
}
}
})
}
}