diff --git a/refactor/baselines/canvas-contract-validation.json b/refactor/baselines/canvas-contract-validation.json new file mode 100644 index 000000000..33dc48bfe --- /dev/null +++ b/refactor/baselines/canvas-contract-validation.json @@ -0,0 +1,109 @@ +{ + "schemaVersion": 1, + "task": "PKG-CANVAS-01", + "status": "done", + "parentCommit": "20f062bc901d525d0659807dceebd1e41535ca7d", + "checks": [ + { + "command": "node --test refactor/scripts/canvas-contract.test.mjs refactor/scripts/contracts.test.mjs", + "result": "pass", + "tests": 20, + "canvas": 13, + "indexValidation": 7, + "scope": "two actual npm archives and frozen workspace; normal property forwarding, bitmap/fallback/callback/event/resize contracts, public export/declaration shapes and exact Git versus npm core distinction" + }, + { + "command": "focused eslint --no-fix", + "result": "pass" + }, + { + "command": "yarn ci:check", + "result": "pass", + "tests": { + "unit": 881, + "engineering": 14, + "baseline": 96, + "mainTotal": 991, + "repeatedContractExecutions": 44 + }, + "strictProductionTypeScriptFiles": 278 + } + ], + "archiveScope": { + "plugins": [ + "npm 1.0.0", + "npm 1.1.0", + "workspace frozen at parentCommit" + ], + "members": 12, + "workspaceFiles": 6, + "gitAssociatedCoreVersions": [ + "5.1.7", + "5.3.1" + ], + "supportedCoreRange": "not inferred from Git associations" + }, + "contractIndex": { + "packages": 22, + "assignedFamilies": 264, + "workspaceVersionPoints": 22, + "publishedVersionPoints": 11, + "exactIndexedCases": 10, + "unindexedFamilyRows": 255 + }, + "inputs": [ + { + "file": "refactor/baselines/canvas-release.json", + "sha256LF": "7f7b229a5c0350a5d02be101d0095548c7ef1e76ce95c3b7152b415b0117398c" + }, + { + "file": "refactor/scripts/canvas-contract.mjs", + "sha256LF": "eb545d0ccb3438413980d292fd2e511342b680e03c5985e58006a1407c8f53aa" + }, + { + "file": "refactor/scripts/canvas-contract.test.mjs", + "sha256LF": "65e5ea0c9a12f47bc7939840d6e4c25b2820e7fd5e6f32fe3bd9f35e7f3e6da6" + }, + { + "file": "test/helpers/canvas.js", + "sha256LF": "cc617b6d1053a9ed9caa5dad03088abaaff4344f078e23d007ee1a935f2a6c3c" + }, + { + "file": "refactor/contract-policy.json", + "sha256LF": "f450c792465b67ebdc92e6d7339fb6be4f6fb19ffbded6c512244d73a57eafd2" + }, + { + "file": "refactor/scripts/contracts.test.mjs", + "sha256LF": "7cb6cfa5fe2cdfe3fd6f8dc5377ff21db2aa748bc7788f4660f61abb98bbf647" + }, + { + "file": "refactor/baselines/vast-core.json", + "sha256LF": "bb703cf37591211f78719665aed0e0cb2524ec5618dad48e6b8e2dbfef132b8e" + } + ], + "logs": [ + { + "file": "refactor/.cache/canvas01-capture.log", + "sha256": "a8dd65ebf981668b0fb1f44a58cd044879ecce10847797622f6bf56ef5189ed4" + }, + { + "file": "refactor/.cache/canvas01-targeted.log", + "sha256": "9ad62bd4c20fd501c197ec77eb19e7c66090fce356e7c26fadb972e5524e0c2c" + }, + { + "file": "refactor/.cache/canvas01-lint-final.log", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "file": "refactor/.cache/canvas01-ci.log", + "sha256": "ca15ff3aaa561911ed0809b6833c3ad47013d683fab4bfbac258b6cdb3be3243" + } + ], + "limitations": [ + "Controlled DOM/RAF/microtasks prove normal adapter contracts, not native browser pixels, video codecs or all enumerable browser properties.", + "Pending bitmap, cleanup, error and delayed subscription concerns remain source-observed; reproduce them in task 02 and fix in task 03.", + "Declaration shape checks are not full installed type consumer validation; optional inference/export/result checks remain task 04.", + "Git commit labeled core 5.1.7 contains proxy support while actual npm 5.1.7 does not; do not merge these identities into a support range.", + "No production source or artifacts modified; no new browser/media claims, push, version bump or publication." + ] +} diff --git a/refactor/baselines/canvas-contract.md b/refactor/baselines/canvas-contract.md new file mode 100644 index 000000000..38581c0f6 --- /dev/null +++ b/refactor/baselines/canvas-contract.md @@ -0,0 +1,57 @@ +# Canvas代理发布与工作区契约 + +PKG-CANVAS-01;工作区冻结于20f062bc。registry观察为1.0.0/1.1.0,latest为1.1.0; +两份实际tarball各6成员,SHA512、SHA256、所有文件及manifest见canvas-release.json。 +1.0.0包含src/index.js,1.1.0不带src、增加真实mjs;测试直接执行两版实际main产物和 +Git冻结工作区,不能用现代构建重生成“旧包”。工作区1.1.0未来按REL-09升至2.0.0。 + +## 入口、属性和事件 + +- factory(callback)返回同步注册函数;注册依次创建canvas/video/2Dcontext,返回该canvas。 + callback省略时正常;不添加插件name或公开destroy句柄。 +- 先收集canvas可枚举方法并绑定canvas,然后仅转发video里可枚举且canvas上不存在的 + 属性。转发get/set为enumerable/configurable;函数读取时绑定video,属性值实时读取。 + 既有canvas属性、style和方法优先;同名addEventListener仍是canvas方法。 +- 通过零延迟setTimeout,按Artplayer.config.events逐项用art.proxy监听内置video; + 原生event对象原样传给art.emit('video:'+type)。这不等于对canvas dispatchEvent。 +- loadedmetadata将输出width/height设为视频intrinsic尺寸;resize按容器比例缩放输出, + 用padding居中。autoSize=true时跳过尺寸变更,但resize依旧会绘制。 +- play取消旧RAF后立即触发异步draw,await后排下一RAF;pause/destroy只取消已存handle。 +- 支持createImageBitmap时await其结果,drawImage至canvas完整输出buffer,close bitmap, + 然后callback(ctx,video),再emit('artplayerProxyCanvas:draw',ctx,video)。无bitmap能力 + 时直接drawImage(video,...),callback/event顺序相同。错误发artplayerProxyCanvas:error。 + +上述是实际正常执行与源码事实;错误/竞争/终止行为由02继续复现,不自动作为应永久 +保留的缺陷。属性枚举器用受控DOM描述符做断言,未宣称穷尽不同浏览器的全部属性。 + +## 类型与分发差异 + +| 维度 | npm1.0.0 | npm1.1.0/工作区 | +| --- | --- | --- | +| CJS | namespace.default | 可调用工厂 | +| ESM | 无独立mjs | mjs默认导出 | +| 类型导出 | export=与全局声明 | export default | +| callback参数声明 | 必填 | 可选 | +| callback形状 | (ctx:CanvasRenderingContext2D,video:HTMLVideoElement)=>void | 同左 | +| Result | HTMLCanvasElement | 同左,未描述转发媒体能力 | + +04必须保留1.1.0的可选参数提取(Parameters包含undefined),不能照搬Ambilight的 +“必填最后重载”。旧require.default、全局、现代ESM/legacy/声明路径需分别验证。 +README仅demo链接;canvas.js示例省略callback并开启多项播放器功能,不构成这些功能 +已在每个代理/核心/设备上验收的证明。03改实现,04补类型,05/06再验证实际消费。 + +## 历史核心关联的限制 + +1.0.0 registryGitHead对应Git核心5.1.7,1.1.0对应Git5.3.1。冻结的前者index/template +已有proxy配置及替换媒体元素逻辑,但实际npm5.1.7的option声明和此前浏览器证据都 +无proxy。版本字符串相同也不能把未发布Git状态当作npm已发布能力;新增断言并固定 +两份Git源码哈希防止误认。这里没有声明一个连续支持范围。 + +前一步Ambilight代理取色使用工作区Canvas源码+实际npm5.4.0/候选核心,另明确记录 +npm5.1.7未启用代理;不是这两版npm代理的浏览器矩阵。来源见ambilight-proxy-validation.json。 + +## 后续风险 + +源码可见await bitmap完成后仍可能在pause/destroy后排帧、draw抛错可能遗漏bitmap.close、 +延迟订阅与art.on缺少主动终止、空context/早期尺寸需要能力边界。01仅记录,02必须 +以受控完成顺序和必要真实浏览器复现,再由03修复,不能为通过旧快照保留泄漏。 diff --git a/refactor/baselines/canvas-release.json b/refactor/baselines/canvas-release.json new file mode 100644 index 000000000..ae43e7e47 --- /dev/null +++ b/refactor/baselines/canvas-release.json @@ -0,0 +1,142 @@ +{ + "schemaVersion": 1, + "task": "PKG-CANVAS-01", + "capturedAt": "2026-09-12T04:06:47.519Z", + "sourceCommit": "20f062bc901d525d0659807dceebd1e41535ca7d", + "registryObservation": { + "versions": [ + "1.0.0", + "1.1.0" + ], + "latest": "1.1.0", + "workspaceVersion": "1.1.0" + }, + "release": { + "name": "artplayer-proxy-canvas", + "version": "1.1.0", + "metadataUrl": "https://registry.npmjs.org/artplayer-proxy-canvas/1.1.0", + "tarball": "https://registry.npmjs.org/artplayer-proxy-canvas/-/artplayer-proxy-canvas-1.1.0.tgz", + "integrity": "sha512-lOq22z+xX2DcpokDSMQrDTk4VGDwUaQm9SovQ0GlhgmvMBdxRBDlvv6QrVxFUFsiGUjXEdx9TmtzdiW38dlxiQ==", + "sha256": "348c1d6671f79d0e0dbf2bb15875a1e6d60d00b73cde833c018bec7e4be96c8e", + "archiveBytes": 2960, + "publishedAt": "2026-03-12T14:20:13.012Z", + "registryGitHead": "daf133b22630b4a0eecfa3336bbddab0e9119d96", + "files": { + "package/README.md": "eb063f1381b4eea2e3f2bf6f7ae76d2979e807119ae53708bb3b2014c38a30d2", + "package/dist/artplayer-proxy-canvas.js": "ef489570e13f402f1a60eb256ce3900a0021a3d05699c2ba15adfaaeff184d4c", + "package/dist/artplayer-proxy-canvas.legacy.js": "7d25f21170c06d067aeaa1c8e79b15f2c0130a27cff1b410532d6c4ef4d2d45f", + "package/dist/artplayer-proxy-canvas.mjs": "4bb4a881787770093652a3262fc214a2529a1c514ef49affaf152b8abf0f4dc6", + "package/package.json": "4df3a0098730cfc12232fc57066fd1f7159bbd41dce87904fa51f39b82252eed", + "package/types/artplayer-proxy-canvas.d.ts": "2d7f4517ab9954ce8925739c2473c553977268bfa120b6420218470952567b47" + }, + "manifest": { + "name": "artplayer-proxy-canvas", + "version": "1.1.0", + "description": "canvas proxy 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-proxy-canvas.d.ts", + "import": "./dist/artplayer-proxy-canvas.mjs", + "require": "./dist/artplayer-proxy-canvas.js" + }, + "./legacy": { + "types": "./types/artplayer-proxy-canvas.d.ts", + "import": "./dist/artplayer-proxy-canvas.legacy.js", + "require": "./dist/artplayer-proxy-canvas.legacy.js" + } + }, + "main": "./dist/artplayer-proxy-canvas.js", + "module": "./dist/artplayer-proxy-canvas.mjs", + "types": "./types/artplayer-proxy-canvas.d.ts", + "legacy": "./dist/artplayer-proxy-canvas.legacy.js", + "browserslist": "last 1 Chrome version" + }, + "historicalCore": { + "version": "5.3.1", + "commit": "daf133b22630b4a0eecfa3336bbddab0e9119d96", + "file": "packages/artplayer/package.json", + "sha256LF": "862f3697876fe915dd0ada178da766ce823aae7563ed30b3e5b11b811202929c", + "scope": "Git association only, not a tested support range" + } + }, + "previous": [ + { + "name": "artplayer-proxy-canvas", + "version": "1.0.0", + "metadataUrl": "https://registry.npmjs.org/artplayer-proxy-canvas/1.0.0", + "tarball": "https://registry.npmjs.org/artplayer-proxy-canvas/-/artplayer-proxy-canvas-1.0.0.tgz", + "integrity": "sha512-Wp/vbobzVfGXWkFWhhyi07TTsO7mtOhJ0BEEh/DWCBbJU1S4ghWyUhB0T9PFMVopbJfY7X51ytrrDeC2vpT1Jg==", + "sha256": "728ba39534b3b8139bdc404ef15fe1ac5f76a95562a14e78c6973db2dcfa6f4f", + "archiveBytes": 8534, + "publishedAt": "2024-09-19T02:48:29.419Z", + "registryGitHead": "1605df29431abfe1fc77683e609a2c3c0fba428a", + "files": { + "package/README.md": "2d2c6283a028e1f3882e855d0abef28a15f67525b1268b5f21c00eeea6244e80", + "package/dist/artplayer-proxy-canvas.js": "b87a0032711457b4c364d628cac3ae41fe071de08ed530ec2bd4cebf24941ec5", + "package/dist/artplayer-proxy-canvas.legacy.js": "21a2509bc01be721bda037fb6cf24461c52326865362e21873c4c32bfbb162dd", + "package/package.json": "600274235ba6d20cd9fe2c34b18acc72eeb6fd3ff262d19bc72a9a4e24bb5d42", + "package/src/index.js": "24add6af4f030e94fe1bdc66ca684e21d8baed7055e1ed44e85437bbff787c39", + "package/types/artplayer-proxy-canvas.d.ts": "aaa5f7886ecdfe1f6948d296fa3436811c466ac6e4da7fcd2d43faaab6bebc4d" + }, + "manifest": { + "license": "MIT", + "version": "1.0.0", + "name": "artplayer-proxy-canvas", + "types": "types/artplayer-proxy-canvas.d.ts", + "description": "canvas proxy for ArtPlayer", + "main": "dist/artplayer-proxy-canvas.js", + "legacy": "dist/artplayer-proxy-canvas.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" + ] + }, + "historicalCore": { + "version": "5.1.7", + "commit": "1605df29431abfe1fc77683e609a2c3c0fba428a", + "file": "packages/artplayer/package.json", + "sha256LF": "8bd532103099c367fb2c289e0d410f144fdeb00b1b62cf850762a24b61e63c8a", + "scope": "Git association only, not a tested support range", + "proxySources": { + "packages/artplayer/src/index.js": "8c29c0d1c46461bf8ce0bb16f927637055fcdb9b76f979d4bf691c956b1bc8c0", + "packages/artplayer/src/template.js": "9f7e8708e79ce2f3a36c767b93721d40027fa85730eebbdd4dc807efea88f9e8" + } + } + } + ], + "source": { + "packages/artplayer-proxy-canvas/src/index.js": "4b5922b05b46adf0d54ae92025b5c70456a5b8c87819c797ef91eb50e0c8f4d4", + "packages/artplayer-proxy-canvas/types/artplayer-proxy-canvas.d.ts": "2d7f4517ab9954ce8925739c2473c553977268bfa120b6420218470952567b47", + "packages/artplayer-proxy-canvas/package.json": "4df3a0098730cfc12232fc57066fd1f7159bbd41dce87904fa51f39b82252eed", + "packages/artplayer-proxy-canvas/README.md": "eb063f1381b4eea2e3f2bf6f7ae76d2979e807119ae53708bb3b2014c38a30d2", + "packages/artplayer-proxy-canvas/.npmignore": "46063058e98be55dba5cf5f33d379e73eca048f4126e7af75f8011bab7f09145", + "docs/assets/example/canvas.js": "95a971773e93cfb340a07eebcd49e333825f49ce0d167514ad98a188cd027799" + }, + "limitation": "Historical core Git association is not compatibility verification; media/proxy/browser and candidate consumer checks remain later tasks." +} diff --git a/refactor/canvas-validation.md b/refactor/canvas-validation.md new file mode 100644 index 000000000..b7d74cc53 --- /dev/null +++ b/refactor/canvas-validation.md @@ -0,0 +1,23 @@ +# Canvas代理测试维护 + +先读 [实际契约](baselines/canvas-contract.md) 和canvas-release.json。来源包含两个真实 +发布、当前Git冻结工作区,以及1.0.0关联核心Git源码,后者不等于同版本npm内容。 + +`node --test refactor/scripts/canvas-contract.test.mjs`运行13项:归档/导出/声明、Git与 +实际npm核心能力区别、三实现属性转发和事件、正常bitmap与无bitmap绘制/resize。 +测试已接入test:baseline;test/helpers/canvas.js控制元素属性、microtask、RAF、延迟 +订阅和事件对象,后续02复用以构造乱序/销毁。假Canvas不证明真实像素、codec或设备。 + +13项正常契约通过以后,02必须分别复现过期bitmap完成、回调错误/close资源、重复 +play/pause、destroy前后订阅、缺context和尺寸异常;保留历史失败与候选通过两套测试。 +03拆媒体adapter/绘制调度/订阅和所有权,04明确公共类型(尤其可选参数及canvas自有 +方法优先),05/06核验新旧核心、实际媒体/浏览器/设备/安装包和8082 demo。 + +当前已有的Ambilight代理颜色测试会直接加载proxy的src/index.js;代理迁移文件入口 +或模块拆分时必须同步改为实际候选入口加载,不能用旧dist掩盖改名或遗漏模块。 +Canvas本身的正式历史浏览器矩阵尚未建立;没有在01使用新浏览器结果作包整体验收。 + +01最终13项Canvas专项、7项契约索引检查与991项完整CI通过;证据见 +[发布契约验证](baselines/canvas-contract-validation.json) 和 +[实施记录](changes/2026-09-12-PKG-CANVAS-01-contract.md)。三个新增风险继续open, +生命周期仅source-observed,02需先复现再推进03源码修复。 diff --git a/refactor/changes/2026-09-12-PKG-CANVAS-01-contract.md b/refactor/changes/2026-09-12-PKG-CANVAS-01-contract.md new file mode 100644 index 000000000..b602bdb46 --- /dev/null +++ b/refactor/changes/2026-09-12-PKG-CANVAS-01-contract.md @@ -0,0 +1,26 @@ +# PKG-CANVAS-01 发布契约与基线 + +获取并固定npm1.0.0/1.1.0实际tarball共12成员,记录registry时点、integrity、逐成员 +哈希与入口、Git工作区src/types/manifest/README/.npmignore/demo。验证器只读Git +冻结来源,未来迁移不会修改这些旧哈希。 + +真实执行三实现的同步canvas返回、属性/方法绑定、延迟媒体事件、callback/draw顺序、 +bitmap.close及无bitmap路径、metadata/resize/autoSize。公开差异包括旧CJS.default与 +新callable、export=与default、旧callback必填与新可选、Result尚未描述转发媒体面。 +对应行为/类型/生命周期风险已登记,后续依次复现和修复。本步没有改生产源码。 + +registryGitHead的核心5.1.7有proxy源码,而实际npm5.1.7没有该配置。固定关联Git的 +index/template哈希及实际旧核心声明对照,防止以相同版本号当作同样实现。5.3.1也是 +Git关联而非已验证支持范围。旧代理浏览器/安装消费者尚未验收;13项为受控契约检查。 + +契约索引新增两个真实发布对照点,当前22工作区+11发布版本;保留255行尚未精确 +索引的家族缺口,没有把本步运行自动计入每项全局契约通过。来源与验证说明见 +[契约](../baselines/canvas-contract.md)、[维护入口](../canvas-validation.md)。 + +13项Canvas专项与7项索引反例共20项通过;专项lint和完整CI991项通过(881单元+ +14工程+96基线),另44项契约重复观察不当作新增测试。278个生产TS文件严格检查; +本步没有新增生产TS文件或声称浏览器验收。结果、输入哈希与日志见 +[验证证据](../baselines/canvas-contract-validation.json)。 + +01完成,独立本地commit并审计自身,不推送/发布。下一步PKG-CANVAS-02复现异步 +bitmap/RAF及终止问题,03进入TS职责拆分;不能把源码观察直接升级为已修复问题。 diff --git a/refactor/contract-coverage.md b/refactor/contract-coverage.md index d3f1ceadc..381b09a01 100644 --- a/refactor/contract-coverage.md +++ b/refactor/contract-coverage.md @@ -10,7 +10,7 @@ ENG-COVERAGE-01建立可校验的归属和证据索引,不把索引建成当 `contract-policy.json`维护逐包逐类责任、版本依据及稳定测试ID。版本用JSON pointer 指向冻结清单,核对包名、版本和发布archive integrity;工作区单独标识。当前22个 -工作区、9个发布对照点(Ambilight新增两个),不由单个版本推断连续支持区间。 +工作区、11个发布对照点(Canvas代理新增两个),不由单个版本推断连续支持区间。 `contract-runs.json`引用归档报告及LF规范化SHA-256。报告保存当时定义、实际事件、 输入指纹、命令、HEAD和Node/OS。其他tasks.json历史JSON证据列为recorded-not-normalized, diff --git a/refactor/contract-index.md b/refactor/contract-index.md index aaa7bc3f8..a993a60ee 100644 --- a/refactor/contract-index.md +++ b/refactor/contract-index.md @@ -318,3 +318,5 @@ - PUB-artplayer-plugin-vast-1.0.0: artplayer-plugin-vast@1.0.0 (published-baseline);[来源](../refactor/baselines/vast-release.json) JSON pointer `/release`。 - PUB-artplayer-plugin-ambilight-1.1.0: artplayer-plugin-ambilight@1.1.0 (published-baseline);[来源](../refactor/baselines/ambilight-release.json) JSON pointer `/release`。 - PUB-artplayer-plugin-ambilight-1.0.0: artplayer-plugin-ambilight@1.0.0 (published-baseline);[来源](../refactor/baselines/ambilight-release.json) JSON pointer `/previous/0`。 +- PUB-artplayer-proxy-canvas-1.1.0: artplayer-proxy-canvas@1.1.0 (published-baseline);[来源](../refactor/baselines/canvas-release.json) JSON pointer `/release`。 +- PUB-artplayer-proxy-canvas-1.0.0: artplayer-proxy-canvas@1.0.0 (published-baseline);[来源](../refactor/baselines/canvas-release.json) JSON pointer `/previous/0`。 diff --git a/refactor/contract-policy.json b/refactor/contract-policy.json index 28866067b..3861dc6dd 100644 --- a/refactor/contract-policy.json +++ b/refactor/contract-policy.json @@ -451,7 +451,9 @@ "API-12": "PKG-CANVAS-02" }, "versionIds": [ - "WS-artplayer-proxy-canvas" + "WS-artplayer-proxy-canvas", + "PUB-artplayer-proxy-canvas-1.1.0", + "PUB-artplayer-proxy-canvas-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" @@ -802,6 +804,24 @@ "file": "refactor/baselines/ambilight-release.json", "pointer": "/previous/0", "archiveSha256": "a95aa8c232ab48493e6b4d46b36edc8091230cbde5d18d7ec41912a4fa2b8956" + }, + { + "id": "PUB-artplayer-proxy-canvas-1.1.0", + "package": "artplayer-proxy-canvas", + "kind": "published-baseline", + "version": "1.1.0", + "file": "refactor/baselines/canvas-release.json", + "pointer": "/release", + "archiveSha256": "348c1d6671f79d0e0dbf2bb15875a1e6d60d00b73cde833c018bec7e4be96c8e" + }, + { + "id": "PUB-artplayer-proxy-canvas-1.0.0", + "package": "artplayer-proxy-canvas", + "kind": "published-baseline", + "version": "1.0.0", + "file": "refactor/baselines/canvas-release.json", + "pointer": "/previous/0", + "archiveSha256": "728ba39534b3b8139bdc404ef15fe1ac5f76a95562a14e78c6973db2dcfa6f4f" } ], "cases": [ diff --git a/refactor/plan.md b/refactor/plan.md index c3c8423a1..c8667160b 100644 --- a/refactor/plan.md +++ b/refactor/plan.md @@ -4,7 +4,7 @@ 基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 221 项,范围 22 个包及工作区/示例。 -状态:todo 126 / doing 6 / blocked 0 / done 89 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 +状态:todo 125 / doing 6 / blocked 0 / done 90 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。 @@ -318,7 +318,7 @@ | ID | 范围 / 步骤 | 前置依赖 | 交付物 | 验收条件 | 风险 | 状态 | | --- | --- | --- | --- | --- | --- | --- | -| PKG-CANVAS-01 | artplayer-proxy-canvas
核对包契约与历史用法 | BASE-05 | callback、canvas 原方法、转发 media 属性/方法/事件 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | todo | +| PKG-CANVAS-01 | artplayer-proxy-canvas
核对包契约与历史用法 | BASE-05 | callback、canvas 原方法、转发 media 属性/方法/事件 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done | | PKG-CANVAS-02 | artplayer-proxy-canvas
建立特有行为与错误测试 | PKG-CANVAS-01, ENG-03, ENG-05 | ready/loadeddata/canplay、play/seek、回调、resize 与销毁 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | todo | | PKG-CANVAS-03 | artplayer-proxy-canvas
整理内部职责与资源 | PKG-CANVAS-02, CORE-06, CORE-11, CORE-16 | video adapter/原 canvas 方法/RAF 绘制/事件订阅分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo | | PKG-CANVAS-04 | artplayer-proxy-canvas
迁移自有源码和公开类型 | PKG-CANVAS-03, ENG-04, ENG-06, CORE-07 | canvas 与媒体能力的精确组合类型,保持 Result | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo | @@ -506,3 +506,4 @@ - PKG-VAST-01: [记录](baselines/vast-contract.md) [记录](baselines/vast-release.json) [记录](changes/2026-09-12-PKG-VAST-01-contract.md) [记录](baselines/vast-contract-validation.json) - PKG-VAST-02: [记录](changes/2026-09-12-PKG-VAST-02-tests.md) [记录](baselines/vast-behavior-validation.json) [记录](baselines/vast-core.json) [记录](vast-validation.md) - PKG-VAST-03: [记录](changes/2026-09-12-PKG-VAST-03-lifecycle.md) [记录](baselines/vast-lifecycle-validation.json) [记录](vast-compatibility-decision.md) +- PKG-CANVAS-01: [记录](changes/2026-09-12-PKG-CANVAS-01-contract.md) [记录](baselines/canvas-contract.md) [记录](baselines/canvas-release.json) [记录](canvas-validation.md) [记录](baselines/canvas-contract-validation.json) diff --git a/refactor/progress.md b/refactor/progress.md index cc6b29f10..896c099f5 100644 --- a/refactor/progress.md +++ b/refactor/progress.md @@ -1,5 +1,16 @@ # 进度与证据 +## 最新完成:Canvas-01 发布契约与正常基线 + +冻结npm1.0.0/1.1.0共12成员及工作区6个Git输入,运行三实现的同步返回、媒体属性/ +方法转发、延迟事件、bitmap关闭、回调顺序和resize。明确同版本Git核心5.1.7已有 +proxy而实际npm5.1.7没有,不能混为支持范围。13项Canvas专项、7项索引检查与991项 +完整CI通过,另44项重复观察;新增两个发布对照点,索引现为22工作区+11发布。 +生命周期/类型/分发风险已登记,生产代理源码尚未修改。当前221项:90 done、6 doing、 +125 todo。下一步Canvas-02复现异步资源和终止问题,Canvas-03开始源码TS拆分。 +见 [实施记录](changes/2026-09-12-PKG-CANVAS-01-contract.md) 和 +[验证证据](baselines/canvas-contract-validation.json)。独立本地提交,无推送/发布。 + ## 最新完成:Ambilight-PROXY-01 Canvas输出区域修复 组合测试复现Canvas缩放后仍按底层视频尺寸截取而取错九格颜色;内部明确video/canvas diff --git a/refactor/risk-table.md b/refactor/risk-table.md index a55c5064d..ea7dcc982 100644 --- a/refactor/risk-table.md +++ b/refactor/risk-table.md @@ -155,3 +155,6 @@ | AMBILIGHT-TYPE-01 | resolved / 已复现 | Ambilight declarations disagree with optional factory argument and differ across published generations | PKG-AMBILIGHT-04, PKG-AMBILIGHT-06 | | AMBILIGHT-DIST-01 | resolved / 已复现 | Ambilight 1.0.0 CommonJS default namespace differs from callable 1.1.0 | PKG-AMBILIGHT-04, PKG-AMBILIGHT-06 | | AMBILIGHT-PROXY-01 | resolved / 已复现 | Canvas proxy output pixels differ from forwarded video dimensions | PKG-AMBILIGHT-PROXY-01, PKG-AMBILIGHT-05 | +| CANVAS-LIFE-01 | open / 源码/产物事实 | Canvas proxy pending draw, bitmap and delayed subscriptions lack terminal ownership | PKG-CANVAS-02, PKG-CANVAS-03, PKG-CANVAS-05 | +| CANVAS-TYPE-01 | open / 源码/产物事实 | Canvas proxy declarations change required callback across generations and omit forwarded media capabilities | PKG-CANVAS-04, PKG-CANVAS-06 | +| CANVAS-DIST-01 | open / 已复现 | Canvas proxy published CommonJS namespace differs from current callable export | PKG-CANVAS-04, PKG-CANVAS-06 | diff --git a/refactor/risks.json b/refactor/risks.json index a16f95ca3..3f3248f47 100644 --- a/refactor/risks.json +++ b/refactor/risks.json @@ -3339,6 +3339,58 @@ "refactor/baselines/ambilight-proxy-validation.json" ], "resolutionRationale": "Node output resize/zero-size and normal video tests pass; actual 5.4.0/candidate canvas proxy nine-color regions pass across three engines. Actual 5.1.7 lacks proxy option and is recorded as a native capability observation, not a canvas pass. Proxy-owned lifecycle and final device acceptance remain tasks CANVAS and AMBILIGHT-05." + }, + { + "id": "CANVAS-LIFE-01", + "title": "Canvas proxy pending draw, bitmap and delayed subscriptions lack terminal ownership", + "confirmation": "source-observed", + "owners": [ + "PKG-CANVAS-02", + "PKG-CANVAS-03", + "PKG-CANVAS-05" + ], + "compatibleResolution": "Reproduce pending bitmap completion and rollback/late subscription before adding explicit draw scheduling and resource ownership.", + "closureCriteria": "Historical failures reproduced and candidate terminal/error/bitmap/real media lifecycle verified.", + "status": "open", + "evidence": [ + "refactor/baselines/canvas-contract.md", + "refactor/baselines/canvas-release.json", + "refactor/changes/2026-09-12-PKG-CANVAS-01-contract.md" + ] + }, + { + "id": "CANVAS-TYPE-01", + "title": "Canvas proxy declarations change required callback across generations and omit forwarded media capabilities", + "confirmation": "source-observed", + "owners": [ + "PKG-CANVAS-04", + "PKG-CANVAS-06" + ], + "compatibleResolution": "Keep optional current callback inference and actual canvas return while validating historical consumers and any additive media surface types.", + "closureCriteria": "Strict old/current compiler consumers and installed callback/result/import variants verified without new inference narrowing.", + "status": "open", + "evidence": [ + "refactor/baselines/canvas-contract.md", + "refactor/baselines/canvas-release.json", + "refactor/changes/2026-09-12-PKG-CANVAS-01-contract.md" + ] + }, + { + "id": "CANVAS-DIST-01", + "title": "Canvas proxy published CommonJS namespace differs from current callable export", + "confirmation": "reproduced", + "owners": [ + "PKG-CANVAS-04", + "PKG-CANVAS-06" + ], + "compatibleResolution": "Preserve both require.default and callable factory with actual global, legacy and ESM paths.", + "closureCriteria": "Packed runtime/type and real local demo/editor consumers pass.", + "status": "open", + "evidence": [ + "refactor/baselines/canvas-contract.md", + "refactor/baselines/canvas-release.json", + "refactor/changes/2026-09-12-PKG-CANVAS-01-contract.md" + ] } ] } diff --git a/refactor/scripts/canvas-contract.mjs b/refactor/scripts/canvas-contract.mjs new file mode 100644 index 000000000..d99f56709 --- /dev/null +++ b/refactor/scripts/canvas-contract.mjs @@ -0,0 +1,49 @@ +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 verifyCanvasContract() { + const baseline = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/canvas-release.json'), 'utf8')) + const archives = new Map() + const coreSources = 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) + for (const field of ['main', 'module', 'types', 'legacy']) { + const entry = release.manifest[field] + if (entry) + assert(Object.hasOwn(release.files, `package/${entry.replace(/^\.\//, '')}`)) + } + if (release.historicalCore) { + 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) + for (const [file, expected] of Object.entries(core.proxySources || {})) { + const content = execFileSync('git', ['show', `${core.commit}:${file}`], { encoding: 'utf8' }).replaceAll('\r\n', '\n') + assert.equal(hash(content), expected) + coreSources.set(`${release.version}:${file}`, content) + } + } + 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, `Canvas frozen source: ${file}`) + sources.set(file, source) + } + return { baseline, archives, sources, coreSources } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { baseline } = await verifyCanvasContract() + console.log(`Canvas proxy verified: ${[baseline.release, ...baseline.previous].map(release => release.version).join(', ')}; 12 members and frozen Git inputs`) +} diff --git a/refactor/scripts/canvas-contract.test.mjs b/refactor/scripts/canvas-contract.test.mjs new file mode 100644 index 000000000..d9c6b3f3f --- /dev/null +++ b/refactor/scripts/canvas-contract.test.mjs @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +// eslint-disable-next-line test/no-import-node-test -- Node baseline runner. +import test from 'node:test' +import vm from 'node:vm' +import { canvasEnvironment, canvasHistorical } from '../../test/helpers/canvas.js' +import { verifyCanvasContract } from './canvas-contract.mjs' +import { ensureArchive, hash, readMember } from './releases.mjs' + +const contract = await verifyCanvasContract() + +test('Canvas Git-associated core 5.1.7 has proxy support that the actual npm 5.1.7 declaration lacks', async () => { + const release = JSON.parse(fs.readFileSync('refactor/baselines/vast-core.json')).release + assert.equal(release.version, '5.1.7') + const file = 'package/types/option.d.ts' + const declaration = readMember(await ensureArchive(release), file) + assert.equal(hash(declaration), release.files[file]) + assert.doesNotMatch(declaration.toString(), /proxy\??\s*:/) + assert.match(contract.coreSources.get('1.0.0:packages/artplayer/src/index.js'), /proxy:\s*undefined/) + assert.match(contract.coreSources.get('1.0.0:packages/artplayer/src/template.js'), /option\.proxy\.call\(this\.art, this\.art\)/) +}) + +test('Canvas actual archives and frozen workspace retain exact files, versions and Git associations', () => { + assert.equal(contract.baseline.release.version, '1.1.0') + assert.equal(contract.baseline.previous[0].version, '1.0.0') + assert.equal(contract.baseline.release.historicalCore.version, '5.3.1') + assert.equal(contract.baseline.previous[0].historicalCore.version, '5.1.7') + assert.equal([...contract.archives].length, 2) + assert.equal([...contract.sources].length, 6) +}) + +test('Canvas published CJS namespaces, callable exports, script globals and actual ESM stay distinct', async () => { + for (const release of [contract.baseline.release, ...contract.baseline.previous]) { + for (const field of ['main', 'legacy']) { + const source = readMember(contract.archives.get(release.version), `package/${release.manifest[field].replace(/^\.\//, '')}`).toString() + const module = { exports: {} } + vm.runInNewContext(source, { module, exports: module.exports, window: {} }) + assert.equal(typeof module.exports, release.version === '1.0.0' ? 'object' : 'function') + assert.equal(typeof (module.exports.default || module.exports)(), 'function') + const global = { window: {} } + vm.runInNewContext(source, global) + assert.equal(typeof (global.artplayerProxyCanvas || global.window.artplayerProxyCanvas), 'function') + } + } + const source = readMember(contract.archives.get('1.1.0'), 'package/dist/artplayer-proxy-canvas.mjs').toString() + const namespace = await import(`data:text/javascript,${encodeURIComponent(source)}`) + assert.deepEqual(Object.keys(namespace), ['default']) + assert.equal(typeof namespace.default(), 'function') +}) + +test('Canvas published declarations preserve callback/result shape and expose the historical optional-argument change', () => { + const old = readMember(contract.archives.get('1.0.0'), 'package/types/artplayer-proxy-canvas.d.ts').toString() + const current = readMember(contract.archives.get('1.1.0'), 'package/types/artplayer-proxy-canvas.d.ts').toString() + assert.match(old, /export = artplayerProxyCanvas/) + assert.match(old, /\(option: Option\)/) + assert.match(current, /export default artplayerProxyCanvas/) + assert.match(current, /\(option\?: Option\)/) + for (const source of [old, current]) { + assert.match(source, /ctx: CanvasRenderingContext2D, video: HTMLVideoElement/) + assert.match(source, /type Result = HTMLCanvasElement/) + } +}) + +for (const implementation of await canvasHistorical()) { + test(`Canvas ${implementation.name}: bitmap closes before the public callback and draw event`, async () => { + const sequence = [] + const bitmap = { close() { + sequence.push('close') + } } + const env = await canvasEnvironment(implementation, { + createImageBitmap(video) { + assert.equal(video, env.video) + sequence.push('bitmap') + return Promise.resolve(bitmap) + }, + onDraw() { sequence.push('draw') }, + }) + env.factory(() => sequence.push('callback'))(env.art) + env.art.on('artplayerProxyCanvas:draw', () => sequence.push('event')) + env.art.emit('resize') + await env.flush() + assert.equal(env.draws[0][0], bitmap) + assert.deepEqual(sequence, ['bitmap', 'draw', 'close', 'callback', 'event']) + assert.equal(env.frames.size, 0) + }) + + test(`Canvas ${implementation.name}: synchronous canvas return, own methods and live bound media forwarding`, async () => { + const env = await canvasEnvironment(implementation) + const result = env.factory()(env.art) + assert.equal(result, env.canvas) + assert.equal(result.title, 'canvas-title') + result.src = 'sample.mp4' + result.currentTime = 3 + assert.equal(env.video.src, 'sample.mp4') + assert.equal(env.video.currentTime, 3) + env.video.videoWidth = 999 + assert.equal(result.videoWidth, 999) + const descriptor = Object.getOwnPropertyDescriptor(result, 'src') + assert.equal(descriptor.enumerable, true) + assert.equal(descriptor.configurable, true) + const play = result.play + await play('argument') + assert.equal(env.calls[0].receiver, env.video) + assert.deepEqual(env.calls[0].args, ['argument']) + const toDataURL = result.toDataURL + assert.equal(toDataURL('image/png'), 'data:canvas') + assert.equal(env.calls[1].receiver, env.canvas) + result.addEventListener('click', () => {}) + assert.equal(env.calls[2].name, 'canvas-listener') + assert.equal(env.proxies.length, 0) + assert.equal(env.timers.size, 1) + env.flushTimers() + assert.equal(env.proxies.length, env.art.constructor.config.events.length) + const event = { type: 'seeked', target: env.video } + env.emitVideo(event) + assert.equal(env.emitted.at(-1).name, 'video:seeked') + assert.equal(env.emitted.at(-1).args[0], event) + }) + + test(`Canvas ${implementation.name}: callback precedes draw event, metadata/resize preserve aspect and autoSize rules`, async () => { + const env = await canvasEnvironment(implementation) + const sequence = [] + env.factory((context, video) => { + assert.equal(context, env.context) + assert.equal(video, env.video) + sequence.push('callback') + })(env.art) + env.art.on('artplayerProxyCanvas:draw', (context, video) => { + assert.equal(context, env.context) + assert.equal(video, env.video) + sequence.push('event') + }) + env.flushTimers() + env.emitVideo({ type: 'loadedmetadata' }) + assert.deepEqual([env.canvas.width, env.canvas.height], [320, 180]) + env.art.emit('resize') + await env.flush() + assert.deepEqual([env.canvas.width, env.canvas.height], [640, 360]) + assert.equal(env.canvas.style.padding, '60px 0px') + assert.deepEqual(env.draws[0], [env.video, 0, 0, 640, 360]) + assert.deepEqual(sequence, ['callback', 'event']) + env.art.option.autoSize = true + env.art.template.$player.clientWidth = 100 + env.art.emit('resize') + await env.flush() + assert.equal(env.canvas.width, 640) + assert.equal(env.draws.length, 2, 'autoSize suppresses sizing, not the historical resize draw') + env.emitVideo({ type: 'play' }) + await env.flush() + assert.equal(env.frames.size, 1) + env.emitVideo({ type: 'pause' }) + assert.equal(env.frames.size, 0) + }) +} diff --git a/refactor/scripts/contracts.test.mjs b/refactor/scripts/contracts.test.mjs index fbb61e29a..532c3f5f9 100644 --- a/refactor/scripts/contracts.test.mjs +++ b/refactor/scripts/contracts.test.mjs @@ -44,7 +44,7 @@ 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, 9) + assert.equal(actual.policy.versions.filter(version => version.kind === 'published-baseline').length, 11) assert.equal(actual.policy.versions.filter(version => version.kind === 'workspace-baseline').length, 22) assert.equal(actual.policy.cases.length, 10) const markdown = renderContracts(actual) diff --git a/refactor/tasks.json b/refactor/tasks.json index 52fc203e0..9491dbade 100644 --- a/refactor/tasks.json +++ b/refactor/tasks.json @@ -3244,11 +3244,17 @@ "dependsOn": [ "BASE-05" ], - "status": "todo", + "status": "done", "risk": "H", "deliverable": "callback、canvas 原方法、转发 media 属性/方法/事件", "acceptance": "源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结", - "evidence": [] + "evidence": [ + "changes/2026-09-12-PKG-CANVAS-01-contract.md", + "baselines/canvas-contract.md", + "baselines/canvas-release.json", + "canvas-validation.md", + "baselines/canvas-contract-validation.json" + ] }, { "id": "PKG-CANVAS-02", diff --git a/test/helpers/canvas.js b/test/helpers/canvas.js new file mode 100644 index 000000000..e779ba4c0 --- /dev/null +++ b/test/helpers/canvas.js @@ -0,0 +1,152 @@ +import vm from 'node:vm' +import { transform } from 'esbuild' +import { verifyCanvasContract } from '../../refactor/scripts/canvas-contract.mjs' +import { readMember } from '../../refactor/scripts/releases.mjs' + +export async function canvasHistorical() { + const contract = await verifyCanvasContract() + return [ + ...[contract.baseline.release, ...contract.baseline.previous].map(release => ({ name: `published-${release.version}`, source: readMember(contract.archives.get(release.version), `package/${release.manifest.main.replace(/^\.\//, '')}`).toString(), format: 'artifact' })), + { name: 'frozen-workspace', source: contract.sources.get('packages/artplayer-proxy-canvas/src/index.js'), format: 'source' }, + ] +} + +export async function canvasEnvironment(implementation, settings = {}) { + const calls = [] + const draws = [] + const listeners = new Map() + const proxies = [] + const emitted = [] + const timers = new Map() + const frames = new Map() + const cancelled = [] + let nextTimer = 0 + let nextFrame = settings.firstFrame ?? 0 + const context = { + drawImage(...args) { + draws.push(args) + settings.onDraw?.(...args) + }, + } + const canvas = { + nodeName: 'CANVAS', + width: 300, + height: 150, + title: 'canvas-title', + style: {}, + getContext(kind) { return kind === '2d' && !settings.noContext ? context : null }, + toDataURL(...args) { + calls.push({ name: 'toDataURL', receiver: this, args }) + return 'data:canvas' + }, + addEventListener(...args) { calls.push({ name: 'canvas-listener', receiver: this, args }) }, + } + const video = { + nodeName: 'VIDEO', + videoWidth: 320, + videoHeight: 180, + width: 320, + height: 180, + title: 'video-title', + src: '', + currentTime: 0, + paused: true, + muted: false, + style: {}, + play(...args) { + calls.push({ name: 'play', receiver: this, args }) + this.paused = false + return Promise.resolve() + }, + pause(...args) { + calls.push({ name: 'pause', receiver: this, args }) + this.paused = true + }, + addEventListener(...args) { calls.push({ name: 'video-listener', receiver: this, args }) }, + } + const art = { + constructor: { config: { events: ['loadedmetadata', 'play', 'pause', 'seeked', 'error'] }, utils: { createElement: tag => tag === 'canvas' ? canvas : video } }, + option: { autoSize: false }, + template: { $player: { clientWidth: 640, clientHeight: 480 } }, + isDestroy: false, + on(name, callback) { + if (!listeners.has(name)) + listeners.set(name, new Set()) + listeners.get(name).add(callback) + return art + }, + off(name, callback) { + listeners.get(name)?.delete(callback) + return art + }, + emit(name, ...args) { + emitted.push({ name, args }) + for (const callback of [...(listeners.get(name) || [])]) callback(...args) + return art + }, + proxy(target, name, callback) { + const entry = { target, name, callback } + proxies.push(entry) + return () => proxies.splice(proxies.indexOf(entry), 1) + }, + destroy() { + art.isDestroy = true + art.emit('destroy') + }, + } + const source = implementation.format === 'source' ? (await transform(implementation.source, { format: 'cjs', target: 'es2020' })).code : implementation.source + const module = { exports: {} } + vm.runInNewContext(source, { + module, + exports: module.exports, + window: {}, + createImageBitmap: settings.createImageBitmap, + setTimeout(callback) { + const id = nextTimer++ + timers.set(id, callback) + return id + }, + clearTimeout(id) { timers.delete(id) }, + requestAnimationFrame(callback) { + const id = nextFrame++ + frames.set(id, callback) + return id + }, + cancelAnimationFrame(id) { + cancelled.push(id) + frames.delete(id) + }, + }, { timeout: 5000 }) + return { + factory: module.exports.default || module.exports, + art, + canvas, + video, + context, + calls, + draws, + listeners, + proxies, + emitted, + timers, + frames, + cancelled, + flushTimers() { + const pending = [...timers.values()] + timers.clear() + for (const callback of pending) callback() + }, + emitVideo(event) { + for (const entry of [...proxies]) { + if (entry.target === video && entry.name === event.type) + entry.callback(event) + } + }, + async flush() { + // Resolve only the controlled draw/animation promise chain, without wall-clock sleeps. + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }, + } +}