From 246bdc18636006ed974fb6237fa15d27441a28d6 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Fri, 9 Oct 2026 08:40:35 +0530 Subject: [PATCH 1/4] fix(deps): pin PartyKit's esbuild to 0.25.12 for GHSA-67mh-4wv8-2f99 PartyKit 0.0.115 (latest) pins esbuild 0.21.5, affected by the moderate dev-server CORS advisory. Override only partykit>esbuild to 0.25.12, the first patched minor line. Motion's partykit.json has no serve.build assets config, so PartyKit uses esbuild only to bundle the worker; the real relay qualifier passes with the new version. pnpm audit: no known vulnerabilities. Refs #26 Co-Authored-By: Claude Opus 5.5 --- package.json | 3 +- pnpm-lock.yaml | 271 +++++++++++++++++++++++++++---------------------- 2 files changed, 153 insertions(+), 121 deletions(-) diff --git a/package.json b/package.json index 3eab2b0..3c7674f 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ "magicast>source-map-js": "1.2.2", "postcss>source-map-js": "1.2.2", "vite>postcss": "8.5.23", - "knip>smol-toml": "1.9.0" + "knip>smol-toml": "1.9.0", + "partykit>esbuild": "0.25.12" }, "patchedDependencies": { "partykit@0.0.115": "patches/partykit@0.0.115.patch" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 242e121..27bc251 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,7 @@ overrides: postcss>source-map-js: 1.2.2 vite>postcss: 8.5.23 knip>smol-toml: 1.9.0 + partykit>esbuild: 0.25.12 patchedDependencies: partykit@0.0.115: @@ -201,9 +202,9 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} cpu: [ppc64] os: [aix] @@ -213,9 +214,9 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} cpu: [arm64] os: [android] @@ -225,9 +226,9 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} cpu: [arm] os: [android] @@ -237,9 +238,9 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} cpu: [x64] os: [android] @@ -249,9 +250,9 @@ packages: cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} cpu: [arm64] os: [darwin] @@ -261,9 +262,9 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} cpu: [x64] os: [darwin] @@ -273,9 +274,9 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} cpu: [arm64] os: [freebsd] @@ -285,9 +286,9 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} cpu: [x64] os: [freebsd] @@ -297,9 +298,9 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} cpu: [arm64] os: [linux] @@ -309,9 +310,9 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} cpu: [arm] os: [linux] @@ -321,9 +322,9 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} cpu: [ia32] os: [linux] @@ -333,9 +334,9 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} cpu: [loong64] os: [linux] @@ -345,9 +346,9 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} cpu: [mips64el] os: [linux] @@ -357,9 +358,9 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} cpu: [ppc64] os: [linux] @@ -369,9 +370,9 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} cpu: [riscv64] os: [linux] @@ -381,9 +382,9 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} cpu: [s390x] os: [linux] @@ -393,9 +394,9 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} cpu: [x64] os: [linux] @@ -405,15 +406,21 @@ packages: cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} cpu: [x64] os: [netbsd] @@ -423,15 +430,21 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} cpu: [x64] os: [openbsd] @@ -441,15 +454,21 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} cpu: [x64] os: [sunos] @@ -459,9 +478,9 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} cpu: [arm64] os: [win32] @@ -471,9 +490,9 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} cpu: [ia32] os: [win32] @@ -483,9 +502,9 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -1046,9 +1065,9 @@ packages: es-module-lexer@2.3.1: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} hasBin: true esbuild@0.28.1: @@ -1756,148 +1775,157 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.21.5': + '@esbuild/aix-ppc64@0.25.12': optional: true '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.21.5': + '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.21.5': + '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.21.5': + '@esbuild/android-x64@0.25.12': optional: true '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.21.5': + '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.21.5': + '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.21.5': + '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.21.5': + '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.21.5': + '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.21.5': + '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.21.5': + '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.21.5': + '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.21.5': + '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.21.5': + '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.21.5': + '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.21.5': + '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.21.5': + '@esbuild/linux-x64@0.25.12': optional: true '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.21.5': + '@esbuild/netbsd-x64@0.25.12': optional: true '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.21.5': + '@esbuild/openbsd-x64@0.25.12': optional: true '@esbuild/openbsd-x64@0.28.1': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.21.5': + '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.21.5': + '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.21.5': + '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.21.5': + '@esbuild/win32-x64@0.25.12': optional: true '@esbuild/win32-x64@0.28.1': @@ -2291,31 +2319,34 @@ snapshots: es-module-lexer@2.3.1: {} - esbuild@0.21.5: + esbuild@0.25.12: optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 esbuild@0.28.1: optionalDependencies: @@ -2630,7 +2661,7 @@ snapshots: dependencies: '@cloudflare/workers-types': 4.20240718.0 clipboardy: 4.0.0 - esbuild: 0.21.5 + esbuild: 0.25.12 miniflare: 3.20240718.0 ts-dedent: 2.3.0 unenv: 2.0.0-rc.0 From dfed05255719cea0c8cfcfd6ccc150990bff7541 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Fri, 9 Oct 2026 09:17:50 +0530 Subject: [PATCH 2/4] refactor(ios): split PoseEstimator hot path into pure PoseMath helpers buildAndEmit (149 lines) and computeHands (CCN 22, 101 lines) were the last two complexity violations. Move joint mapping, smoothing, quality, ROI geometry and ROI lifting into PoseMath (no Vision requests, no pixel buffers) and split the hand pass into computeHands (MediaPipe primary), visionHands and performHandRequests. Arithmetic, thresholds, left/right swaps, fallback order and request side effects are unchanged. Complexity ratchet: violations 2 -> 0, max CCN 22 -> 15, max length 149 -> 95. Refs #26 Co-Authored-By: Claude Opus 5.5 --- ios/Sources/Motion/Vision/PoseEstimator.swift | 313 +++++------------- ios/Sources/Motion/Vision/PoseMath.swift | 139 ++++++++ scripts/check-code-health.mjs | 6 +- 3 files changed, 234 insertions(+), 224 deletions(-) create mode 100644 ios/Sources/Motion/Vision/PoseMath.swift diff --git a/ios/Sources/Motion/Vision/PoseEstimator.swift b/ios/Sources/Motion/Vision/PoseEstimator.swift index dd006bf..eb36b09 100644 --- a/ios/Sources/Motion/Vision/PoseEstimator.swift +++ b/ios/Sources/Motion/Vision/PoseEstimator.swift @@ -199,66 +199,10 @@ final class PoseEstimator: @unchecked Sendable { pixelBuffer: CVPixelBuffer, timestampSeconds: TimeInterval ) { - // Pull a Vision point if it clears the confidence threshold, converting to - // top-left origin. x is kept as-is (buffer already mirrored); y is flipped. - func pt(_ name: VNHumanBodyPoseObservation.JointName) -> (Point2, Double)? { - guard let p = points[name], p.confidence >= confidenceThreshold else { return nil } - let x = Double(p.location.x) // already mirror-space - let y = 1.0 - Double(p.location.y) // bottom-left → top-left - return ([x, y], Double(p.confidence)) - } - - // Same conversion as `pt`, but gated at the looser `armConfidenceThreshold` — used - // ONLY for the optional arm-chain (shoulders + elbows) so they survive to draw a - // bent arm even when Vision is only ~0.15 confident about the elbow. - func ptArm(_ name: VNHumanBodyPoseObservation.JointName) -> (Point2, Double)? { - guard let p = points[name], p.confidence >= armConfidenceThreshold else { return nil } - let x = Double(p.location.x) - let y = 1.0 - Double(p.location.y) - return ([x, y], Double(p.confidence)) - } - - /// Midpoint of two optional points (needs both). Confidence = min of the two. - func mid(_ a: (Point2, Double)?, _ b: (Point2, Double)?) -> (Point2, Double)? { - guard let a, let b else { return nil } - return ([(a.0[0] + b.0[0]) / 2, (a.0[1] + b.0[1]) / 2], min(a.1, b.1)) - } - - var raw: [JointName: (Point2, Double)] = [:] - - // HEAD: prefer the nose; fall back to neck if the face is turned/occluded. - raw[.head] = pt(.nose) ?? pt(.neck) - - // HANDS: wrists. SWAP because the mirrored buffer flips visual sides — - // Vision's LEFT wrist is the player's RIGHT hand, and vice-versa. - raw[.rightHand] = pt(.leftWrist) - raw[.leftHand] = pt(.rightWrist) - - // TORSO: Vision's `.root` is the pelvis/hip center. Fall back to the midpoint - // of the shoulders if root is missing, then to the midpoint of the hips. - raw[.torso] = - pt(.root) - ?? mid(pt(.leftShoulder), pt(.rightShoulder)) - ?? mid(pt(.leftHip), pt(.rightHip)) - - // KNEES (swapped, same reason as hands). - raw[.rightKnee] = pt(.leftKnee) - raw[.leftKnee] = pt(.rightKnee) - - // FEET: ankles are the lowest reliable body-pose joint (no toe joint in the - // 2D body model), so ankle == foot. (Swapped.) - raw[.rightFoot] = pt(.leftAnkle) - raw[.leftFoot] = pt(.rightAnkle) - - // ARM CHAIN (shoulders + elbows) — OPTIONAL. SWAPPED exactly like hands/knees/feet - // above: the mirrored buffer flips visual sides, so Vision's LEFT is the player's - // RIGHT. Same y-flip and confidence threshold (via `pt`). Below-threshold joints - // stay absent here, so the packet omits them rather than sending garbage. - var rawArms: [ArmJointName: (Point2, Double)] = [:] - rawArms[.rightShoulder] = ptArm(.leftShoulder) - rawArms[.leftShoulder] = ptArm(.rightShoulder) - rawArms[.rightElbow] = ptArm(.leftElbow) - rawArms[.leftElbow] = ptArm(.rightElbow) + // Required joints gate at `confidenceThreshold`; the cosmetic arm chain (shoulders + + // elbows) at the looser `armConfidenceThreshold` so a bent arm survives a ~0.15 elbow. + let raw = PoseMath.bodyJoints { self.reading(points[$0], threshold: self.confidenceThreshold) } + let rawArms = PoseMath.armJoints { self.reading(points[$0], threshold: self.armConfidenceThreshold) } // Nothing usable this frame. guard !raw.isEmpty else { @@ -266,188 +210,128 @@ final class PoseEstimator: @unchecked Sendable { return } - // Exponential smoothing per joint. A joint that was absent adopts the new - // value directly; a joint absent this frame keeps its previous smoothed value - // so brief dropouts don't jitter the overlay. - var outJoints: [JointName: Point2] = [:] - var confidences: [JointName: Double] = [:] - let a = smoothing - for name in JointName.allCases { - if let (p, conf) = raw[name] { - if let prev = smoothed[name] { - let sx = a * p[0] + (1 - a) * prev[0] - let sy = a * p[1] + (1 - a) * prev[1] - outJoints[name] = [sx, sy] - smoothed[name] = [sx, sy] - } else { - outJoints[name] = p - smoothed[name] = p - } - confidences[name] = conf - } else if let prev = smoothed[name] { - // Missing this frame: reuse last known position, mark low confidence. - outJoints[name] = prev - confidences[name] = 0 - } - } - - let quality = - confidences.values.isEmpty - ? 0 - : confidences.values.reduce(0, +) / Double(JointName.allCases.count) - - // Arm-chain smoothing — same exponential filter (`a`) as the required joints above. - // A joint present this frame is smoothed against its previous value (or adopts the - // new value if fresh); a joint absent this frame reuses its last smoothed value so a - // brief dropout doesn't make the arm snap. A joint never seen stays absent (omitted). - var outArms: [ArmJointName: Point2] = [:] - for name in ArmJointName.allCases { - if let (p, _) = rawArms[name] { - if let prev = smoothedArms[name] { - let sx = a * p[0] + (1 - a) * prev[0] - let sy = a * p[1] + (1 - a) * prev[1] - outArms[name] = [sx, sy] - smoothedArms[name] = [sx, sy] - } else { - outArms[name] = p - smoothedArms[name] = p - } - } else if let prev = smoothedArms[name] { - // Missing this frame: reuse last known position (kept in sync with the - // required-joint dropout behavior above). Not fabricated from nothing. - outArms[name] = prev - } - // else: never detected → leave absent so the packet omits this arm joint. - } + // Exponential smoothing per joint; a brief dropout keeps the last smoothed value so + // the overlay doesn't jitter and the arm doesn't snap. + let body = PoseMath.smooth(raw, previous: &smoothed, alpha: smoothing) + let arms = PoseMath.smooth(rawArms, previous: &smoothedArms, alpha: smoothing) // ── PASS 2: ROI-ZOOMED HANDS ───────────────────────────────────────────────────── - // For EACH player hand, run a hand-pose request cropped to a small ROI around that - // wrist so Vision zooms the hand and returns precise landmarks. The ROI is centered - // on the smoothed, mirror-corrected wrist we just computed (`outJoints[.leftHand]` / - // `[.rightHand]`), and because each request is tied to a specific side, left/right is - // deterministic. A side whose wrist is missing/low-confidence this frame falls back to - // the full-frame request so the hand isn't lost. - let (hands, fingertips) = self.computeHands( - leftWrist: outJoints[.leftHand], - leftWristConf: confidences[.leftHand], - rightWrist: outJoints[.rightHand], - rightWristConf: confidences[.rightHand], - handler: handler, - pixelBuffer: pixelBuffer, - timestampSeconds: timestampSeconds - ) + // The ROI is centered on the smoothed, mirror-corrected wrist just computed, and each + // request is tied to a specific side, so left/right is deterministic. A side whose + // wrist is missing/low-confidence this frame falls back to the full-frame request. + let wrists = WristReadings( + left: body.points[.leftHand], leftConfidence: body.confidences[.leftHand], + right: body.points[.rightHand], rightConfidence: body.confidences[.rightHand]) + let (hands, fingertips) = computeHands( + wrists: wrists, handler: handler, + pixelBuffer: pixelBuffer, timestampSeconds: timestampSeconds) let frame = PoseFrame( - joints: outJoints, armJoints: outArms, quality: quality, - confidences: confidences, hands: hands, fingertips: fingertips) + joints: body.points, armJoints: arms.points, quality: PoseMath.quality(body.confidences), + confidences: body.confidences, hands: hands, fingertips: fingertips) Task { @MainActor in self.delegate?.poseEstimator(self, didProduce: frame) } } + /// A Vision point if it clears `threshold`, converted to top-left origin. x is kept as-is + /// (buffer already mirrored); y is flipped. + private func reading(_ point: VNRecognizedPoint?, threshold: Float) -> PoseReading? { + guard let point, point.confidence >= threshold else { return nil } + let location = PoseMath.topLeft(x: Double(point.location.x), y: Double(point.location.y)) + return (location, Double(point.confidence)) + } + // MARK: - ROI-zoomed hands (pass 2) - /// Run the per-wrist ROI hand requests, plus a full-frame fallback when a wrist is - /// missing, and turn the results into openness (`HandState`) + index fingertips - /// (`Fingertips`). Returns `nil` for a field when there is nothing meaningful to send. + /// Turn the frame's hands into openness (`HandState`) + index fingertips (`Fingertips`). + /// Returns `nil` for a field when there is nothing meaningful to send. private func computeHands( - leftWrist: Point2?, leftWristConf: Double?, - rightWrist: Point2?, rightWristConf: Double?, + wrists: WristReadings, handler: VNImageRequestHandler, pixelBuffer: CVPixelBuffer, timestampSeconds: TimeInterval ) -> (HandState?, Fingertips?) { // ── PRIMARY: MediaPipe HandLandmarker ──────────────────────────────────────────────── - // When MediaPipe is available it OWNS the hand signal: run it on the same frame and - // return its openness + fingertips, skipping the Vision ROI hand pass entirely. It only - // needs the confidently-detected body wrists (conf > 0) to assign left/right by nearest - // wrist; a stale/dropout wrist (conf == 0) is passed as nil so it doesn't mis-assign. - if mediaPipeHands.isAvailable { - let mpLeftWrist = (leftWristConf ?? 0) > 0 ? leftWrist : nil - let mpRightWrist = (rightWristConf ?? 0) > 0 ? rightWrist : nil - if let out = mediaPipeHands.analyze( + // When available it OWNS the hand signal and the Vision ROI pass is skipped. Only + // wrists detected this frame are passed, so a stale dropout wrist can't mis-assign. + // `analyze` returns nil only if MediaPipe became unavailable mid-run; the Vision path + // below is then the safety net. + if mediaPipeHands.isAvailable, + let out = mediaPipeHands.analyze( pixelBuffer: pixelBuffer, timestampSeconds: timestampSeconds, - leftWrist: mpLeftWrist, - rightWrist: mpRightWrist - ) { - return out - } - // analyze returned nil only if MediaPipe became unavailable mid-run; fall through - // to the Vision path below as a safety net. - } - - // ── FALLBACK: Apple Vision ROI hand pose (original path) ───────────────────────────── - // A wrist is usable for an ROI only if it was actually detected this frame (conf > 0); - // a value carried over from a dropout (conf == 0) is too stale to crop around. - let haveLeft = (leftWristConf ?? 0) > 0 && leftWrist != nil - let haveRight = (rightWristConf ?? 0) > 0 && rightWrist != nil - - // Build + perform each side's ROI request. Collect into per-side requests so results - // stay attributed to the correct side no matter what. - var toPerform: [VNDetectHumanHandPoseRequest] = [] - if haveLeft, let w = leftWrist { - leftHandRequest.regionOfInterest = roi(aroundTopLeftWrist: w) - toPerform.append(leftHandRequest) - } - if haveRight, let w = rightWrist { - rightHandRequest.regionOfInterest = roi(aroundTopLeftWrist: w) - toPerform.append(rightHandRequest) + leftWrist: wrists.freshLeft, + rightWrist: wrists.freshRight + ) + { + return out } + return visionHands(wrists: wrists, handler: handler) + } - // Fallback: if EITHER wrist is missing, run the full-frame request once so we can still - // pick up that hand (best-effort — lower resolution, but better than losing it). - let needFallback = !haveLeft || !haveRight - if needFallback { - fallbackHandRequest.regionOfInterest = CGRect(x: 0, y: 0, width: 1, height: 1) - toPerform.append(fallbackHandRequest) - } - - // Nothing to do (no wrists at all AND fallback found nothing is handled below). - guard !toPerform.isEmpty else { return (nil, nil) } - - do { - try handler.perform(toPerform) - } catch { - // ROI can still be rejected in edge cases; fail soft (no hands this frame). + /// FALLBACK: Apple Vision ROI hand pose (original path). A per-wrist ROI request runs for + /// each wrist detected this frame, plus one full-frame request when either is missing. + private func visionHands(wrists: WristReadings, handler: VNImageRequestHandler) -> (HandState?, Fingertips?) { + let leftROI = wrists.freshLeft.map { roi(aroundTopLeftWrist: $0) } + let rightROI = wrists.freshRight.map { roi(aroundTopLeftWrist: $0) } + // ROI can still be rejected in edge cases; fail soft (no hands this frame). + guard performHandRequests(leftROI: leftROI, rightROI: rightROI, handler: handler) else { return (nil, nil) } - // Resolve each side's single observation, preferring the ROI result and falling back - // to the nearest full-frame observation by wrist when the ROI side was absent. + // Prefer each side's ROI result; a side without one takes the nearest full-frame + // observation by its (possibly stale) wrist. let leftObs: VNHumanHandPoseObservation? = - haveLeft + leftROI != nil ? leftHandRequest.results?.first - : nearestFallbackObservation(toTopLeftWrist: leftWrist) + : nearestFallbackObservation(toTopLeftWrist: wrists.left) let rightObs: VNHumanHandPoseObservation? = - haveRight + rightROI != nil ? rightHandRequest.results?.first - : nearestFallbackObservation(toTopLeftWrist: rightWrist) + : nearestFallbackObservation(toTopLeftWrist: wrists.right) - // If literally nothing was detected on either side and neither has ever been seen, - // emit nothing so the packet omits `hands`/`fingertips`. + // Nothing on either side and never seen: omit `hands`/`fingertips` from the packet. if leftObs == nil && rightObs == nil && !handEstimator.hasEverSeenHand { return (nil, nil) } - // Each side's points must be lifted out of ITS OWN ROI. When a side fell back to the - // full-frame request, its ROI is the full frame (identity remap). - let leftROI = haveLeft ? leftHandRequest.regionOfInterest : fullFrameROI - let rightROI = haveRight ? rightHandRequest.regionOfInterest : fullFrameROI + // Each side's points are lifted out of ITS OWN ROI; a fallback side uses the full frame. let leftReading = handEstimator.analyzeSide( observation: leftObs, side: .left, - mapPoint: { self.mapRecognizedPoint($0, roi: leftROI) }) + mapPoint: { self.mapRecognizedPoint($0, roi: leftROI ?? self.fullFrameROI) }) let rightReading = handEstimator.analyzeSide( observation: rightObs, side: .right, - mapPoint: { self.mapRecognizedPoint($0, roi: rightROI) }) + mapPoint: { self.mapRecognizedPoint($0, roi: rightROI ?? self.fullFrameROI) }) let hands = HandState(left: leftReading.openness, right: rightReading.openness) + return (hands, PoseMath.fingertips(left: leftReading.indexTip, right: rightReading.indexTip)) + } - // Fingertips: omit the whole field only when BOTH sides are nil. - let fingertips: Fingertips? = - (leftReading.indexTip == nil && rightReading.indexTip == nil) - ? nil - : Fingertips(left: leftReading.indexTip, right: rightReading.indexTip) - - return (hands, fingertips) + /// Configure and perform this frame's hand requests. Results stay attributed to the + /// correct side because each side has its own request. Returns false when nothing ran + /// or Vision rejected the batch. + private func performHandRequests(leftROI: CGRect?, rightROI: CGRect?, handler: VNImageRequestHandler) -> Bool { + var toPerform: [VNDetectHumanHandPoseRequest] = [] + if let leftROI { + leftHandRequest.regionOfInterest = leftROI + toPerform.append(leftHandRequest) + } + if let rightROI { + rightHandRequest.regionOfInterest = rightROI + toPerform.append(rightHandRequest) + } + // If EITHER wrist is missing, run the full-frame request once so that hand can still + // be picked up (best-effort: lower resolution, but better than losing it). + if leftROI == nil || rightROI == nil { + fallbackHandRequest.regionOfInterest = fullFrameROI + toPerform.append(fallbackHandRequest) + } + guard !toPerform.isEmpty else { return false } + do { + try handler.perform(toPerform) + } catch { + return false + } + return true } /// Pick the full-frame (fallback) hand observation whose wrist is nearest the given @@ -495,16 +379,7 @@ final class PoseEstimator: @unchecked Sendable { /// [0,1] (Vision REJECTS an ROI that exceeds the image — that would throw in /// `perform` and we'd lose the hand). private func roi(aroundTopLeftWrist wrist: Point2) -> CGRect { - let cx = CGFloat(wrist[0]) // mirror-space x, same as Vision - let cy = 1.0 - CGFloat(wrist[1]) // top-left y → Vision bottom-left y - let half = roiSize / 2 - // Clamp the CENTER so a full-size box fits, then place the box. This keeps the box - // exactly `roiSize` wide (rather than shrinking it) while staying inside [0,1]. - let minC = half - let maxC = 1 - half - let clampedCx = min(max(cx, minC), maxC) - let clampedCy = min(max(cy, minC), maxC) - return CGRect(x: clampedCx - half, y: clampedCy - half, width: roiSize, height: roiSize) + PoseMath.roi(aroundTopLeftWrist: wrist, size: roiSize) } /// Map a raw hand `VNRecognizedPoint` to the protocol's TOP-LEFT, mirror-corrected, @@ -523,13 +398,9 @@ final class PoseEstimator: @unchecked Sendable { /// two-line change and nothing else needs to move. private func mapRecognizedPoint(_ point: VNRecognizedPoint, roi: CGRect) -> Point2 { // CONFIRMED ON DEVICE: Vision returns hand-landmark coords RELATIVE TO THE ROI - // (0..1 within the crop), NOT remapped to the full image. So we lift them into - // full-image space using the ROI rect (Vision bottom-left), then flip to top-left. - // For the full-frame fallback (roi = 0,0,1,1) this is an identity — so both the - // ROI-zoomed and fallback paths use the exact same helper. - let fullX = Double(roi.minX + point.location.x * roi.width) // full-image, mirror-space - let fullYBottomLeft = Double(roi.minY + point.location.y * roi.height) - return [fullX, 1.0 - fullYBottomLeft] // bottom-left → top-left + // (0..1 within the crop), NOT remapped to the full image, so lift them with the + // ROI rect. The full-frame fallback ROI makes this an identity apart from the flip. + PoseMath.liftFromROI(point.location, roi: roi) } /// The full-frame ROI — used for fallback (full-image) observations, where the diff --git a/ios/Sources/Motion/Vision/PoseMath.swift b/ios/Sources/Motion/Vision/PoseMath.swift new file mode 100644 index 0000000..e8c8ed5 --- /dev/null +++ b/ios/Sources/Motion/Vision/PoseMath.swift @@ -0,0 +1,139 @@ +// +// PoseMath.swift +// Motion +// +// The pure parts of `PoseEstimator`: joint mapping (y flip + mirrored left/right +// swap), exponential smoothing, quality, ROI geometry and ROI→full-image lifting. +// Nothing here runs a Vision request or touches a pixel buffer, so it is covered by +// `MotionTests/PoseMathTests.swift` without a camera. See PoseEstimator's header for +// why each convention holds; those comments are the source of truth. +// + +import CoreGraphics +import Vision + +/// A converted point (top-left origin, mirror space) and its Vision confidence. +typealias PoseReading = (point: Point2, confidence: Double) + +/// The body wrists handed to the hand pass. A confidence of 0 marks a position carried +/// over from a dropout, which is too stale to crop around or to assign hands by. +struct WristReadings { + let left: Point2? + let leftConfidence: Double? + let right: Point2? + let rightConfidence: Double? + + /// The left wrist only when it was detected this frame (confidence > 0). + var freshLeft: Point2? { (leftConfidence ?? 0) > 0 ? left : nil } + /// The right wrist only when it was detected this frame (confidence > 0). + var freshRight: Point2? { (rightConfidence ?? 0) > 0 ? right : nil } +} + +enum PoseMath { + typealias BodyLookup = (VNHumanBodyPoseObservation.JointName) -> PoseReading? + + /// Vision's bottom-left normalized point → the protocol's top-left. x is already in + /// mirror space (the connection mirrors the buffer), so only y flips. + static func topLeft(x: Double, y: Double) -> Point2 { + [x, 1.0 - y] + } + + /// Midpoint of two optional readings (needs both). Confidence = min of the two. + static func midpoint(_ a: PoseReading?, _ b: PoseReading?) -> PoseReading? { + guard let a, let b else { return nil } + return ([(a.point[0] + b.point[0]) / 2, (a.point[1] + b.point[1]) / 2], min(a.confidence, b.confidence)) + } + + /// The 8 protocol joints from thresholded Vision points. Sides are SWAPPED because the + /// mirrored buffer puts Vision's LEFT on the player's RIGHT. Missing joints are absent. + static func bodyJoints(_ pt: BodyLookup) -> [JointName: PoseReading] { + var raw: [JointName: PoseReading] = [:] + // HEAD: prefer the nose; fall back to neck if the face is turned/occluded. + raw[.head] = pt(.nose) ?? pt(.neck) + raw[.rightHand] = pt(.leftWrist) + raw[.leftHand] = pt(.rightWrist) + // TORSO: `.root` is the pelvis; then the shoulder midpoint, then the hip midpoint. + raw[.torso] = + pt(.root) + ?? midpoint(pt(.leftShoulder), pt(.rightShoulder)) + ?? midpoint(pt(.leftHip), pt(.rightHip)) + raw[.rightKnee] = pt(.leftKnee) + raw[.leftKnee] = pt(.rightKnee) + // FEET: the 2D body model has no toe joint, so ankle == foot. + raw[.rightFoot] = pt(.leftAnkle) + raw[.leftFoot] = pt(.rightAnkle) + return raw + } + + /// The optional arm chain (shoulders + elbows), swapped exactly like `bodyJoints`. + static func armJoints(_ pt: BodyLookup) -> [ArmJointName: PoseReading] { + var raw: [ArmJointName: PoseReading] = [:] + raw[.rightShoulder] = pt(.leftShoulder) + raw[.leftShoulder] = pt(.rightShoulder) + raw[.rightElbow] = pt(.leftElbow) + raw[.leftElbow] = pt(.rightElbow) + return raw + } + + /// Exponential smoothing per joint: new = alpha*current + (1-alpha)*previous. A joint + /// seen for the first time adopts its value; a joint missing this frame reuses its last + /// smoothed value with confidence 0; a joint never seen stays absent. + static func smooth( + _ raw: [Key: PoseReading], + previous: inout [Key: Point2], + alpha a: Double + ) -> (points: [Key: Point2], confidences: [Key: Double]) { + var points: [Key: Point2] = [:] + var confidences: [Key: Double] = [:] + for name in Key.allCases { + if let (p, conf) = raw[name] { + if let prev = previous[name] { + let sx = a * p[0] + (1 - a) * prev[0] + let sy = a * p[1] + (1 - a) * prev[1] + points[name] = [sx, sy] + previous[name] = [sx, sy] + } else { + points[name] = p + previous[name] = p + } + confidences[name] = conf + } else if let prev = previous[name] { + points[name] = prev + confidences[name] = 0 + } + } + return (points, confidences) + } + + /// Aggregate confidence over ALL protocol joints (absent joints count as 0). + static func quality(_ confidences: [JointName: Double]) -> Double { + confidences.values.isEmpty + ? 0 + : confidences.values.reduce(0, +) / Double(JointName.allCases.count) + } + + /// A `size`×`size` Vision ROI (bottom-left origin) centered on a top-left wrist, with the + /// center clamped so the whole box stays inside [0,1] (Vision rejects an overflowing ROI). + static func roi(aroundTopLeftWrist wrist: Point2, size: CGFloat) -> CGRect { + let cx = CGFloat(wrist[0]) // mirror-space x, same as Vision + let cy = 1.0 - CGFloat(wrist[1]) // top-left y → Vision bottom-left y + let half = size / 2 + let clampedCx = min(max(cx, half), 1 - half) + let clampedCy = min(max(cy, half), 1 - half) + return CGRect(x: clampedCx - half, y: clampedCy - half, width: size, height: size) + } + + /// Lift an ROI-relative hand landmark (confirmed on device: Vision returns hand points + /// relative to the crop) into the full image, then flip to top-left. A full-frame ROI + /// is an identity apart from the flip. + static func liftFromROI(_ location: CGPoint, roi: CGRect) -> Point2 { + let fullX = Double(roi.minX + location.x * roi.width) // full-image, mirror-space + let fullYBottomLeft = Double(roi.minY + location.y * roi.height) + return [fullX, 1.0 - fullYBottomLeft] // bottom-left → top-left + } + + /// Fingertips for the packet: omitted entirely only when BOTH sides are nil. + static func fingertips(left: Point2?, right: Point2?) -> Fingertips? { + (left == nil && right == nil) ? nil : Fingertips(left: left, right: right) + } +} diff --git a/scripts/check-code-health.mjs b/scripts/check-code-health.mjs index bc4e1cc..bb464c1 100644 --- a/scripts/check-code-health.mjs +++ b/scripts/check-code-health.mjs @@ -170,9 +170,9 @@ function checkComplexity() { ); // Ratcheted legacy debt: https://github.com/Significant-Hobbies/motion/issues/26 failRegressions("Complexity", observed, { - violations: 2, - maxCcn: 22, - maxLength: 149, + violations: 0, + maxCcn: 15, + maxLength: 95, maxParams: 7, }); } From 73bb310b6933b70ff6352bcb01801d2e7d40de13 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Fri, 9 Oct 2026 09:17:51 +0530 Subject: [PATCH 3/4] test(ios): add MotionTests unit-test target for PoseMath Hosted Swift Testing target generated by XcodeGen, added to the Motion scheme, with a CocoaPods search-path inherit so it resolves MediaPipe. 14 tests pin the y flip, mirrored left/right swap, head/torso fallbacks, smoothing and dropout reuse, quality, ROI clamping and ROI-to-image lifting. CI runs them after the Swift ratchets on the first available iPhone simulator. Refs #26 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 15 ++ ios/Podfile | 5 + ios/Podfile.lock | 2 +- ios/Tests/MotionTests/PoseMathTests.swift | 165 ++++++++++++++++++++++ ios/project.yml | 21 +++ 5 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 ios/Tests/MotionTests/PoseMathTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b42e322..89e5361 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,3 +66,18 @@ jobs: env: MOTION_INDEX_STORE: ${{ runner.temp }}/motion-derived/Index.noindex/DataStore run: pnpm quality:swift + - name: Run iOS unit tests + run: | + udid="$(xcrun simctl list devices available --json | python3 -c ' + import json, sys + devices = json.load(sys.stdin)["devices"] + print(next(d["udid"] for runtime, items in sorted(devices.items(), reverse=True) + if ".iOS-" in runtime for d in items if ".iPhone-" in d["deviceTypeIdentifier"])) + ')" + xcodebuild \ + -workspace ios/Motion.xcworkspace \ + -scheme Motion \ + -destination "id=$udid" \ + -derivedDataPath "$RUNNER_TEMP/motion-derived" \ + CODE_SIGNING_ALLOWED=NO \ + test diff --git a/ios/Podfile b/ios/Podfile index 6542acd..677b041 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -26,6 +26,11 @@ target 'Motion' do # `MPImage`, VIDEO running mode, etc. Pinned to a minor range so `pod install` is # reproducible while still picking up patch fixes. pod 'MediaPipeTasksVision', '~> 0.10.14' + + # Unit tests run inside the app host, so they reuse its pods (search paths only). + target 'MotionTests' do + inherit! :search_paths + end end post_install do |installer| diff --git a/ios/Podfile.lock b/ios/Podfile.lock index dfe3ba7..ce19841 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -15,6 +15,6 @@ SPEC CHECKSUMS: MediaPipeTasksCommon: ae27d88c4d185db114a1a768c504f8270dda79b5 MediaPipeTasksVision: 1d6c3efa89830a131dbd0dc4fb80dea283fac521 -PODFILE CHECKSUM: f8e3d2a0ce3856587e72d7a5725865203bd44dec +PODFILE CHECKSUM: df7e29f79290c2ba4ed26465642157a262370846 COCOAPODS: 1.17.0 diff --git a/ios/Tests/MotionTests/PoseMathTests.swift b/ios/Tests/MotionTests/PoseMathTests.swift new file mode 100644 index 0000000..9deb564 --- /dev/null +++ b/ios/Tests/MotionTests/PoseMathTests.swift @@ -0,0 +1,165 @@ +// +// PoseMathTests.swift +// MotionTests +// +// Pins the coordinate and smoothing contract `PoseEstimator` relies on: y flip, +// mirrored left/right swap, fallbacks, dropout reuse, quality, ROI clamping and +// ROI→full-image lifting. Camera, Vision requests and MediaPipe stay device-only. +// + +import CoreGraphics +import Testing +import Vision + +@testable import Motion + +private typealias VisionJoint = VNHumanBodyPoseObservation.JointName + +private func lookup(_ readings: [VisionJoint: PoseReading]) -> PoseMath.BodyLookup { + { readings[$0] } +} + +private func approx(_ a: Point2?, _ b: Point2, tolerance: Double = 1e-12) -> Bool { + guard let a, a.count == b.count else { return false } + return zip(a, b).allSatisfy { abs($0 - $1) <= tolerance } +} + +@Suite struct PoseMathMappingTests { + @Test func topLeftFlipsOnlyY() { + #expect(PoseMath.topLeft(x: 0.2, y: 0.9) == [0.2, 1.0 - 0.9]) + } + + @Test func bodyJointsSwapSidesForTheMirroredBuffer() { + let raw = PoseMath.bodyJoints( + lookup([ + .leftWrist: ([0.1, 0.1], 0.9), .rightWrist: ([0.9, 0.1], 0.8), + .leftKnee: ([0.2, 0.7], 0.7), .rightKnee: ([0.8, 0.7], 0.6), + .leftAnkle: ([0.2, 0.9], 0.5), .rightAnkle: ([0.8, 0.9], 0.4), + ])) + #expect(raw[.rightHand]?.point == [0.1, 0.1]) + #expect(raw[.leftHand]?.point == [0.9, 0.1]) + #expect(raw[.rightKnee]?.confidence == 0.7) + #expect(raw[.leftKnee]?.confidence == 0.6) + #expect(raw[.rightFoot]?.confidence == 0.5) + #expect(raw[.leftFoot]?.confidence == 0.4) + #expect(raw[.head] == nil) + #expect(raw[.torso] == nil) + } + + @Test func headPrefersNoseThenNeck() { + let both = PoseMath.bodyJoints(lookup([.nose: ([0.5, 0.1], 0.9), .neck: ([0.5, 0.2], 0.8)])) + #expect(both[.head]?.point == [0.5, 0.1]) + let neckOnly = PoseMath.bodyJoints(lookup([.neck: ([0.5, 0.2], 0.8)])) + #expect(neckOnly[.head]?.point == [0.5, 0.2]) + } + + @Test func torsoFallsBackFromRootToShouldersToHips() { + let root = PoseMath.bodyJoints( + lookup([.root: ([0.5, 0.5], 0.9), .leftShoulder: ([0.4, 0.3], 0.9), .rightShoulder: ([0.6, 0.3], 0.9)])) + #expect(root[.torso]?.point == [0.5, 0.5]) + + let shoulders = PoseMath.bodyJoints( + lookup([.leftShoulder: ([0.4, 0.3], 0.9), .rightShoulder: ([0.6, 0.5], 0.7), .leftHip: ([0, 0], 1)])) + #expect(approx(shoulders[.torso]?.point, [0.5, 0.4])) + #expect(shoulders[.torso]?.confidence == 0.7) + + let hips = PoseMath.bodyJoints( + lookup([.leftShoulder: ([0.4, 0.3], 0.9), .leftHip: ([0.4, 0.6], 0.5), .rightHip: ([0.6, 0.6], 0.6)])) + #expect(approx(hips[.torso]?.point, [0.5, 0.6])) + #expect(hips[.torso]?.confidence == 0.5) + } + + @Test func armJointsSwapSides() { + let arms = PoseMath.armJoints( + lookup([ + .leftShoulder: ([0.4, 0.3], 0.2), .rightShoulder: ([0.6, 0.3], 0.3), + .leftElbow: ([0.3, 0.4], 0.15), + ])) + #expect(arms[.rightShoulder]?.point == [0.4, 0.3]) + #expect(arms[.leftShoulder]?.point == [0.6, 0.3]) + #expect(arms[.rightElbow]?.confidence == 0.15) + #expect(arms[.leftElbow] == nil) + } +} + +@Suite struct PoseMathSmoothingTests { + @Test func firstSightingAdoptsThenBlendsThenReusesOnDropout() { + var previous: [JointName: Point2] = [:] + + let first = PoseMath.smooth([.head: ([0.2, 0.4], 0.9)], previous: &previous, alpha: 0.5) + #expect(first.points[.head] == [0.2, 0.4]) + #expect(first.confidences[.head] == 0.9) + #expect(first.points[.torso] == nil) + + let second = PoseMath.smooth([.head: ([0.6, 0.8], 0.7)], previous: &previous, alpha: 0.5) + #expect(approx(second.points[.head], [0.4, 0.6])) + #expect(approx(previous[.head], [0.4, 0.6])) + #expect(second.confidences[.head] == 0.7) + + let dropout = PoseMath.smooth([:] as [JointName: PoseReading], previous: &previous, alpha: 0.5) + #expect(approx(dropout.points[.head], [0.4, 0.6])) + #expect(dropout.confidences[.head] == 0) + #expect(approx(previous[.head], [0.4, 0.6])) + } + + @Test func alphaWeightsTheCurrentFrame() { + var previous: [ArmJointName: Point2] = [.leftElbow: [0, 0]] + let out = PoseMath.smooth([.leftElbow: ([1, 1], 0.2)], previous: &previous, alpha: 0.25) + #expect(approx(out.points[.leftElbow], [0.25, 0.25])) + } + + @Test func qualityAveragesOverAllEightJoints() { + #expect(PoseMath.quality([:]) == 0) + #expect(PoseMath.quality([.head: 0.8, .torso: 0.8]) == 1.6 / 8) + #expect(PoseMath.quality([.head: 0]) == 0) + } +} + +@Suite struct PoseMathGeometryTests { + @Test func roiIsCenteredWithFlippedY() { + let roi = PoseMath.roi(aroundTopLeftWrist: [0.5, 0.25], size: 0.3) + #expect(abs(roi.midX - 0.5) < 1e-9) + #expect(abs(roi.midY - 0.75) < 1e-9) + #expect(abs(roi.width - 0.3) < 1e-9 && abs(roi.height - 0.3) < 1e-9) + } + + @Test func roiClampsInsideTheFrameWithoutShrinking() { + for wrist: Point2 in [[0, 0], [1, 1], [-0.2, 1.4], [0.99, 0.01]] { + let roi = PoseMath.roi(aroundTopLeftWrist: wrist, size: 0.3) + #expect(roi.minX >= -1e-9 && roi.minY >= -1e-9) + #expect(roi.maxX <= 1 + 1e-9 && roi.maxY <= 1 + 1e-9) + #expect(abs(roi.width - 0.3) < 1e-9) + } + } + + @Test func liftFromROIMapsCropRelativePointsToTheFullImage() { + let roi = CGRect(x: 0.2, y: 0.4, width: 0.3, height: 0.3) + #expect(approx(PoseMath.liftFromROI(CGPoint(x: 0, y: 0), roi: roi), [0.2, 0.6])) + #expect(approx(PoseMath.liftFromROI(CGPoint(x: 1, y: 1), roi: roi), [0.5, 0.3])) + let full = CGRect(x: 0, y: 0, width: 1, height: 1) + #expect(approx(PoseMath.liftFromROI(CGPoint(x: 0.3, y: 0.8), roi: full), [0.3, 0.2])) + } + + @Test func roiCenterRoundTripsThroughLift() { + let wrist: Point2 = [0.42, 0.37] + let roi = PoseMath.roi(aroundTopLeftWrist: wrist, size: 0.3) + #expect(approx(PoseMath.liftFromROI(CGPoint(x: 0.5, y: 0.5), roi: roi), wrist, tolerance: 1e-9)) + } +} + +@Suite struct HandInputTests { + @Test func onlyWristsDetectedThisFrameAreFresh() { + let wrists = WristReadings(left: [0.3, 0.5], leftConfidence: 0, right: [0.7, 0.5], rightConfidence: 0.6) + #expect(wrists.freshLeft == nil) + #expect(wrists.freshRight == [0.7, 0.5]) + let missing = WristReadings(left: nil, leftConfidence: nil, right: nil, rightConfidence: 0.9) + #expect(missing.freshLeft == nil && missing.freshRight == nil) + } + + @Test func fingertipsAreOmittedOnlyWhenBothSidesAreMissing() { + #expect(PoseMath.fingertips(left: nil, right: nil) == nil) + let one = PoseMath.fingertips(left: [0.1, 0.2], right: nil) + #expect(one?.left == [0.1, 0.2]) + #expect(one?.right == nil) + } +} diff --git a/ios/project.yml b/ios/project.yml index e79174c..e9279b3 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -27,6 +27,12 @@ settings: targets: Motion: type: application + # Unit tests for the pure pose math (no camera or device). Run with: + # xcodebuild test -workspace Motion.xcworkspace -scheme Motion \ + # -destination 'platform=iOS Simulator,name=' CODE_SIGNING_ALLOWED=NO + scheme: + testTargets: + - MotionTests platform: iOS deploymentTarget: "17.0" sources: @@ -102,3 +108,18 @@ targets: - sdk: ReplayKit.framework # Photos: saving the recorded (game + camera) screen video to the library. - sdk: Photos.framework + + MotionTests: + type: bundle.unit-test + platform: iOS + deploymentTarget: "17.0" + sources: + - path: Tests/MotionTests + dependencies: + - target: Motion + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.motion.controller.tests + GENERATE_INFOPLIST_FILE: YES + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Motion.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Motion" + BUNDLE_LOADER: "$(TEST_HOST)" From 044f2c2ce42522df444e45c78aef6a6047c9df77 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Fri, 9 Oct 2026 09:26:34 +0530 Subject: [PATCH 4/4] fix(ios): keep MediaPipe graph force_load out of the test bundle CI aborted the hosted test run at launch: CocoaPods copied MediaPipe's -force_load graph library into Pods-MotionTests, so its classes were registered twice (app + test bundle). Strip -force_load from the test target xcconfigs in post_integrate; the app keeps it. Refs #26 Co-Authored-By: Claude Opus 5.5 --- ios/Podfile | 13 +++++++++++++ ios/Podfile.lock | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/ios/Podfile b/ios/Podfile index 677b041..01c39b5 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -44,3 +44,16 @@ post_install do |installer| end end end + +# The test bundle runs inside the app, which already force-loads MediaPipe's graph library. +# CocoaPods copies that -force_load into the test target too; loading the graph twice aborts +# the host at launch (duplicate class/graph registration). Keep it in the app only. +post_integrate do |installer| + installer.aggregate_targets.select { |t| t.name == 'Pods-MotionTests' }.each do |target| + target.user_build_configurations.each_key do |config| + path = target.xcconfig_path(config) + lines = File.readlines(path).reject { |line| line.include?('-force_load') } + File.write(path, lines.join) + end + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ce19841..a2df97b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -15,6 +15,6 @@ SPEC CHECKSUMS: MediaPipeTasksCommon: ae27d88c4d185db114a1a768c504f8270dda79b5 MediaPipeTasksVision: 1d6c3efa89830a131dbd0dc4fb80dea283fac521 -PODFILE CHECKSUM: df7e29f79290c2ba4ed26465642157a262370846 +PODFILE CHECKSUM: ecc473230b10c4c49d821baf000f8c481c49fdf9 COCOAPODS: 1.17.0