test(jassub): [PKG-JASSUB-02] establish lifecycle failures and native render baseline

This commit is contained in:
Harvey Zhao committed 2026-09-14 01:00:06 +08:00
1 parent 0cf56b64d2
commit 944e2cc9d0
14 files changed
+4311 -11

No files matched your search

+1 -1
View File
@@ -90,7 +90,7 @@
"test:vtt-thumbnail-types-package": "node refactor/scripts/vtt-thumbnail-package-types.mjs",
"test:multiple-subtitles": "node --test test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js refactor/scripts/multiple-subtitles-contract.test.mjs refactor/scripts/multiple-subtitles-runtime-types.test.mjs refactor/scripts/multiple-subtitles-types.test.mjs",
"test:multiple-subtitles-types-package": "node refactor/scripts/multiple-subtitles-package-types.mjs",
"test:jassub": "node --test test/jassub.test.js refactor/scripts/jassub-contract.test.mjs refactor/scripts/jassub-provenance.test.mjs",
"test:jassub": "node --test test/jassub.test.js refactor/scripts/jassub-contract.test.mjs refactor/scripts/jassub-provenance.test.mjs refactor/scripts/jassub-failures.test.mjs",
"check:ci": "node refactor/scripts/ci-workflow.mjs",
"test:ci": "node --test test/ci-summary.test.js refactor/scripts/ci-workflow.test.mjs refactor/scripts/impact.test.mjs test/package-runtime.test.js",
"test:package:runtime": "node scripts/package-runtime.mjs --canonical",
@@ -42,5 +42,16 @@ asset comparisons and fails on any request error, without substituting cached ev
Set ARTPLAYER_JASSUB_CANDIDATE=1 for current source behavior; ARTPLAYER_JASSUB_ARTIFACT can point
at a main/legacy artifact. Tests use controlled DOM, Worker and SIMD detection, with actual
vendor JavaScript. They do not render ASS or execute worker WASM. Real browser, failures,
repeated destroy and resource cleanup remain later steps. See
repeated destroy and resource cleanup remain later steps. Frozen failure tests now
cover repeated teardown, cross-parent setVideo, custom canvas, Worker construction
failure, stale video frames and incorrect fallback ratechange payloads. They assert
the old failures, not candidate fixes. PKG-JASSUB-03 owns adapter cleanup; 07 owns
separately tracked vendor lifecycle/clock changes and 05 verifies native behavior.
`yarn test:browser test/browser/jassub-native.spec.js --workers=1` loads the actual
published wrapper, Worker, WASM and default font with offscreenRender=false.
Chromium/Firefox render and seek correctly; Windows WebKit's default frame-clock
path remains a recorded failure. The onDemandRender=false diagnostic does not
waive the default behavior gate. Native failure recovery, default offscreen mode,
full devices and sustained resource release remain unverified. See
`../../refactor/baselines/jassub-contract.md` for precise contract and provenance evidence.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,72 @@
# PKG-JASSUB-02 行为、错误与原生渲染基线
本步骤建立迁移前基线,生产源码、第三方文件和资源未修改。新增的历史失败
断言证明问题存在,不能当作修复通过;后续 03/07 负责修复,05 完成组合验收。
## 受控行为
七份真实发布/冻结实现各九个失败或保护场景,共 63 项;既有工厂、入口和
来源测试加上事件时钟与切换字幕轨道,`yarn test:jassub` 联合 120 项通过。
实际运行 vendor JS,DOM/Worker/RVFC 为受控平台,以下六类问题均已复现:
| 问题 | 具体结果与后续责任 |
| --- | --- |
| 直接 destroy 再宿主 destroy,或直接重复 destroy | 二次移除容器抛错;03 修宿主重复调用,07 保证公开实例重复销毁 |
| setVideo 切到其他父容器再 destroy | 从新视频父节点删除旧容器失败,Worker 未终止、七监听器保留;07 |
| 自定义 canvas | vendor 已创建 Worker,adapter 访问不存在的 _canvasParent.style 抛错,尚未注册宿主清理;03 |
| Worker 构造抛 SecurityError | 原错误保留,但先前创建的容器和七监听器遗留;07 |
| setVideo 后旧 RVFC 到达 | 实际已排队的旧回调以 time=123 驱动 currentTime=9 的新视频,再建立第二条回调链;07 |
| 事件模式 ratechange | 直接 setRate(2) 正常;媒体 ratechange 却将 Event 当作 rate 传给 Worker,未使用 playbackRate=1.5;07 |
测试还保护已有正常行为:ready 前 destroy 会终止 Worker 并去监听器,虽然测试
能观察到终止后仍尝试发 init;Worker error 原样分发,宿主销毁仍能清理;
destroy 后遗留的单个 RVFC 到达会退出且不再排队。不能将这些观察夸大成
“终止后 Worker 仍实际执行”或“销毁后永远循环”。受控 removeChild 抛的是
ERR_ASSERTION,不冒充原生 DOMException;rate 错误负载尚非真实 clone 异常证据。
正常事件用例验证时间偏移、playing/seeking 状态,以及同步 setTrackByUrl、
setTrack、freeTrack 的实际 Worker 消息顺序。保留公开 instance 与返回值。
## 真实浏览器
新 `jassub-native.spec.js` 加载真实 npm 1.1.0 主产物、原生 Worker、本地固定 WASM
和 Liberation 字体,使用两条自编 ASS 字幕及实际视频。明确设置
offscreenRender=false 以读取真实字幕 canvas 像素,其他默认路径未冒充验收。
原生 Worker 子类只记录 postMessage/message/terminate,仍委托浏览器原实现。
断言初始非透明像素、seek 后不同字幕像素、网页全屏尺寸和正常单次销毁。
Chromium/Firefox 各三个核心组合通过:候选、实际 npm 5.4.0、实际 5.3.1-beta.1。
截图确认字幕已由真实 WASM 绘制。首次 Chromium 三例失败是测试服务器未提供
application/wasm,补齐 MIME 后通过;没有改 SDK、替换编译器或放宽等待时间。
Windows WebKit 26.6 的三个默认 onDemandRender 场景均没有可见字幕。保留的
进一步观测显示:资源 200、Worker ready,但媒体已到 6.4992094 秒仍只有 init/
canvas 消息,没有 demand 请求;canvas 为零可见像素。显式设置旧选项
onDemandRender=false 的候选核心诊断通过。这个诊断不能替代默认路径验收,
也不足以断言所有 Safari/设备不支持 RVFC。新增 JASSUB-NATIVE-FRAME-01,后续
07/05 继续验证原因与兼容处理。默认 WebKit 用例保持失败,不加 skip、重试或
扩大超时。02 的完成只表示已建立成功/失败基线,不表示全浏览器绿色。
## 维护与验收边界
```sh
yarn test:jassub
yarn test:browser test/browser/jassub-native.spec.js --workers=1
```
历史失败测试进入既有 baseline glob 和 test:jassub。设置
ARTPLAYER_JASSUB_FAILURE_REPORT 可输出详细受控观察;默认测试不写报告。
原生诊断可设置 ARTPLAYER_JASSUB_ON_DEMAND=false,报告保存该选择。按不同运行
分别归档 report/results,等待进程终态后再跑下一轮。机器报告包含成功、失败和
诊断结果,见 [验证](../baselines/jassub-failures-validation.json)。
自有 adapter 修复与 vendor 方法修复必须分开。新增 PKG-JASSUB-07,在 03 之后、
05 之前处理有证据的 vendor 生命周期/时钟问题;保留原文件及来源基线,任何
实际补丁都必须单列差异与候选正例,不得把未修改 vendor 宣称为已修复。
完整设备、默认 OffscreenCanvas、真实资源失败、CSP、倍率、长时播放/内存和
VideoFrame/ResizeObserver 回收仍属后续验证。源码/类型/发布门槛全部保留。
本次新增测试及测试服务器 MIME 修正,定向 lint、计划和风险检查通过;完整
浏览器流程仍有上述 WebKit 失败,不宣称 CI 或后续组合验收通过。
没有新依赖、锁文件或生产产物变化。独立本地提交,不推送或发布;回退本步骤
仅移除新基线/测试与 MIME 支持,生产行为保持原样。
+7 -5
View File
@@ -2,9 +2,9 @@
> 由 tasks.json 生成。请修改数据后运行 `node refactor/scripts/plan.mjs --write`,不要手改本表。
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 231 项,范围 22 个包及工作区/示例。
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 232 项,范围 22 个包及工作区/示例。
状态:todo 65 / doing 14 / blocked 0 / done 152 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 65 / doing 14 / blocked 0 / done 153 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -27,7 +27,7 @@
| artplayer-plugin-dash-control | 1.1.0 | PKG-DASH-01, PKG-DASH-02, PKG-DASH-03, PKG-DASH-04, 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 |
| artplayer-plugin-jassub | 1.1.0 | PKG-JASSUB-01, PKG-JASSUB-02, PKG-JASSUB-03, PKG-JASSUB-04, PKG-JASSUB-05, PKG-JASSUB-06 |
| artplayer-plugin-jassub | 1.1.0 | PKG-JASSUB-01, PKG-JASSUB-02, PKG-JASSUB-03, PKG-JASSUB-04, PKG-JASSUB-05, PKG-JASSUB-06, PKG-JASSUB-07 |
| artplayer-plugin-multiple-subtitles | 1.2.0 | PKG-MULTI-SUB-01, PKG-MULTI-SUB-02, PKG-MULTI-SUB-03, PKG-MULTI-SUB-04, PKG-MULTI-SUB-07, PKG-MULTI-SUB-05, PKG-MULTI-SUB-06 |
| artplayer-plugin-vast | 1.2.0 | PKG-VAST-01, PKG-VAST-02, PKG-VAST-03, PKG-VAST-04, PKG-VAST-05, PKG-VAST-06 |
| artplayer-plugin-vtt-thumbnail | 1.1.0 | PKG-VTT-THUMB-01, PKG-VTT-THUMB-02, PKG-VTT-THUMB-03, PKG-VTT-THUMB-04, PKG-VTT-THUMB-05, PKG-VTT-THUMB-06 |
@@ -230,11 +230,12 @@
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| PKG-JASSUB-01 | artplayer-plugin-jassub<br>核对包契约与历史用法 | BASE-05 | 选项透传、result.instance、vendor 来源、worker/WASM/font 路径 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结;接续 VENDOR-04/05,核对 wrapper/worker/WASM/font 的独立来源与通知,保持资源路径和选项透传 | H | done |
| PKG-JASSUB-02 | artplayer-plugin-jassub<br>建立特有行为与错误测试 | PKG-JASSUB-01, ENG-03, ENG-05 | ASS 字体、时钟/seek/倍率/resize、加载失败和销毁 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | todo |
| PKG-JASSUB-02 | artplayer-plugin-jassub<br>建立特有行为与错误测试 | PKG-JASSUB-01, ENG-03, ENG-05 | ASS 字体、时钟/seek/倍率/resize、加载失败和销毁 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done |
| PKG-JASSUB-03 | artplayer-plugin-jassub<br>整理内部职责与资源 | PKG-JASSUB-02, CORE-15, CORE-16 | 仅整理自有 adapter/销毁;保留第三方文件及来源 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo |
| PKG-JASSUB-04 | artplayer-plugin-jassub<br>迁移自有源码和公开类型 | PKG-JASSUB-03, ENG-04, ENG-06, CORE-07 | JASSUB option/instance 的兼容类型包装,vendor JS 例外记录 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo |
| PKG-JASSUB-05 | artplayer-plugin-jassub<br>验证新旧核心和组合 | PKG-JASSUB-04, CORE-22 | 真实 worker/WASM 字幕渲染与全屏、旧核心测试 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
| PKG-JASSUB-05 | artplayer-plugin-jassub<br>验证新旧核心和组合 | PKG-JASSUB-04, CORE-22, PKG-JASSUB-07 | 真实 worker/WASM 字幕渲染与全屏、旧核心测试 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
| PKG-JASSUB-06 | artplayer-plugin-jassub<br>验证分发并同步文档 | PKG-JASSUB-05, ENG-07 | jassub.js、外部资源路径、许可和离线失败记录;接续 VENDOR-04/05 完整组件/字体通知、LGPL 源码分发核对及六份未明确字体的分发处置 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录;来源身份已由01冻结,完整通知和未明确字体的发布处理必须闭环,不能以来源相同替代许可结论 | H | todo |
| PKG-JASSUB-07 | artplayer-plugin-jassub<br>修复 vendor 生命周期与字幕时钟 | PKG-JASSUB-03, PKG-JASSUB-02 | 独立记录 vendor 适配差异;幂等销毁、跨容器 setVideo、构造回滚、逐帧归属和倍率/能力边界 | 保持公开真实 instance、同步方法与资源路径;保留原 vendor 来源指纹并记录每项实际补丁;候选修复正例和真实字幕/失败/时钟验证通过,未证实设备不得冒充通过 | H | todo |
## 5 包迁移:artplayer-plugin-danmuku-mask
@@ -532,6 +533,7 @@
- PKG-MULTI-SUB-04: [记录](baselines/multiple-subtitles-runtime-types.json) [记录](changes/2026-09-13-PKG-MULTI-SUB-04-runtime-types.md) [记录](baselines/multiple-subtitles-public-types.json) [记录](changes/2026-09-13-PKG-MULTI-SUB-04-public-types.md) [记录](type-compatibility-policy.md) [记录](baselines/multiple-subtitles-approved-types.json) [记录](changes/2026-09-13-PKG-MULTI-SUB-04-approved-types.md)
- PKG-MULTI-SUB-07: [记录](baselines/multiple-subtitles-timestamps.json) [记录](changes/2026-09-13-PKG-MULTI-SUB-07-timestamps.md)
- PKG-JASSUB-01: [记录](baselines/jassub-release.json) [记录](baselines/jassub-vendor.json) [记录](baselines/jassub-font-metadata.json) [记录](baselines/jassub-contract.md) [记录](baselines/jassub-contract-validation.json) [记录](changes/2026-09-13-PKG-JASSUB-01-baseline.md) [记录](baselines/jassub-provenance.json) [记录](baselines/jassub-provenance-validation.json) [记录](changes/2026-09-14-PKG-JASSUB-01-provenance.md)
- PKG-JASSUB-02: [记录](changes/2026-09-14-PKG-JASSUB-02-failure-baseline.md) [记录](baselines/jassub-failures-validation.json)
- PKG-MASK-01: [记录](baselines/danmuku-mask-release.json) [记录](baselines/danmuku-mask-registry.json) [记录](baselines/danmuku-mask-contract.md) [记录](baselines/danmuku-mask-contract-validation.json) [记录](changes/2026-09-13-PKG-MASK-01-contract.md)
- PKG-MASK-02: [记录](changes/2026-09-13-PKG-MASK-02-failures.md) [记录](baselines/danmuku-mask-failures-validation.json)
- PKG-MASK-03: [记录](changes/2026-09-13-PKG-MASK-03-lifecycle.md) [记录](baselines/danmuku-mask-lifecycle-validation.json)
+9
View File
@@ -1,5 +1,14 @@
# 进度与证据
## PKG-JASSUB-02 历史缺陷与原生渲染基线完成
联合 120 项通过,其中 63 项确认旧失败/已有保护;没有宣称修复。Chromium/Firefox
实际 npm 插件和 WASM/字体六个核心组合通过,WebKit 默认逐帧路径仍失败,显式
事件时钟诊断单独通过。测试 server 的 WASM MIME 错误已修正并保存首次失败。
[基线记录](changes/2026-09-14-PKG-JASSUB-02-failure-baseline.md)明确新增六项风险;
03 处理自有 adapter,新增 07 专门修 vendor 生命周期/时钟且纳入 05 前置。生产文件
未改,02 仅完成基线职责,全部兼容与发布门槛保留。
## PKG-JASSUB-01 来源及契约核对完成
两份 WASM 与 11 份字体的上游 blob 已精确匹配,nightly 对应源码、七子模块和
+6
View File
@@ -242,3 +242,9 @@
| 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 |
| MASK-SCHEDULING-01 | open / 已复现 | Native Mask startup can coincide with a missed narrow Danmuku timestamp window | PKG-MASK-05 |
| JASSUB-DESTROY-01 | open / 已复现 | Repeated destruction or cross-parent setVideo can throw before JASSUB cleanup | PKG-JASSUB-02, PKG-JASSUB-03, PKG-JASSUB-07 |
| JASSUB-CANVAS-01 | open / 已复现 | Custom canvas registration throws after JASSUB Worker allocation | PKG-JASSUB-02, PKG-JASSUB-03 |
| JASSUB-CONSTRUCTION-01 | open / 已复现 | Worker construction failure leaves previously allocated JASSUB DOM and listeners | PKG-JASSUB-02, PKG-JASSUB-07 |
| JASSUB-VIDEO-FRAME-01 | open / 已复现 | Old video frame callbacks can drive replacement subtitle time and duplicate chains | PKG-JASSUB-02, PKG-JASSUB-07 |
| JASSUB-RATE-01 | open / 已复现 | Fallback ratechange sends an Event instead of numeric playbackRate | PKG-JASSUB-02, PKG-JASSUB-07 |
| JASSUB-NATIVE-FRAME-01 | open / 已复现 | Windows WebKit default frame-clock path does not produce subtitle demands in actual playback | PKG-JASSUB-02, PKG-JASSUB-07, PKG-JASSUB-05 |
+98
View File
@@ -5425,6 +5425,104 @@
],
"compatibleResolution": "Retain startup sampling evidence and distinguish it from post-model-ready delivery; investigate actual startup scheduling without silently adding catch-up semantics.",
"closureCriteria": "Identify the startup scheduling cause and assess a compatible mitigation or explicitly reviewed limitation using actual model/media evidence; post-ready success alone cannot close this risk."
},
{
"id": "JASSUB-DESTROY-01",
"title": "Repeated destruction or cross-parent setVideo can throw before JASSUB cleanup",
"confirmation": "reproduced",
"status": "open",
"owners": [
"PKG-JASSUB-02",
"PKG-JASSUB-03",
"PKG-JASSUB-07"
],
"evidence": [
"refactor/changes/2026-09-14-PKG-JASSUB-02-failure-baseline.md",
"refactor/baselines/jassub-failures-validation.json"
],
"compatibleResolution": "Use actual resource ownership and tolerate direct instance teardown before host teardown.",
"closureCriteria": "Candidate direct/repeated/host teardown and cross-parent video replacement release container, Worker and listeners without throwing or removing user nodes."
},
{
"id": "JASSUB-CANVAS-01",
"title": "Custom canvas registration throws after JASSUB Worker allocation",
"confirmation": "reproduced",
"status": "open",
"owners": [
"PKG-JASSUB-02",
"PKG-JASSUB-03"
],
"evidence": [
"refactor/changes/2026-09-14-PKG-JASSUB-02-failure-baseline.md",
"refactor/baselines/jassub-failures-validation.json"
],
"compatibleResolution": "Guard adapter-created parent styling; retain the supplied canvas and roll back failed registration.",
"closureCriteria": "Custom canvas registration succeeds and cleanup releases owned work while preserving caller DOM; public instance and option override behavior remain compatible."
},
{
"id": "JASSUB-CONSTRUCTION-01",
"title": "Worker construction failure leaves previously allocated JASSUB DOM and listeners",
"confirmation": "reproduced",
"status": "open",
"owners": [
"PKG-JASSUB-02",
"PKG-JASSUB-07"
],
"evidence": [
"refactor/changes/2026-09-14-PKG-JASSUB-02-failure-baseline.md",
"refactor/baselines/jassub-failures-validation.json"
],
"compatibleResolution": "Roll back each construction phase and rethrow the original error without assuming new returned an instance.",
"closureCriteria": "Controlled and native construction failure do not retain owned DOM/listeners/callbacks; original error identity and successful construction remain intact."
},
{
"id": "JASSUB-VIDEO-FRAME-01",
"title": "Old video frame callbacks can drive replacement subtitle time and duplicate chains",
"confirmation": "reproduced",
"status": "open",
"owners": [
"PKG-JASSUB-02",
"PKG-JASSUB-07"
],
"evidence": [
"refactor/changes/2026-09-14-PKG-JASSUB-02-failure-baseline.md",
"refactor/baselines/jassub-failures-validation.json"
],
"compatibleResolution": "Track video generation and callback ownership across setVideo and destroy.",
"closureCriteria": "Actually queued old callbacks cannot deliver stale mediaTime or rearm; replacement video owns one loop, with native evidence."
},
{
"id": "JASSUB-RATE-01",
"title": "Fallback ratechange sends an Event instead of numeric playbackRate",
"confirmation": "reproduced",
"status": "open",
"owners": [
"PKG-JASSUB-02",
"PKG-JASSUB-07"
],
"evidence": [
"refactor/changes/2026-09-14-PKG-JASSUB-02-failure-baseline.md",
"refactor/baselines/jassub-failures-validation.json"
],
"compatibleResolution": "Adapt event subscription to read the current video rate while retaining direct setRate(number).",
"closureCriteria": "Real ratechange and direct calls send numeric values, preserve timing, and avoid stale listeners after source replacement."
},
{
"id": "JASSUB-NATIVE-FRAME-01",
"title": "Windows WebKit default frame-clock path does not produce subtitle demands in actual playback",
"confirmation": "reproduced",
"status": "open",
"owners": [
"PKG-JASSUB-02",
"PKG-JASSUB-07",
"PKG-JASSUB-05"
],
"evidence": [
"refactor/changes/2026-09-14-PKG-JASSUB-02-failure-baseline.md",
"refactor/baselines/jassub-failures-validation.json"
],
"compatibleResolution": "Investigate native frame delivery and establish a compatible capability response; explicit event-clock success is diagnostic only.",
"closureCriteria": "Default native behavior is verified or an explicit reviewed environment limitation is recorded; no silent global fallback or substitution of diagnostic mode for default acceptance."
}
]
}
+239
View File
@@ -0,0 +1,239 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- Frozen failure observations use the repository runner.
import test from 'node:test'
import { jassubEnvironment, jassubHistorical } from '../../test/helpers/jassub.js'
const observations = []
const implementations = await jassubHistorical()
function environment(implementation) {
// Expose only VM platform constructors for controlled faults; vendor methods stay unchanged.
const code = `${implementation.code}\nObject.defineProperty(module.exports.default || module.exports, '__reviewPlatform', { value: { createCanvas: () => document.createElement('canvas'), setGlobal: (name, value) => { globalThis[name] = value }, ErrorEvent, Video: HTMLVideoElement } });`
const env = jassubEnvironment({ ...implementation, code })
env.platform = env.factory.__reviewPlatform
return env
}
function trackListeners(video) {
const listeners = new Map()
const add = video.addEventListener
const remove = video.removeEventListener
video.addEventListener = function (type, callback, ...options) {
if (!listeners.has(type))
listeners.set(type, new Set())
listeners.get(type).add(callback)
return add.call(this, type, callback, ...options)
}
video.removeEventListener = function (type, callback, ...options) {
listeners.get(type)?.delete(callback)
return remove.call(this, type, callback, ...options)
}
return () => [...listeners.values()].reduce((count, entries) => count + entries.size, 0)
}
function frames(env) {
const pending = new Map()
const cancelled = []
let next = 0
env.platform.Video.prototype.requestVideoFrameCallback = function (callback) {
pending.set(++next, { video: this, callback })
return next
}
env.platform.Video.prototype.cancelVideoFrameCallback = function (id) {
cancelled.push(id)
pending.delete(id)
}
return {
pending,
cancelled,
deliver(video, metadata) {
const entry = [...pending].find(([, row]) => row.video === video)
assert(entry, 'must deliver an actually queued frame callback')
const [id, row] = entry
pending.delete(id)
row.callback(0, metadata)
},
}
}
for (const implementation of implementations) {
test(`JASSUB failure baseline ${implementation.name}: fallback ratechange forwards the Event instead of the numeric media rate`, async () => {
const env = environment(implementation)
const { instance } = env.factory()(env.art)
await env.ready()
instance.setRate(2)
await env.flush()
assert.equal(env.workers[0].messages.at(-1).rate, 2)
env.art.video.playbackRate = 1.5
const event = new Event('ratechange')
env.art.video.dispatchEvent(event)
await env.flush()
const message = env.workers[0].messages.at(-1)
assert.equal(message.target, 'video')
assert.equal(message.rate, event)
assert.notEqual(message.rate, env.art.video.playbackRate)
observations.push({ implementation: implementation.name, case: 'rate-event-payload', mediaRate: 1.5, forwardedType: message.rate.type, directNumericRatePreserved: true })
instance.destroy()
})
test(`JASSUB failure baseline ${implementation.name}: direct destroy followed by art destroy throws on the removed container`, async () => {
const env = environment(implementation)
const active = trackListeners(env.art.video)
const { instance } = env.factory()(env.art)
await env.ready()
assert.equal(active(), 7)
instance.destroy()
assert.equal(active(), 0)
assert.equal(env.workers[0].terminated, 1)
assert.equal(env.parent.children.length, 1)
assert.throws(() => env.emit('destroy'), { code: 'ERR_ASSERTION' })
assert.throws(() => instance.destroy(), { code: 'ERR_ASSERTION' })
assert.equal(env.workers[0].terminated, 1)
observations.push({ implementation: implementation.name, case: 'repeated-destroy', throws: true, workerTerminations: 1 })
})
test(`JASSUB failure baseline ${implementation.name}: setVideo across parents makes destroy throw before releasing the worker`, async () => {
const env = environment(implementation)
const { instance } = env.factory()(env.art)
await env.ready()
const secondParent = new env.parent.constructor()
const secondVideo = secondParent.appendChild(new env.platform.Video())
const active = trackListeners(secondVideo)
instance.setVideo(secondVideo)
assert.equal(active(), 7)
assert.throws(() => instance.destroy(), { code: 'ERR_ASSERTION' })
assert.notEqual(instance._destroyed, true)
assert.equal(env.workers[0].terminated, 0)
assert.equal(env.parent.children.length, 2)
assert.equal(active(), 7)
observations.push({ implementation: implementation.name, case: 'set-video-parent', workerTerminations: 0, listenersRetained: active(), originalContainerRetained: true })
instance.setVideo(env.art.video)
instance.destroy()
})
test(`JASSUB failure baseline ${implementation.name}: a supplied canvas makes the adapter throw after allocating the worker`, async () => {
const env = environment(implementation)
const active = trackListeners(env.art.video)
const canvas = env.parent.appendChild(env.platform.createCanvas())
assert.throws(() => env.factory({ canvas })(env.art), { name: 'TypeError', message: /reading 'style'/ })
await env.flush()
assert.equal(env.workers.length, 1)
assert.equal(env.workers[0].terminated, 0)
assert.equal(env.workers[0].messages[0].target, 'init')
assert.equal(env.listeners.has('destroy'), false)
assert.equal(canvas.parentNode, env.parent)
assert.equal(active(), 7)
observations.push({ implementation: implementation.name, case: 'custom-canvas', workerTerminations: 0, listenersRetained: active(), hostDestroyHandlerRegistered: false, initSent: true })
env.workers[0].terminate()
})
test(`JASSUB failure baseline ${implementation.name}: Worker construction failure leaves the already inserted container and video listeners`, async () => {
const env = environment(implementation)
const active = trackListeners(env.art.video)
const failure = new DOMException('Controlled Worker construction failure', 'SecurityError')
env.platform.setGlobal('Worker', class {
constructor() { throw failure }
})
assert.throws(() => env.factory()(env.art), error => error === failure)
await env.flush()
assert.equal(env.workers.length, 0)
assert.equal(env.listeners.has('destroy'), false)
assert.equal(env.parent.children.length, 2)
assert.equal(env.parent.children[1].className, 'JASSUB')
assert.equal(active(), 7)
observations.push({ implementation: implementation.name, case: 'worker-construction-failure', originalErrorPreserved: true, insertedContainerRetained: true, listenersRetained: active() })
})
test(`JASSUB failure baseline ${implementation.name}: a previous video's queued frame drives the replacement video and creates a second loop`, async () => {
const env = environment(implementation)
const clock = frames(env)
const oldVideo = env.art.video
const { instance } = env.factory()(env.art)
await env.ready()
assert.equal(clock.pending.size, 1)
const newVideo = env.createVideo()
newVideo.currentTime = 9
instance.setVideo(newVideo)
await env.flush()
assert.equal(clock.pending.size, 2)
assert.equal(clock.cancelled.length, 0)
clock.deliver(oldVideo, { mediaTime: 123, width: 640, height: 360 })
await env.flush()
assert.equal(env.workers[0].messages.filter(item => item.target === 'demand').at(-1).time, 123)
assert.equal([...clock.pending.values()].filter(row => row.video === newVideo).length, 2)
observations.push({ implementation: implementation.name, case: 'old-video-frame', replacementCurrentTime: newVideo.currentTime, staleDemandTime: 123, replacementPendingCallbacks: 2, cancelledCallbacks: 0 })
instance.destroy()
})
test(`JASSUB containment baseline ${implementation.name}: destroy before ready terminates and detaches, but initialization continues after termination`, async () => {
const env = environment(implementation)
const active = trackListeners(env.art.video)
const { instance } = env.factory()(env.art)
const worker = env.workers[0]
const post = worker.postMessage
const afterTermination = []
worker.postMessage = function (message) {
if (this.terminated)
afterTermination.push(message.target)
return post.call(this, message)
}
let settled = false
instance._loaded.then(() => {
settled = true
})
instance.setTrack('[Script Info]')
instance.destroy()
await env.flush()
assert.equal(worker.terminated, 1)
assert.equal(env.parent.children.length, 1)
assert.equal(active(), 0)
assert.deepEqual(afterTermination, ['init'])
assert.equal(settled, false)
assert.equal(worker.messages.some(item => item.target === 'setTrack'), false)
observations.push({ implementation: implementation.name, case: 'destroy-before-ready', workerTerminations: 1, listenersRetained: 0, postAfterTermination: afterTermination, loadedSettledWithinFixtureFlush: settled })
})
test(`JASSUB containment baseline ${implementation.name}: Worker errors reach the instance and explicit host destroy still releases resources`, async () => {
const env = environment(implementation)
const active = trackListeners(env.art.video)
const { instance } = env.factory()(env.art)
const failure = new Error('Controlled Worker script load failure')
const errors = []
instance.addEventListener('error', event => errors.push(event.error))
env.workers[0].onerror(new env.platform.ErrorEvent('error', { error: failure }))
await env.flush()
assert.deepEqual(errors, [failure])
assert.equal(env.errors.at(-1), failure)
assert.equal(env.workers[0].terminated, 0)
assert.equal(active(), 7)
env.emit('destroy')
assert.equal(env.workers[0].terminated, 1)
assert.equal(env.parent.children.length, 1)
assert.equal(active(), 0)
observations.push({ implementation: implementation.name, case: 'worker-error', exactErrorForwarded: true, automaticallyTerminated: false, explicitHostCleanup: true })
})
test(`JASSUB containment baseline ${implementation.name}: the outstanding frame after destroy does no work and does not rearm`, async () => {
const env = environment(implementation)
const clock = frames(env)
const { instance } = env.factory()(env.art)
await env.ready()
instance.destroy()
await env.flush()
assert.equal(clock.pending.size, 1)
assert.equal(clock.cancelled.length, 0)
const before = env.workers[0].messages.length
clock.deliver(env.art.video, { mediaTime: 123, width: 640, height: 360 })
await env.flush()
assert.equal(env.workers[0].messages.length, before)
assert.equal(clock.pending.size, 0)
observations.push({ implementation: implementation.name, case: 'frame-after-destroy', activelyCancelled: false, callbackDoesNoWork: true, callbackRearms: false })
})
}
test.after(() => {
if (process.env.ARTPLAYER_JASSUB_FAILURE_REPORT)
fs.writeFileSync(process.env.ARTPLAYER_JASSUB_FAILURE_REPORT, `${JSON.stringify(observations, null, 2)}\n`)
})
+24 -3
View File
@@ -2434,11 +2434,14 @@
"ENG-03",
"ENG-05"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "ASS 字体、时钟/seek/倍率/resize、加载失败和销毁",
"acceptance": "旧版本行为可重跑,成功/失败/切源/销毁有必要断言",
"evidence": []
"evidence": [
"changes/2026-09-14-PKG-JASSUB-02-failure-baseline.md",
"baselines/jassub-failures-validation.json"
]
},
{
"id": "PKG-JASSUB-03",
@@ -2486,7 +2489,8 @@
],
"dependsOn": [
"PKG-JASSUB-04",
"CORE-22"
"CORE-22",
"PKG-JASSUB-07"
],
"status": "todo",
"risk": "H",
@@ -2511,6 +2515,23 @@
"acceptance": "tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录;来源身份已由01冻结,完整通知和未明确字体的发布处理必须闭环,不能以来源相同替代许可结论",
"evidence": []
},
{
"id": "PKG-JASSUB-07",
"phase": "5 包迁移:artplayer-plugin-jassub",
"title": "修复 vendor 生命周期与字幕时钟",
"scope": [
"artplayer-plugin-jassub"
],
"dependsOn": [
"PKG-JASSUB-03",
"PKG-JASSUB-02"
],
"status": "todo",
"risk": "H",
"deliverable": "独立记录 vendor 适配差异;幂等销毁、跨容器 setVideo、构造回滚、逐帧归属和倍率/能力边界",
"acceptance": "保持公开真实 instance、同步方法与资源路径;保留原 vendor 来源指纹并记录每项实际补丁;候选修复正例和真实字幕/失败/时钟验证通过,未证实设备不得冒充通过",
"evidence": []
},
{
"id": "PKG-MASK-01",
"phase": "5 包迁移:artplayer-plugin-danmuku-mask",
+9
View File
@@ -169,3 +169,12 @@ The startup time=6 row remains diagnostic because initial model work can coincid
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.
`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.
Windows WebKit currently fails the default frame-clock path; the explicitly
selected ARTPLAYER_JASSUB_ON_DEMAND=false diagnostic passes on the candidate core.
This is a retained historical failure, not a waived browser gate. The server must
serve WASM as application/wasm for native streaming compilation. Archive failed
and successful runs independently. See the PKG-JASSUB-02 change record.
+150
View File
@@ -0,0 +1,150 @@
import fs from 'node:fs'
import process from 'node:process'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { expect, test } from './fixtures.js'
const baseline = JSON.parse(fs.readFileSync('refactor/baselines/jassub-release.json', 'utf8'))
const release = baseline.release
const archive = await ensureArchive(release)
const member = `package/${release.manifest.main.replace(/^\.\//, '')}`
const code = readMember(archive, member).toString()
const onDemandRender = process.env.ARTPLAYER_JASSUB_ON_DEMAND !== 'false'
const subtitles = `[Script Info]
ScriptType: v4.00+
PlayResX: 640
PlayResY: 360
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Liberation Sans,36,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,20,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:00.00,0:00:04.00,Default,,0,0,0,,Before seek
Dialogue: 0,0:00:05.00,0:00:30.00,Default,,0,0,0,,After seek with actual WASM
`
for (const core of ['candidate', 'published', 'published-5.3.1-beta.1']) {
test(`JASSUB published ${release.version} renders actual WASM subtitles through seek and layout with ${core} core`, async ({ page, context }, testInfo) => {
const resources = []
const external = []
const origin = new URL(testInfo.project.use.baseURL).origin
await context.route('**/*', (route) => {
if (new URL(route.request().url()).origin !== origin) {
external.push(route.request().url())
return route.abort()
}
return route.continue()
})
context.on('response', (response) => {
if (response.url().includes('/assets/jassub/'))
resources.push({ url: response.url(), status: response.status(), contentType: response.headers()['content-type'] })
})
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: code })
await page.evaluate(() => {
window.jassubNative = { workers: [], snapshots: [], errors: [] }
const NativeWorker = window.Worker
window.Worker = class extends NativeWorker {
constructor(...args) {
super(...args)
this.observation = { url: String(args[0]), terminated: 0, sent: [], received: [] }
window.jassubNative.workers.push(this.observation)
this.addEventListener('message', ({ data }) => this.observation.received.push({ target: data.target, images: data.images?.length, width: data.width, height: data.height }))
}
postMessage(message, ...rest) {
this.observation.sent.push({ target: message.target, time: message.time, width: message.width, height: message.height })
return super.postMessage(message, ...rest)
}
terminate() {
this.observation.terminated++
return super.terminate()
}
}
window.createPlayer('/assets/sample/steve-jobs.mp4')
})
try {
await expect.poll(() => page.evaluate(() => window.art.duration)).toBeGreaterThan(30)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.locator('#pause').click()
await page.evaluate(async ({ subContent, onDemandRender }) => {
const register = window.artplayerPluginJassub.default || window.artplayerPluginJassub
window.art.plugins.add(register({
subContent,
workerUrl: '/assets/jassub/jassub-worker.js',
wasmUrl: '/assets/jassub/jassub-worker.wasm',
modernWasmUrl: '/assets/jassub/jassub-worker-modern.wasm',
availableFonts: { 'liberation sans': '/assets/jassub/default.woff2' },
fonts: ['/assets/jassub/default.woff2'],
offscreenRender: false,
onDemandRender,
}))
window.jassub = window.art.plugins.artplayerPluginJassub.instance
window.jassub.addEventListener('error', event => window.jassubNative.errors.push(String(event.error)))
await new Promise((resolve, reject) => {
window.jassub.addEventListener('ready', resolve, { once: true })
window.jassub.addEventListener('error', event => reject(event.error), { once: true })
})
window.jassubPixels = (phase) => {
const canvas = window.jassub._canvas
const rect = canvas.getBoundingClientRect()
const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data
let visible = 0
let signature = 0
for (let i = 3; i < pixels.length; i += 4) {
if (pixels[i] > 0) {
visible++
signature = (signature + i * pixels[i]) >>> 0
}
}
const state = { phase, visible, signature, width: canvas.width, height: canvas.height, cssWidth: rect.width, cssHeight: rect.height, time: window.art.currentTime, connected: canvas.isConnected }
window.jassubNative.lastPixel = state
if (phase)
window.jassubNative.snapshots.push(state)
return state
}
}, { subContent: subtitles, onDemandRender })
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.jassubPixels().visible)).toBeGreaterThan(100)
const before = await page.evaluate(() => window.jassubPixels('before-seek'))
expect(before.connected).toBe(true)
await page.locator('#pause').click()
await page.evaluate(async () => {
const seeked = new Promise(resolve => window.art.video.addEventListener('seeked', resolve, { once: true }))
window.art.currentTime = 10
await seeked
})
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.jassubPixels().signature)).not.toBe(before.signature)
await expect.poll(() => page.evaluate(() => window.jassubPixels().visible)).toBeGreaterThan(100)
const after = await page.evaluate(() => window.jassubPixels('after-seek'))
expect(after.time).toBeGreaterThanOrEqual(10)
expect(after.signature).not.toBe(before.signature)
await page.evaluate(() => {
window.art.fullscreenWeb = true
})
await expect.poll(() => page.evaluate(() => window.jassubPixels().cssWidth)).toBeGreaterThan(before.cssWidth)
await expect.poll(() => page.evaluate(() => window.jassubPixels().visible)).toBeGreaterThan(100)
await page.evaluate(() => window.jassubPixels('fullscreen'))
await testInfo.attach('actual-jassub-page', { body: await page.screenshot(), contentType: 'image/png' })
await page.evaluate(() => window.art.destroy(false))
expect(await page.evaluate(() => document.querySelectorAll('.JASSUB').length)).toBe(0)
expect(await page.evaluate(() => window.jassubNative.workers.map(worker => worker.terminated))).toEqual([1])
expect(await page.evaluate(() => window.jassubNative.errors)).toEqual([])
expect(external).toEqual([])
expect(resources.some(item => item.url.endsWith('.wasm') && item.status === 200 && item.contentType === 'application/wasm')).toBe(true)
expect(resources.some(item => item.url.endsWith('.woff2') && item.status === 200)).toBe(true)
}
finally {
const state = await page.evaluate(() => window.jassubNative).catch(error => ({ unavailable: error.message }))
await testInfo.attach('actual-jassub-evidence', { body: JSON.stringify({ core, onDemandRender, release: { version: release.version, integrity: release.integrity, member, sha256: hash(code) }, subtitleSha256: hash(subtitles), resources, external, state, limitation: 'Actual published plugin, worker, WASM and local font with offscreenRender=false. No default offscreen path, failure recovery, repeated destruction or device acceptance.' }, null, 2), contentType: 'application/json' })
if (!page.isClosed()) {
await page.evaluate(() => {
if (window.art && !window.art.isDestroy)
window.art.destroy()
})
}
}
})
}
+1 -1
View File
@@ -83,7 +83,7 @@ add('/test/audio-tone.m4a', fs.readFileSync(path.join(workspace, 'test/browser/m
add('/test/thumbnail-pattern.mp4', fs.readFileSync(path.join(workspace, 'test/browser/media/thumbnail-pattern.mp4')), { kind: 'generated-media', file: 'test/browser/media/thumbnail-pattern.mp4' })
add('/test/auto-thumbnail-timeline.mp4', fs.readFileSync(path.join(workspace, 'test/browser/media/auto-thumbnail-timeline.mp4')), { kind: 'generated-media', file: 'test/browser/media/auto-thumbnail-timeline.mp4' })
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.mp4': 'video/mp4', '.m4a': 'audio/mp4', '.webm': 'video/webm', '.vtt': 'text/vtt', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg' }
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.mp4': 'video/mp4', '.m4a': 'audio/mp4', '.webm': 'video/webm', '.vtt': 'text/vtt', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.wasm': 'application/wasm', '.woff2': 'font/woff2' }
add('/test/declaration-cues.vtt', fs.readFileSync(path.join(workspace, 'test/browser/media/declaration-cues.vtt')), { kind: 'test-subtitles', file: 'test/browser/media/declaration-cues.vtt' })
add('/test/thumbnail-grid.svg', fs.readFileSync(path.join(workspace, 'test/browser/media/thumbnail-grid.svg')), { kind: 'test-thumbnail-grid', file: 'test/browser/media/thumbnail-grid.svg' })
add('/test/legacy-safe-area.js', fs.readFileSync(path.join(workspace, 'test/helpers/legacy-safe-area.js')), { kind: 'frozen-own-source', file: 'test/helpers/legacy-safe-area.js', commit: 'ccf77c4e' })
+26
View File
@@ -76,4 +76,30 @@ for (const implementation of implementations) {
assert.equal(init.availableFonts['liberation sans'], './default.woff2')
result.instance.destroy()
})
test(`JASSUB ${implementation.name}: fallback clock keeps offsets and synchronous track replacement preserves message order`, async () => {
const env = jassubEnvironment(implementation)
const { instance } = env.factory({ timeOffset: -0.25 })(env.art)
await env.ready()
env.art.video.currentTime = 12
env.art.video.paused = false
env.art.video.dispatchEvent(new Event('playing'))
await env.flush()
const playing = env.workers[0].messages.at(-1)
assert.equal(playing.target, 'video')
assert.equal(playing.currentTime, 11.75)
assert.equal(playing.isPaused, false)
env.art.video.dispatchEvent(new Event('seeking'))
await env.flush()
assert.equal(env.workers[0].messages.at(-1).isPaused, true)
const start = env.workers[0].messages.length
assert.equal(instance.setTrackByUrl('/replacement.ass'), undefined)
assert.equal(instance.setTrack('[Script Info]\nTitle: Replacement'), undefined)
assert.equal(instance.freeTrack(), undefined)
await env.flush()
const messages = env.workers[0].messages.slice(start)
assert.deepEqual(messages.map(message => message.target), ['setTrackByUrl', 'setTrack', 'freeTrack'])
assert.equal(messages[0].url, '/replacement.ass')
assert.equal(messages[1].content, '[Script Info]\nTitle: Replacement')
instance.destroy()
})
}