diff --git a/refactor/baselines/auto-thumbnail-contract-validation.json b/refactor/baselines/auto-thumbnail-contract-validation.json new file mode 100644 index 000000000..e0e6b4b39 --- /dev/null +++ b/refactor/baselines/auto-thumbnail-contract-validation.json @@ -0,0 +1,93 @@ +{ + "schemaVersion": 1, + "task": "PKG-AUTO-THUMB-01", + "status": "done", + "parentCommit": "17befee4b1df7cf4ec29bbda4fd3271567f44223", + "checks": [ + { + "command": "node --test refactor/scripts/auto-thumbnail-contract.test.mjs refactor/scripts/contracts.test.mjs refactor/scripts/risk-register.test.mjs", + "result": "pass", + "tests": 47, + "packageContract": 38, + "indexValidation": 7, + "riskValidation": 2, + "log": "refactor/.cache/auto-thumbnail01-focused.log" + }, + { + "command": "focused eslint --no-fix", + "result": "pass" + }, + { + "command": "yarn ci:check", + "result": "pass", + "tests": { + "unit": 1456, + "engineering": 14, + "baseline": 232, + "total": 1702 + }, + "strictProductionTypeScriptFiles": 339, + "log": "refactor/.cache/auto-thumbnail01-ci.log", + "logSha256": "5f47030026c9cfad99830281a0b825bc2247c52bab7368a831e4b86ba914c880", + "warnings": [ + "Existing generated core editor declaration unused eslint suppression", + "Existing Yarn DEP0169 url.parse warning" + ] + } + ], + "archiveScope": { + "publishedVersions": [ + "1.1.0", + "1.0.1", + "1.0.0" + ], + "members": 16, + "workspaceFiles": 8, + "runnableRootVersions": [ + "1.1.0", + "1.0.1" + ], + "sourceOnlyFixture": "Actual 1.0.0 tarball source transformed by esbuild; not a runnable npm root entry", + "behaviorFixtures": 8, + "latestWorkspaceComparison": "All six matching 1.1.0 package members equal frozen workspace after LF normalization", + "supportedCoreRange": "Not inferred from Git associations 5.2.2/5.3.1" + }, + "limitations": [ + "Controlled DOM, seek and toBlob callbacks are not native decoding, pixel, CORS, device or browser evidence.", + "The eight fixtures include one explicitly source-only historical package; missing 1.0.0 main/legacy are never fabricated.", + "Lifecycle and encoder concerns remain source-observed pending task02 failure reproduction.", + "No production source, declarations, examples, versions or build artifacts change in task01.", + "Package-family exact case indexing remains incomplete and is not promoted by these frozen baseline results." + ], + "completedAt": "2026-09-12T21:47:22.218Z", + "inputs": [ + { + "file": "refactor/baselines/auto-thumbnail-release.json", + "sha256LF": "d87b151ed653dca83047132f1ed3ca474ef52124db7f6a20503204e74ba392ba" + }, + { + "file": "refactor/baselines/auto-thumbnail-contract.md", + "sha256LF": "dc14c8e51afbd86cedd1da08100812c97e459680b13021269dcb0117ce12a6db" + }, + { + "file": "refactor/scripts/auto-thumbnail-contract.mjs", + "sha256LF": "53871d5c387152b332f9e337ac23969de227533af8cd3196f8f5a444d2aa4f4d" + }, + { + "file": "refactor/scripts/auto-thumbnail-contract.test.mjs", + "sha256LF": "df43a7f581aec0dc286383cfe4eb64f5737b38e9b75bac1a410c85fb15e4e461" + }, + { + "file": "test/helpers/auto-thumbnail.js", + "sha256LF": "6c4fb8a27d5495578fb18df813458b2cb1ae467f0a182de14d75b72f2f760c3a" + }, + { + "file": "refactor/contract-policy.json", + "sha256LF": "420064c3288adfab9813a6163a6ab294c2dc2459d008cef3c82011293a19c0bb" + }, + { + "file": "refactor/scripts/contracts.test.mjs", + "sha256LF": "8492e6eafe5fa656cf84a726881a62766a13228f218d9d0894125b59759724e1" + } + ] +} diff --git a/refactor/baselines/auto-thumbnail-contract.md b/refactor/baselines/auto-thumbnail-contract.md new file mode 100644 index 000000000..7f2d8acb4 --- /dev/null +++ b/refactor/baselines/auto-thumbnail-contract.md @@ -0,0 +1,81 @@ +# Auto-thumbnail 历史契约 + +`PKG-AUTO-THUMB-01` 的基线是实际 npm 归档和固定 Git 输入。 +见 [归档清单](auto-thumbnail-release.json) 与 [验证结果](auto-thumbnail-contract-validation.json)。 +本步没有修改生产源码、公开声明、包版本或 dist;目标版本仍按原计划为 `2.0.0`。 + +## 发布范围与来源 + +2026-09-13(北京时间)读取 npm registry,latest 为 `1.1.0`,全部版本是 +`1.0.0`、`1.0.1`、`1.1.0`。三份原始 tarball 共 16 个文件逐一校验 +registry SHA-512、归档 SHA-256、成员 SHA-256;离线缓存缺失时按固定 URL 重新下载并校验。 +归档保存在忽略的 `.cache/releases`,可重跑脚本不依赖可变 latest。 + +| 版本 | 实际文件与入口 | 声明 | Git 关联核心 | +| --- | --- | --- | --- | +| 1.0.0 | 4 文件,只有源码/README/manifest/types;声明的 main、legacy 不存在 | export= / UMD namespace;错写 height、漏写 number | 5.2.2 | +| 1.0.1 | 6 文件,main/legacy 实际存在;CJS 为 `{ default: function }`;源码随包发布 | 同 1.0.0 | 5.2.2 | +| 1.1.0 | 6 文件,main/legacy 为直接函数;真实 mjs 仅 default;root/legacy exports | default;number 替换 height;仍误写同步 Result | 5.3.1 | +| 固定工作区 1.1.0 | 8 个输入,另含源码/demo;六个对应发布文件按 LF 完全一致 | 同 1.1.0 | 另测最终核心 | + +registry gitHead 只证明关联仓库状态,不证明构建可重现或该核心组合通过。 +三个 manifest 都没有 peerDependencies、运行依赖或声明支持范围。 +后续05至少验证实际核心 5.2.2、5.3.1、已发布稳定核心 5.4.0 与最终候选核心; +这些是验证目标,不能称为已支持的整个版本区间。 + +八份行为夹具包含两份可运行发布包的 main/legacy、三个工作区 source/main/legacy, +以及 **1.0.0 随包源码经 esbuild 转换的 source-only 夹具**。 +绝不将 source-only 写成可从 npm root 正常导入的 1.0.0。 +1.0.0/1.0.1 随包源码格式化/minify 后相同;1.1.0 去掉无用途的 loadedCount +以及手写 window 注入,正常运行契约相同;新构建仍有脚本全局入口。 + +## 必须保留的可用行为 + +- 函数/global 名 `artplayerPluginAutoThumbnail`;工厂返回注册函数。 + 注册同步安装 `video:loadedmetadata` 监听器,实际返回 Promise,resolve 后仅有 + `{ name: 'artplayerPluginAutoThumbnail' }`。不会在导入/注册时创建 video。 +- options 是活引用,每次核心 metadata 事件重新读取;url 优先显式 option.url, + 否则读取 `art.option.url`,不是任意假定的 `art.url`。width/number/scale 使用 `||` + 默认 160/100/1,旧调用中的 0 和空字符串走默认。声明上 options 必填。 +- 独立 video,`crossOrigin='anonymous'`;高度按视频比例向下取整。 + 永远十列;canvas 宽 width*10,高 height*ceil(number/10)。scale 只传给核心缩略图配置。 +- 帧时间为 duration*index/number(从 0 开始),不是 Thumbnail 工具的中点策略。 + 输出 JPEG,每次更新赋值核心 thumbnails,包含 url/height/column/number/width/scale。 + 保留渐进可用的拼图更新,不偷偷改成整段生成完才更新。 +- 旧声明中的 height 实际从未参与布局。迁移应兼容该参数的旧类型用法并明确它无效; + 不能突然赋予它改变既有运行结果的新含义。 + +## 观察到的缺陷与迁移边界 + +以下是历史对照,**不是要求候选永久保留的缺陷**。 + +1. 类型把 async 注册写成同步 Result;1.0.x/1.1.0 的 Option 和导出声明不同。 + 04须用实际安装的旧消费者验证,不用 `any` 或仅类型断言伪造 Promise 行为。 + 若完整推导兼容确实冲突,要列出具体旧调用再按用户的兼容决策流程处理。 +2. 1.0.1 CJS 使用者可能调用 `require(name).default`,1.1.0 直接调用 require 结果。 + 04/06须同时验证;不能因最新包不再有 .default 就忽略旧使用者。 +3. 1.0.0 缺运行入口;无需复制坏发布,但新 tarball 必须验证真实 root/legacy/ESM/types + 和历史源路径的处置。使用 `yarn pack`/仓库外安装,禁止以工作区软链接冒充安装证据。 +4. 没有 destroy/restart 清理,重复 metadata 创建多个解码任务;最终 Blob URL 无人释放。 + 02需复现切源、迟到 metadata/seek/blob、destroy 和跨实例;03明确任务与资源所有权。 +5. 每轮在 seek/draw **之前**排入 toBlob,包含第一个全空快照;第一轮先设 currentTime + 再装 onseeked。编码回调不串行,可能乱序覆盖;没有处理 null Blob、媒体/画布/核心 + setter 失败、非有限尺寸。02补可控故障回归和真实浏览器对照,再由03修复。 + 保留有效帧顺序/坐标/渐进更新;空白预览、失效更新、竞态不作为有效 API。 +6. options 省略时注册 Promise 正常 resolve,直到 metadata 回调才抛 TypeError。 + 当前声明不允许省略;修复输入行为需单独说明,不将既有失效调用宣传为可用功能。 + +## 验证与后续职责 + +`node --test refactor/scripts/auto-thumbnail-contract.test.mjs` 的38项核对上述版本、 +分发/声明/global/ESM、注册时序、活参数、默认值、帧时间、坐标、JPEG及旧URL替换顺序。 +VM 的 DOM/video/toBlob 可控替身 **不证明真实解码、CORS、画布像素或浏览器兼容性**。 + +02补失败复现;03在自有入口/任务/抽帧/清理模块落实修复;04迁严格 TS 和消费声明; +05使用真实核心与原生 video/canvas/Blob 跑三浏览器、切源、销毁、并发实例和故障; +06再验实际安装、入口、README/demo/编辑器和资源。优先 Chrome,不可用使用内置浏览器; +Windows WebKit 不能代替受支持 Safari 实机。相关缺证据保持显式待办。 + +真实示例是 `docs/assets/example/auto.thumbnail.js`,README 链接参数 `example=auto.thumbnail` +正确;样例视频文件存在。本步只核对文件,8082 demo、Monaco 和像素验证尚未执行。 +不要误改外部 thumbnail 插件示例,也不要混同独立 `artplayer-tool-thumbnail`。 diff --git a/refactor/baselines/auto-thumbnail-release.json b/refactor/baselines/auto-thumbnail-release.json new file mode 100644 index 000000000..fbaa7ea3c --- /dev/null +++ b/refactor/baselines/auto-thumbnail-release.json @@ -0,0 +1,197 @@ +{ + "schemaVersion": 1, + "task": "PKG-AUTO-THUMB-01", + "capturedAt": "2026-09-12T21:35:30.078Z", + "sourceCommit": "17befee4b1df7cf4ec29bbda4fd3271567f44223", + "registryObservation": { + "url": "https://registry.npmjs.org/artplayer-plugin-auto-thumbnail", + "status": 200, + "sha256": "52f735ef9be271f06f806995635b7b9a6ba1aedea9c1ad2c307a63788cd28986", + "versions": [ + "1.0.0", + "1.0.1", + "1.1.0" + ], + "latest": "1.1.0", + "workspaceVersion": "1.1.0" + }, + "release": { + "name": "artplayer-plugin-auto-thumbnail", + "version": "1.1.0", + "metadataUrl": "https://registry.npmjs.org/artplayer-plugin-auto-thumbnail/1.1.0", + "tarball": "https://registry.npmjs.org/artplayer-plugin-auto-thumbnail/-/artplayer-plugin-auto-thumbnail-1.1.0.tgz", + "integrity": "sha512-ALrcLv+qEDime+R8p4628RZTfMGlrpX6d7PLIQaYWsQEN3lPzVjcVcARCFpqnFI8gvRdqt21w72sJphf50xtVw==", + "sha256": "5ea83d4e45d42fdaf42d3e37b6c666857872adaa276ab329c74edcdaa3c1a978", + "archiveBytes": 2420, + "publishedAt": "2026-03-12T14:21:45.753Z", + "registryGitHead": "daf133b22630b4a0eecfa3336bbddab0e9119d96", + "files": { + "package/README.md": "fb5331337763bf50a3dfafab221dbbb4510cfb89a1816e81a42f6832b99a4546", + "package/dist/artplayer-plugin-auto-thumbnail.js": "ce1f1472bf02b836dd85b92eadf2f00786c02cde11a20ac2801136a0f675d12a", + "package/dist/artplayer-plugin-auto-thumbnail.legacy.js": "3561e51e0a5f294b56641bf0884a24a4a9e326582d2e99416a04f9526c7d0cc0", + "package/dist/artplayer-plugin-auto-thumbnail.mjs": "99da4748422bad54663c669d78963589a64c7cb02102e3544439fec7570a7f8b", + "package/package.json": "8d9e186c8767a88cd2f6b7d5fa07d35f9c5dad1a29bf321906d17dab7fc95504", + "package/types/artplayer-plugin-auto-thumbnail.d.ts": "33dc298c73da947f17bc3b8a73f3c4f96f4e176097de4f5a274f9d995aaf4e5b" + }, + "manifest": { + "name": "artplayer-plugin-auto-thumbnail", + "version": "1.1.0", + "description": "auto-thumbnail plugin for ArtPlayer", + "author": "Harvey Zhao ", + "license": "MIT", + "homepage": "https://artplayer.org", + "repository": { + "type": "git", + "url": "git+https://github.com/zhw2590582/ArtPlayer.git" + }, + "bugs": { + "url": "https://github.com/zhw2590582/ArtPlayer/issues" + }, + "keywords": [ + "html5", + "video", + "player" + ], + "exports": { + ".": { + "types": "./types/artplayer-plugin-auto-thumbnail.d.ts", + "import": "./dist/artplayer-plugin-auto-thumbnail.mjs", + "require": "./dist/artplayer-plugin-auto-thumbnail.js" + }, + "./legacy": { + "types": "./types/artplayer-plugin-auto-thumbnail.d.ts", + "import": "./dist/artplayer-plugin-auto-thumbnail.legacy.js", + "require": "./dist/artplayer-plugin-auto-thumbnail.legacy.js" + } + }, + "main": "./dist/artplayer-plugin-auto-thumbnail.js", + "module": "./dist/artplayer-plugin-auto-thumbnail.mjs", + "types": "./types/artplayer-plugin-auto-thumbnail.d.ts", + "legacy": "./dist/artplayer-plugin-auto-thumbnail.legacy.js", + "browserslist": "last 1 Chrome version" + }, + "missingEntrypoints": {}, + "historicalCore": { + "version": "5.3.1", + "commit": "daf133b22630b4a0eecfa3336bbddab0e9119d96", + "file": "packages/artplayer/package.json", + "sha256LF": "862f3697876fe915dd0ada178da766ce823aae7563ed30b3e5b11b811202929c", + "scope": "Git association only; no npm core integration result is implied." + } + }, + "previous": [ + { + "name": "artplayer-plugin-auto-thumbnail", + "version": "1.0.1", + "metadataUrl": "https://registry.npmjs.org/artplayer-plugin-auto-thumbnail/1.0.1", + "tarball": "https://registry.npmjs.org/artplayer-plugin-auto-thumbnail/-/artplayer-plugin-auto-thumbnail-1.0.1.tgz", + "integrity": "sha512-jewDuGQNqa9e0dDJ3DzC4UghnbJ1+sHgFbTVcVCPzlvh3teHSjjU3/173V5Lq8jaPCRosY3dJWUUi2ZRULf8JQ==", + "sha256": "e7e0e09865cf4b46350097137ec292a64e6feedece2c90f724f0498993bfa812", + "archiveBytes": 7619, + "publishedAt": "2025-03-01T03:23:42.583Z", + "registryGitHead": "38ef03d31f4d8c4fcfe1f94009625cfb55eac6c1", + "files": { + "package/README.md": "e3eddeee9ea9baf00b4df05d9623585f512622a0892aabf1d6636262f9741590", + "package/dist/artplayer-plugin-auto-thumbnail.js": "3d9756b7437f24372d34138002b46937bb09e7392b82fb1caf5f09460f151c46", + "package/dist/artplayer-plugin-auto-thumbnail.legacy.js": "e8253301c9d6d36bf6623caae52ef4019a8bf30522a52ee20850c2ed3a11769b", + "package/package.json": "eb03c9825d5229adc486dee0b560e9dc731fb4b9d4f3cbca45249fe235295dfb", + "package/src/index.js": "4bfd833404aa6b3f25594dc4544d62f516df90193e80d82f32bf234d081acfc0", + "package/types/artplayer-plugin-auto-thumbnail.d.ts": "2deb15e0e21225809b304b9f84adc1bed6ecce3a016f5b2fd5456bd597d3e7df" + }, + "manifest": { + "license": "MIT", + "version": "1.0.1", + "name": "artplayer-plugin-auto-thumbnail", + "types": "types/artplayer-plugin-auto-thumbnail.d.ts", + "description": "auto-thumbnail plugin for ArtPlayer", + "main": "dist/artplayer-plugin-auto-thumbnail.js", + "legacy": "dist/artplayer-plugin-auto-thumbnail.legacy.js", + "author": "Harvey Zack ", + "browserslist": "last 1 Chrome version", + "homepage": "https://artplayer.org", + "repository": { + "type": "git", + "url": "git+https://github.com/zhw2590582/ArtPlayer.git" + }, + "bugs": { + "url": "https://github.com/zhw2590582/ArtPlayer/issues" + }, + "keywords": [ + "html5", + "video", + "player" + ] + }, + "missingEntrypoints": {}, + "historicalCore": { + "version": "5.2.2", + "commit": "38ef03d31f4d8c4fcfe1f94009625cfb55eac6c1", + "file": "packages/artplayer/package.json", + "sha256LF": "59b29c249778ee2ab4fc603a35e5de1509bad5104565315fcfdcb7a5e30944a9", + "scope": "Git association only; no npm core integration result is implied." + } + }, + { + "name": "artplayer-plugin-auto-thumbnail", + "version": "1.0.0", + "metadataUrl": "https://registry.npmjs.org/artplayer-plugin-auto-thumbnail/1.0.0", + "tarball": "https://registry.npmjs.org/artplayer-plugin-auto-thumbnail/-/artplayer-plugin-auto-thumbnail-1.0.0.tgz", + "integrity": "sha512-IcGdX3ezuQSu4TtIxaYAu0je2MZYLKRzJiEfszKrnpRuPRqkYSbSioJo9e1dLmw5MpMLezqkuldrsb96FFUwww==", + "sha256": "0a907bc8dbf2ef773764645730309b2c95592f84b31f5c4f9cec636d29c0263e", + "archiveBytes": 1414, + "publishedAt": "2024-12-05T14:09:42.640Z", + "registryGitHead": "eb8cc22bf2524482c26187a6f9346fb0137bb9f6", + "files": { + "package/README.md": "e3eddeee9ea9baf00b4df05d9623585f512622a0892aabf1d6636262f9741590", + "package/package.json": "4f43e319293cd970215941476473185c87c67aafab2b027335fd30461310a707", + "package/src/index.js": "4bfd833404aa6b3f25594dc4544d62f516df90193e80d82f32bf234d081acfc0", + "package/types/artplayer-plugin-auto-thumbnail.d.ts": "2deb15e0e21225809b304b9f84adc1bed6ecce3a016f5b2fd5456bd597d3e7df" + }, + "manifest": { + "license": "MIT", + "version": "1.0.0", + "name": "artplayer-plugin-auto-thumbnail", + "types": "types/artplayer-plugin-auto-thumbnail.d.ts", + "description": "auto-thumbnail plugin for ArtPlayer", + "main": "dist/artplayer-plugin-auto-thumbnail.js", + "legacy": "dist/artplayer-plugin-auto-thumbnail.legacy.js", + "author": "Harvey Zack ", + "browserslist": "last 1 Chrome version", + "homepage": "https://artplayer.org", + "repository": { + "type": "git", + "url": "git+https://github.com/zhw2590582/ArtPlayer.git" + }, + "bugs": { + "url": "https://github.com/zhw2590582/ArtPlayer/issues" + }, + "keywords": [ + "html5", + "video", + "player" + ] + }, + "missingEntrypoints": { + "main": "dist/artplayer-plugin-auto-thumbnail.js", + "legacy": "dist/artplayer-plugin-auto-thumbnail.legacy.js" + }, + "historicalCore": { + "version": "5.2.2", + "commit": "eb8cc22bf2524482c26187a6f9346fb0137bb9f6", + "file": "packages/artplayer/package.json", + "sha256LF": "59b29c249778ee2ab4fc603a35e5de1509bad5104565315fcfdcb7a5e30944a9", + "scope": "Git association only; no npm core integration result is implied." + } + } + ], + "source": { + "packages/artplayer-plugin-auto-thumbnail/package.json": "8d9e186c8767a88cd2f6b7d5fa07d35f9c5dad1a29bf321906d17dab7fc95504", + "packages/artplayer-plugin-auto-thumbnail/README.md": "fb5331337763bf50a3dfafab221dbbb4510cfb89a1816e81a42f6832b99a4546", + "packages/artplayer-plugin-auto-thumbnail/src/index.js": "bc9102c014fbb7b87fe94dc71b779e24a88afb92f5e70a97cf26fcaa2d5a864f", + "packages/artplayer-plugin-auto-thumbnail/types/artplayer-plugin-auto-thumbnail.d.ts": "33dc298c73da947f17bc3b8a73f3c4f96f4e176097de4f5a274f9d995aaf4e5b", + "packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.js": "ce1f1472bf02b836dd85b92eadf2f00786c02cde11a20ac2801136a0f675d12a", + "packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.legacy.js": "3561e51e0a5f294b56641bf0884a24a4a9e326582d2e99416a04f9526c7d0cc0", + "packages/artplayer-plugin-auto-thumbnail/dist/artplayer-plugin-auto-thumbnail.mjs": "99da4748422bad54663c669d78963589a64c7cb02102e3544439fec7570a7f8b", + "docs/assets/example/auto.thumbnail.js": "e0f706bbbab8e2d0f3201ea6b91f1d5530c6bb65f5c1745dc8ade9acc6d66295" + } +} diff --git a/refactor/changes/2026-09-13-PKG-AUTO-THUMB-01-contract.md b/refactor/changes/2026-09-13-PKG-AUTO-THUMB-01-contract.md new file mode 100644 index 000000000..eae0eb1b5 --- /dev/null +++ b/refactor/changes/2026-09-13-PKG-AUTO-THUMB-01-contract.md @@ -0,0 +1,22 @@ +# PKG-AUTO-THUMB-01 发布和行为契约 + +固定 npm 三份真实归档/16成员、8份Git工作区输入,补可重跑归档验证与38项契约测试。 +捕获1.0.0缺main/legacy,1.0.1 CJS default对象与1.1.0直接函数的差异。 +首次类型断言发现1.0.x实际声明height而非number;测试改为分别锁定真实历史版本, +没有将旧类型假设覆盖到实际文件。原始下载和最终测试日志保存在.cache。 + +冻结正常异步注册、name、活参数/defaults、十列布局、帧时间、渐进JPEG更新和URL替换。 +声明的同步Result、无效height、旧导出形状和资源/异步缺陷登记为后续任务风险。 +1.0.0随包源码只作为source-only行为夹具,不冒充缺失的npm运行入口。 +npm1.1.0六个成员与冻结工作区对应文件按LF相同;不声称Git关联核心等于已验支持范围。 + +契约索引新增三个发布点,22工作区+20实际发布;264家族和10精确case数量不变, +没有自动把新测试提升为最终核心/设备/分发验收。新增测试由既有test:baseline自动执行, +无需新增依赖。生产源码/声明/示例/包版本/构建文件不变,无需为纯基线任务重建。 + +见[完整契约](../baselines/auto-thumbnail-contract.md)和 +[验证证据](../baselines/auto-thumbnail-contract-validation.json)。 +回退此任务提交即可去掉本次基线及索引/风险记录,不影响生产行为;不推送、不发布。 + +最终47项定向检查、完整CI1702项(1456单元+14工程+232基线)和339个生产TS严格检查通过。 +五项风险保持open,后续独立任务承接;本任务独立本地提交。 diff --git a/refactor/contract-index.md b/refactor/contract-index.md index 8e90a28c6..be3413af6 100644 --- a/refactor/contract-index.md +++ b/refactor/contract-index.md @@ -326,3 +326,6 @@ - PUB-artplayer-plugin-document-pip-1.0.0: artplayer-plugin-document-pip@1.0.0 (published-baseline);[来源](../refactor/baselines/dpip-release.json) JSON pointer `/previous/2`。 - PUB-artplayer-proxy-mediabunny-1.2.0: artplayer-proxy-mediabunny@1.2.0 (published-baseline);[来源](../refactor/baselines/mb-release.json) JSON pointer `/release`。 - PUB-artplayer-proxy-mediabunny-1.0.0: artplayer-proxy-mediabunny@1.0.0 (published-baseline);[来源](../refactor/baselines/mb-release.json) JSON pointer `/previous/0`。 +- PUB-artplayer-plugin-auto-thumbnail-1.1.0: artplayer-plugin-auto-thumbnail@1.1.0 (published-baseline);[来源](../refactor/baselines/auto-thumbnail-release.json) JSON pointer `/release`。 +- PUB-artplayer-plugin-auto-thumbnail-1.0.1: artplayer-plugin-auto-thumbnail@1.0.1 (published-baseline);[来源](../refactor/baselines/auto-thumbnail-release.json) JSON pointer `/previous/0`。 +- PUB-artplayer-plugin-auto-thumbnail-1.0.0: artplayer-plugin-auto-thumbnail@1.0.0 (published-baseline);[来源](../refactor/baselines/auto-thumbnail-release.json) JSON pointer `/previous/1`。 diff --git a/refactor/contract-policy.json b/refactor/contract-policy.json index 6efac4e65..c42e6da78 100644 --- a/refactor/contract-policy.json +++ b/refactor/contract-policy.json @@ -183,10 +183,13 @@ "API-12": "PKG-AUTO-THUMB-02" }, "versionIds": [ - "WS-artplayer-plugin-auto-thumbnail" + "WS-artplayer-plugin-auto-thumbnail", + "PUB-artplayer-plugin-auto-thumbnail-1.1.0", + "PUB-artplayer-plugin-auto-thumbnail-1.0.1", + "PUB-artplayer-plugin-auto-thumbnail-1.0.0" ], "supportWindow": "not-inferred-from-baseline", - "scopeReview": "Each public member, callback, option, event and capability requires package contract review; family ownership alone is not exhaustive coverage" + "scopeReview": "Three verified npm archives and a separate workspace snapshot. 1.0.0 lacks main/legacy and is not runnable through its declared entries. See auto-thumbnail-contract.md; source-only tests and Git core associations do not establish a supported range." }, { "name": "artplayer-plugin-chapter", @@ -882,6 +885,33 @@ "file": "refactor/baselines/mb-release.json", "pointer": "/previous/0", "archiveSha256": "d62f3ad75d07abbc8ca597846e213f34abd15b3742f7e87f294800fdef2e8962" + }, + { + "id": "PUB-artplayer-plugin-auto-thumbnail-1.1.0", + "package": "artplayer-plugin-auto-thumbnail", + "kind": "published-baseline", + "version": "1.1.0", + "file": "refactor/baselines/auto-thumbnail-release.json", + "pointer": "/release", + "archiveSha256": "5ea83d4e45d42fdaf42d3e37b6c666857872adaa276ab329c74edcdaa3c1a978" + }, + { + "id": "PUB-artplayer-plugin-auto-thumbnail-1.0.1", + "package": "artplayer-plugin-auto-thumbnail", + "kind": "published-baseline", + "version": "1.0.1", + "file": "refactor/baselines/auto-thumbnail-release.json", + "pointer": "/previous/0", + "archiveSha256": "e7e0e09865cf4b46350097137ec292a64e6feedece2c90f724f0498993bfa812" + }, + { + "id": "PUB-artplayer-plugin-auto-thumbnail-1.0.0", + "package": "artplayer-plugin-auto-thumbnail", + "kind": "published-baseline", + "version": "1.0.0", + "file": "refactor/baselines/auto-thumbnail-release.json", + "pointer": "/previous/1", + "archiveSha256": "0a907bc8dbf2ef773764645730309b2c95592f84b31f5c4f9cec636d29c0263e" } ], "cases": [ diff --git a/refactor/plan.md b/refactor/plan.md index 5be1d6978..bb4ef5c0b 100644 --- a/refactor/plan.md +++ b/refactor/plan.md @@ -4,7 +4,7 @@ 基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 222 项,范围 22 个包及工作区/示例。 -状态:todo 99 / doing 11 / blocked 0 / done 112 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 +状态:todo 98 / doing 11 / blocked 0 / done 113 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。 @@ -171,7 +171,7 @@ | ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 | | --- | --- | --- | --- | --- | --- | --- | -| PKG-AUTO-THUMB-01 | artplayer-plugin-auto-thumbnail
核对包契约与历史用法 | BASE-05 | url/width/number/scale、异步工厂和渐进缩略图更新 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | todo | +| PKG-AUTO-THUMB-01 | artplayer-plugin-auto-thumbnail
核对包契约与历史用法 | BASE-05 | url/width/number/scale、异步工厂和渐进缩略图更新 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done | | PKG-AUTO-THUMB-02 | artplayer-plugin-auto-thumbnail
建立特有行为与错误测试 | PKG-AUTO-THUMB-01, ENG-03, ENG-05 | 抽帧边界、短视频、失败、连续来源和销毁中的抽帧 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | todo | | PKG-AUTO-THUMB-03 | artplayer-plugin-auto-thumbnail
整理内部职责与资源 | PKG-AUTO-THUMB-02, CORE-09, CORE-19 | 隐藏 video、seek 队列、canvas 编码与 Blob URL 清理 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo | | PKG-AUTO-THUMB-04 | artplayer-plugin-auto-thumbnail
迁移自有源码和公开类型 | PKG-AUTO-THUMB-03, ENG-04, ENG-06, CORE-07 | 抽帧结果和真实异步插件返回类型兼容 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo | @@ -494,6 +494,7 @@ - PKG-AUDIO-03: [记录](changes/2026-09-12-PKG-AUDIO-03-lifecycle.md) [记录](baselines/audio-lifecycle-validation.json) - PKG-AUDIO-04: [记录](changes/2026-09-12-PKG-AUDIO-04-types.md) [记录](baselines/audio-types-validation.json) - PKG-AUDIO-05: [记录](changes/2026-09-12-PKG-AUDIO-05-combinations.md) [记录](baselines/audio-combinations-first.json) [记录](baselines/audio-combinations-checkpoint.json) [记录](audio-validation.md) +- PKG-AUTO-THUMB-01: [记录](baselines/auto-thumbnail-release.json) [记录](baselines/auto-thumbnail-contract.md) [记录](changes/2026-09-13-PKG-AUTO-THUMB-01-contract.md) [记录](baselines/auto-thumbnail-contract-validation.json) - 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 690ca3274..10c0a4245 100644 --- a/refactor/progress.md +++ b/refactor/progress.md @@ -1,5 +1,17 @@ # 进度与证据 +## PKG-AUTO-THUMB-01 完成:自动缩略图发布与行为基线 + +核实全部三个npm版本及16成员,冻结8份工作区输入。明确1.0.0缺main/legacy, +其随包源码仅作为source-only夹具;1.0.1 CJS为default对象,1.1.0为直接函数。 +发现旧声明height无效且漏number、注册Promise错写同步Result。38项契约固定正常 +注册/活参数/默认值/十列JPEG/渐进更新;47项定向检查及完整CI1702项通过,339生产TS。 +登记5项公开类型/导出/分发/资源/编码风险。生产源码/类型/版本/构建未改;02开始故障 +复现后03实施源码修复。真实解码、核心组合与设备证据仍属于后续05/06。 +见[契约](baselines/auto-thumbnail-contract.md)及[验证](baselines/auto-thumbnail-contract-validation.json)。 +222项:113 done、11 doing、98 todo。契约索引22工作区+20发布,未补齐的精确索引不冒充通过。 + + ## PKG-TOOL-THUMB-04 事件注册表修复检查点(doing) 复现并修复特殊事件名碰撞对象原型、继承getter/setter干扰,以及嵌套emit导致once重复执行。 diff --git a/refactor/risk-table.md b/refactor/risk-table.md index 1e931b706..2e79823de 100644 --- a/refactor/risk-table.md +++ b/refactor/risk-table.md @@ -183,3 +183,8 @@ | THUMB-MEDIA-01 | open / 已复现 | Windows WebKit native Blob URLs cannot load tested MP4 media despite successful HTTP controls | PKG-TOOL-THUMB-05, PKG-TOOL-THUMB-06 | | VENDOR-09 | resolved / 源码/产物事实 | Thumbnail tiny-emitter 改写来源与最终分发许可 | PKG-TOOL-THUMB-04, PKG-TOOL-THUMB-06 | | THUMB-EVENT-01 | resolved / 已复现 | Thumbnail 原型事件名与嵌套 once 派发缺陷 | PKG-TOOL-THUMB-04 | +| AUTO-THUMB-TYPE-01 | open / 已复现 | Published declarations misdescribe async registration and switch height/number option fields across generations | PKG-AUTO-THUMB-04 | +| AUTO-THUMB-EXPORT-01 | open / 已复现 | Published 1.0.1 CommonJS default namespace differs from the direct function exported by 1.1.0 | PKG-AUTO-THUMB-04, PKG-AUTO-THUMB-06 | +| AUTO-THUMB-DIST-01 | open / 已复现 | Published 1.0.0 archive lacks declared main and legacy runtimes | PKG-AUTO-THUMB-06 | +| AUTO-THUMB-LIFE-01 | open / 源码/产物事实 | Extraction tasks, decoder handlers and final object URLs lack source-switch and destroy ownership | PKG-AUTO-THUMB-02, PKG-AUTO-THUMB-03, PKG-AUTO-THUMB-05 | +| AUTO-THUMB-ENCODE-01 | open / 源码/产物事实 | Unserialized encodes include blank output and lack media/canvas/input failure handling | PKG-AUTO-THUMB-02, PKG-AUTO-THUMB-03, PKG-AUTO-THUMB-05 | diff --git a/refactor/risks.json b/refactor/risks.json index 3b32a5fdd..34c255921 100644 --- a/refactor/risks.json +++ b/refactor/risks.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "task": "BASE-07", "sourceCommit": "e01f39ff", - "updatedAt": "2026-09-12", + "updatedAt": "2026-09-13", "note": "Open means closure evidence is outstanding; source-observed and unverified are not reproduced runtime bugs. A source issue already mitigated in the workspace still requires candidate regression evidence. No accepted-with-scope item is implied by baseline task completion.", "items": [ { @@ -3998,6 +3998,91 @@ "test/browser/thumbnail-emitter.spec.js" ], "resolutionRationale": "Frozen workspace reproduces five failures. Own-key/data-descriptor registry and once-per-registration consumption pass source/main/legacy, actual browser and installed candidate checks while ordinary snapshot/ctx/off/duplicate behavior remains tested." + }, + { + "id": "AUTO-THUMB-TYPE-01", + "title": "Published declarations misdescribe async registration and switch height/number option fields across generations", + "confirmation": "reproduced", + "status": "open", + "owners": [ + "PKG-AUTO-THUMB-04" + ], + "evidence": [ + "refactor/baselines/auto-thumbnail-release.json", + "refactor/baselines/auto-thumbnail-contract.md", + "refactor/scripts/auto-thumbnail-contract.test.mjs" + ], + "compatibleResolution": "Preserve actual Promise runtime and old valid consumers; explicitly retain the historically ignored height field and verify paired CJS/ESM declarations.", + "closureCriteria": "Old and current isolated consumers, negative type cases and matching runtime registration pass; any irreconcilable inference change has an explicit user decision." + }, + { + "id": "AUTO-THUMB-EXPORT-01", + "title": "Published 1.0.1 CommonJS default namespace differs from the direct function exported by 1.1.0", + "confirmation": "reproduced", + "status": "open", + "owners": [ + "PKG-AUTO-THUMB-04", + "PKG-AUTO-THUMB-06" + ], + "evidence": [ + "refactor/baselines/auto-thumbnail-release.json", + "refactor/baselines/auto-thumbnail-contract.md", + "refactor/scripts/auto-thumbnail-contract.test.mjs" + ], + "compatibleResolution": "Exercise both old .default calls and direct require calls plus globals, actual ESM and legacy.", + "closureCriteria": "Installed tarballs and matching type consumers retain both usable calling forms with documented entry behavior." + }, + { + "id": "AUTO-THUMB-DIST-01", + "title": "Published 1.0.0 archive lacks declared main and legacy runtimes", + "confirmation": "reproduced", + "status": "open", + "owners": [ + "PKG-AUTO-THUMB-06" + ], + "evidence": [ + "refactor/baselines/auto-thumbnail-release.json", + "refactor/baselines/auto-thumbnail-contract.md", + "refactor/scripts/auto-thumbnail-contract.test.mjs" + ], + "compatibleResolution": "Keep the historical missing-entry baseline explicit; never substitute Git source as a released artifact.", + "closureCriteria": "Candidate pack has all declared runtime/types/assets and reviewed historical deep paths; missing old entries remain explicitly recorded." + }, + { + "id": "AUTO-THUMB-LIFE-01", + "title": "Extraction tasks, decoder handlers and final object URLs lack source-switch and destroy ownership", + "confirmation": "source-observed", + "status": "open", + "owners": [ + "PKG-AUTO-THUMB-02", + "PKG-AUTO-THUMB-03", + "PKG-AUTO-THUMB-05" + ], + "evidence": [ + "refactor/baselines/auto-thumbnail-release.json", + "refactor/baselines/auto-thumbnail-contract.md", + "refactor/scripts/auto-thumbnail-contract.test.mjs" + ], + "compatibleResolution": "Cancel obsolete work and release owned resources while retaining live options and progressive usable sheet updates.", + "closureCriteria": "Delayed callbacks, repeated metadata, switching, destroy, multiple instances and failure cleanup are reproduced then pass source/artifacts and native browser checks." + }, + { + "id": "AUTO-THUMB-ENCODE-01", + "title": "Unserialized encodes include blank output and lack media/canvas/input failure handling", + "confirmation": "source-observed", + "status": "open", + "owners": [ + "PKG-AUTO-THUMB-02", + "PKG-AUTO-THUMB-03", + "PKG-AUTO-THUMB-05" + ], + "evidence": [ + "refactor/baselines/auto-thumbnail-release.json", + "refactor/baselines/auto-thumbnail-contract.md", + "refactor/scripts/auto-thumbnail-contract.test.mjs" + ], + "compatibleResolution": "Preserve JPEG grid/time/scale while bounding jobs, sequencing valid updates and handling null Blob, invalid media and thrown callbacks.", + "closureCriteria": "Fault controls and native valid/corrupt media prove no stale sheet replacement, leaked URLs or unhandled async callbacks; capability limitations remain explicit." } ] } diff --git a/refactor/scripts/auto-thumbnail-contract.mjs b/refactor/scripts/auto-thumbnail-contract.mjs new file mode 100644 index 000000000..03f104adb --- /dev/null +++ b/refactor/scripts/auto-thumbnail-contract.mjs @@ -0,0 +1,38 @@ +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 { archiveFiles, ensureArchive, hash, readMember, refactorDir } from './releases.mjs' + +export async function verifyAutoThumbnailContract() { + const baseline = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/auto-thumbnail-release.json'), 'utf8')) + const archives = new Map() + for (const release of [baseline.release, ...baseline.previous]) { + const archive = await ensureArchive(release) + assert.deepEqual(archiveFiles(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}`) + assert.deepEqual(JSON.parse(readMember(archive, 'package/package.json')), release.manifest) + const missing = Object.fromEntries(['main', 'module', 'types', 'legacy'].filter(field => release.manifest[field] && !Object.hasOwn(release.files, `package/${release.manifest[field].replace(/^\.\//, '')}`)).map(field => [field, release.manifest[field]])) + assert.deepEqual(missing, release.missingEntrypoints, 'Missing entries are historical defects, not runnable artifacts') + const core = release.historicalCore + const source = execFileSync('git', ['show', `${core.commit}:${core.file}`], { encoding: 'utf8' }).replaceAll('\r\n', '\n') + assert.equal(hash(source), core.sha256LF) + assert.equal(JSON.parse(source).version, core.version) + archives.set(release.version, archive) + } + const sources = new Map() + for (const [file, expected] of Object.entries(baseline.source)) { + const source = execFileSync('git', ['show', `${baseline.sourceCommit}:${file}`], { encoding: 'utf8' }).replaceAll('\r\n', '\n') + assert.equal(hash(source), expected, `Auto-thumbnail frozen source: ${file}`) + sources.set(file, source) + } + return { baseline, archives, sources } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await verifyAutoThumbnailContract() + console.log('Auto-thumbnail verified: 3 actual npm archives, 16 members, 8 frozen workspace inputs; 1.0.0 missing main/legacy explicitly retained') +} diff --git a/refactor/scripts/auto-thumbnail-contract.test.mjs b/refactor/scripts/auto-thumbnail-contract.test.mjs new file mode 100644 index 000000000..237735e04 --- /dev/null +++ b/refactor/scripts/auto-thumbnail-contract.test.mjs @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +// eslint-disable-next-line test/no-import-node-test -- Node frozen contract runner. +import test from 'node:test' +import { transform } from 'esbuild' +import { autoThumbnailEnvironment, autoThumbnailHistorical } from '../../test/helpers/auto-thumbnail.js' +import { verifyAutoThumbnailContract } from './auto-thumbnail-contract.mjs' +import { readMember } from './releases.mjs' + +const contract = await verifyAutoThumbnailContract() +const implementations = await autoThumbnailHistorical() +const name = 'artplayer-plugin-auto-thumbnail' + +test('Auto-thumbnail freezes all three actual archives, sixteen members and eight workspace inputs', () => { + const { baseline } = contract + const releases = [baseline.release, ...baseline.previous] + assert.deepEqual(releases.map(item => item.version), ['1.1.0', '1.0.1', '1.0.0']) + assert.equal(releases.reduce((sum, item) => sum + Object.keys(item.files).length, 0), 16) + assert.equal(Object.keys(baseline.source).length, 8) + assert.deepEqual(releases.map(item => item.historicalCore.version), ['5.3.1', '5.2.2', '5.2.2']) + for (const release of releases) { + assert.equal(release.manifest.peerDependencies, undefined) + assert.equal(release.manifest.dependencies, undefined) + } +}) + +test('Auto-thumbnail 1.0.0 lacks both declared runtimes; its shipped source is explicitly a source-only fixture', async () => { + const { baseline, archives } = contract + const release = baseline.previous.find(item => item.version === '1.0.0') + assert.deepEqual(release.missingEntrypoints, { main: `dist/${name}.js`, legacy: `dist/${name}.legacy.js` }) + assert(!implementations.some(item => item.name === 'published-1.0.0-main' || item.name === 'published-1.0.0-legacy')) + assert(implementations.some(item => item.name === 'published-1.0.0-source-only')) + const sources = ['1.0.0', '1.0.1'].map(version => readMember(archives.get(version), 'package/src/index.js').toString()) + const compiled = await Promise.all(sources.map(source => transform(source, { format: 'esm', target: 'es2020', minify: true }))) + assert.equal(compiled[0].code, compiled[1].code, 'The two shipped source implementations differ only in formatting') +}) + +test('Auto-thumbnail historical declarations require options and falsely describe async registration as synchronous', () => { + const { baseline, archives, sources } = contract + const declarations = [baseline.release, ...baseline.previous].map(release => ({ version: release.version, source: readMember(archives.get(release.version), `package/${release.manifest.types.replace(/^\.\//, '')}`).toString() })) + declarations.push({ version: 'workspace', source: sources.get(`packages/${name}/types/${name}.d.ts`) }) + for (const { version, source } of declarations) { + assert.match(source, /\(option: Option\) => \(art: Artplayer\) => Result/) + assert.match(source, /url\?: string/) + for (const field of ['width', 'scale']) assert.match(source, new RegExp(`${field}\\?: number`)) + if (version === '1.0.0' || version === '1.0.1') { + assert.match(source, /height\?: number/) + assert.doesNotMatch(source, /number\?: number/) + assert.match(source, /export = artplayerPluginAutoThumbnail/) + assert.match(source, /export as namespace artplayerPluginAutoThumbnail/) + } + else { + assert.match(source, /number\?: number/) + assert.doesNotMatch(source, /height\?: number/) + assert.match(source, /export default artplayerPluginAutoThumbnail/) + } + assert.match(source, /name: ['"]artplayerPluginAutoThumbnail['"]/) + assert.doesNotMatch(source, /Promise { + for (const implementation of implementations.filter(item => !item.name.includes('source'))) { + const env = autoThumbnailEnvironment(implementation) + assert.equal(typeof env.factory, 'function') + assert.equal(typeof env.exported, implementation.name.startsWith('published-1.0.1-') ? 'object' : 'function') + if (typeof env.exported === 'object') + assert.deepEqual(Object.keys(env.exported), ['default']) + else assert.equal(env.exported.default, undefined) + const global = autoThumbnailEnvironment(implementation, { script: true }) + assert.equal(typeof global.factory, 'function') + assert.equal(global.videos.length, 0, 'Import/global setup itself creates no decoder') + } +}) + +test('Auto-thumbnail native ESM has only a default function and preserves asynchronous registration', async () => { + const { baseline, archives, sources } = contract + const codes = [readMember(archives.get('1.1.0'), `package/${baseline.release.manifest.module.replace(/^\.\//, '')}`).toString(), sources.get(`packages/${name}/dist/${name}.mjs`)] + for (const code of codes) { + const exports = await import(`data:text/javascript,${encodeURIComponent(code)}`) + assert.deepEqual(Object.keys(exports), ['default']) + let registration + const result = exports.default({})({ on: (...args) => { + registration = args + } }) + assert.equal(registration[0], 'video:loadedmetadata') + assert.equal(typeof result.then, 'function') + assert.deepEqual(await result, { name: 'artplayerPluginAutoThumbnail' }) + } +}) + +test('Auto-thumbnail README selects its own runnable demo; workspace demo and declarations are frozen separately from external thumbnail plugin', () => { + const { baseline, archives, sources } = contract + for (const release of [baseline.release, ...baseline.previous]) { + const readme = readMember(archives.get(release.version), 'package/README.md').toString() + assert.match(readme, /example=auto\.thumbnail/) + } + assert.match(sources.get(`packages/${name}/README.md`), /example=auto\.thumbnail/) + const example = sources.get('docs/assets/example/auto.thumbnail.js') + assert.match(example, /artplayerPluginAutoThumbnail\(\{/) + assert.match(example, /\/assets\/sample\/video\.mp4/) + assert(fs.existsSync('docs/assets/sample/video.mp4')) +}) + +for (const implementation of implementations) { + test(`Auto-thumbnail ${implementation.name}: registration installs one listener synchronously and returns Promise of name only`, async () => { + const env = autoThumbnailEnvironment(implementation) + const register = env.factory({}) + assert.equal(typeof register, 'function') + assert.equal(env.listeners.size, 0) + const result = register(env.art) + assert.equal(env.listeners.get('video:loadedmetadata').size, 1) + assert.equal(typeof result.then, 'function') + assert.equal(result.name, undefined) + assert.deepEqual({ ...await result }, { name: 'artplayerPluginAutoThumbnail' }) + assert.equal(env.videos.length, 0) + assert.deepEqual([...env.listeners.keys()], ['video:loadedmetadata']) + }) + + test(`Auto-thumbnail ${implementation.name}: defaults use truthiness and options remain live until each metadata event`, async () => { + const env = autoThumbnailEnvironment(implementation) + const options = { url: '', width: 0, number: 0, scale: 0 } + await env.factory(options)(env.art) + env.art.emit('video:loadedmetadata') + assert.equal(env.videos[0].src, 'original.mp4') + assert.equal(env.videos[0].crossOrigin, 'anonymous') + env.metadata() + assert.equal(env.canvases[0].width, 1600) + assert.equal(env.canvases[0].height, 900) + env.finish() + assert.deepEqual({ ...env.updates[0] }, { url: 'blob:auto-thumbnail-1', height: 90, column: 10, number: 100, width: 160, scale: 1 }) + Object.assign(options, { url: 'override.mp4', width: 80, number: 12, scale: 0.5 }) + env.art.option.url = 'switched.mp4' + env.art.emit('video:loadedmetadata') + assert.equal(env.videos[1].src, 'override.mp4') + env.metadata() + assert.equal(env.canvases[1].width, 800) + assert.equal(env.canvases[1].height, 90) + env.finish(1) + assert.deepEqual({ ...env.updates[1] }, { url: 'blob:auto-thumbnail-2', height: 45, column: 10, number: 12, width: 80, scale: 0.5 }) + }) + + test(`Auto-thumbnail ${implementation.name}: ten-column JPEG grid uses frame starts and progressive encodes including initial blank`, async () => { + const env = autoThumbnailEnvironment(implementation) + await env.factory({ width: 80, number: 12, scale: 2, height: 999 })(env.art) + env.art.emit('video:loadedmetadata') + env.metadata() + assert.equal(env.blobs.length, 1) + assert.equal(env.blobs[0].draws.length, 0, 'Old implementation encodes an empty sheet before drawing') + for (let i = 0; i < 12; i++) env.seeked() + assert.deepEqual(env.operations.filter(item => item.name === 'seek').map(item => item.value), Array.from({ length: 12 }, (_, i) => i * 10)) + assert.equal(env.operations.find(item => item.name === 'seek').handlerInstalled, false, 'First seek occurs before onseeked installation') + assert.equal(env.blobs.length, 13) + assert.equal(env.blobs.at(-1).draws.length, 12) + assert.deepEqual(env.canvases[0].draws.map(args => args.slice(1)), Array.from({ length: 12 }, (_, i) => [(i % 10) * 80, Math.floor(i / 10) * 45, 80, 45])) + for (let i = 0; i < env.blobs.length; i++) { + assert.equal(env.blobs[i].type, 'image/jpeg') + env.finish(i) + } + assert.equal(env.updates.length, 13) + assert.deepEqual(env.operations.filter(item => item.name === 'revokeURL').map(item => item.url), [null, ...Array.from({ length: 12 }, (_, i) => `blob:auto-thumbnail-${i + 1}`)]) + assert.deepEqual([...env.urls.keys()], ['blob:auto-thumbnail-13']) + assert.equal(env.updates.at(-1).number, 12) + assert.equal(env.updates.at(-1).scale, 2) + }) + + test(`Auto-thumbnail ${implementation.name}: omitted options fail only when metadata fires and no destroyed cleanup is registered`, async () => { + const env = autoThumbnailEnvironment(implementation) + const result = await env.factory()(env.art) + assert.equal(result.name, 'artplayerPluginAutoThumbnail') + assert.throws(() => env.art.emit('video:loadedmetadata'), /Cannot read properties of undefined/) + assert.equal(env.videos.length, 0) + assert.equal(env.listeners.has('destroy'), false) + assert.equal(env.listeners.has('restart'), false) + }) +} diff --git a/refactor/scripts/contracts.test.mjs b/refactor/scripts/contracts.test.mjs index 82e7a2278..0b788e5ec 100644 --- a/refactor/scripts/contracts.test.mjs +++ b/refactor/scripts/contracts.test.mjs @@ -44,7 +44,8 @@ function fixture(t) { test('Actual contract model assigns all 264 family rows and separates exact published versions from workspace snapshots', () => { assert.equal(actual.policy.packages.length, 22) assert.equal(actual.families.length, 12) - assert.equal(actual.policy.versions.filter(version => version.kind === 'published-baseline').length, 17) + assert.equal(actual.policy.versions.filter(version => version.kind === 'published-baseline').length, 20) + assert.deepEqual(actual.policy.versions.filter(version => version.kind === 'published-baseline' && version.package === 'artplayer-plugin-auto-thumbnail').map(version => version.version).sort(), ['1.0.0', '1.0.1', '1.1.0']) assert.deepEqual(actual.policy.versions.filter(version => version.kind === 'published-baseline' && version.package === 'artplayer-proxy-mediabunny').map(version => version.version).sort(), ['1.0.0', '1.2.0']) assert.equal(actual.policy.versions.filter(version => version.kind === 'workspace-baseline').length, 22) assert.equal(actual.policy.cases.length, 10) diff --git a/refactor/tasks.json b/refactor/tasks.json index b8d6c205a..8b89f74d6 100644 --- a/refactor/tasks.json +++ b/refactor/tasks.json @@ -1732,11 +1732,16 @@ "dependsOn": [ "BASE-05" ], - "status": "todo", + "status": "done", "risk": "H", "deliverable": "url/width/number/scale、异步工厂和渐进缩略图更新", "acceptance": "源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结", - "evidence": [] + "evidence": [ + "baselines/auto-thumbnail-release.json", + "baselines/auto-thumbnail-contract.md", + "changes/2026-09-13-PKG-AUTO-THUMB-01-contract.md", + "baselines/auto-thumbnail-contract-validation.json" + ] }, { "id": "PKG-AUTO-THUMB-02", diff --git a/test/helpers/auto-thumbnail.js b/test/helpers/auto-thumbnail.js new file mode 100644 index 000000000..68b0b833e --- /dev/null +++ b/test/helpers/auto-thumbnail.js @@ -0,0 +1,162 @@ +import fs from 'node:fs' +import process from 'node:process' +import vm from 'node:vm' +import { transform } from 'esbuild' +import { verifyAutoThumbnailContract } from '../../refactor/scripts/auto-thumbnail-contract.mjs' +import { readMember } from '../../refactor/scripts/releases.mjs' +import { compilePackage } from './load.js' + +export async function autoThumbnailHistorical() { + const { baseline, archives, sources } = await verifyAutoThumbnailContract() + const name = 'artplayer-plugin-auto-thumbnail' + const implementations = [] + for (const release of [baseline.release, ...baseline.previous]) { + if (release.missingEntrypoints.main) + continue + for (const field of ['main', 'legacy']) { + implementations.push({ name: `published-${release.version}-${field}`, code: readMember(archives.get(release.version), `package/${release.manifest[field].replace(/^\.\//, '')}`).toString() }) + } + } + implementations.push({ name: 'published-1.0.0-source-only', code: (await transform(readMember(archives.get('1.0.0'), 'package/src/index.js').toString(), { format: 'cjs', target: 'es2020' })).code }) + implementations.push({ name: 'frozen-workspace-source', code: (await transform(sources.get(`packages/${name}/src/index.js`), { format: 'cjs', target: 'es2020' })).code }) + for (const suffix of ['js', 'legacy.js']) { + implementations.push({ name: `frozen-workspace-${suffix}`, code: sources.get(`packages/${name}/dist/${name}.${suffix}`) }) + } + return implementations +} + +export async function autoThumbnailCandidate() { + if (process.env.ARTPLAYER_AUTO_THUMBNAIL_BASELINE === '1') + return (await autoThumbnailHistorical()).find(item => item.name === 'frozen-workspace-js') + return { name: 'candidate', code: process.env.ARTPLAYER_AUTO_THUMBNAIL_ARTIFACT ? fs.readFileSync(process.env.ARTPLAYER_AUTO_THUMBNAIL_ARTIFACT, 'utf8') : await compilePackage('artplayer-plugin-auto-thumbnail', 'umd') } +} + +// Controlled media events and deferred encodes are not evidence of native decoding. +export function autoThumbnailEnvironment(implementation, { script = false } = {}) { + const operations = [] + const videos = [] + const canvases = [] + const blobs = [] + const urls = new Map() + const listeners = new Map() + const updates = [] + let nextUrl = 0 + const controls = { nullContext: false, drawError: null, encodeError: null, updateError: null } + const document = { + createElement(tag) { + if (tag === 'video') { + const video = { + duration: 120, + videoWidth: 1920, + videoHeight: 1080, + onloadedmetadata: null, + onseeked: null, + onerror: null, + pause() { operations.push({ name: 'pause', video }) }, + load() { operations.push({ name: 'load', video }) }, + removeAttribute(name) { + delete this[name] + operations.push({ name: 'removeAttribute', attribute: name, video }) + }, + } + let time = 0 + Object.defineProperty(video, 'currentTime', { + get: () => time, + set(value) { + time = value + operations.push({ name: 'seek', value, handlerInstalled: typeof video.onseeked === 'function', video }) + }, + }) + videos.push(video) + return video + } + if (tag === 'canvas') { + const draws = [] + const ctx = { drawImage(...args) { + if (controls.drawError) + throw controls.drawError + draws.push(args) + operations.push({ name: 'draw', args }) + } } + const canvas = { + width: 0, + height: 0, + draws, + getContext() { return controls.nullContext ? null : ctx }, + toBlob(callback, type) { + if (controls.encodeError) + throw controls.encodeError + const encode = { callback, type, draws: draws.map(args => [...args]), canvas } + blobs.push(encode) + operations.push({ name: 'encode', encode }) + }, + } + canvases.push(canvas) + return canvas + } + throw new Error(`Unexpected element: ${tag}`) + }, + } + const module = { exports: {} } + const box = { document, URL: { + createObjectURL(blob) { + if (blob == null) + throw new TypeError('Blob required') + const url = `blob:auto-thumbnail-${++nextUrl}` + urls.set(url, blob) + operations.push({ name: 'createURL', url, blob }) + return url + }, + revokeObjectURL(url) { + urls.delete(url) + operations.push({ name: 'revokeURL', url }) + }, + }, window: {} } + if (!script) + Object.assign(box, { module, exports: module.exports }) + vm.runInNewContext(implementation.code, box) + const exported = script ? box.artplayerPluginAutoThumbnail || box.window.artplayerPluginAutoThumbnail : module.exports + const factory = typeof exported === 'function' ? exported : exported.default + const art = { + option: { url: 'original.mp4' }, + on(name, fn) { + if (!listeners.has(name)) + listeners.set(name, new Set()) + listeners.get(name).add(fn) + return this + }, + off(name, fn) { + listeners.get(name)?.delete(fn) + return this + }, + emit(name, ...args) { + for (const fn of [...(listeners.get(name) || [])]) fn(...args) + return this + }, + } + Object.defineProperty(art, 'thumbnails', { + get: () => updates.at(-1), + set(value) { + if (controls.updateError) + throw controls.updateError + updates.push(value) + }, + }) + return { + art, + factory, + exported, + box, + operations, + videos, + canvases, + blobs, + urls, + listeners, + updates, + controls, + metadata(video = videos.at(-1)) { video.onloadedmetadata?.() }, + seeked(video = videos.at(-1)) { video.onseeked?.() }, + finish(index = 0, blob = { type: 'image/jpeg' }) { blobs[index].callback(blob) }, + } +}