test(danmuku): [PKG-DANMUKU-01] freeze public contracts and release provenance

This commit is contained in:
Harvey Zhao committed 2026-09-13 17:38:25 +08:00
1 parent 7d44bce06f
commit d81b881085
11 files changed
+4229 -6

No files matched your search

@@ -0,0 +1,112 @@
{
"schemaVersion": 1,
"task": "PKG-DANMUKU-01",
"capturedAt": "2026-09-13T09:35:02.032Z",
"sourceCommit": "b0cfbfe3a09a84a295a257e45e4577847588d1cb",
"environment": {
"node": "v24.21.0",
"platform": "win32",
"architecture": "x64",
"executable": "refactor/.cache/toolchains/node-v24.21.0-win-x64/node.exe",
"yarnPolicy": "1.22.22; no install or lockfile changes"
},
"inputs": {
"refactor/baselines/danmuku-release.json": {
"sha256LF": "82a0ab2e87f108cbb1d09ce1b6647cfefc896d974da1cb60b2668fded18a4b2f"
},
"refactor/scripts/danmuku-contract.mjs": {
"sha256LF": "8ca0996605cfbcd732030708bd67ecedff5018d2b7436fa7247de7bbb39d7573"
},
"refactor/scripts/danmuku-contract.test.mjs": {
"sha256LF": "37836083e849cd8b7d5d47a4e9d952949d3b28bd885225e3197fb6715febf44e"
},
"refactor/baselines/danmuku-contract.md": {
"sha256LF": "ff51611322cfbb5ad744a37eebc555b74e4d9853b59c91bf13547889fa976bb0"
},
"refactor/changes/2026-09-13-PKG-DANMUKU-01-contracts.md": {
"sha256LF": "fb7355058ee1b13688c2e8c713118f6b802aa086209cb8a6e236694f2764abd9"
}
},
"registry": {
"url": "https://registry.npmjs.org/artplayer-plugin-danmuku",
"status": 200,
"catalogStable": 81,
"archivedReleases": 15,
"unverifiedCatalogArchives": 66,
"bodySha256": "6fccd0198b2322827b3271a807b60f73a3e4d6f46aeb5494a86aade409caf1d6"
},
"evidence": [
{
"command": "node refactor/scripts/danmuku-contract.mjs",
"log": "refactor/.cache/danmuku01/verify.log",
"exitCode": 0,
"logSha256": "655d90ee59df53dd2136c79f14886bd5b281ea142bc0032e4b7e52b13ee7f5bd"
},
{
"command": "node --test refactor/scripts/danmuku-contract.test.mjs",
"log": "refactor/.cache/danmuku01/contract-test.log",
"exitCode": 0,
"passed": 44,
"failed": 0,
"skipped": 0,
"logSha256": "492c7313c98f197a8241e370854860a0980ada771d1c86b26e5e2303920fb6a0"
},
{
"command": "node node_modules/eslint/bin/eslint.js refactor/scripts/danmuku-contract.mjs refactor/scripts/danmuku-contract.test.mjs",
"log": "refactor/.cache/danmuku01/lint.log",
"exitCode": 0,
"logSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
],
"coverage": {
"immutableSourceFiles": 28,
"commonJsModulesExecuted": 28,
"manifestEsmModulesExecuted": 3,
"esmMethod": "Dynamic data URL imports of the actual archive bytes, not package-resolution tests",
"runtimeFixtures": [
"frozen-Git-source with controlled Worker/CSS loader",
"real npm 5.3.0 main",
"real npm 5.3.0 legacy"
],
"runtimeCasesPerFixture": 4,
"parser": "Frozen actual Bilibili parser exercised through URL adapter with controlled fetch and absent-Worker fallback",
"validator": {
"name": "artplayer",
"version": "5.4.0",
"tarballSha256": "42b269a66b9658d53f9a92572bb36d656ea9af56254a387b3a69c7ddea43979e",
"source": "Actual frozen published core bundle static validator, not a replacement validator"
}
},
"reproduced": [
"Actual CJS namespace.default/direct function changes and 12 enumerable writable static icons from 5.1.0.",
"Latest and frozen source registration synchronous; enumerable facade/getter keys and defaults stable.",
"emit returns Promise; chain methods and load resolve the internal Danmuku rather than the facade described by Result.",
"Initial Promise danmuku is rejected by published core validator; load(Promise) works in the same realm.",
"time:0 becomes currentTime+0.5 and input fields are mutated; public emit bypasses beforeEmit.",
"load() replacement, load(target) append, filter this and loaded queue reference identity.",
"Function-only filter config updates are ignored by JSON comparison; load rejection emits and rejects the same error.",
"Empty-array synchronous show/config/reset/loaded ordering and video play/pause state hooks.",
"Settings beforeEmit callback receives trimmed input and option this; border/time reset and lock timer observed.",
"destroy leaves registered resize handlers and a started settings lock timer.",
"Bilibili fallback parser mode/entity/metadata behavior and unpadded hexadecimal color."
],
"observedNotReproduced": [
"id/icons declaration omissions; Slider value/hide and heatmap points declaration drift",
"Bilibili async Promise executor/fetch rejection and unrevoked URL",
"worker onmessage replacement and Date.now ids; late callback/duplicate-start cases",
"heatmap zero sampling, option.points not consumed, repeated SVG ids",
"12 SVG source authorization not separately established"
],
"limitations": [
"No browser playback/layout/Worker/CSP/device validation",
"No actual Bilibili request, media fetch, load/performance/long-running memory validation",
"No package-installed NodeNext/Node10/TypeScript or AMD/global matrix",
"Older selected archives have module-shape coverage, not full old-major runtime compatibility",
"Historical defect assertions are observations and must not become candidate normal expectations",
"Registry signatures and publisher authorization not independently verified"
],
"sourceChanged": false,
"browserValidated": false,
"installedConsumerValidated": false,
"releaseReady": false
}
+196
View File
@@ -0,0 +1,196 @@
# Danmuku 公共契约基线
PKG-DANMUKU-01;冻结提交 `b0cfbfe3a09a84a295a257e45e4577847588d1cb`。
实际输入及逐文件 SHA 见 [danmuku-release.json](danmuku-release.json),执行结果见
[danmuku-contract-validation.json](danmuku-contract-validation.json)。本次不改生产代码。
## 发布依据与覆盖范围
2026-09-13 直接读取 [npm registry](https://registry.npmjs.org/artplayer-plugin-danmuku),
HTTP 200,目录有 81 个稳定版,latest 为 5.3.0。冻结全部版本的发布时间、tarball、
integrity、gitHead;实际下载并校验全部 13 个 5.x 版,另加前两代末版 4.4.11、3.5.31,
共 15 个归档。其余 66 个版本只有目录证据,不能说其运行时已验证或全部兼容。
这是代表性历史覆盖,不是新最低支持版本,也不把旧 major 的产品行为自动带入候选。
当前包全部跟踪文件、12 个 SVG、三个 dist、声明、README、可运行 demo 和 VitePress
弹幕文档共 28 个输入冻结。最新归档中的 README、manifest、声明及三个 dist 与冻结
Git 文件逐字节相同;源码行为另通过冻结 Git 模块受控执行对照,不把可读源码等同于
历史打包过程已可重复构建。
| 归档版本 | registry gitHead 的插件版本 | 同提交核心版本 | 说明 |
| --- | --- | --- | --- |
| 3.5.31 | 3.5.31 | 3.5.31 | 历史 major 对照 |
| 4.4.11 | 4.4.10 | 4.5.8 | manifest 版本不一致 |
| 5.0.0 / 5.0.1 | 5.0.0 / 5.0.1 | 5.0.0 / 5.0.5 | 分别冻结 |
| 5.1.0 / 5.1.1 | 5.1.0 / 5.1.0 | 5.1.2 / 5.1.5 | 5.1.1 版本不一致 |
| 5.1.2 / 5.1.3 / 5.1.4 | 同归档版本 | 5.1.6 | 分别冻结 |
| 5.1.5 / 5.1.6 | 5.1.4 / 5.1.6 | 5.2.2 | 5.1.5 版本不一致 |
| 5.1.7 | 缺 gitHead | 未知 | 不推测来源提交 |
| 5.1.8 | 5.1.7 | 5.2.4 | manifest 版本不一致 |
| 5.2.0 | 5.1.9 | 5.3.0-beta.3 | 核心是提交关联预发布号 |
| 5.3.0 | 5.2.1 | 5.3.1 | 不宣称核心 5.3.1 是 npm 稳定版 |
真实 manifest 是归档身份依据;提交关联不是最低核心支持声明。归档与关联 Git 路径
各成员的匹配/不匹配均在 JSON 中保存。registry 缺失的提交信息保持缺失。
未独立验证 npm 签名及发布者授权。5.1.7 归档的目录头没有尾随斜线;专属 verifier
用 tar 条目类型区分目录与普通文件,拒绝重复、越界和非普通文件,不改共享解包函数。
## 分发与静态属性
| 版本 | 实际 CommonJS | manifest 分发入口 | 静态 icons |
| --- | --- | --- | --- |
| 3.5.31 | 直接函数 | main;未声明 types/module/legacy | 无 |
| 4.4.11 | 对象.default | main/types | 无 |
| 5.0.0–5.0.1 | 对象.default | main/types/legacy | 无 |
| 5.1.0–5.1.6、5.1.8 | 对象.default | main/types/legacy;无 module/exports | 12 项 |
| 5.1.7 | 对象.default | module 是 `.esm.js`;平铺 import/require/default exports | 12 项 |
| 5.2.0 | 对象.default | `.mjs`、root/legacy 条件 exports;types 条件在后 | 12 项 |
| 5.3.0 | 直接函数 | `.mjs`、root/legacy 条件 exports;types 条件在前 | 12 项 |
main/legacy 共 28 个实际模块已在 Node VM 执行;三个 manifest ESM 的实际字节通过
data URL 动态 import 执行。此项只证明模块代码能求值,不等于在消费者目录通过
Node 包解析;真实安装、NodeNext/Node10、AMD/global/script、编辑器仍由 06/09 验证。
`artplayerPluginDanmuku.icons` 是可写、可配置、可枚举的对象值,顺序为:
`$on/$off/$config/$style/$mode_0_off/$mode_0_on/$mode_1_off/$mode_1_on/`
`$mode_2_off/$mode_2_on/$check_on/$check_off`。静态 icons 不是 getter;设置模板使用
模块内 SVG 字符串,没有读取工厂 icons 的后续改写。这一读码事实尚未验证热替换用法。
## 最新版工厂与方法
`factory(option)(art)` 同步返回门面;字段按 `name/emit/load/config/hide/show/reset/`
`mount/option/isHide/isStop` 顺序枚举,name 为 `artplayerPluginDanmuku`。
后三项是可枚举、可配置、没有 setter 的 getter,读取内部当前值。方法已 bind,可抽取调用。
工厂 option 没有默认对象;`{ danmuku: [] }` 是基准合法调用。初始化不等待异步载入完成。
| API | 最新真实行为 | 声明/兼容注意 |
| --- | --- | --- |
| `emit(danmu)` | async;验证并修改传入对象,filter 通过后同步入队;Promise 最终返回内部 Danmuku | 旧声明错误写同步 Result;不自动改旧根类型 |
| `load()` | 读当前 option.danmuku,reset 后清空队列再逐条 emit | Promise 返回内部对象;不是门面 |
| `load(target)` | 追加到现有队列,不自动改 option.danmuku | 包括数组、函数、同 realm Promise、XML URL |
| `config(partial)` | 合并默认值、当前值与 partial,验证、钳位;变化比较用 JSON.stringify,单独更换函数会被忽略 | 旧类型要求完整 Option,实际 partial 有效 |
| `hide/show` | 更新 isHide、层 opacity、option.visible,并发事件 | 返回内部对象;重复调用也发事件 |
| `reset()` | 全队列回到 wait,回收已分配 DOM,再发 reset | 返回内部对象,不清空队列 |
| `mount(target)` | selector 或元素;移动设置输入框并 reset 设置 UI | 返回 undefined;声明允许省略,但运行时无目标会抛错 |
| `option` | 当前合并后的对象;config 可能更换其引用 | 声明可写,实际仅 getter |
| `isHide/isStop` | 初始 false;show/hide 与 video start/stop 更新 | 不应解释为 video.paused 或 layer DOM 是否存在 |
内部 Danmuku 没有公开门面的 `name/mount`,但历史链式返回会将其暴露给调用方。
后续整理必须先评估返回身份,不能为了匹配旧错误声明静默返回门面。
`load(undefined)` 清空;其他显式 falsy 值通过 `target = argument || option.danmuku`
回退但不走清空分支;这只是读码事实,不把未声明参数纳入必须支持的接口。
## 全部配置与弹幕项
| 配置 | 默认值/范围 |
| --- | --- |
| danmuku | `[]`;声明含数组、URL、返回 Promise 的函数、Promise;初始 Promise 被实际 validator 拒绝,load(Promise) 可用 |
| speed / margin / opacity | `5`,钳位 1–10;`[10,'25%']`;`1`,钳位 0–1 |
| color / mode / modes | `#FFFFFF`;`0`,钳位 0–2;`[0,1,2]`,显示模式由 dataset/CSS 控制 |
| fontSize | `25`;数值或百分比,使用时钳位 12 到播放器高度 |
| antiOverlap / synchronousPlayback | `true` / `false` |
| mount / heatmap / width / points | controlsCenter / false / 512 / [] |
| filter / beforeEmit / beforeVisible | 默认均返回 true;filter 同步,另外两个支持异步 |
| visible / emitter | true / true |
| maxLength / lockTime / theme | 200(1–1000)/ 5(1–60)/ dark |
| OPACITY / FONT_SIZE / MARGIN / SPEED / COLOR | `{}` / `{}` / `{}` / `{}` / `[]`,设置组件再补默认滑块与颜色 |
Danmu 声明成员为 `text/mode/color/time/border/style`;runtime 还验证可选 string `id`,
并传播额外字段。mode 只入队 0/1/2,空白 text 与 filter 拒绝项忽略。time 不存在或为 0
时写为 currentTime + 0.5;负数钳到 0。入队时额外生成 `$state/$index/$ref/$restTime/`
`$lastStartTime`,loaded/visible 事件会暴露这些字段和对象引用。
直接 emit/load 调用 filter,以当前 option 为 this,不经过 beforeEmit。设置 UI 的
发送按钮/Enter 先 trim 输入,防锁定/重复提交,await beforeEmit;只有严格 true 继续,
加 border、删除 time、再调用 emit,清输入并锁定。beforeVisible 在准备显示前调用,
也以 option 为 this。三者不能合并成一个全局异步过滤器。
最新声明有六个命名类型 `Mode/Danmuku/Slider/Danmu/Option/Result`。
4.4.11/5.0.x 旧声明中 Danmuku 表示结果,而 5.1.x 起表示输入,旧配置还出现
useWorker/minWidth/maxWidth;不能将较早声明与最新声明拼成未经评估的根类型。
06 按已批准的 [统一类型规则](../type-compatibility-policy.md) 处理:最新 npm 根声明的
合法完整工厂赋值、Parameters/ReturnType、NodeNext 历史模块形态都需安装消费者证据;
准确异步类型可用独立 runtime 入口,较早冲突写迁移说明。本次没有改类型或声称编译矩阵通过。
## 事件、DOM、设置与热力图
| 事件 | 参数/时机 |
| --- | --- |
| artplayerPluginDanmuku:config | 当前 option 引用;show/hide 在它之前发生 |
| artplayerPluginDanmuku:loaded | 完成当前 load 后的实际 queue 引用;空数组初始化可在工厂返回前同步发生 |
| artplayerPluginDanmuku:error | load 捕获的原错误,然后再次抛出;直接 emit 不自动发这个事件 |
| artplayerPluginDanmuku:visible | worker 返回位置且允许显示后,实际内部 danmu |
| artplayerPluginDanmuku:start / stop | 无参数,video:play/playing / pause/waiting 对应触发 |
| artplayerPluginDanmuku:show / hide / reset | 无参数,对应方法更新状态之后 |
| artplayerPluginDanmuku:destroy | stop、worker terminate 和解除部分监听之后 |
| artplayerPluginDanmuku:points | 消费方发入的热力图数据事件,插件不主动 emit |
这些是十个输出事件和一个输入事件;不新增虚构的 `:emit` 事件。
[] 初始化的已复现顺序为 show → config → reset → loaded;非空/异步输入顺序和错误
重入仍需 02 扩展。Setting 还订阅 resize/fullscreen/fullscreenWeb/document:pointermove/
document:pointerup/show/hide。热力图订阅 ready/resize/loaded/timeupdate/setBar/points。
设置入口是 `new Setting` 创建的独立 `.artplayer-plugin-danmuku` 输入框和弹出面板,
不是核心 setting registry 的单个项目。内部使用 `.apd-*` 类、mode/color/state/id 数据,
外部挂载可在 fullscreen/fullscreenWeb 时移回控制栏;默认挂载在宽度低于 width 时移到
播放器底部。默认不透明度 0–100、字号 12–120;MARGIN 四档 `[10,'75%']/[10,'50%']/`
`[10,'25%']/[10,10]`;SPEED 五档 10/7.5/5/2.5/1。滑块读取 step.hide,而声明写 show;
MARGIN step.value 是数组,声明只有 number|string。
设置 style 在模块求值时插入 id=`artplayer-plugin-danmuku` 的全局 style;完整类名、
选择器、SVG 和样式源码已整体哈希冻结。视觉、CSS 覆盖、重复 style 与外部节点回收
未在本次 mock 中模拟,交 02/05/08 的真实页面验证。
heatmap=true/object 创建 name=`heatmap`、position=`top` 的控件。源码按 `[x,y]` 二元组
使用 points 事件数据;`option.points` 当前只存储,未被 heatmap update 消费,声明却为
`{time,value}[]`。初始化开启 heatmap 后 config 开关不会自动新增/移除控件。上面两项为
读码事实,尚未浏览器复现。窄尺寸下默认 sampling=floor(width/100) 可为 0;需 02 用
独立超时保护测试确认循环风险,不在 UI 主线程盲跑。
## Bilibili XML 与 worker
string 输入调用 fetch(url) → response.text(),没有显式 HTTP 状态判断。XML 用正则读
`<d p="...">...</d>`,要求至少八个逗号分隔字段;模式 1/2/3→0、4→2、5→1,其他→0;
trim 后按 quot/apos/lt/gt/amp 顺序解实体。输出 text/time/mode/fontSize/color/timestamp/
pool/userID/rowID;color 用十六进制字符串,不补齐六位,数字异常也不额外过滤。
优先生成 Blob worker,message 含 `{xml,id}`,结果 `{danmus,id}`,成功后 terminate;
Worker 构建同步失败时 console.error 并退回相同 parser。正则和 fallback 已执行;真实
网络、Worker 消息和 CSP/Blob 能力未验证。源码用 async Promise executor,fetch/text
拒绝发生在 try 之外,且未 revokeObjectURL;空 XML/worker error/销毁竞态是待 02 复现项。
调度 worker 接口 `{type:'getDanmuTop',id,target,visibles,antiOverlap,clientWidth,clientHeight,marginBottom,marginTop}`
→ `{result,id}`,当前 Date.now 作为 id 并覆盖 onmessage;并发/旧回复仍待测试。
## 已复现差异与后续责任
| 编号 | 证据级别 | 内容 | 后续任务 |
| --- | --- | --- | --- |
| DANMUKU-TYPE-01 | 运行时复现 + 逐字声明 | emit Promise、内部返回身份、getter、mount 省略、初始 Promise 和声明不一致 | 03/06 |
| DANMUKU-INPUT-01 | 冻结源码和真实 5.3 main/legacy 复现 | time:0 被改为 current+0.5;输入对象被修改 | 02/03,分别评估修正和保留 |
| DANMUKU-CONFIG-01 | 同上 | 单独 config({filter:新函数}) 被 JSON.stringify 比较判为未变化 | 02/03 |
| DANMUKU-CLEANUP-01 | 同上 | resize 注册 this.resize,销毁 off(this.reset);设置锁定 timer 未清 | 02/05 |
| DANMUKU-TYPE-02 | 读码/声明观察 | id/icons 缺失、Slider value/hide、points 类型与使用漂移、config partial | 05/06 |
| DANMUKU-ASYNC-01 | 待复现 | Bilibili fetch/text/worker 错误、load 乱序、beforeVisible/worker 晚回调、重复 start | 02/03/04/05 |
| DANMUKU-HEATMAP-01 | 待复现 | 小于 100px 时 sampling=0、事件 tuple/option.points、重复实例 SVG id | 02/05 |
| DANMUKU-SOURCE-01 | 归档与 Git 核验 | gitHead 版本错位、5.1.7 无 gitHead、12 SVG 无单独来源授权证据 | 09 |
历史缺陷断言仅冻结观察,不要求候选保留。修复必须有独立候选正常断言与明确迁移影响。
包 README 只有简介/demo/license,主要公开说明来自冻结 VitePress 文档和 demo。
demo 使用本地 video.mp4/danmuku.xml,适合作为后续 8082/Monaco/Run 的本地输入。
本次不证明实际播放、密集弹幕碰撞/时钟、mask/全屏/PiP、真实 WebKit/设备或 npm 发布可行。
## 重放
在固定 Node 24.21.0 / Yarn 1.22.22 与已安装根依赖下运行:
```text
node refactor/scripts/danmuku-contract.mjs
node --test refactor/scripts/danmuku-contract.test.mjs
node node_modules/eslint/bin/eslint.js refactor/scripts/danmuku-contract.mjs refactor/scripts/danmuku-contract.test.mjs
```
缺缓存时 verifier 仅从冻结 npm URL 下载并验证 SHA512/SHA256;不执行 registry 包脚本。
完整目录可再次读取 `https://registry.npmjs.org/artplayer-plugin-danmuku`,但返回的新目录
不应覆盖本次不可变基线。原始 registry body 的 SHA 保存在 JSON,原始响应在专属 cache。
测试使用真实发布核心 5.4.0 的 validator、受控 DOM/计时器/Worker,仅源码编译在内存中
完成,不生成或修改 dist/docs。仅访问 npm 元数据及归档,未请求媒体或 Bilibili 资源。
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,52 @@
# PKG-DANMUKU-01 弹幕公共契约与发布来源
冻结起点:`b0cfbfe3a09a84a295a257e45e4577847588d1cb`。
本任务只增加不可变基线、校验器和契约测试。未改生产源码、公开声明、构建产物、
共享脚本或发布版本;整合代理维护任务状态并完成单独本地提交,不推送或发布。
## 交付与兼容范围
- [发布来源](../baselines/danmuku-release.json):81 个真实稳定版目录,15 个实际 tarball,
SHA512/SHA256、全部成员、manifest 入口、可用 Git 关联及 28 个冻结工作区输入。
- [契约](../baselines/danmuku-contract.md):emit/load/config/hide/show/reset/mount、三个
getter、icons、27 配置、事件、设置 UI、热力图、Bilibili 与 Worker 协议。
- [校验器](../scripts/danmuku-contract.mjs):返回 `{baseline,archives,sources}` 供后续
行为测试重用;下载只用冻结 npm URL,Git 内容只读取冻结完整 SHA。
- [44 项测试](../scripts/danmuku-contract.test.mjs) 与
[实际验证记录](../baselines/danmuku-contract-validation.json):运行真实历史模块字节;
最新 main/legacy 和冻结源码比对方法、返回身份、事件、输入和 settings 发送路径。
API-02/03/04/06/08/09/11/12 都有明确契约记录;其中 UI/Worker 的视觉与浏览器行为
仍是待验收内容。3.5.31/4.4.11 是前代对照,不是自动承诺任意历史 major 的所有行为。
## 发现与处置
真实模块形态在 3.5.31、4.4.11–5.2.0、5.3.0 之间变化;后续保留已有合法 JS 入口。
多数关联 Git manifest 与发布号不同,5.1.7 无 gitHead,不据此推测最低核心版本。
5.1.7 的合法 tar 目录头使共享 archiveFiles 的旧假设不适用,专属读取器依据条目类型
识别目录,同时保留路径/重复/非普通成员检查,没有扩大共享脚本影响。
已复现 emit 异步却声明同步、链式返回内部对象、初始 Promise validator 拒绝、
mount 省略时报错、time:0 被替换、resize 解绑错误及设置 timer 未清。源码读码还观察到
id/icons、Slider 和 heatmap points 类型漂移。根类型后续遵循已经批准的统一类型政策,
不因修正异步描述破坏最新 npm 合法双向工厂赋值。缺陷正常/候选断言必须分离。
完整风险分类、责任任务与重放命令见契约,未验证问题没有伪装成已修复或已通过。
测试使用已存在 esbuild/Node runner,不安装依赖、不改 Yarn 锁文件。
## 实际验证与限制
44 tests passed / 0 failed / 0 skipped;专属 lint 和归档 verifier 通过。
Node 24.21.0,Windows;日志/命令/输入 SHA 见 validation JSON。
没有运行真实浏览器、网络 Bilibili 请求、设备、负载/内存或安装消费者矩阵。
编译测试只把冻结源码及受控依赖在内存打包,不能当作正式生产构建或重构完成。
## 下一步与回退
PKG-DANMUKU-02 从真实归档/冻结源码扩展浏览器、时钟、轨道、乱序/拒绝和销毁基线,
优先为已观察的异步输入、重复 start、Worker 相关问题建立受控复现。03/04/05 分别处理
输入、调度、渲染/设置/Worker,06 处理 TS 与旧根类型,07/08/09 做能力/组合/分发验收。
本批无消费者行为变更;如需回退,回退专属契约任务提交即可,保留旧 tarball 与日志
用于后续对照。缓存可重新下载,冻结 SHA 与元数据不能随当前源码变动而更新。
+4 -3
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 228 项,范围 22 个包及工作区/示例。
状态:todo 74 / doing 16 / blocked 0 / done 138 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 73 / doing 16 / blocked 0 / done 139 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -309,8 +309,8 @@
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| PKG-DANMUKU-01 | artplayer-plugin-danmuku<br>核对弹幕全部公开契约 | BASE-05 | emit/load/config/hide/show/reset/mount、option/isHide/isStop/icons 与事件清单 | 源码、声明、发布包、Bilibili 输入和设置入口对照 | H | doing |
| PKG-DANMUKU-02 | artplayer-plugin-danmuku<br>建立弹幕算法与浏览器基线 | PKG-DANMUKU-01, ENG-05, ENG-08 | 密集/稀疏弹幕、过滤/异步输入、seek/倍率/重载测试 | 轨道选择、发射顺序、事件、内存和可视结果可重跑 | H | todo |
| PKG-DANMUKU-01 | artplayer-plugin-danmuku<br>核对弹幕全部公开契约 | BASE-05 | emit/load/config/hide/show/reset/mount、option/isHide/isStop/icons 与事件清单 | 源码、声明、发布包、Bilibili 输入和设置入口对照 | H | done |
| PKG-DANMUKU-02 | artplayer-plugin-danmuku<br>建立弹幕算法与浏览器基线 | PKG-DANMUKU-01, ENG-05, ENG-08 | 密集/稀疏弹幕、过滤/异步输入、seek/倍率/重载测试 | 轨道选择、发射顺序、事件、内存和可视结果可重跑 | H | doing |
| PKG-DANMUKU-03 | artplayer-plugin-danmuku<br>整理加载、解析与配置 | PKG-DANMUKU-02, CORE-08 | bilibili/input/parser/config 的分层与取消 | 旧格式、回调、过滤、追加/替换语义保持 | M | todo |
| PKG-DANMUKU-04 | artplayer-plugin-danmuku<br>整理时钟、队列与轨道调度 | PKG-DANMUKU-03, CORE-10 | danmuku 调度器和确定性时钟测试 | seek/暂停/倍率/长时间运行无顺序和碰撞回归 | H | todo |
| PKG-DANMUKU-05 | artplayer-plugin-danmuku<br>整理 DOM 渲染、设置、热力图与 worker | PKG-DANMUKU-04, CORE-14, CORE-18 | renderer/setting/heatmap/worker 职责及资源归属 | mount/icons/设置和 worker 协议保持,销毁无后台工作 | H | todo |
@@ -552,6 +552,7 @@
- PKG-DPIP-03: [记录](changes/2026-09-12-PKG-DPIP-03-lifecycle.md) [记录](baselines/dpip-lifecycle-validation.json) [记录](dpip-validation.md)
- PKG-DPIP-04: [记录](changes/2026-09-12-PKG-DPIP-04-types.md) [记录](baselines/dpip-types-validation.json) [记录](dpip-validation.md)
- PKG-DPIP-05: [记录](changes/2026-09-12-PKG-DPIP-05-native-checkpoint.md) [记录](baselines/dpip-native-validation.json)
- PKG-DANMUKU-01: [记录](baselines/danmuku-release.json) [记录](baselines/danmuku-contract.md) [记录](baselines/danmuku-contract-validation.json) [记录](changes/2026-09-13-PKG-DANMUKU-01-contracts.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)
+10
View File
@@ -1,5 +1,15 @@
# 进度与证据
## PKG-DANMUKU-01 弹幕契约与归档完成
冻结81稳定版目录、15真实归档和28源码/文档/图标输入。44项测试及父代理复核通过;
实际模块、完整方法与配置、设置、Bilibili/worker接口、返回身份和旧缺陷已登记。
其他66版只有目录证据,没有扩张支持承诺。
见[契约](baselines/danmuku-contract.md)和[变更](changes/2026-09-13-PKG-DANMUKU-01-contracts.md)。
228项:139 done、16 doing、73 todo。02开始独立受控异步失败
与真实浏览器基线;03至09的源码迁移、性能和最终分发仍待实施。无推送或发布。
## PKG-MASK-01 弹幕遮罩契约完成
冻结全部两个真实稳定发布版、Git关联和导出差异;核对六个Yarn/SDK版本与12个已有
+8
View File
@@ -227,3 +227,11 @@
| 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 |
| DANMUKU-CONFIG-01 | open / 已复现 | Danmuku JSON configuration comparison ignores a replacement filter function | PKG-DANMUKU-02, PKG-DANMUKU-03 |
| DANMUKU-CLEANUP-01 | open / 已复现 | Danmuku removes a different resize callback and retains the settings lock timer on destroy | PKG-DANMUKU-02, PKG-DANMUKU-05 |
| DANMUKU-TYPE-02 | open / 源码/产物事实 | Danmuku id, icons, Slider steps, heatmap points and partial configuration drift from declarations | PKG-DANMUKU-05, PKG-DANMUKU-06 |
| DANMUKU-ASYNC-01 | open / 源码/产物事实 | Danmuku asynchronous input, worker replies and pending visibility have unverified failure and cancellation paths | PKG-DANMUKU-02, PKG-DANMUKU-03, PKG-DANMUKU-04, PKG-DANMUKU-05 |
| DANMUKU-HEATMAP-01 | open / 源码/产物事实 | Danmuku heatmap sampling can become zero and points/instance ownership need browser validation | PKG-DANMUKU-02, PKG-DANMUKU-05 |
| DANMUKU-SOURCE-01 | open / 已复现 | Danmuku registry Git associations differ from releases and static icon provenance remains incomplete | PKG-DANMUKU-09 |
+137
View File
@@ -5006,6 +5006,143 @@
],
"compatibleResolution": "Trace included SDK assets and license notices against actual bundle sources; preserve required notices in candidate packages.",
"closureCriteria": "Document incorporated SDK provenance and applicable notices; inspect built and packed artifacts, without treating manifest license claims as full clearance."
},
{
"id": "DANMUKU-TYPE-01",
"title": "Danmuku historical declared results, optional mount and initial Promise input disagree with runtime",
"confirmation": "reproduced",
"status": "open",
"owners": [
"PKG-DANMUKU-03",
"PKG-DANMUKU-06"
],
"evidence": [
"refactor/baselines/danmuku-contract.md",
"refactor/baselines/danmuku-contract-validation.json",
"refactor/baselines/danmuku-release.json"
],
"compatibleResolution": "Preserve latest published complete root type contracts and actual legal JS return identities; expose accurate types separately.",
"closureCriteria": "Installed consumers verify latest root assignments and precise async/getter/mount contracts; initial Promise mismatch has an explicit tested resolution."
},
{
"id": "DANMUKU-INPUT-01",
"title": "Danmuku replaces time zero with current time plus 0.5 and mutates supplied input objects",
"confirmation": "reproduced",
"status": "open",
"owners": [
"PKG-DANMUKU-02",
"PKG-DANMUKU-03"
],
"evidence": [
"refactor/baselines/danmuku-contract.md",
"refactor/baselines/danmuku-contract-validation.json",
"refactor/baselines/danmuku-release.json"
],
"compatibleResolution": "Separate the time-zero defect from observable input object identity/mutation before changing parsing or queue ownership.",
"closureCriteria": "Zero, absent, negative and explicit times plus object identity are covered with candidate expectations and compatibility decisions."
},
{
"id": "DANMUKU-CONFIG-01",
"title": "Danmuku JSON configuration comparison ignores a replacement filter function",
"confirmation": "reproduced",
"status": "open",
"owners": [
"PKG-DANMUKU-02",
"PKG-DANMUKU-03"
],
"evidence": [
"refactor/baselines/danmuku-contract.md",
"refactor/baselines/danmuku-contract-validation.json",
"refactor/baselines/danmuku-release.json"
],
"compatibleResolution": "Compare relevant configuration values without dropping callable properties; retain callback receivers and config event ordering.",
"closureCriteria": "Function-only changes take effect, unchanged options remain stable and partial configuration/callback reentry has regression evidence."
},
{
"id": "DANMUKU-CLEANUP-01",
"title": "Danmuku removes a different resize callback and retains the settings lock timer on destroy",
"confirmation": "reproduced",
"status": "open",
"owners": [
"PKG-DANMUKU-02",
"PKG-DANMUKU-05"
],
"evidence": [
"refactor/baselines/danmuku-contract.md",
"refactor/baselines/danmuku-contract-validation.json",
"refactor/baselines/danmuku-release.json"
],
"compatibleResolution": "Track and release exact subscription and timer ownership across settings, renderer and instances.",
"closureCriteria": "Repeated lifecycle and pending settings actions leave no owned subscriptions, timers or late DOM writes."
},
{
"id": "DANMUKU-TYPE-02",
"title": "Danmuku id, icons, Slider steps, heatmap points and partial configuration drift from declarations",
"confirmation": "source-observed",
"status": "open",
"owners": [
"PKG-DANMUKU-05",
"PKG-DANMUKU-06"
],
"evidence": [
"refactor/baselines/danmuku-contract.md",
"refactor/baselines/danmuku-contract-validation.json",
"refactor/baselines/danmuku-release.json"
],
"compatibleResolution": "Validate actual accepted data and static properties, retain root compatibility and document effective fields.",
"closureCriteria": "Positive and negative installed consumers and source behavior tests cover these fields without widening away old factory contracts."
},
{
"id": "DANMUKU-ASYNC-01",
"title": "Danmuku asynchronous input, worker replies and pending visibility have unverified failure and cancellation paths",
"confirmation": "source-observed",
"status": "open",
"owners": [
"PKG-DANMUKU-02",
"PKG-DANMUKU-03",
"PKG-DANMUKU-04",
"PKG-DANMUKU-05"
],
"evidence": [
"refactor/baselines/danmuku-contract.md",
"refactor/baselines/danmuku-contract-validation.json",
"refactor/baselines/danmuku-release.json"
],
"compatibleResolution": "Reproduce load ordering, Bilibili rejection and late worker/visibility paths before defining operation ownership.",
"closureCriteria": "Failures settle, obsolete results cannot alter new queues or destroyed instances, and worker resources are released with browser evidence."
},
{
"id": "DANMUKU-HEATMAP-01",
"title": "Danmuku heatmap sampling can become zero and points/instance ownership need browser validation",
"confirmation": "source-observed",
"status": "open",
"owners": [
"PKG-DANMUKU-02",
"PKG-DANMUKU-05"
],
"evidence": [
"refactor/baselines/danmuku-contract.md",
"refactor/baselines/danmuku-contract-validation.json",
"refactor/baselines/danmuku-release.json"
],
"compatibleResolution": "Use isolated timeout-protected reproduction for narrow dimensions, then define finite sampling and per-instance rendering.",
"closureCriteria": "Narrow widths terminate, points/config behavior is explicit and multiple SVG/control instances remain independent."
},
{
"id": "DANMUKU-SOURCE-01",
"title": "Danmuku registry Git associations differ from releases and static icon provenance remains incomplete",
"confirmation": "reproduced",
"status": "open",
"owners": [
"PKG-DANMUKU-09"
],
"evidence": [
"refactor/baselines/danmuku-contract.md",
"refactor/baselines/danmuku-contract-validation.json",
"refactor/baselines/danmuku-release.json"
],
"compatibleResolution": "Use actual archive identity, preserve missing historical Git information and audit icon source/notice obligations.",
"closureCriteria": "Candidate tarball paths, complete module forms and icon/source provenance are explicitly reviewed; unresolved history is not invented."
}
]
}
+103
View File
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { ensureArchive, hash, readMember, refactorDir } from './releases.mjs'
const workspace = path.dirname(refactorDir.replace(/[\\/]$/u, ''))
const prefix = 'packages/artplayer-plugin-danmuku/'
export function danmukuArchiveFiles(archive) {
const names = execFileSync('tar', ['-tzf', archive], { encoding: 'utf8' }).trim().split(/\r?\n/u)
const descriptions = execFileSync('tar', ['-tvzf', archive], { encoding: 'utf8' }).trim().split(/\r?\n/u)
assert.equal(names.length, descriptions.length)
assert.equal(new Set(names).size, names.length, 'Duplicate archive entries')
return names.filter((name, index) => {
const kind = descriptions[index][0]
assert(kind === 'd' || kind === '-', `Unsupported archive entry: ${name}`)
// npm 5.1.7 contains directory headers without a trailing slash, including package.
assert((name.startsWith('package/') || (name === 'package' && kind === 'd'))
&& !name.includes('\\') && !name.split('/').includes('..'), `Invalid archive path: ${name}`)
return kind === '-'
}).sort()
}
function gitText(commit, file) {
return execFileSync('git', ['show', `${commit}:${file}`], { cwd: workspace, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).replaceAll('\r\n', '\n')
}
export async function verifyDanmukuContract() {
const baseline = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/danmuku-release.json'), 'utf8'))
const releases = [baseline.release, ...baseline.previous]
assert.deepEqual(releases.map(release => release.version).sort(), [...baseline.registrySnapshot.selectedVersions].sort())
assert.deepEqual(baseline.registrySnapshot.catalog.map(item => item.version), baseline.registrySnapshot.stableVersions)
const archives = new Map()
for (const release of releases) {
const metadata = baseline.registrySnapshot.catalog.find(item => item.version === release.version)
for (const field of ['tarball', 'integrity', 'publishedAt'])
assert.equal(release[field], metadata[field], `${release.version}/${field}`)
assert.equal(release.registryGitHead, metadata.gitHead)
const archive = await ensureArchive(release)
assert.deepEqual(danmukuArchiveFiles(archive), Object.keys(release.files).sort())
for (const [file, expected] of Object.entries(release.files))
assert.equal(hash(readMember(archive, file)), expected, `${release.version}/${file}`)
const manifest = JSON.parse(readMember(archive, 'package/package.json'))
assert.deepEqual(manifest, release.manifest)
assert.equal(manifest.name, release.name)
assert.equal(manifest.version, release.version)
const memberExists = target => assert(Object.hasOwn(release.files, `package/${target.replace(/^\.\//u, '')}`), `${release.version}/${target}`)
for (const field of ['main', 'module', 'types', 'legacy']) {
assert.equal(manifest[field] ?? null, release.entrypoints[field])
if (manifest[field])
memberExists(manifest[field])
}
assert.deepEqual(manifest.exports ?? null, release.entrypoints.exports)
function checkExports(value) {
if (typeof value === 'string')
memberExists(value)
else if (value && typeof value === 'object')
Object.values(value).forEach(checkExports)
}
checkExports(manifest.exports)
for (const association of [release.registrySourceManifest, release.historicalCore]) {
if (association.status === 'verified-local-git-object') {
assert.equal(association.commit, release.registryGitHead)
const text = gitText(association.commit, association.file)
assert.equal(hash(text), association.sha256LF)
assert.equal(JSON.parse(text).version, association.version)
}
else {
assert(['registry-gitHead-absent', 'git-object-or-path-unavailable'].includes(association.status))
if (association.status === 'registry-gitHead-absent')
assert.equal(release.registryGitHead, null)
}
}
for (const comparison of release.registrySourceComparison) {
if (comparison.sha256LF) {
const digest = hash(gitText(release.registryGitHead, comparison.file))
assert.equal(digest, comparison.sha256LF)
assert.equal(comparison.matchesArchiveBytes, digest === release.files[`package/${comparison.file.slice(prefix.length)}`])
}
else {
assert.equal(comparison.status, 'git-object-or-path-unavailable')
}
}
archives.set(release.version, archive)
}
const sources = new Map()
for (const [file, expected] of Object.entries(baseline.source)) {
const source = gitText(baseline.sourceCommit, file)
assert.equal(hash(source), expected, file)
sources.set(file, source)
}
for (const file of baseline.observations.latestArchiveMatchesFrozenSourceFiles)
assert.equal(hash(sources.get(file)), baseline.release.files[`package/${file.slice(prefix.length)}`])
return { baseline, archives, sources }
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const { baseline, archives, sources } = await verifyDanmukuContract()
console.log(JSON.stringify({ sourceCommit: baseline.sourceCommit, sourceFiles: sources.size, catalogStableVersions: baseline.registrySnapshot.stableVersions.length, releases: [...archives.keys()], validation: 'SHA512/SHA256, actual archive members and entrypoints, frozen Git source and available registry Git associations; no playback or publishing claim' }, null, 2))
}
+334
View File
@@ -0,0 +1,334 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
import fs from 'node:fs'
import path from 'node:path'
// eslint-disable-next-line test/no-import-node-test -- Immutable Node contract runner.
import { test } from 'node:test'
import vm from 'node:vm'
import { build } from 'esbuild'
import { verifyDanmukuContract } from './danmuku-contract.mjs'
import { ensureArchive, readMember, refactorDir } from './releases.mjs'
const { baseline, archives, sources } = await verifyDanmukuContract()
const prefix = 'packages/artplayer-plugin-danmuku/'
const icons = ['$on', '$off', '$config', '$style', '$mode_0_off', '$mode_0_on', '$mode_1_off', '$mode_1_on', '$mode_2_off', '$mode_2_on', '$check_on', '$check_off']
const publicKeys = ['name', 'emit', 'load', 'config', 'hide', 'show', 'reset', 'mount', 'option', 'isHide', 'isStop']
const optionKeys = ['danmuku', 'speed', 'margin', 'opacity', 'color', 'mode', 'modes', 'fontSize', 'antiOverlap', 'synchronousPlayback', 'mount', 'heatmap', 'width', 'points', 'filter', 'beforeEmit', 'beforeVisible', 'visible', 'emitter', 'maxLength', 'lockTime', 'theme', 'OPACITY', 'FONT_SIZE', 'MARGIN', 'SPEED', 'COLOR']
const coreRelease = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/releases.json'), 'utf8')).releases.find(item => item.name === 'artplayer')
const coreModule = { exports: {} }
vm.runInNewContext(readMember(await ensureArchive(coreRelease), 'package/dist/artplayer.js').toString(), { module: coreModule, exports: coreModule.exports }, { timeout: 1000 })
const core = coreModule.exports
function exported(code, globals = {}) {
const module = { exports: {} }
const context = vm.createContext({ console, ...globals, module, exports: module.exports })
vm.runInContext(code, context, { timeout: 1000 })
const factory = typeof module.exports === 'function' ? module.exports : module.exports.default
return { module: module.exports, factory, context }
}
for (const release of [baseline.release, ...baseline.previous]) {
for (const field of ['main', 'legacy']) {
if (!release.manifest[field])
continue
test(`danmuku published ${release.version} ${field}: actual CommonJS shape and static icons`, () => {
const code = readMember(archives.get(release.version), `package/${release.manifest[field].replace(/^\.\//u, '')}`).toString()
const result = exported(code)
const direct = ['3.5.31', '5.3.0'].includes(release.version)
assert.equal(typeof result.module, direct ? 'function' : 'object')
assert.equal(typeof result.factory, 'function')
const hasIcons = release.version.startsWith('5.') && !release.version.startsWith('5.0.')
assert.deepEqual(Object.keys(result.factory.icons || {}), hasIcons ? icons : [])
if (hasIcons) {
const descriptor = Object.getOwnPropertyDescriptor(result.factory, 'icons')
assert.equal(descriptor.writable, true)
assert.equal(descriptor.enumerable, true)
assert.equal(descriptor.configurable, true)
}
})
}
if (release.manifest.module) {
test(`danmuku published ${release.version}: manifest ESM bytes expose a default factory`, async () => {
const code = readMember(archives.get(release.version), `package/${release.manifest.module.replace(/^\.\//u, '')}`)
const module = await import(`data:text/javascript;base64,${code.toString('base64')}`)
assert.equal(typeof module.default, 'function')
assert.deepEqual(Object.keys(module.default.icons), icons)
})
}
}
// Build only immutable Git modules in memory. Inline worker transport and CSS are
// controlled dependencies here; real Worker/layout/visual behavior belongs to 02.
const frozenSource = await build({ entryPoints: [`${prefix}src/index.js`], bundle: true, write: false, format: 'cjs', platform: 'browser', plugins: [{ name: 'frozen-danmuku', setup(build) {
build.onResolve({ filter: /.*/ }, args => ({ path: path.posix.normalize(args.importer ? path.posix.join(path.posix.dirname(args.importer), args.path) : args.path), namespace: 'frozen' }))
build.onLoad({ filter: /.*/, namespace: 'frozen' }, (args) => {
if (args.path.includes('?worker'))
return { contents: 'export default Worker', loader: 'js' }
const file = args.path.split('?')[0]
const found = sources.get(file) ?? sources.get(`${file}.js`)
assert.notEqual(found, undefined, file)
if (args.path.includes('?'))
return { contents: `export default ${JSON.stringify(found)}`, loader: 'js' }
return { contents: found, loader: 'js' }
})
} }] })
class Element {
constructor() {
this.style = {}
this.dataset = {}
this.children = []
this.nodes = new Map()
this.clientWidth = 640
this.clientHeight = 360
this.className = ''
this.textContent = ''
this.value = ''
}
get [Symbol.toStringTag]() { return 'HTMLDivElement' }
appendChild(child) {
child.parentElement = this
this.children.push(child)
return child
}
querySelector(selector) {
if (!this.nodes.has(selector))
this.nodes.set(selector, new Element())
return this.nodes.get(selector)
}
getBoundingClientRect() { return { top: 0, left: 0, right: 640, bottom: 360, width: 640, height: 360 } }
setAttribute(key, value) { this[key] = value }
addEventListener() {}
}
function environment(code) {
const events = []
const listeners = new Map()
const workers = []
const frames = new Map()
const timers = new Map()
const proxies = []
const host = {
requestAnimationFrame(callback) {
frames.set(frames.size + 1, callback)
return frames.size
},
cancelAnimationFrame(id) { frames.delete(id) },
}
class Worker {
constructor() { workers.push(this) }
terminate() { this.terminated = true }
postMessage() {}
addEventListener() {}
}
const loaded = exported(code, {
window: host,
Worker,
Blob,
URL: { createObjectURL: () => 'blob:controlled-worker', revokeObjectURL() {} },
atob: value => Buffer.from(value, 'base64').toString('binary'),
setTimeout(callback) {
timers.set(timers.size + 1, callback)
return timers.size
},
clearTimeout(id) { timers.delete(id) },
})
const body = new Element()
loaded.context.document = { querySelector: selector => selector === '#external' ? body : null, createElement: () => new Element() }
const utils = {
clamp: (value, min, max) => Math.min(Math.max(value, min), max),
setStyle: (element, key, value) => element.style[key] = value,
setStyles: (element, style) => Object.assign(element.style, style),
errorHandle: (value, message) => {
if (!value)
throw new Error(message)
return value
},
createElement: () => new Element(),
query: (selector, element) => element.querySelector(selector),
append: (element, child) => element.appendChild(child),
tooltip() {},
inverseClass() {},
addClass() {},
removeClass() {},
}
const art = {
constructor: { utils, validator: core.validator },
template: { $danmuku: new Element(), $player: new Element(), $controlsCenter: new Element() },
currentTime: 10,
width: 640,
playbackRate: 1,
playing: false,
plugins: {},
on(name, callback) { listeners.set(name, [...listeners.get(name) || [], callback]) },
off(name, callback) { listeners.set(name, (listeners.get(name) || []).filter(item => item !== callback)) },
emit(name, ...args) {
events.push({ name, args })
for (const callback of listeners.get(name) || [])
callback(...args)
},
proxy(target, name, callback) { proxies.push({ target, name, callback }) },
}
return { ...loaded, art, workers, events, listeners, frames, timers, proxies, external: body }
}
const implementations = [
{ name: 'frozen-source', code: frozenSource.outputFiles[0].text },
...['main', 'legacy'].map(field => ({ name: `published-5.3.0-${field}`, code: readMember(archives.get('5.3.0'), `package/${baseline.release.manifest[field].replace(/^\.\//u, '')}`).toString() })),
]
for (const implementation of implementations) {
test(`danmuku ${implementation.name}: synchronous facade, defaults, descriptors and actual method return identity`, async () => {
const env = environment(implementation.code)
const factory = env.factory({ danmuku: [] })
const result = factory(env.art)
assert.equal(typeof result.then, 'undefined')
assert.deepEqual(Object.keys(result), publicKeys)
assert.deepEqual(Object.keys(result.option), optionKeys)
for (const key of ['option', 'isHide', 'isStop']) {
const descriptor = Object.getOwnPropertyDescriptor(result, key)
assert.equal(typeof descriptor.get, 'function')
assert.equal(descriptor.set, undefined)
assert.equal(descriptor.enumerable, true)
}
assert.equal(result.option.speed, 5)
assert.deepEqual(Array.from(result.option.margin), [10, '25%'])
assert.equal(result.option.mount, env.art.template.$controlsCenter)
assert.equal(result.isStop, false)
const internal = result.hide()
assert.notEqual(internal, result, 'Historical methods return the internal Danmuku, not the public facade')
assert.equal(internal.name, undefined)
assert.equal(result.isHide, true)
assert.equal(result.show(), internal)
assert.equal(result.reset(), internal)
assert.equal(result.config({ opacity: 0.6 }), internal)
assert.equal(result.option.opacity, 0.6)
const emitted = result.emit({ id: 'row', text: 'hello', time: 15 })
assert.equal(typeof emitted.then, 'function', 'The old declaration incorrectly promises a synchronous Result')
assert.equal(await emitted, internal)
assert.equal(await result.load([{ text: 'appended', time: 16 }]), internal)
assert.equal(internal.queue.length, 2)
assert.equal(internal.queue[0].id, 'row')
assert.equal(result.mount('#external'), undefined)
assert.equal(env.external.children.length, 1)
assert.throws(() => result.mount(), /Can not find the mount point/u)
env.art.emit('destroy')
assert(env.workers.every(worker => worker.terminated))
})
test(`danmuku ${implementation.name}: load replacement/append, filters, input mutation and event argument identity`, async () => {
const env = environment(implementation.code)
let beforeEmit = 0
let filterReceiver
const result = env.factory({
danmuku: [],
beforeEmit() {
beforeEmit++
return true
},
filter(danmu) {
filterReceiver = this
return danmu.text !== 'filtered'
},
})(env.art)
const initial = await result.load()
const input = { text: 'zero', time: 0 }
await result.emit(input)
assert.equal(input.time, 10.5, 'Historical time:0 uses currentTime + 0.5')
assert.equal(input.mode, 0)
assert.equal(input.color, '#FFFFFF')
assert.equal(filterReceiver, result.option)
assert.equal(beforeEmit, 0, 'Public emit bypasses the setting beforeEmit callback')
const originalFilter = result.option.filter
result.config({ filter: () => false })
assert.equal(result.option.filter, originalFilter, 'Historical JSON comparison ignores a function-only config change')
await result.load([{ text: 'filtered' }, { text: 'two', time: 12 }])
assert.equal(initial.queue.length, 2)
const loaded = env.events.filter(event => event.name === 'artplayerPluginDanmuku:loaded').at(-1)
assert.equal(loaded.args[0], initial.queue)
await result.load()
assert.equal(initial.queue.length, 0)
await result.load(() => Promise.resolve([{ text: 'function' }]))
assert.equal(initial.queue.length, 1)
const promisedInput = vm.runInContext('Promise.resolve([{text:"promise"}])', env.context)
await result.load(promisedInput)
assert.equal(initial.queue.length, 2)
const failure = new Error('controlled input failure')
await assert.rejects(result.load(() => Promise.reject(failure)), error => error === failure)
assert.equal(env.events.at(-1).name, 'artplayerPluginDanmuku:error')
assert.equal(env.events.at(-1).args[0], failure)
env.art.emit('destroy')
})
test(`danmuku ${implementation.name}: initial Promise validation and resize cleanup historical discrepancies`, async () => {
const env = environment(implementation.code)
const promise = vm.runInContext('Promise.resolve([])', env.context)
assert.throws(() => env.factory({ danmuku: promise })(env.art), /danmuku/iu)
const result = env.factory({ danmuku: [] })(env.art)
await result.load()
const before = env.listeners.get('resize').length
env.art.emit('destroy')
assert.equal(env.listeners.get('resize').length, before, 'Historical destroy removes this.reset instead of registered this.resize; Setting listener also remains')
})
test(`danmuku ${implementation.name}: synchronous initial events, video hooks and settings beforeEmit path`, async () => {
const env = environment(implementation.code)
const seen = []
const result = env.factory({
danmuku: [],
beforeEmit(danmu) {
seen.push({ danmu: { ...danmu }, receiver: this })
return Promise.resolve(true)
},
})(env.art)
assert.deepEqual(env.events.map(event => event.name), ['artplayerPluginDanmuku:show', 'artplayerPluginDanmuku:config', 'artplayerPluginDanmuku:reset', 'artplayerPluginDanmuku:loaded'])
env.art.emit('video:play')
assert.equal(result.isStop, false)
env.art.emit('video:pause')
assert.equal(result.isStop, true)
const root = env.art.template.$controlsCenter.children[0]
const input = root.querySelector('.apd-input')
const send = root.querySelector('.apd-send')
input.value = ' via settings '
await env.proxies.find(proxy => proxy.target === send && proxy.name === 'click').callback()
assert.equal(seen.length, 1)
assert.equal(seen[0].receiver, result.option)
assert.equal(seen[0].danmu.text, 'via settings')
assert.equal(seen[0].danmu.time, 10)
const internal = result.show()
assert.equal(internal.queue[0].border, true)
assert.equal(internal.queue[0].time, 10.5)
assert.equal(input.value, '')
assert.equal(env.timers.size, 1)
env.art.emit('destroy')
assert.equal(env.events.at(-1).name, 'artplayerPluginDanmuku:destroy')
assert.equal(env.timers.size, 1, 'Historical settings lock timer is not cleared by destroy')
})
}
test('danmuku frozen Bilibili parser: mapped modes, entities, malformed attributes and URL fallback', async () => {
const source = sources.get(`${prefix}src/bilibili.js`).replace('export function bilibiliDanmuParseFromUrl', 'function bilibiliDanmuParseFromUrl')
const requests = []
const errors = []
const xml = '<i><d p="1,1,25,255,100,0,user,9"> &lt;hi&gt; &amp; &quot;x&quot; </d><d p="2,4,24,16777215,101,1,u,10">bottom</d><d p="3,5,23,0,102,0,u,11">top</d><d p="4,7,22,10,103,0,u,12">special</d><d p="1,2">invalid</d></i>'
const context = vm.createContext({
fetch: async (url) => {
requests.push(url)
return { text: async () => xml }
},
console: { error: (...args) => errors.push(args) },
})
vm.runInContext(`${source}\nglobalThis.parse=bilibiliDanmuParseFromXml;globalThis.load=bilibiliDanmuParseFromUrl`, context)
const data = await context.load('/fixture.xml')
assert.deepEqual(requests, ['/fixture.xml'])
assert.deepEqual(Array.from(data, item => item.mode), [0, 2, 1, 0])
assert.equal(data[0].text, '<hi> & "x"')
assert.equal(data[0].color, '#ff', 'Historical parser does not left-pad RGB')
assert.deepEqual(Object.keys(data[0]), ['text', 'time', 'mode', 'fontSize', 'color', 'timestamp', 'pool', 'userID', 'rowID'])
assert.equal(context.parse(null).length, 0)
assert.equal(context.parse('<d p="1,2">x</d>').length, 0)
assert.equal(errors.length, 1, 'Unavailable Worker falls back to the real parser')
})
+8 -3
View File
@@ -3252,11 +3252,16 @@
"dependsOn": [
"BASE-05"
],
"status": "doing",
"status": "done",
"risk": "H",
"deliverable": "emit/load/config/hide/show/reset/mount、option/isHide/isStop/icons 与事件清单",
"acceptance": "源码、声明、发布包、Bilibili 输入和设置入口对照",
"evidence": []
"evidence": [
"baselines/danmuku-release.json",
"baselines/danmuku-contract.md",
"baselines/danmuku-contract-validation.json",
"changes/2026-09-13-PKG-DANMUKU-01-contracts.md"
]
},
{
"id": "PKG-DANMUKU-02",
@@ -3270,7 +3275,7 @@
"ENG-05",
"ENG-08"
],
"status": "todo",
"status": "doing",
"risk": "H",
"deliverable": "密集/稀疏弹幕、过滤/异步输入、seek/倍率/重载测试",
"acceptance": "轨道选择、发射顺序、事件、内存和可视结果可重跑",