diff --git a/refactor/baselines/thumbnail-contract-validation.json b/refactor/baselines/thumbnail-contract-validation.json new file mode 100644 index 000000000..034b00960 --- /dev/null +++ b/refactor/baselines/thumbnail-contract-validation.json @@ -0,0 +1,79 @@ +{ + "schemaVersion": 1, + "task": "PKG-TOOL-THUMB-01", + "verifiedAt": "2026-09-12T19:16:08.007Z", + "startedFrom": "cb6c7677319f22074ae2ec09989022ddf0f8ea87", + "runtime": { + "node": "v24.21.0", + "yarn": "1.22.22", + "platform": "win32" + }, + "checks": { + "contract": { + "passed": 26, + "failed": 0, + "skipped": 0 + }, + "focusedLint": { + "exitCode": 0 + }, + "ci": { + "exitCode": 0, + "unit": 1365, + "engineering": 14, + "baseline": 191, + "total": 1570, + "productionTs": 330 + } + }, + "inputs": [ + { + "file": "refactor/baselines/thumbnail-release.json", + "sha256": "ad02af2abdb3185598b76a0c617eb5a5ffc89f89a7ef6f01ba8f3db0a87012be", + "algorithm": "sha256-lf" + }, + { + "file": "refactor/baselines/thumbnail-contract.md", + "sha256": "7048d504335f4e39c39d833bae36e7a3c6fccc6ad29d475781ca84158ff23278", + "algorithm": "sha256-lf" + }, + { + "file": "refactor/scripts/thumbnail-contract.mjs", + "sha256": "14ab6157f14bac00ecc4974f67e7afbe98001e7f6490e94da7839bf4556837fb", + "algorithm": "sha256-lf" + }, + { + "file": "refactor/scripts/thumbnail-contract.test.mjs", + "sha256": "77786d54a20a485203812be9145c13f1a9cbca62d3180993e6cc6ee296cb7437", + "algorithm": "sha256-lf" + }, + { + "file": "test/helpers/thumbnail.js", + "sha256": "18d3ab1a73867399ec32e99ba2c60a0427289fe9773e8cee6284e1d699520ed8", + "algorithm": "sha256-lf" + } + ], + "logs": [ + { + "file": "refactor/.cache/thumbnail01-contract.log", + "sha256": "c4b44783bffb1a997614d0ff5677d253a536302a1ef05c4d9fd975fd8effd75b", + "algorithm": "sha256-lf" + }, + { + "file": "refactor/.cache/thumbnail01-lint.log", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "algorithm": "sha256-lf" + }, + { + "file": "refactor/.cache/thumbnail01-ci.log", + "sha256": "fb0c351f0697efc3059f86f5efb7deb4a2ad25810e4733fc1a3054368c61d76c", + "algorithm": "sha256-lf" + } + ], + "limitations": [ + "Controlled DOM/media only; no new browser or coverage run.", + "Original npm archive unavailable; recovered CDN main verified byte-for-byte against frozen Git.", + "No production source/build/manifest/type changes; default compatibility decision and resource fixes remain open.", + "Existing generated core editor lint warning and Yarn url.parse deprecation remain; no new focused lint warnings." + ] +} diff --git a/refactor/baselines/thumbnail-contract.md b/refactor/baselines/thumbnail-contract.md new file mode 100644 index 000000000..eb9170412 --- /dev/null +++ b/refactor/baselines/thumbnail-contract.md @@ -0,0 +1,92 @@ +# Thumbnail tool compatibility baseline + +Task: PKG-TOOL-THUMB-01. This is a contract inventory, not completion of the +runtime migration or release acceptance. No production source or build output +changes in this task. Run `node --test refactor/scripts/thumbnail-contract.test.mjs`. + +## Evidence and version boundaries + +`thumbnail-release.json` freezes 11 HTTP observations, 12 baseline workspace Git +inputs and 8 historical Git inputs. The verifier checks bytes/hashes and core +associations without accessing the network. Registry observations are dated +snapshots, not permanent assertions about package availability. + +The registry returned unpublished metadata, listing releases through 3.5.31 and +an unpublish timestamp of 2024-05-26T01:46:10.518Z. Version metadata and original +tarballs for 3.5.31 and 4.4.0 were unavailable. Cached jsDelivr 3.5.31 package.json +and main JS were recovered. The main JS is byte-identical to Git commit +af1a116a31b088efdd77afa81c4b6565d12bd162; its manifest matches that commit except +Git's stale gitHead field. This proves the recovered runtime identity, **not a +complete original npm archive or original registry integrity**. The associated +historical core is 3.5.31, while baseline workspace tool 4.4.0 is associated with +core 5.4.1 at 40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f. + +| Surface | Recovered 3.5.31 | Baseline workspace 4.4.0 | +| --- | --- | --- | +| Main | Explicit dist/artplayer-tool-thumbnail.js | No main; require/default exports point to existing JS | +| ESM | No declared ESM entry | Declared .esm.js missing; actual .mjs exists | +| Types | No declaration entry | Declared types/artplayer-tool-thumbnail.d.ts missing | +| CSS | Declared dist/artplayer-tool-thumbnail.css; recovery returned 404 | No style entry | +| Emitter | Declared tiny-emitter ^2.1.0, bundled into recovered main | Local src/emitter.js; provenance review remains open | +| File event | Synchronous, before assigning video.src | Same | +| Video event | After delay (default 300 ms) | Synchronous after assigning video.src | +| Screenshot height | Configured height, clamped 10–1000 | Derived from source aspect ratio at start | +| Frame completion | Configured delay per frame; final delay * 2 | oncanplay per frame; no final delay | +| File input | Does not reset value | Resets event.target.value after load | +| Missing metadata | Timer calls unbound this.start | Timer preserves receiver but can poll indefinitely | + +The missing historical CSS observation does not prove it never shipped. The +missing workspace entries are checked against the actual frozen Git tree. +The same constructor call cannot simultaneously preserve both versions' height +and event timing defaults. This difference predates the refactor. Tasks 02–04 +must document a concrete compatibility decision before changing these defaults; +there is no user approval to silently reinterpret the recovered version. + +## Public surface to preserve + +CJS exports the constructor directly, with no self-default property. Script +global is `ArtplayerToolThumbnail`; workspace ESM has only the default export. +The constructor accepts an option object and requires a same-realm Element in +fileInput. A non-file-input element receives a transparent child file input. +Unknown option fields survive setup; number/width/column clamp without rounding. +DEFAULTS returns a fresh object and end is NaN. + +Keep historical spellings `creatVideo`, `creatScreenshotDate`, `creatCanvas`. +Other methods: setup, inputChange, ondrop, loadVideo, start, download, +errorHandle, destroy; static ondragover. Initial own fields are processing, +option, video, duration, inputChange and ondrop in that order. file, videoUrl, +thumbnailUrl, density and emitter e appear during use. setup/download and +on/once/emit/off return this; loadVideo/destroy return undefined. start returns +a promise after synchronous preflight, which can emit error and throw. + +Events: file(File), video(video element), canvas(canvas), update(blob URL, +progress), done(), download(filename), error(message), destroy(). Error listeners +run before synchronous errors escape. Emitter preserves context, listener +snapshots and removal of once listeners by their original callback. + +Grid samples interval midpoints, lays rows by column and appends a 30 px footer +with the existing attribution and geometry. Download replaces the final filename +extension with .png; a filename without a dot currently becomes .png. + +## Defects and remaining validation + +Historical and workspace tests reproduce undefined static drop listener binding +(the callable method is on the instance), non-idempotent destroy, and odd +extensionless download names. Source review additionally finds unreleased old +video URLs on replacement, no pending extraction/timer cancellation, generated +input ownership gaps and asynchronous callback paths that can remain unsettled. +These are repair candidates, not requirements to perpetuate defects. Task 02 +must reproduce asynchronous paths; 03 must pair fixes with regression evidence. + +The 26 Node contract cases use controlled DOM/media objects. They establish +export shape, event timing, defaults, geometry and deterministic legacy behavior; +they do not prove native decoding, seek readiness, toBlob failure behavior or +real browser cleanup. Task 02 adds extraction/failure sequences; 05 validates +real media and generated sheets with old/new cores; 06 verifies installed +tarballs, old paths, CSS disposition and the actual docs editor on port 8082. + +The tool demo is `docs/assets/example/tool.thumbnail.js`. Current +`docs/assets/example/thumbnail.js` is an external thumbnail plugin and must not +be substituted. The package README's example=thumbnail link needs correction +with final demo verification. Emitter source/license attribution needs evidence +before classifying it as fully self-authored or removing dependency notices. diff --git a/refactor/baselines/thumbnail-release.json b/refactor/baselines/thumbnail-release.json new file mode 100644 index 000000000..7ad0a4815 --- /dev/null +++ b/refactor/baselines/thumbnail-release.json @@ -0,0 +1,172 @@ +{ + "schemaVersion": 1, + "task": "PKG-TOOL-THUMB-01", + "capturedAt": "2026-09-12T19:02:10.350Z", + "sourceCommit": "40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f", + "observations": [ + { + "url": "https://registry.npmjs.org/artplayer-tool-thumbnail", + "status": 200, + "contentType": "application/json", + "sha256": "1a5bbf4d14852e0dba2ed51351818bddf742a1278b46ef60366a6a544d443bcf", + "bytes": 2552, + "text": "{\"_id\":\"artplayer-tool-thumbnail\",\"name\":\"artplayer-tool-thumbnail\",\"time\":{\"created\":\"2018-12-17T07:19:51.643Z\",\"1.0.6\":\"2018-12-17T07:19:51.763Z\",\"modified\":\"2024-05-26T01:46:10.518Z\",\"1.0.7\":\"2019-04-15T12:31:02.044Z\",\"1.0.8\":\"2019-04-15T12:37:58.551Z\",\"1.0.9\":\"2019-04-16T02:15:59.423Z\",\"3.1.5\":\"2019-04-16T03:47:41.590Z\",\"3.1.6\":\"2019-04-19T09:01:49.496Z\",\"3.1.7\":\"2019-04-22T22:56:08.033Z\",\"3.1.8\":\"2019-05-03T14:22:48.767Z\",\"3.1.9\":\"2019-05-12T22:09:41.935Z\",\"3.1.10\":\"2019-07-19T09:55:45.112Z\",\"3.1.12\":\"2019-07-19T10:25:47.480Z\",\"3.1.13\":\"2019-08-15T00:37:21.970Z\",\"3.1.14\":\"2019-08-21T03:16:24.138Z\",\"3.1.15\":\"2019-09-15T08:17:18.441Z\",\"3.1.16\":\"2019-09-25T04:44:37.557Z\",\"3.1.17\":\"2019-11-10T07:53:32.969Z\",\"3.1.18\":\"2019-11-11T01:22:47.207Z\",\"3.1.19\":\"2019-11-20T01:50:06.770Z\",\"3.2.3\":\"2019-11-22T13:38:56.548Z\",\"3.2.4\":\"2019-11-22T13:42:09.267Z\",\"3.2.5\":\"2019-11-28T03:55:29.655Z\",\"3.2.6\":\"2019-12-02T00:40:50.888Z\",\"3.3.1\":\"2019-12-09T09:38:40.061Z\",\"3.3.2\":\"2019-12-11T09:11:27.149Z\",\"3.3.3\":\"2020-02-24T03:20:49.402Z\",\"3.4.1\":\"2020-02-27T09:05:21.228Z\",\"3.4.2\":\"2020-02-28T01:17:51.271Z\",\"3.5.1\":\"2020-03-21T23:37:20.360Z\",\"3.5.2\":\"2020-04-14T05:00:02.544Z\",\"3.5.4\":\"2020-04-27T03:45:46.217Z\",\"3.5.5\":\"2020-05-20T09:16:01.958Z\",\"3.5.8\":\"2020-05-21T02:21:53.746Z\",\"3.5.9\":\"2020-05-29T14:06:45.737Z\",\"3.5.10\":\"2020-08-27T08:28:59.925Z\",\"3.5.11\":\"2020-09-02T14:10:51.585Z\",\"3.5.12\":\"2020-09-04T00:57:36.451Z\",\"3.5.13\":\"2020-09-04T01:05:29.301Z\",\"3.5.14\":\"2020-09-04T14:41:02.505Z\",\"3.5.15\":\"2020-09-23T07:02:37.076Z\",\"3.5.17\":\"2020-10-18T06:50:21.989Z\",\"3.5.18\":\"2020-11-07T14:19:34.420Z\",\"3.5.19\":\"2020-11-09T13:32:20.716Z\",\"3.5.20\":\"2020-11-21T00:43:09.744Z\",\"3.5.21\":\"2020-11-22T13:22:03.785Z\",\"3.5.22\":\"2020-11-23T12:28:59.918Z\",\"3.5.23\":\"2020-11-24T00:03:18.563Z\",\"3.5.24\":\"2020-11-24T06:11:25.292Z\",\"3.5.26\":\"2020-12-01T04:01:24.276Z\",\"3.5.27\":\"2020-12-17T07:35:02.960Z\",\"3.5.28\":\"2021-05-06T05:10:54.117Z\",\"3.5.29\":\"2021-05-11T02:37:37.309Z\",\"3.5.30\":\"2021-05-20T01:39:24.688Z\",\"3.5.31\":\"2021-11-01T01:38:03.589Z\",\"unpublished\":{\"time\":\"2024-05-26T01:46:10.518Z\",\"versions\":[\"1.0.6\",\"1.0.7\",\"1.0.8\",\"1.0.9\",\"3.1.10\",\"3.1.12\",\"3.1.13\",\"3.1.14\",\"3.1.15\",\"3.1.16\",\"3.1.17\",\"3.1.18\",\"3.1.19\",\"3.1.5\",\"3.1.6\",\"3.1.7\",\"3.1.8\",\"3.1.9\",\"3.2.3\",\"3.2.4\",\"3.2.5\",\"3.2.6\",\"3.3.1\",\"3.3.2\",\"3.3.3\",\"3.4.1\",\"3.4.2\",\"3.5.1\",\"3.5.10\",\"3.5.11\",\"3.5.12\",\"3.5.13\",\"3.5.14\",\"3.5.15\",\"3.5.17\",\"3.5.18\",\"3.5.19\",\"3.5.2\",\"3.5.20\",\"3.5.21\",\"3.5.22\",\"3.5.23\",\"3.5.24\",\"3.5.26\",\"3.5.27\",\"3.5.28\",\"3.5.29\",\"3.5.30\",\"3.5.31\",\"3.5.4\",\"3.5.5\",\"3.5.8\",\"3.5.9\"]}}}" + }, + { + "url": "https://registry.npmjs.org/artplayer-tool-thumbnail/4.4.0", + "status": 404, + "contentType": "application/json", + "sha256": "a29f419cca9cebf83537c6e6f756310c63d1452c02a1e49b1a6869b33ef0f982", + "bytes": 26, + "text": "\"version not found: 4.4.0\"" + }, + { + "url": "https://registry.npmjs.org/artplayer-tool-thumbnail/3.5.31", + "status": 404, + "contentType": "application/json", + "sha256": "7b65582bd77080c46c5a306ef6108709b5b8919a678498b2d9dda8c6fcf9e49e", + "bytes": 27, + "text": "\"version not found: 3.5.31\"" + }, + { + "url": "https://registry.npmjs.org/artplayer-tool-thumbnail/-/artplayer-tool-thumbnail-3.5.31.tgz", + "status": 404, + "contentType": "application/json", + "sha256": "c8d3eae160a892e32837db3dcae515e843e5383fef52b8141940c8bcf8b6d59f", + "bytes": 21, + "text": "{\"error\":\"Not found\"}" + }, + { + "url": "https://registry.npmjs.org/artplayer-tool-thumbnail/-/artplayer-tool-thumbnail-4.4.0.tgz", + "status": 404, + "contentType": "application/json", + "sha256": "c8d3eae160a892e32837db3dcae515e843e5383fef52b8141940c8bcf8b6d59f", + "bytes": 21, + "text": "{\"error\":\"Not found\"}" + }, + { + "url": "https://cdn.jsdelivr.net/npm/artplayer-tool-thumbnail@3.5.31/package.json", + "status": 200, + "contentType": "application/json; charset=utf-8", + "sha256": "f9607cb3c5d6edfbfc417b71abdf88148da1ed8ed5d23b03017ba851b2b6c9de", + "bytes": 799, + "text": "{\n \"name\": \"artplayer-tool-thumbnail\",\n \"version\": \"3.5.31\",\n \"expose\": \"ArtplayerToolThumbnail\",\n \"description\": \"thumbnail tool for ArtPlayer\",\n \"main\": \"dist/artplayer-tool-thumbnail.js\",\n \"style\": \"dist/artplayer-tool-thumbnail.css\",\n \"scripts\": {\n \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\"\n },\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+https://github.com/zhw2590582/ArtPlayer.git\"\n },\n \"keywords\": [\n \"video\",\n \"player\"\n ],\n \"author\": \"Harvey Zack \",\n \"license\": \"MIT\",\n \"bugs\": {\n \"url\": \"https://github.com/zhw2590582/ArtPlayer/issues\"\n },\n \"homepage\": \"https://github.com/zhw2590582/ArtPlayer#readme\",\n \"dependencies\": {\n \"tiny-emitter\": \"^2.1.0\"\n },\n \"gitHead\": \"af1a116a31b088efdd77afa81c4b6565d12bd162\"\n}\n" + }, + { + "url": "https://cdn.jsdelivr.net/npm/artplayer-tool-thumbnail@3.5.31/dist/artplayer-tool-thumbnail.js", + "status": 200, + "contentType": "application/javascript; charset=utf-8", + "sha256": "27fdc506c9b228c40df39f8508b3a42c145571c06c0b9cc60327a007a2064c87", + "bytes": 8716, + "identicalGit": { + "commit": "af1a116a31b088efdd77afa81c4b6565d12bd162", + "file": "packages/artplayer-tool-thumbnail/dist/artplayer-tool-thumbnail.js" + } + }, + { + "url": "https://cdn.jsdelivr.net/npm/artplayer-tool-thumbnail@3.5.31/dist/artplayer-tool-thumbnail.css", + "status": 404, + "contentType": "text/plain; charset=utf-8", + "sha256": "525a0f43f5b5fa874ba9306afade56063dcf874a59a2769af9efbc9cbf7a32b0", + "bytes": 58, + "text": "Failed to fetch version info for artplayer-tool-thumbnail." + }, + { + "url": "https://cdn.jsdelivr.net/npm/artplayer-tool-thumbnail@3.5.31/src/index.js", + "status": 404, + "contentType": "text/plain; charset=utf-8", + "sha256": "525a0f43f5b5fa874ba9306afade56063dcf874a59a2769af9efbc9cbf7a32b0", + "bytes": 58, + "text": "Failed to fetch version info for artplayer-tool-thumbnail." + }, + { + "url": "https://cdn.jsdelivr.net/npm/artplayer-tool-thumbnail@4.4.0/package.json", + "status": 404, + "contentType": "text/plain; charset=utf-8", + "sha256": "525a0f43f5b5fa874ba9306afade56063dcf874a59a2769af9efbc9cbf7a32b0", + "bytes": 58, + "text": "Failed to fetch version info for artplayer-tool-thumbnail." + }, + { + "url": "https://data.jsdelivr.com/v1/package/npm/artplayer-tool-thumbnail@3.5.31", + "status": 404, + "contentType": "application/json; charset=utf-8", + "sha256": "ca673be59ba098d94b6734968b8e9b6f8c57c3a1b0f03d2a80e97ec0cc599255", + "bytes": 176, + "text": "{\n\t\"status\": 404,\n\t\"message\": \"Couldn't find version 3.5.31 for artplayer-tool-thumbnail. Make sure you use a specific version number, and not a version range or an npm tag.\"\n}" + } + ], + "recovered": { + "name": "artplayer-tool-thumbnail", + "version": "3.5.31", + "manifest": { + "name": "artplayer-tool-thumbnail", + "version": "3.5.31", + "expose": "ArtplayerToolThumbnail", + "description": "thumbnail tool for ArtPlayer", + "main": "dist/artplayer-tool-thumbnail.js", + "style": "dist/artplayer-tool-thumbnail.css", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/zhw2590582/ArtPlayer.git" + }, + "keywords": [ + "video", + "player" + ], + "author": "Harvey Zack ", + "license": "MIT", + "bugs": { + "url": "https://github.com/zhw2590582/ArtPlayer/issues" + }, + "homepage": "https://github.com/zhw2590582/ArtPlayer#readme", + "dependencies": { + "tiny-emitter": "^2.1.0" + }, + "gitHead": "af1a116a31b088efdd77afa81c4b6565d12bd162" + }, + "historicalCommit": "af1a116a31b088efdd77afa81c4b6565d12bd162", + "originalTarballAvailable": false, + "completeArchiveAvailable": false, + "scope": "CDN manifest and main runtime recovered; runtime is byte-identical to the matching Git build. Registry manifests/tarballs are unavailable. Missing CSS is an observed unavailable target, not proof it never existed." + }, + "source": { + "packages/artplayer-tool-thumbnail/src/index.js": "67bc1b6a58faf11b14c8d410ae829edd64b84dc9e7b4b388b2e2dd60788bcbe4", + "packages/artplayer-tool-thumbnail/src/emitter.js": "5aa71e4317037c06b94e259beed2e964bbbb7117edbe78d29cc941c4916fd2ff", + "packages/artplayer-tool-thumbnail/src/utils.js": "dc19fe2f338df7058084eb2301d37db59ec4c914a85ba819a856d270c1267105", + "packages/artplayer-tool-thumbnail/package.json": "9f4f9c6628ee48068f380bc52f4058c1f6aac31c20de6ed069a2c96ba635e23f", + "packages/artplayer-tool-thumbnail/README.md": "91477fe542a44713089e384308ae6f311f887cfe63aafe1483b778a37acfa458", + "packages/artplayer-tool-thumbnail/.npmignore": "6b340a1f9a1f36dbd84cc1fff61de20f4ab2041d767a996da9f9d8de1a582ae8", + "packages/artplayer-tool-thumbnail/dist/artplayer-tool-thumbnail.js": "f77419cc62eeaa45c0413f4b04d169f78178fd539443cbf7b1e5db4ad4c95c98", + "packages/artplayer-tool-thumbnail/dist/artplayer-tool-thumbnail.legacy.js": "f77419cc62eeaa45c0413f4b04d169f78178fd539443cbf7b1e5db4ad4c95c98", + "packages/artplayer-tool-thumbnail/dist/artplayer-tool-thumbnail.mjs": "4d46df8cccdc92d18efc6f12abedf85e8881737023f4ead28697c8b1e85eead1", + "docs/assets/example/tool.thumbnail.js": "ef830a5f499e14c09d51aa31aaa0a194ea1f910b27f14ef97c0ca8f67d529a54", + "docs/assets/example/thumbnail.js": "e166845905f131dea0979eb77471f5531a59b93ab2dc28fef1b6791adb42fc30", + "packages/artplayer/package.json": "06cbc0de3f944dfc215ea1c4e7c3376e6352ee5d6f1f459828f45025c62f57f0" + }, + "historicalSource": { + "packages/artplayer-tool-thumbnail/src/index.js": "cfd4487f508378fd90f5252c9ae92954b93d04e263ce0927146ff9c3160700e0", + "packages/artplayer-tool-thumbnail/src/utils.js": "c70c6752565aff6aa74232dfcda706b98976d466bad799945b6501579cd7c05c", + "packages/artplayer-tool-thumbnail/package.json": "a06b12475ede33cf24af18896e76af52908fab2e5fe3b4c9395164cbe9a22573", + "packages/artplayer-tool-thumbnail/README.md": "d56adcd606f98b8a9be526954d9874831d89e5ae0e1d8dc070a8afe13a3f0547", + "packages/artplayer-tool-thumbnail/.npmignore": "6b340a1f9a1f36dbd84cc1fff61de20f4ab2041d767a996da9f9d8de1a582ae8", + "packages/artplayer-tool-thumbnail/dist/artplayer-tool-thumbnail.js": "27fdc506c9b228c40df39f8508b3a42c145571c06c0b9cc60327a007a2064c87", + "docs/assets/example/thumbnail.js": "ad807af57b1c006acf9cb5476f934aed0a77487c82be050b908d3d7743bac8b6", + "packages/artplayer/package.json": "8b67bd5fcd17a5e481dd477cc0e2b8327160a7b5699dab7b22c884c109a494eb" + }, + "coreAssociations": { + "historical": { + "commit": "af1a116a31b088efdd77afa81c4b6565d12bd162", + "file": "packages/artplayer/package.json", + "version": "3.5.31" + }, + "workspace": { + "commit": "40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f", + "file": "packages/artplayer/package.json", + "version": "5.4.1" + } + } +} diff --git a/refactor/changes/2026-09-13-PKG-TOOL-THUMB-01-contract.md b/refactor/changes/2026-09-13-PKG-TOOL-THUMB-01-contract.md new file mode 100644 index 000000000..2cc24dfef --- /dev/null +++ b/refactor/changes/2026-09-13-PKG-TOOL-THUMB-01-contract.md @@ -0,0 +1,26 @@ +# PKG-TOOL-THUMB-01 contract inventory + +Started from cb6c7677319f22074ae2ec09989022ddf0f8ea87. Frozen published-cache and +workspace identities establish the thumbnail tool's actual public contract. +Recovered CDN main matches historical Git byte-for-byte; original npm tarballs +remain unavailable. No complete-archive verification is claimed. + +The contract document distinguishes 3.5.31 delayed events/configured height from +4.4.0 synchronous events/aspect-derived height, including different input reset +and metadata waiting behavior. Default compatibility remains an explicit open +decision for implementation, not an approved runtime break. It corrects the +plan's earlier conflation of workspace missing main/types/ESM with old npm facts. + +Added an offline verifier and 26 contract tests across recovered old main, +frozen workspace main/legacy and workspace ESM. Tests cover export/field shape, +historical method spellings, setup/defaults, emitter semantics, event timing, +input/drop, height, grid/footer, errors, downloads and cleanup. The helper uses +controlled DOM/media; real browser extraction is not claimed by this task. + +Production source, declarations, manifests and dist/docs artifacts are untouched. +No dependencies changed. PKG-TOOL-THUMB-02 owns extraction and asynchronous failure +baselines; 03/04 own resource/module/TS implementation; 05/06 own real integration +and distribution. BASE-DIST-01 and thumbnail-specific risks remain open. + +Validation results are recorded in thumbnail-contract-validation.json. Task +completion uses its own local commit; there is no push, tag or npm publication. diff --git a/refactor/plan.md b/refactor/plan.md index 0afcdb028..1bbfbee11 100644 --- a/refactor/plan.md +++ b/refactor/plan.md @@ -4,7 +4,7 @@ 基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 222 项,范围 22 个包及工作区/示例。 -状态:todo 103 / doing 10 / blocked 0 / done 109 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 +状态:todo 102 / doing 10 / blocked 0 / done 110 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。 @@ -355,7 +355,7 @@ | ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 | | --- | --- | --- | --- | --- | --- | --- | -| PKG-TOOL-THUMB-01 | artplayer-tool-thumbnail
核对包契约与历史用法 | BASE-05 | 构造/defaults/事件/方法/历史拼写、.esm.js 入口和缺失 types | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结;核实 BASE-DIST-01 的 .esm.js 旧入口、缺失声明及无 main 的历史发布事实 | H | todo | +| PKG-TOOL-THUMB-01 | artplayer-tool-thumbnail
核对包契约与历史用法 | BASE-05 | 构造/defaults/事件/方法/历史拼写、.esm.js 入口和缺失 types | 源码/声明/README/demo/可恢复发布内容差异已登记;公开形状和版本范围冻结;区分旧 npm 的 main/CSS 与当前工作区无 main、.esm.js/类型缺失,不把工作区状态冒称旧 npm 发布事实 | H | done | | PKG-TOOL-THUMB-02 | artplayer-tool-thumbnail
建立特有行为与错误测试 | PKG-TOOL-THUMB-01, ENG-03, ENG-05 | 文件输入/拖放、抽帧网格、begin/end、进度/失败/重复任务 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | todo | | PKG-TOOL-THUMB-03 | artplayer-tool-thumbnail
整理内部职责与资源 | PKG-TOOL-THUMB-02, CORE-01 | 输入、抽帧队列、网格导出、URL/监听清理分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo | | PKG-TOOL-THUMB-04 | artplayer-tool-thumbnail
迁移自有源码和公开类型 | PKG-TOOL-THUMB-03, ENG-04, ENG-06, CORE-07 | 补齐真实 API 声明,自有 emitter/utils TS 化 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容;为 BASE-DIST-01 提供兼容入口/声明及消费者验证 | H | todo | @@ -535,4 +535,5 @@ - PKG-IFRAME-03: [记录](changes/2026-09-12-PKG-IFRAME-03-requests-checkpoint.md) [记录](baselines/iframe-requests-checkpoint.json) [记录](changes/2026-09-12-PKG-IFRAME-03-boundaries-checkpoint.md) [记录](baselines/iframe-boundaries-checkpoint.json) [记录](iframe-message-boundary.md) [记录](changes/2026-09-13-PKG-IFRAME-03-navigation.md) [记录](baselines/iframe-navigation-validation.json) [记录](iframe-document-protocol.md) - PKG-IFRAME-04: [记录](changes/2026-09-13-PKG-IFRAME-04-types.md) [记录](baselines/iframe-types-validation.json) - PKG-IFRAME-05: [记录](changes/2026-09-13-PKG-IFRAME-05-integration.md) [记录](baselines/iframe-integration-validation.json) [记录](changes/2026-09-13-PKG-IFRAME-05-history.md) [记录](baselines/iframe-history-validation.json) +- PKG-TOOL-THUMB-01: [记录](baselines/thumbnail-release.json) [记录](baselines/thumbnail-contract.md) [记录](changes/2026-09-13-PKG-TOOL-THUMB-01-contract.md) [记录](baselines/thumbnail-contract-validation.json) - PKG-FACTORY-01: [记录](baselines/factory-assignment-gaps.json) [记录](baselines/factory-compatibility-proposals.json) [记录](factory-compatibility-decision.md) [记录](changes/2026-09-12-PKG-FACTORY-01-decision.md) diff --git a/refactor/progress.md b/refactor/progress.md index 513778036..1a181b59b 100644 --- a/refactor/progress.md +++ b/refactor/progress.md @@ -1,5 +1,17 @@ # 进度与证据 +## PKG-TOOL-THUMB-01 完成:历史契约与发布证据 + +冻结11条HTTP观察、12个工作区及8个历史Git输入;恢复3.5.31 CDN主文件并逐字节 +匹配Git,不冒称找回完整npm包。区分旧版delay/固定高度与4.4.0同步事件/比例高度, +保留公开拼写、导出和事件的26项可重跑契约。复现拖放绑定、重复销毁等既有问题; +默认语义、资源治理和分发风险保持open,由02–06继续。实际工具demo与外部插件区分。 +完整CI1570项通过,330生产TS;本轮无生产源码/产物变化,无新浏览器/覆盖率结论。 +见[契约](baselines/thumbnail-contract.md)、[验证](baselines/thumbnail-contract-validation.json) +和[变更](changes/2026-09-13-PKG-TOOL-THUMB-01-contract.md)。下一项为抽帧与失败测试。 +222项:110 done、10 doing、102 todo;单项本地提交,不推送或发布。 + + ## PKG-IFRAME-05 原生缓存与中断导航检查点(doing) 新增独立 `yarn test:iframe-history`:完整Chromium通道与可缓存HTTP页面,避免默认 diff --git a/refactor/risk-table.md b/refactor/risk-table.md index 53bb75f33..1e19d6af0 100644 --- a/refactor/risk-table.md +++ b/refactor/risk-table.md @@ -19,7 +19,7 @@ | BASE-TYPE-02 | resolved / 已复现 | chapter 可选运行时参数被声明为必填 | PKG-CHAPTER-02 | | BASE-TYPE-03 | resolved / 已复现 | chapter legacy 缺旧模块解析的声明回退 | ENG-04, PKG-CHAPTER-04 | | BASE-TYPE-04 | resolved / 已复现 | plugins.add / toggle 的旧声明返回值与运行时不同 | CORE-07, CORE-08, CORE-10, CORE-21 | -| BASE-DIST-01 | open / 源码/产物事实 | thumbnail tool 分发入口与类型文件缺失 | PKG-TOOL-THUMB-01, PKG-TOOL-THUMB-04 | +| BASE-DIST-01 | open / 已复现 | thumbnail tool 分发入口与类型文件缺失 | PKG-TOOL-THUMB-04, PKG-TOOL-THUMB-06 | | BASE-PERF-01 | resolved / 已复现 | 销毁后的 resize 防抖重新创建 notice timer | CORE-17, CORE-04, CORE-18 | | BASE-SOURCE-01 | open / 源码/产物事实 | 工作区 5.4.1 与采集时真实发布 5.4.0 不同 | BASE-08, REL-01 | | BASE-SOURCE-02 | open / 源码/产物事实 | registry gitHead 与发布包版本对应不可靠 | ENG-07, REL-01 | @@ -177,3 +177,6 @@ | IFRAME-LIFE-01 | open / 已复现 | Iframe timestamp IDs and unowned pending requests/timers lack terminal cleanup | PKG-IFRAME-02, PKG-IFRAME-03, PKG-IFRAME-05 | | IFRAME-TRUST-01 | open / 已复现 | Iframe executable commit protocol accepts messages without source/origin validation | PKG-IFRAME-03, PKG-IFRAME-05 | | IFRAME-DIST-01 | open / 已复现 | Iframe tool name is unavailable at registry observation and old archive includes distinct helper exports | PKG-IFRAME-04, PKG-IFRAME-06 | +| THUMB-COMPAT-01 | open / 已复现 | Thumbnail recovered 3.5.31 and workspace 4.4.0 have conflicting height and event timing defaults | PKG-TOOL-THUMB-02, PKG-TOOL-THUMB-03, PKG-TOOL-THUMB-04 | +| THUMB-LIFE-01 | open / 已复现 | Thumbnail drop binding and repeated destroy fail; pending work and replacement resource ownership need repair | PKG-TOOL-THUMB-02, PKG-TOOL-THUMB-03, PKG-TOOL-THUMB-05 | +| THUMB-PROVENANCE-01 | open / 源码/产物事实 | Thumbnail local emitter attribution and historical distribution completeness remain unverified | PKG-TOOL-THUMB-04, PKG-TOOL-THUMB-06 | diff --git a/refactor/risks.json b/refactor/risks.json index 14c9d78d8..da4bc67c1 100644 --- a/refactor/risks.json +++ b/refactor/risks.json @@ -360,16 +360,19 @@ { "id": "BASE-DIST-01", "title": "thumbnail tool 分发入口与类型文件缺失", - "confirmation": "source-observed", + "confirmation": "reproduced", "status": "open", "owners": [ - "PKG-TOOL-THUMB-01", - "PKG-TOOL-THUMB-04" + "PKG-TOOL-THUMB-04", + "PKG-TOOL-THUMB-06" ], "evidence": [ - "refactor/baselines/distribution.json" + "refactor/baselines/distribution.json", + "refactor/baselines/thumbnail-contract.md", + "refactor/baselines/thumbnail-release.json", + "refactor/scripts/thumbnail-contract.test.mjs" ], - "compatibleResolution": "核实真实旧 npm 后保留 .esm.js 等历史路径的兼容入口并补声明。", + "compatibleResolution": "Recovered 3.5.31 has main and a CSS entry; baseline workspace lacks main and points to missing .esm.js/types. Recoverable CDN main is verified against Git, not an original tarball. Preserve supported paths, decide historical CSS disposition, add real declarations and verify isolated installs in 04/06.", "closureCriteria": "隔离打包消费者能加载所有原承诺路径,dist 不作为源码直接手改。" }, { @@ -3844,6 +3847,59 @@ "refactor/changes/2026-09-13-PKG-IFRAME-04-types.md", "refactor/baselines/iframe-types-validation.json" ] + }, + { + "id": "THUMB-COMPAT-01", + "title": "Thumbnail recovered 3.5.31 and workspace 4.4.0 have conflicting height and event timing defaults", + "confirmation": "reproduced", + "owners": [ + "PKG-TOOL-THUMB-02", + "PKG-TOOL-THUMB-03", + "PKG-TOOL-THUMB-04" + ], + "compatibleResolution": "Freeze both implementations and derive a concrete compatibility policy before changing defaults; no silent claim that identical calls have identical historical semantics.", + "closureCriteria": "Explicit documented compatibility decision, old/candidate tests and migration examples covering delay, height, input reset and readiness.", + "status": "open", + "evidence": [ + "refactor/baselines/thumbnail-contract.md", + "refactor/baselines/thumbnail-release.json", + "refactor/scripts/thumbnail-contract.test.mjs" + ] + }, + { + "id": "THUMB-LIFE-01", + "title": "Thumbnail drop binding and repeated destroy fail; pending work and replacement resource ownership need repair", + "confirmation": "reproduced", + "owners": [ + "PKG-TOOL-THUMB-02", + "PKG-TOOL-THUMB-03", + "PKG-TOOL-THUMB-05" + ], + "compatibleResolution": "Drop and duplicate-destroy defects are reproduced; reproduce source-observed extraction/timer/blob/input ownership gaps before fixes, then introduce owned cleanup without changing public shape.", + "closureCriteria": "Deterministic old failure/candidate success for replacement, error and destruction plus real media/browser resource validation.", + "status": "open", + "evidence": [ + "refactor/baselines/thumbnail-contract.md", + "refactor/baselines/thumbnail-release.json", + "refactor/scripts/thumbnail-contract.test.mjs" + ] + }, + { + "id": "THUMB-PROVENANCE-01", + "title": "Thumbnail local emitter attribution and historical distribution completeness remain unverified", + "confirmation": "source-observed", + "owners": [ + "PKG-TOOL-THUMB-04", + "PKG-TOOL-THUMB-06" + ], + "compatibleResolution": "Historical release declares tiny-emitter; review local emitter provenance/license before TS migration. Keep recovered individual CDN files distinct from full npm archive evidence.", + "closureCriteria": "Reviewed emitter attribution and license ledger plus explicit historical artifact recovery limits and complete candidate tarball evidence.", + "status": "open", + "evidence": [ + "refactor/baselines/thumbnail-contract.md", + "refactor/baselines/thumbnail-release.json", + "refactor/scripts/thumbnail-contract.test.mjs" + ] } ] } diff --git a/refactor/scripts/thumbnail-contract.mjs b/refactor/scripts/thumbnail-contract.mjs new file mode 100644 index 000000000..155e24b90 --- /dev/null +++ b/refactor/scripts/thumbnail-contract.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict' +import { Buffer } from 'node:buffer' +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 { hash, refactorDir } from './releases.mjs' + +export function verifyThumbnailContract() { + const baseline = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/thumbnail-release.json'))) + assert.equal(baseline.recovered.originalTarballAvailable, false) + assert.equal(baseline.recovered.completeArchiveAvailable, false) + assert.equal(new Set(baseline.observations.map(item => item.url)).size, baseline.observations.length) + const source = new Map() + const historical = new Map() + for (const [commit, entries, target] of [[baseline.sourceCommit, baseline.source, source], [baseline.recovered.historicalCommit, baseline.historicalSource, historical]]) { + for (const [file, expected] of Object.entries(entries)) { + const text = execFileSync('git', ['show', `${commit}:${file}`], { encoding: 'utf8' }).replaceAll('\r\n', '\n') + assert.equal(hash(text), expected, file) + target.set(file, text) + } + } + for (const item of baseline.observations) { + const bytes = item.identicalGit + ? execFileSync('git', ['show', `${item.identicalGit.commit}:${item.identicalGit.file}`]) + : Buffer.from(item.text) + assert.equal(hash(bytes), item.sha256, item.url) + assert.equal(bytes.length, item.bytes, item.url) + } + const registry = baseline.observations.find(item => item.url === 'https://registry.npmjs.org/artplayer-tool-thumbnail') + assert.equal(registry.status, 200) + const metadata = JSON.parse(registry.text) + assert.equal(metadata.name, baseline.recovered.name) + assert(metadata.time.unpublished.versions.includes('3.5.31')) + assert.equal(metadata.versions, undefined) + const manifest = baseline.observations.find(item => item.url.endsWith('@3.5.31/package.json')) + assert.equal(manifest.status, 200) + assert.deepEqual(JSON.parse(manifest.text), baseline.recovered.manifest) + assert.equal(baseline.recovered.manifest.gitHead, baseline.recovered.historicalCommit) + const oldManifest = JSON.parse(historical.get('packages/artplayer-tool-thumbnail/package.json')) + assert.deepEqual({ ...oldManifest, gitHead: null }, { ...baseline.recovered.manifest, gitHead: null }) + for (const [name, association] of Object.entries(baseline.coreAssociations)) { + const files = name === 'historical' ? historical : source + assert.equal(JSON.parse(files.get(association.file)).version, association.version) + } + return { baseline, source, historical } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { baseline } = verifyThumbnailContract() + console.log(`Thumbnail contract: ${baseline.observations.length} frozen observations, ${Object.keys(baseline.source).length} workspace inputs, ${Object.keys(baseline.historicalSource).length} historical Git inputs; recovered CDN main is byte-identical, original npm archive remains unavailable`) +} diff --git a/refactor/scripts/thumbnail-contract.test.mjs b/refactor/scripts/thumbnail-contract.test.mjs new file mode 100644 index 000000000..6a308b85a --- /dev/null +++ b/refactor/scripts/thumbnail-contract.test.mjs @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +// eslint-disable-next-line test/no-import-node-test -- Frozen package contract runner. +import test from 'node:test' +import { thumbnailEnvironment, thumbnailHistorical } from '../../test/helpers/thumbnail.js' +import { verifyThumbnailContract } from './thumbnail-contract.mjs' + +const plain = value => JSON.parse(JSON.stringify(value)) +const root = 'packages/artplayer-tool-thumbnail/' + +test('Thumbnail recovered CDN files are not represented as a verified npm archive', () => { + const { baseline, source, historical } = verifyThumbnailContract() + assert.equal(baseline.observations.length, 11) + assert.equal(source.size, 12) + assert.equal(historical.size, 8) + assert.equal(baseline.recovered.originalTarballAvailable, false) + assert.equal(baseline.recovered.completeArchiveAvailable, false) + const old = baseline.recovered.manifest + const current = JSON.parse(source.get(`${root}package.json`)) + assert.equal(old.main, 'dist/artplayer-tool-thumbnail.js') + assert.equal(old.style, 'dist/artplayer-tool-thumbnail.css') + assert.equal(old.dependencies['tiny-emitter'], '^2.1.0') + assert.equal(current.main, undefined) + assert.equal(current.module, 'dist/artplayer-tool-thumbnail.esm.js') + assert.equal(current.types, 'types/artplayer-tool-thumbnail.d.ts') + assert.equal(source.has(`${root}${current.module}`), false) + assert.equal(source.has(`${root}${current.types}`), false) + const tree = execFileSync('git', ['ls-tree', '-r', '--name-only', baseline.sourceCommit, root], { encoding: 'utf8' }).split('\n') + assert.equal(tree.includes(`${root}${current.module}`), false) + assert.equal(tree.includes(`${root}${current.types}`), false) + assert.match(source.get('docs/assets/example/tool.thumbnail.js'), /new ArtplayerToolThumbnail/) + assert.match(source.get('docs/assets/example/thumbnail.js'), /artplayerPluginThumbnail/) +}) + +test('Thumbnail frozen workspace ESM exports only the default constructor', async () => { + const { source } = verifyThumbnailContract() + const esm = await import(`data:text/javascript,${encodeURIComponent(source.get(`${root}dist/artplayer-tool-thumbnail.mjs`))}`) + assert.deepEqual(Object.keys(esm), ['default']) + assert.equal(typeof esm.default, 'function') +}) + +for (const implementation of thumbnailHistorical()) { + const create = (options = {}) => { + const env = thumbnailEnvironment(implementation) + const instance = new env.Factory({ fileInput: new env.Element('input'), ...options }) + return { ...env, instance } + } + + test(`Thumbnail ${implementation.name}: direct CJS and script class retain public spellings and fields`, () => { + const env = create() + assert.equal(typeof env.exported, 'function') + assert.equal(env.exported.default, undefined) + assert.equal(typeof thumbnailEnvironment(implementation, true).exported, 'function') + assert.deepEqual(Object.getOwnPropertyNames(env.Factory.prototype), ['constructor', 'ondrop', 'setup', 'inputChange', 'loadVideo', 'start', 'creatScreenshotDate', 'creatCanvas', 'download', 'errorHandle', 'destroy']) + assert.deepEqual(Object.keys(env.instance), ['processing', 'option', 'video', 'duration', 'inputChange', 'ondrop']) + assert.equal(typeof env.Factory.creatVideo, 'function') + assert.equal(typeof env.Factory.ondragover, 'function') + assert.equal(env.Factory.ondrop, undefined) + assert.equal(env.instance.video.muted, true) + assert.equal(env.instance.video.controls, true) + assert.equal(env.instance.video.parentNode, env.body) + assert.equal(env.instance.processing, false) + assert.equal(env.instance.duration, 0) + }) + + test(`Thumbnail ${implementation.name}: fresh defaults, setup clamps, unknown options and wrapper`, () => { + const env = create({ number: 1, width: 2000, column: 0, custom: 123 }) + assert.notEqual(env.Factory.DEFAULTS, env.Factory.DEFAULTS) + assert.deepEqual(plain(env.Factory.DEFAULTS), { ...(implementation.legacy ? { delay: 300 } : {}), number: 60, width: 160, height: 90, column: 10, begin: 0, end: null }) + assert(Number.isNaN(env.Factory.DEFAULTS.end)) + assert.equal(env.instance.option.number, 10) + assert.equal(env.instance.option.width, 1000) + assert.equal(env.instance.option.column, 1) + assert.equal(env.instance.option.custom, 123) + assert.equal(env.instance.setup({ number: 12.5 }), env.instance) + assert.equal(env.instance.option.number, 12.5) + assert.throws(() => env.instance.setup({ width: '160' }), /The 'width' is not a number/) + assert.throws(() => new env.Factory({ fileInput: {} }), /The 'fileInput' is not a Element/) + const wrapper = new env.Element('div') + const wrapped = new env.Factory({ fileInput: wrapper }) + assert.equal(wrapper.style.position, 'relative') + assert.equal(wrapped.option.fileInput, wrapper.children[0]) + assert.equal(wrapped.option.fileInput.style.opacity, '0') + assert.equal(wrapped.option.fileInput.type, 'file') + }) + + test(`Thumbnail ${implementation.name}: event snapshot, once identity, context and chaining`, () => { + const { instance } = create() + const seen = [] + const ctx = { marker: 1 } + function second(value) { + seen.push([this, value]) + } + assert.equal(instance.on('value', () => instance.off('value', second)), instance) + assert.equal(instance.once('value', second, ctx), instance) + assert.equal(instance.emit('value', 7), instance) + instance.emit('value', 8) + assert.deepEqual(seen, [[ctx, 7]]) + instance.once('removed', second, ctx) + assert.equal(instance.off('removed', second), instance) + instance.emit('removed', 9) + assert.equal(seen.length, 1) + assert.equal(instance.off('value'), instance) + }) + + test(`Thumbnail ${implementation.name}: file and video event timing identifies old delay semantics`, async () => { + const env = create() + const file = { name: 'video.mp4', type: 'video/mp4' } + const seen = [] + env.instance.on('file', value => seen.push(['file', value, env.instance.video.src])) + env.instance.on('video', value => seen.push(['video', value, value.src])) + assert.equal(env.instance.loadVideo(file), undefined) + assert.equal(env.instance.file, file) + assert.equal(env.instance.videoUrl, 'blob:thumbnail-1') + assert.deepEqual(seen[0], ['file', file, undefined]) + assert.equal(seen.length, implementation.legacy ? 1 : 2) + assert.equal(env.timers.size, implementation.legacy ? 1 : 0) + if (implementation.legacy) { + const [id, timer] = [...env.timers][0] + assert.equal(timer.delay, 300) + env.timers.delete(id) + timer.callback() + await Promise.resolve() + } + assert.deepEqual(seen[1], ['video', env.instance.video, 'blob:thumbnail-1']) + assert.equal(env.instance.loadVideo(undefined), undefined) + assert.equal(seen.length, 2) + }) + + test(`Thumbnail ${implementation.name}: input reset and existing ineffective drop binding stay distinguished`, () => { + const env = create() + const input = env.instance.option.fileInput + input.value = 'selected.mp4' + input.files = [{ name: 'selected.mp4', type: 'video/mp4' }] + assert.equal(input.handlers.get('change').has(env.instance.inputChange), true) + assert.equal(input.handlers.has('drop'), false) + env.instance.inputChange({ target: input }) + assert.equal(input.value, implementation.legacy ? 'selected.mp4' : '') + let prevented = 0 + const preventDefault = () => { + prevented++ + } + env.instance.ondrop({ preventDefault, dataTransfer: { files: [] } }) + env.Factory.ondragover({ preventDefault }) + assert.equal(prevented, 2) + }) + + test(`Thumbnail ${implementation.name}: midpoint grid and footer geometry`, () => { + const env = create({ number: 10, width: 20, height: 30, column: 3, begin: 5 }) + env.instance.duration = 100 + assert.deepEqual(plain(env.instance.creatScreenshotDate()), Array.from({ length: 10 }, (_, i) => ({ time: 10 + i * 10, x: i % 3 * 20, y: Math.floor(i / 3) * 30 }))) + const canvas = env.instance.creatCanvas() + assert.equal(canvas.width, 60) + assert.equal(canvas.height, 150) + assert.deepEqual(env.operations.filter(item => item.name === 'fillRect' || item.name === 'fillText'), [ + { name: 'fillRect', args: [0, 0, 60, 150] }, + { name: 'fillText', args: ['From: https://artplayer.org/, Number: 10, Width: 20, Height: 30, Column: 3', 10, 139] }, + ]) + assert.equal(env.context2D.font, '14px Georgia') + }) + + test(`Thumbnail ${implementation.name}: configured height versus source aspect ratio predates this refactor`, () => { + const { instance } = create({ height: 25, width: 160 }) + instance.video.duration = 1 + instance.file = { name: 'short.mp4' } + assert.throws(() => instance.start(), /preview density cannot be greater than 1/) + assert.equal(instance.option.height, implementation.legacy ? 25 : 90) + assert.equal(instance.processing, false) + }) + + test(`Thumbnail ${implementation.name}: synchronous errors, download names and destroy cleanup contract`, () => { + const env = create() + const errors = [] + env.instance.on('error', message => errors.push(message)) + assert.throws(() => env.instance.download(), /Download does not seem to be ready/) + assert.equal(errors.length, 1) + assert.equal(env.instance.errorHandle(true, 'unused'), undefined) + env.instance.video.canPlayType = () => '' + assert.throws(() => env.instance.loadVideo({ type: 'bad/type' }), /Playback of this file format is not supported: bad\/type/) + assert.equal(errors.length, 2) + env.instance.videoUrl = 'blob:video' + env.instance.thumbnailUrl = 'blob:sheet' + const downloads = [] + env.instance.on('download', name => downloads.push(name)) + for (const name of ['movie.part.mp4', 'noextension']) { + env.instance.file = { name } + assert.equal(env.instance.download(), env.instance) + } + assert.deepEqual(downloads, ['movie.part.png', '.png']) + assert.deepEqual(env.operations.filter(item => item.name === 'click').map(item => item.download), downloads) + let destroyed = 0 + env.instance.on('destroy', () => destroyed++) + assert.equal(env.instance.destroy(), undefined) + assert.equal(destroyed, 1) + assert.equal(env.body.children.length, 0) + assert.equal(env.instance.option.fileInput.handlers.get('change').size, 0) + assert.deepEqual(env.operations.filter(item => item.name === 'revokeURL').map(item => item.url), ['blob:video', 'blob:sheet']) + assert.throws(() => env.instance.destroy(), /NotFoundError/) + }) +} diff --git a/refactor/tasks.json b/refactor/tasks.json index 5612857b9..6738cf4b5 100644 --- a/refactor/tasks.json +++ b/refactor/tasks.json @@ -3742,11 +3742,16 @@ "dependsOn": [ "BASE-05" ], - "status": "todo", + "status": "done", "risk": "H", "deliverable": "构造/defaults/事件/方法/历史拼写、.esm.js 入口和缺失 types", - "acceptance": "源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结;核实 BASE-DIST-01 的 .esm.js 旧入口、缺失声明及无 main 的历史发布事实", - "evidence": [] + "acceptance": "源码/声明/README/demo/可恢复发布内容差异已登记;公开形状和版本范围冻结;区分旧 npm 的 main/CSS 与当前工作区无 main、.esm.js/类型缺失,不把工作区状态冒称旧 npm 发布事实", + "evidence": [ + "baselines/thumbnail-release.json", + "baselines/thumbnail-contract.md", + "changes/2026-09-13-PKG-TOOL-THUMB-01-contract.md", + "baselines/thumbnail-contract-validation.json" + ] }, { "id": "PKG-TOOL-THUMB-02", diff --git a/test/helpers/thumbnail.js b/test/helpers/thumbnail.js new file mode 100644 index 000000000..b5ac250e0 --- /dev/null +++ b/test/helpers/thumbnail.js @@ -0,0 +1,95 @@ +import vm from 'node:vm' +import { verifyThumbnailContract } from '../../refactor/scripts/thumbnail-contract.mjs' + +export function thumbnailHistorical() { + const { source, historical } = verifyThumbnailContract() + return [ + { name: 'cdn-3.5.31', code: historical.get('packages/artplayer-tool-thumbnail/dist/artplayer-tool-thumbnail.js'), legacy: true }, + ...['.js', '.legacy.js'].map(suffix => ({ name: `workspace${suffix}`, code: source.get(`packages/artplayer-tool-thumbnail/dist/artplayer-tool-thumbnail${suffix}`), legacy: false })), + ] +} + +export function thumbnailEnvironment(implementation, script = false) { + const operations = [] + const timers = new Map() + let nextTimer = 0 + let nextUrl = 0 + const context2D = Object.fromEntries(['fillRect', 'fillText', 'drawImage'].map(name => [name, (...args) => operations.push({ name, args })])) + class Element { + constructor(tagName = 'div') { + this.tagName = tagName.toUpperCase() + this.type = this.tagName === 'INPUT' ? 'file' : '' + this.style = {} + this.children = [] + this.handlers = new Map() + this.files = [] + if (this.tagName === 'VIDEO') { + this.duration = 0 + this.videoWidth = 160 + this.videoHeight = 90 + this.currentTime = 0 + this.canPlayType = () => 'probably' + } + } + + appendChild(child) { + child.parentNode = this + this.children.push(child) + operations.push({ name: 'append', parent: this.tagName, child: child.tagName }) + return child + } + + removeChild(child) { + const index = this.children.indexOf(child) + if (index === -1) + throw new Error('NotFoundError') + this.children.splice(index, 1) + child.parentNode = null + return child + } + + addEventListener(name, callback) { + operations.push({ name: 'listen', type: name, callback }) + if (!callback) + return + const list = this.handlers.get(name) || new Set() + list.add(callback) + this.handlers.set(name, list) + } + + removeEventListener(name, callback) { this.handlers.get(name)?.delete(callback) } + getContext() { return context2D } + click() { operations.push({ name: 'click', download: this.download, href: this.href }) } + toBlob(callback) { callback({ kind: 'image/png' }) } + } + const body = new Element('body') + const module = { exports: {} } + const box = { + Element, + document: { body, createElement: tag => new Element(tag) }, + URL: { + createObjectURL(value) { + const url = `blob:thumbnail-${++nextUrl}` + operations.push({ name: 'createURL', value, url }) + return url + }, + revokeObjectURL(url) { operations.push({ name: 'revokeURL', url }) }, + }, + setTimeout(callback, delay) { + const id = ++nextTimer + timers.set(id, { callback, delay }) + return id + }, + clearTimeout(id) { timers.delete(id) }, + } + box.window = box + box.self = box + if (!script) { + box.module = module + box.exports = module.exports + } + vm.runInNewContext(implementation.code, box) + const exported = script ? box.ArtplayerToolThumbnail : module.exports + const Factory = exported.default || exported + return { box, Element, body, Factory, exported, operations, context2D, timers } +}