diff --git a/package.json b/package.json
index 269ba0ba8..5bda74426 100644
--- a/package.json
+++ b/package.json
@@ -51,7 +51,7 @@
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
- "test:unit": "node --test test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
+ "test:unit": "node --test test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
@@ -85,7 +85,8 @@
"test:iframe-types-package": "node refactor/scripts/iframe-package-types.mjs",
"test:thumbnail": "node --test test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js refactor/scripts/thumbnail-contract.test.mjs refactor/scripts/thumbnail-types.test.mjs",
"test:thumbnail-types-package": "node refactor/scripts/thumbnail-package-types.mjs",
- "test:auto-thumbnail": "node --test test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js refactor/scripts/auto-thumbnail-contract.test.mjs"
+ "test:auto-thumbnail": "node --test test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js refactor/scripts/auto-thumbnail-contract.test.mjs",
+ "test:vtt-thumbnail": "node --test test/vtt-thumbnail.test.js refactor/scripts/vtt-thumbnail-contract.test.mjs"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
diff --git a/refactor/baselines/vtt-thumbnail-failures.json b/refactor/baselines/vtt-thumbnail-failures.json
new file mode 100644
index 000000000..58ced9daa
--- /dev/null
+++ b/refactor/baselines/vtt-thumbnail-failures.json
@@ -0,0 +1,42 @@
+{
+ "schemaVersion": 1,
+ "task": "PKG-VTT-THUMB-02",
+ "status": "done",
+ "parentCommit": "d4ede650e8188dab3c21524cf2e524887412a436",
+ "checks": [
+ {
+ "command": "node --test test/vtt-thumbnail.test.js",
+ "result": "pass",
+ "tests": 108,
+ "log": "refactor/.cache/vtt-thumbnail02-initial.log"
+ },
+ {
+ "command": "yarn test:vtt-thumbnail",
+ "result": "pass",
+ "tests": 199,
+ "log": "refactor/.cache/vtt-thumbnail02-targeted.log"
+ },
+ {
+ "command": "yarn ci:check",
+ "result": "pass",
+ "tests": {
+ "unit": 1680,
+ "engineering": 14,
+ "baseline": 323,
+ "total": 2017
+ },
+ "log": "refactor/.cache/vtt-thumbnail02-ci.log",
+ "logSha256": "1d478933a55cde3b947b5a20a6dc7f1d346729a8afaa6adf5075365f7fcd0643"
+ }
+ ],
+ "fixture": {
+ "file": "docs/assets/sample/bbb-thumbnails.vtt",
+ "sha256": "1b88c30cf67d5c3da8281a150e8277858781c8077df0c6bfd2f32550da7323ba",
+ "cues": 120
+ },
+ "limitations": [
+ "Historical failures are reproduced, not fixed",
+ "Controlled host only; no native browser, installed package or final core claim",
+ "Twelve fixtures preserve actual archive/source distinctions from task 01"
+ ]
+}
diff --git a/refactor/changes/2026-09-13-PKG-VTT-THUMB-02-failures.md b/refactor/changes/2026-09-13-PKG-VTT-THUMB-02-failures.md
new file mode 100644
index 000000000..ff4f2fd07
--- /dev/null
+++ b/refactor/changes/2026-09-13-PKG-VTT-THUMB-02-failures.md
@@ -0,0 +1,35 @@
+# PKG-VTT-THUMB-02 异常与生命周期复现
+
+新增 test/vtt-thumbnail.test.js,108 项历史测试全部通过。这里的通过表示
+**成功复现历史结果**,不表示候选已经修复。十二个固定夹具复用 01 的归档/源码校验;
+1.0.0 仅有效随包源码,1.0.1–1.0.3 编译产物显式使用其可接受的 compact 时间戳。
+
+## 证据与处置
+
+| 场景 | 真实旧代码在可控 host 中的结果 | 03 的处置方向 |
+| --- | --- | --- |
+| fetch 尚未完成即 destroy | 没有请求选项或取消信号,返回后仍添加控件并响应 hover | 注册前建立生命周期,阻止迟到安装并处理取消 |
+| 移动拖动后 destroy | 500ms 计时器和 setBar 仍保留;迟到回调继续改样式 | 所有权覆盖监听器、计时器和控件,阻断销毁后写入 |
+| fetch 或 response.text 拒绝 | 原始异常通过注册 Promise 拒绝,无控件/计时器 | 保持原始拒绝,并释放新增生命周期资源 |
+| HTTP 404 但 body 为有效 VTT | 未检查 ok,仍显示图片 | 后续显式拒绝 HTTP 失败,作为缺陷修复记录 |
+| 空文本或仅 WEBVTT | 添加空控件,hover 隐藏 | 保留有效空结果,避免误判为请求异常 |
+| 错误时间、无 payload、无 hash、cue ID、NOTE | TypeError,安装之前拒绝 | 分开有效标准扩展与格式损坏,给出可定位错误,不静默写入坏 CSS |
+| 重排 yxhw、坏坐标、缺坐标 | 字符串按键映射;坏值直接形成 CSS | 正常几何保持,异常输入补明确检查,不能把重排键当成不存在 |
+| restart 后修改 option.vtt | 不重新请求,继续使用注册时的 cue 列表 | 不能未经公开行为评估新增自动重新下载 |
+| cue 间隙、NaN 进度 | 隐藏预览,不选择另一个 cue | 保持边界行为 |
+
+另用真实 docs/assets/sample/bbb-thumbnails.vtt 的 **120 个 cue**逐个触发命中,
+核对图片路径、x/y 背景位置、宽高。该文件 SHA 与运行结果在验证记录中;
+这仍是受控样式记录,不等于浏览器 CSS 接受了这些值或真实图片已加载。
+
+复现不修改候选生产源码、声明或 dist,也不增加依赖。新增 `yarn test:vtt-thumbnail`
+用于运行本包历史契约和故障测试;故障测试同时纳入 `test:unit`,CI 会持续执行。
+VTT-THUMB-LIFE-01 从 source-observed 提升为 reproduced,所有风险继续 open。
+
+## 后续任务与回退
+
+03 拆分纯解析/查找、请求、视图和注册资源,添加面向修复结果的候选测试,先在旧版上
+观察失败再验证候选;当前历史断言永久保留为对照,不能直接改成新行为消除差异。
+04 严格 TS 和声明消费者;05 真实核心/浏览器,06 构建安装包与 demo/编辑器。
+执行结果见 [验证记录](../baselines/vtt-thumbnail-failures.json)。本任务独立本地提交;
+回退只移除这批故障测试、命令和记录,不改变播放器运行逻辑。没有推送或发布。
diff --git a/refactor/environment-matrix.md b/refactor/environment-matrix.md
index 95256bfbb..ff3415673 100644
--- a/refactor/environment-matrix.md
+++ b/refactor/environment-matrix.md
@@ -57,7 +57,7 @@ Node10 是 TS 模块解析模式名,不代表支持 Node.js 10。现代 es2020
| artplayer-plugin-jassub / 1.1.0 | ASS、worker/WASM/font 加载、切源/销毁;短 ASS+明确来源字体 | 内嵌资源哈希已有;版本对应、跨源/worker 限制、实际渲染待 | PKG-JASSUB-01/02/05/06 |
| artplayer-plugin-multiple-subtitles / 1.2.0 | VTT 合并/排序/解析错误、URL 回收;双语言/空/坏 VTT | parser 来源头已观察;特有行为和浏览器字幕待 | PKG-MULTI-SUB-01/02/05/06 |
| artplayer-plugin-vast / 1.2.0 | Glomex/Google IMA、广告错误/恢复、重复初始化;供应商测试广告标签 | 依赖版本已记录;实际 IMA 网络/广告流程待,PR 用受控 adapter | PKG-VAST-01/02/05/06 |
-| artplayer-plugin-vtt-thumbnail / 1.1.0 | WebVTT 时间和 sprite xywh、图片失败/跨源;本地 VTT+sprite | 发布/浏览器/边界待 | PKG-VTT-THUMB-01/02/05/06 |
+| artplayer-plugin-vtt-thumbnail / 1.1.0 | WebVTT 时间和 sprite xywh、图片失败/跨源;本地 VTT+sprite | 五个实际发布归档和可控历史边界/故障已核对;原生图片、浏览器、核心组合仍待 05/06 | PKG-VTT-THUMB-01/02/05/06 |
| artplayer-proxy-canvas / 1.1.0 | video-like 属性/事件、Canvas 绘制、seek/销毁;MP4 | shim 与实际媒体顺序、跨源/移动待 | PKG-CANVAS-01/02/05/06 |
| artplayer-proxy-mediabunny / 1.2.0 | Mediabunny 1.56.1 当前解析;WebCodecs/AudioContext、HLS/轨道拓扑、AV 同步 | 库依赖不等于 codec 可用;准确 codec/profile、设备能力、长播放待 | PKG-MB-01/02/07/08/09/10 |
| artplayer-tool-iframe / 1.1.0 | 跨窗协议、ready/错误/销毁;同源与两个受控不同 origin 页面 | 消息/嵌套浏览器及沙箱权限待 | PKG-IFRAME-01/02/05/06 |
diff --git a/refactor/plan.md b/refactor/plan.md
index f59dddd89..fe5ac80b3 100644
--- a/refactor/plan.md
+++ b/refactor/plan.md
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 222 项,范围 22 个包及工作区/示例。
-状态:todo 95 / doing 12 / blocked 0 / done 115 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
+状态:todo 94 / doing 12 / blocked 0 / done 116 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -183,7 +183,7 @@
| ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 |
| --- | --- | --- | --- | --- | --- | --- |
| PKG-VTT-THUMB-01 | artplayer-plugin-vtt-thumbnail
核对包契约与历史用法 | BASE-05 | vtt/style、异步返回、预览控件名与样式契约 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | M | done |
-| PKG-VTT-THUMB-02 | artplayer-plugin-vtt-thumbnail
建立特有行为与错误测试 | PKG-VTT-THUMB-01, ENG-03, ENG-05 | VTT 解析、xywh、相对 URL、时间边界、请求失败和定位 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | M | todo |
+| PKG-VTT-THUMB-02 | artplayer-plugin-vtt-thumbnail
建立特有行为与错误测试 | PKG-VTT-THUMB-01, ENG-03, ENG-05 | VTT 解析、xywh、相对 URL、时间边界、请求失败和定位 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | M | done |
| PKG-VTT-THUMB-03 | artplayer-plugin-vtt-thumbnail
整理内部职责与资源 | PKG-VTT-THUMB-02, CORE-09, CORE-19 | 纯解析/区间查找与 DOM、请求/定时器生命周期分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | M | todo |
| PKG-VTT-THUMB-04 | artplayer-plugin-vtt-thumbnail
迁移自有源码和公开类型 | PKG-VTT-THUMB-03, ENG-04, ENG-06, CORE-07 | cue/rect/result 和参数可选性保持兼容 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | M | todo |
| PKG-VTT-THUMB-05 | artplayer-plugin-vtt-thumbnail
验证新旧核心和组合 | PKG-VTT-THUMB-04, CORE-22 | 鼠标和移动进度、全屏、切源、chapter 组合 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | M | todo |
@@ -498,6 +498,7 @@
- PKG-AUTO-THUMB-02: [记录](baselines/auto-thumbnail-failures.md) [记录](baselines/auto-thumbnail-failures-validation.json) [记录](changes/2026-09-13-PKG-AUTO-THUMB-02-failures.md)
- PKG-AUTO-THUMB-03: [记录](changes/2026-09-13-PKG-AUTO-THUMB-03-lifecycle-checkpoint.md) [记录](baselines/auto-thumbnail-lifecycle-checkpoint.json) [记录](changes/2026-09-13-PKG-AUTO-THUMB-03-hidden-renderer.md) [记录](baselines/auto-thumbnail-hidden-renderer.json) [记录](baselines/auto-thumbnail-timeline-media.json) [记录](changes/2026-09-13-PKG-AUTO-THUMB-03-frame-presentation.md) [记录](baselines/auto-thumbnail-frame-presentation.json)
- PKG-VTT-THUMB-01: [记录](baselines/vtt-thumbnail-release.json) [记录](baselines/vtt-thumbnail-contract.md) [记录](changes/2026-09-13-PKG-VTT-THUMB-01-contract.md)
+- PKG-VTT-THUMB-02: [记录](baselines/vtt-thumbnail-failures.json) [记录](changes/2026-09-13-PKG-VTT-THUMB-02-failures.md)
- PKG-HLS-01: [记录](baselines/hls-control-contract.md) [记录](baselines/hls-control-release.json) [记录](changes/2026-09-11-PKG-HLS-01-contract.md)
- PKG-HLS-02: [记录](hls-validation.md) [记录](baselines/hls-sdk.json) [记录](baselines/hls-validation.json) [记录](changes/2026-09-11-PKG-HLS-02-tests.md)
- PKG-HLS-03: [记录](changes/2026-09-12-PKG-HLS-03-modules.md) [记录](baselines/hls-modules-validation.json) [记录](hls-validation.md)
diff --git a/refactor/progress.md b/refactor/progress.md
index 9908038c8..aedb2a6b9 100644
--- a/refactor/progress.md
+++ b/refactor/progress.md
@@ -1,5 +1,15 @@
# 进度与证据
+## PKG-VTT-THUMB-02 特有失败与生命周期复现完成
+
+108 项新历史测试复现销毁后安装/计时器写入、请求拒绝、HTTP 状态忽略、非法 VTT/坐标、
+切源不重载和边界;真实 demo 的 120 cue 坐标逐条核对。包命令共 199 项通过,
+完整 CI 2017 项(1680 单元、14 工程、323 基线)通过。历史复现不是候选修复,
+原生图片/核心组合仍待 05/06;见[变更](changes/2026-09-13-PKG-VTT-THUMB-02-failures.md)和
+[验证](baselines/vtt-thumbnail-failures.json)。下一步 03 拆分并修复资源所有权,04 严格 TS。
+222 项:116 done、12 doing、94 todo;独立本地提交,不推送、不发布。
+
+
## PKG-VTT-THUMB-01 历史契约完成
五份真实 npm 归档、34 成员、十份 Git 输入已冻结,91 项包契约及 9 项索引/风险检查通过。
diff --git a/refactor/risk-table.md b/refactor/risk-table.md
index 7cbcbb493..1820d1246 100644
--- a/refactor/risk-table.md
+++ b/refactor/risk-table.md
@@ -191,6 +191,6 @@
| AUTO-THUMB-PIXEL-01 | open / 已复现 | Historical auto-thumbnail yields black sampled JPEG cells on Windows WebKit while played native video and static JPEG controls yield color | PKG-AUTO-THUMB-03, PKG-AUTO-THUMB-05 |
| VTT-THUMB-DIST-01 | open / 已复现 | Historical 1.0.0 main/legacy contain invalid regular expressions and cannot load | PKG-VTT-THUMB-06 |
| VTT-THUMB-PARSE-01 | open / 已复现 | Historical compiled arrows differ from shipped source; parsing floors bounds and assumes alternating lines | PKG-VTT-THUMB-02, PKG-VTT-THUMB-03 |
-| VTT-THUMB-LIFE-01 | open / 源码/产物事实 | Fetch completion, setBar listeners and mobile timers have no destroy ownership | PKG-VTT-THUMB-02, PKG-VTT-THUMB-03, PKG-VTT-THUMB-05 |
+| VTT-THUMB-LIFE-01 | open / 已复现 | Fetch completion, setBar listeners and mobile timers have no destroy ownership | PKG-VTT-THUMB-02, PKG-VTT-THUMB-03, PKG-VTT-THUMB-05 |
| VTT-THUMB-TYPE-01 | open / 已复现 | Published declarations falsely describe async registration as a synchronous result | PKG-VTT-THUMB-04 |
| VTT-THUMB-EXPORT-01 | open / 已复现 | Older CommonJS default objects and thumbnails control names differ from latest direct export and vtt-thumbnail name | PKG-VTT-THUMB-04, PKG-VTT-THUMB-05, PKG-VTT-THUMB-06 |
diff --git a/refactor/risks.json b/refactor/risks.json
index e3ca481d5..85118ae04 100644
--- a/refactor/risks.json
+++ b/refactor/risks.json
@@ -4149,7 +4149,9 @@
"evidence": [
"refactor/baselines/vtt-thumbnail-release.json",
"refactor/baselines/vtt-thumbnail-contract.md",
- "refactor/scripts/vtt-thumbnail-contract.test.mjs"
+ "refactor/scripts/vtt-thumbnail-contract.test.mjs",
+ "test/vtt-thumbnail.test.js",
+ "refactor/baselines/vtt-thumbnail-failures.json"
],
"compatibleResolution": "Preserve valid cue selection and geometry; test malformed data and standard VTT separately from old compiler defects.",
"closureCriteria": "Parser fixtures include malformed, empty, real demo VTT, fractional and shared bounds, relative URLs and historical accepted formats."
@@ -4157,7 +4159,7 @@
{
"id": "VTT-THUMB-LIFE-01",
"title": "Fetch completion, setBar listeners and mobile timers have no destroy ownership",
- "confirmation": "source-observed",
+ "confirmation": "reproduced",
"status": "open",
"owners": [
"PKG-VTT-THUMB-02",
@@ -4167,7 +4169,9 @@
"evidence": [
"refactor/baselines/vtt-thumbnail-release.json",
"refactor/baselines/vtt-thumbnail-contract.md",
- "refactor/scripts/vtt-thumbnail-contract.test.mjs"
+ "refactor/scripts/vtt-thumbnail-contract.test.mjs",
+ "test/vtt-thumbnail.test.js",
+ "refactor/baselines/vtt-thumbnail-failures.json"
],
"compatibleResolution": "Own requests, listeners, timers and controls per registration without stale writes or cross-instance cleanup.",
"closureCriteria": "Deferred request, destroy, failure, timer, repeated registration and native core scenarios demonstrate cleanup."
diff --git a/refactor/tasks.json b/refactor/tasks.json
index 931af6e15..4bfadafd7 100644
--- a/refactor/tasks.json
+++ b/refactor/tasks.json
@@ -1876,11 +1876,14 @@
"ENG-03",
"ENG-05"
],
- "status": "todo",
+ "status": "done",
"risk": "M",
"deliverable": "VTT 解析、xywh、相对 URL、时间边界、请求失败和定位",
"acceptance": "旧版本行为可重跑,成功/失败/切源/销毁有必要断言",
- "evidence": []
+ "evidence": [
+ "baselines/vtt-thumbnail-failures.json",
+ "changes/2026-09-13-PKG-VTT-THUMB-02-failures.md"
+ ]
},
{
"id": "PKG-VTT-THUMB-03",
diff --git a/test/vtt-thumbnail.test.js b/test/vtt-thumbnail.test.js
new file mode 100644
index 000000000..5733f01d2
--- /dev/null
+++ b/test/vtt-thumbnail.test.js
@@ -0,0 +1,138 @@
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+// eslint-disable-next-line test/no-import-node-test -- Frozen historical failure characterization.
+import test from 'node:test'
+import { acceptedVttText, vttText, vttThumbnailEnvironment, vttThumbnailHistorical } from './helpers/vtt-thumbnail.js'
+
+for (const implementation of await vttThumbnailHistorical()) {
+ const text = acceptedVttText(implementation)
+ test(`VTT-thumbnail ${implementation.name} historical: pending fetch creates controls after destroy without cancellation`, async () => {
+ const env = vttThumbnailEnvironment(implementation, { text, deferred: true })
+ const pending = env.factory({ vtt: '/old.vtt' })(env.art)
+ await env.emit('destroy')
+ assert.equal(env.requests[0].length, 1, 'No AbortSignal or request options')
+ env.resolve()
+ assert.equal((await pending).name, 'artplayerPluginVttThumbnail')
+ assert.equal(env.controls.length, 1)
+ await env.hover(0.3)
+ assert.equal(env.styles.backgroundImage, 'url(/sheet.jpg)')
+ assert.equal(env.listeners.get('destroy'), undefined)
+ })
+
+ test(`VTT-thumbnail ${implementation.name} historical: network and response-body failures preserve the original rejection`, async () => {
+ for (const phase of ['fetch', 'text']) {
+ const env = vttThumbnailEnvironment(implementation, { text, deferred: phase === 'fetch' })
+ const failure = new Error(`${phase} failed`)
+ if (phase === 'text')
+ env.box.fetch = async () => ({ text: async () => { throw failure } })
+ const pending = env.factory({ vtt: '/cues.vtt' })(env.art)
+ if (phase === 'fetch')
+ env.reject(failure)
+ await assert.rejects(pending, error => error === failure)
+ assert.equal(env.controls.length, 0)
+ assert.equal(env.listeners.size, 0)
+ assert.equal(env.timers.size, 0)
+ }
+ })
+
+ test(`VTT-thumbnail ${implementation.name} historical: HTTP failure status is ignored when its body parses`, async () => {
+ const env = vttThumbnailEnvironment(implementation)
+ env.box.fetch = async () => ({ ok: false, status: 404, text: async () => text })
+ await env.factory({ vtt: '/missing.vtt' })(env.art)
+ await env.hover(0.3)
+ assert.equal(env.styles.backgroundImage, 'url(/sheet.jpg)')
+ })
+
+ test(`VTT-thumbnail ${implementation.name} historical: empty and header-only responses create an empty hidden preview`, async () => {
+ for (const source of ['', 'WEBVTT\r\n\r\n']) {
+ const env = vttThumbnailEnvironment(implementation, { text: source })
+ await env.factory({ vtt: '/empty.vtt' })(env.art)
+ assert.equal(env.controls.length, 1)
+ await env.hover(0.3)
+ assert.equal(env.styles.display, 'none')
+ }
+ })
+
+ test(`VTT-thumbnail ${implementation.name} historical: unmatched timestamps, missing payload, cue IDs and NOTE reject before mounting`, async () => {
+ for (const source of [
+ 'WEBVTT\n\nnot a timestamp\nsheet.jpg#xywh=0,0,80,45',
+ 'WEBVTT\n\n00:00.000 --> 00:05.000',
+ 'WEBVTT\n\n00:00.000 --> 00:05.000\nsheet.jpg',
+ 'WEBVTT\n\ncue-id\n00:00.000 --> 00:05.000\nsheet.jpg#xywh=0,0,80,45',
+ 'WEBVTT\n\nNOTE comment\n\n00:00.000 --> 00:05.000\nsheet.jpg#xywh=0,0,80,45',
+ ]) {
+ const env = vttThumbnailEnvironment(implementation, { text: acceptedVttText(implementation, source) })
+ await assert.rejects(env.factory({ vtt: '/invalid.vtt' })(env.art), error => error.name === 'TypeError')
+ assert.equal(env.controls.length, 0)
+ }
+ })
+
+ test(`VTT-thumbnail ${implementation.name} historical: raw reordered xywh keys and malformed coordinate values reach styling`, async () => {
+ for (const [coordinates, expected] of [
+ ['yxhw=20,10,45,80', { width: '80px', height: '45px', backgroundPosition: '-10px -20px' }],
+ ['xywh=bad,-2,80', { width: '80px', height: 'undefinedpx', backgroundPosition: '-badpx --2px' }],
+ ]) {
+ const env = vttThumbnailEnvironment(implementation, { text: acceptedVttText(implementation, `WEBVTT\n\n00:00.000 --> 00:10.000\nsheet.jpg#${coordinates}`) })
+ await env.factory({ vtt: '/cues.vtt' })(env.art)
+ await env.hover(0.3)
+ for (const [key, value] of Object.entries(expected)) assert.equal(env.styles[key], value)
+ }
+ })
+
+ test(`VTT-thumbnail ${implementation.name} historical: restart retains the original cue list without another request`, async () => {
+ const env = vttThumbnailEnvironment(implementation, { text })
+ const options = { vtt: '/old/cues.vtt' }
+ await env.factory(options)(env.art)
+ options.vtt = '/new/cues.vtt'
+ await env.emit('restart', '/new-video.mp4')
+ await env.hover(0.7)
+ assert.equal(env.requests.length, 1)
+ assert.equal(env.styles.backgroundImage, 'url(/old/second.jpg)')
+ })
+
+ test(`VTT-thumbnail ${implementation.name} historical: gaps and non-finite percentages hide without selecting another cue`, async () => {
+ const env = vttThumbnailEnvironment(implementation, { text: acceptedVttText(implementation, vttText.replace('00:00:05.100', '00:00:07.100')) })
+ await env.factory({ vtt: '/cues.vtt' })(env.art)
+ await env.hover(0.3)
+ assert.equal(env.styles.backgroundImage, 'url(/sheet.jpg)')
+ await env.hover(0.6)
+ assert.equal(env.styles.display, 'none')
+ await env.hover(Number.NaN)
+ assert.equal(env.styles.display, 'none')
+ })
+
+ if (implementation.profile !== 'mouse-source-only') {
+ test(`VTT-thumbnail ${implementation.name} historical: destroy leaves the mobile timer and setBar callback active`, async () => {
+ const env = vttThumbnailEnvironment(implementation, { text, mobile: true })
+ await env.factory({ vtt: '/cues.vtt' })(env.art)
+ await env.hover(0.3, 'played', {})
+ const timer = [...env.timers.values()][0]
+ await env.emit('destroy')
+ assert.equal(env.timers.size, 1)
+ assert.equal(env.listeners.get('setBar').length, 1)
+ env.styles.display = 'sentinel'
+ timer.callback()
+ assert.equal(env.styles.display, 'none', 'A stale callback still writes the old element')
+ await env.hover(0.7)
+ assert.equal(env.styles.backgroundImage, 'url(/second.jpg)')
+ })
+ }
+}
+
+test('VTT-thumbnail frozen current implementation parses every cue in the real local demo VTT', async () => {
+ const implementation = (await vttThumbnailHistorical()).find(item => item.name === 'frozen-workspace-source')
+ const text = fs.readFileSync('docs/assets/sample/bbb-thumbnails.vtt', 'utf8')
+ const rows = text.match(/bbb-sprite\.jpg#xywh=[^\r\n]+/g)
+ assert(rows.length > 100)
+ const env = vttThumbnailEnvironment(implementation, { text })
+ await env.factory({ vtt: '/assets/sample/bbb-thumbnails.vtt' })(env.art)
+ env.art.duration = rows.length * 5
+ for (const [index, row] of rows.entries()) {
+ await env.hover((index * 5 + 2.5) / env.art.duration)
+ const [x, y, width, height] = row.split('=')[1].split(',')
+ assert.equal(env.styles.backgroundImage, 'url(/assets/sample/bbb-sprite.jpg)')
+ assert.equal(env.styles.backgroundPosition, `-${x}px -${y}px`)
+ assert.equal(env.styles.width, `${width}px`)
+ assert.equal(env.styles.height, `${height}px`)
+ }
+})