@vincentt-xr/sdk 2.0.0-alpha.6 → 2.0.0-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (287) hide show
  1. package/GROUNDING.md +1188 -14
  2. package/dist/{DebuggerUI-C_rbLdkh.js → DebuggerUI-C-ldiozL.js} +1 -1
  3. package/dist/{DebuggerUI-DYT_dk1E.cjs → DebuggerUI-CWX5EtQv.cjs} +1 -1
  4. package/dist/FaceMeshRenderer-BEEXZwS6.cjs +158 -0
  5. package/dist/FaceMeshRenderer-UuH5sTXG.js +1322 -0
  6. package/dist/FaceRetouch-C7h7x9kd.js +683 -0
  7. package/dist/FaceRetouch-DqftT26Y.cjs +134 -0
  8. package/dist/GLTFLoader-C5q63sKO.js +1633 -0
  9. package/dist/GLTFLoader-Cb_CRwmG.cjs +1 -0
  10. package/dist/SegmentationOverlay-DQuW3dsc.js +133 -0
  11. package/dist/SegmentationOverlay-aWVod8Kl.cjs +1 -0
  12. package/dist/assets/cache.d.ts +99 -0
  13. package/dist/assets/estimate.d.ts +11 -0
  14. package/dist/assets/index.d.ts +7 -0
  15. package/dist/assets/loaders.d.ts +28 -0
  16. package/dist/assets/react.d.ts +33 -0
  17. package/dist/calibration/calibration.d.ts +70 -0
  18. package/dist/calibration/index.d.ts +6 -0
  19. package/dist/calibration/storage.d.ts +13 -0
  20. package/dist/calibration/types.d.ts +53 -0
  21. package/dist/calibration/useXRCalibration.d.ts +46 -0
  22. package/dist/camera/CameraPermissionGate.d.ts +26 -0
  23. package/dist/camera/devices.d.ts +26 -0
  24. package/dist/camera/index.d.ts +8 -0
  25. package/dist/camera/permission.d.ts +86 -0
  26. package/dist/camera/useCameraPermission.d.ts +35 -0
  27. package/dist/capabilities/detect.d.ts +45 -0
  28. package/dist/capabilities/index.d.ts +5 -0
  29. package/dist/capabilities/types.d.ts +120 -0
  30. package/dist/capabilities/useXRCapabilities.d.ts +18 -0
  31. package/dist/compat/environment.d.ts +5 -0
  32. package/dist/compat/index.d.ts +4 -0
  33. package/dist/compat/run.d.ts +18 -0
  34. package/dist/compat/scenarios.d.ts +10 -0
  35. package/dist/compat/types.d.ts +101 -0
  36. package/dist/components/BodyTracker/index.d.ts +9 -0
  37. package/dist/components/FaceMesh/FaceMeshRenderer.d.ts +11 -3
  38. package/dist/components/FaceMesh/canonical/CanonicalFaceMeshRenderer.d.ts +41 -5
  39. package/dist/components/FaceMesh/canonical/parseCanonicalFaceModel.d.ts +12 -0
  40. package/dist/components/FaceMesh/canonical/shaders.d.ts +1 -1
  41. package/dist/components/FaceMesh/index.d.ts +2 -2
  42. package/dist/components/GestureListener/index.d.ts +11 -0
  43. package/dist/components/HandTracker/index.d.ts +10 -0
  44. package/dist/components/HolisticTracker/index.d.ts +69 -0
  45. package/dist/components/HolisticTracker/settings.d.ts +58 -0
  46. package/dist/components/HolisticTracker/signals.d.ts +49 -0
  47. package/dist/components/IrisEffect/IrisMaterial.d.ts +73 -0
  48. package/dist/components/IrisEffect/index.d.ts +51 -0
  49. package/dist/components/IrisEffect/settings.d.ts +117 -0
  50. package/dist/components/LipEffect/LipEffect.d.ts +7 -1
  51. package/dist/components/LipEffect/LipEffectMaterial.d.ts +43 -7
  52. package/dist/components/LipEffect/LipEffects.d.ts +9 -0
  53. package/dist/components/LipEffect/defaults.d.ts +27 -4
  54. package/dist/components/LipEffect/index.d.ts +11 -3
  55. package/dist/components/LipEffect/settings.d.ts +271 -0
  56. package/dist/components/LipEffect/types.d.ts +56 -9
  57. package/dist/components/LipEffect/useLipDesignTextures.d.ts +11 -0
  58. package/dist/components/LipEffect/uvGuide.d.ts +59 -0
  59. package/dist/components/PersonCutout/index.d.ts +50 -0
  60. package/dist/components/PersonCutout/personCutout.d.ts +16 -0
  61. package/dist/components/SceneObjectRenderer/index.d.ts +9 -2
  62. package/dist/components/ScreenImage/components/ScreenImageBody.d.ts +1 -1
  63. package/dist/components/ScreenImage/index.d.ts +3 -1
  64. package/dist/components/ScreenImage/materials/ScreenImageBlendMaterial.d.ts +1 -1
  65. package/dist/components/ScreenImage/materials/ScreenImageMaskMaterial.d.ts +11 -0
  66. package/dist/components/ScreenImage/source.d.ts +6 -0
  67. package/dist/components/ScreenImage/types.d.ts +18 -0
  68. package/dist/components/SegmentationOverlay/SegmentationOverlay.d.ts +37 -0
  69. package/dist/components/SegmentationOverlay/SegmentationOverlayMaterial.d.ts +31 -0
  70. package/dist/components/SegmentationOverlay/index.d.ts +9 -0
  71. package/dist/components/SegmentationOverlay/maskTexture.d.ts +23 -0
  72. package/dist/components/SegmentationOverlay/providers.d.ts +72 -0
  73. package/dist/components/SegmentationOverlay/settings.d.ts +65 -0
  74. package/dist/components/XRMediaSurface/index.d.ts +2 -1
  75. package/dist/components/XRProvider/index.d.ts +16 -2
  76. package/dist/components/XRViewport/hooks/useViewportExternalFaces.d.ts +1 -1
  77. package/dist/components/XRViewport/hooks/useViewportMediaSource.d.ts +1 -1
  78. package/dist/components/XRViewport/hooks/useViewportPipeline.d.ts +1 -1
  79. package/dist/components/index.d.ts +3 -0
  80. package/dist/constants/index.d.ts +4 -1
  81. package/dist/contexts-CO0sd4JQ.cjs +1 -0
  82. package/dist/{contexts-DvQI4PqI.js → contexts-FzlnnMYD.js} +252 -246
  83. package/dist/coordinates/feed.d.ts +52 -0
  84. package/dist/coordinates/index.d.ts +5 -0
  85. package/dist/coordinates/spaces.d.ts +49 -0
  86. package/dist/coordinates/viewport.d.ts +22 -0
  87. package/dist/coordinates/world.d.ts +15 -0
  88. package/dist/debug-ui/gesture-settings/index.d.ts +14 -0
  89. package/dist/debug-ui/holistic-tracker-settings/index.d.ts +16 -0
  90. package/dist/debug-ui/index.d.ts +14 -0
  91. package/dist/debug-ui/iris-effect-settings/index.d.ts +12 -0
  92. package/dist/debug-ui/lip-effect-settings/index.d.ts +15 -0
  93. package/dist/debug-ui/media-source.cjs +1 -1
  94. package/dist/debug-ui/media-source.js +1 -1
  95. package/dist/debug-ui/product-hotspots/index.d.ts +24 -0
  96. package/dist/debug-ui/segmentation-overlay-settings/index.d.ts +12 -0
  97. package/dist/debug-ui/xr-diagnostics/index.d.ts +25 -0
  98. package/dist/debug-ui.cjs +3 -3
  99. package/dist/debug-ui.js +2057 -1687
  100. package/dist/detector-K8aej4MB.js +59 -0
  101. package/dist/detector-ijypGtse.cjs +1 -0
  102. package/dist/entries/core.d.ts +21 -3
  103. package/dist/entries/face-effects.d.ts +12 -4
  104. package/dist/entries/low-level.d.ts +22 -3
  105. package/dist/entries/product.d.ts +11 -0
  106. package/dist/entries/tracking.d.ts +26 -1
  107. package/dist/face-effects.cjs +155 -1
  108. package/dist/face-effects.js +1134 -2196
  109. package/dist/gestures/definitions.d.ts +85 -0
  110. package/dist/gestures/detector.d.ts +87 -0
  111. package/dist/gestures/index.d.ts +8 -0
  112. package/dist/gestures/measures.d.ts +101 -0
  113. package/dist/gestures/settings.d.ts +49 -0
  114. package/dist/{getFaceInfo-B43Cwq4n.js → getFaceInfo-BkZfaQs-.js} +5 -5
  115. package/dist/{getFaceInfo-DEzcL_Ub.cjs → getFaceInfo-ahPxQFGu.cjs} +1 -1
  116. package/dist/hooks/selectors.d.ts +68 -1
  117. package/dist/hooks/useCalibrationFaceTracker.d.ts +1 -1
  118. package/dist/hooks/useFaceInfo.d.ts +13 -1
  119. package/dist/hooks/useFaceInfos.d.ts +1 -1
  120. package/dist/hooks/useGesture.d.ts +32 -0
  121. package/dist/hooks/useIrisTracking.d.ts +45 -0
  122. package/dist/hooks/useXRDiagnostics.d.ts +81 -0
  123. package/dist/hooks/useXRModel.d.ts +7 -0
  124. package/dist/hooks/useXRSession/facade.d.ts +44 -2
  125. package/dist/hooks/useXRSession/index.d.ts +2 -2
  126. package/dist/hooks/useXRSession/reason.d.ts +3 -1
  127. package/dist/hooks/useXRSession/registry.d.ts +14 -0
  128. package/dist/hooks/useXRSession/store.d.ts +52 -3
  129. package/dist/index--RgGzZ6X.cjs +47 -0
  130. package/dist/{index-uE3sKczj.js → index-CHVbH0hz.js} +4 -4
  131. package/dist/index-CeVkgsag.cjs +1 -0
  132. package/dist/index-CnGox6Xr.js +3054 -0
  133. package/dist/{index-B-s4YbWu.js → index-CsUdQ-pn.js} +113 -111
  134. package/dist/{index-CY7Z8cHV.cjs → index-DKBAh6hV.cjs} +1 -1
  135. package/dist/index-DsZwTfG0.cjs +1 -0
  136. package/dist/index-Dw6tj2Mi.js +144 -0
  137. package/dist/index-OCPjOaeP.cjs +1 -0
  138. package/dist/{index-G_5h_FJQ.js → index-s9pF4zkG.js} +999 -1014
  139. package/dist/internal-BYza1h8_.cjs +1 -0
  140. package/dist/{internal-ldPYiUF0.js → internal-vsfNQycj.js} +15 -16
  141. package/dist/internal.cjs +1 -1
  142. package/dist/internal.js +2 -2
  143. package/dist/iris/compute.d.ts +25 -0
  144. package/dist/iris/index.d.ts +4 -0
  145. package/dist/iris/lead.d.ts +19 -0
  146. package/dist/iris/types.d.ts +87 -0
  147. package/dist/landmarkMask-CbTFzBYG.js +224 -0
  148. package/dist/landmarkMask-u7es2k4X.cjs +1 -0
  149. package/dist/low-level.cjs +1 -1
  150. package/dist/low-level.js +1104 -639
  151. package/dist/main.cjs +1 -1
  152. package/dist/main.js +1519 -462
  153. package/dist/measures-9dQPdw96.js +154 -0
  154. package/dist/measures-KfJzpvod.cjs +1 -0
  155. package/dist/models/Base/index.d.ts +49 -0
  156. package/dist/models/Body/index.d.ts +1 -1
  157. package/dist/models/Face/index.d.ts +1 -1
  158. package/dist/models/Hand/GestureTracking.d.ts +1 -1
  159. package/dist/models/Hand/HandTracking.d.ts +1 -1
  160. package/dist/models/Holistic/index.d.ts +49 -0
  161. package/dist/models/Holistic/result.d.ts +68 -0
  162. package/dist/models/SemanticSegmentation/index.d.ts +31 -0
  163. package/dist/models/SemanticSegmentation/presets.d.ts +28 -0
  164. package/dist/models/index.d.ts +2 -0
  165. package/dist/product/ProductDimensions.d.ts +16 -0
  166. package/dist/product/ProductHotspot.d.ts +16 -0
  167. package/dist/product/ProductViewer.d.ts +9 -0
  168. package/dist/product/details.d.ts +53 -0
  169. package/dist/product/index.d.ts +14 -0
  170. package/dist/product/loadModelViewer.d.ts +4 -0
  171. package/dist/product/types.d.ts +71 -0
  172. package/dist/product/useProductAr.d.ts +37 -0
  173. package/dist/product/useProductDetails.d.ts +60 -0
  174. package/dist/product.cjs +1 -0
  175. package/dist/product.js +414 -0
  176. package/dist/providers-C0Dlhh9Z.js +365 -0
  177. package/dist/providers-YNwkmNeK.cjs +62 -0
  178. package/dist/quality/XRQualityController.d.ts +25 -0
  179. package/dist/quality/controller.d.ts +33 -0
  180. package/dist/quality/index.d.ts +6 -0
  181. package/dist/quality/types.d.ts +100 -0
  182. package/dist/quality/useXRQuality.d.ts +28 -0
  183. package/dist/{renderGroups-CFt1yYLB.js → renderGroups-CN1cGFUo.js} +1 -1
  184. package/dist/{renderGroups-BJsx32q5.cjs → renderGroups-O4KmaMl9.cjs} +1 -1
  185. package/dist/renderPasses-Cg-zqmEz.cjs +21 -0
  186. package/dist/renderPasses-NnKZVfBy.js +1460 -0
  187. package/dist/scene-object.cjs +1 -1
  188. package/dist/scene-object.js +2 -2
  189. package/dist/screen2d/coordinates.d.ts +20 -0
  190. package/dist/screenTransform-BenfCtZt.js +44 -0
  191. package/dist/screenTransform-BiXdpVf0.cjs +1 -0
  192. package/dist/segmentation-9PhSvc-R.cjs +1 -0
  193. package/dist/segmentation-CeOTKemr.js +5 -0
  194. package/dist/segmentation2d/faceMemory.d.ts +28 -0
  195. package/dist/segmentation2d/faceRegions.d.ts +24 -0
  196. package/dist/segmentation2d/index.d.ts +9 -0
  197. package/dist/segmentation2d/maskUv.d.ts +23 -0
  198. package/dist/segmentation2d/rasterize.d.ts +14 -0
  199. package/dist/segmentation2d/types.d.ts +46 -0
  200. package/dist/selectors-C5G1PMYL.js +195 -0
  201. package/dist/selectors-Du_PsVnW.cjs +1 -0
  202. package/dist/{session-CANvoLD6.cjs → session-BBr0yN8h.cjs} +7 -7
  203. package/dist/{session-Dhi0OqZo.js → session-Cy0m7l7-.js} +76 -76
  204. package/dist/settings-DRR7-snV.cjs +1 -0
  205. package/dist/settings-aQk845NJ.js +73 -0
  206. package/dist/subjects/index.d.ts +2 -0
  207. package/dist/subjects/select.d.ts +62 -0
  208. package/dist/subjects/types.d.ts +55 -0
  209. package/dist/tracking/coordinates.d.ts +5 -0
  210. package/dist/tracking-config/apply.d.ts +57 -0
  211. package/dist/tracking-config/index.d.ts +7 -0
  212. package/dist/tracking-config/resolve.d.ts +25 -0
  213. package/dist/tracking-config/storage.d.ts +8 -0
  214. package/dist/tracking-config/types.d.ts +106 -0
  215. package/dist/tracking-config/useXRTrackingConfig.d.ts +38 -0
  216. package/dist/tracking.cjs +1 -1
  217. package/dist/tracking.js +1056 -380
  218. package/dist/types/common.d.ts +11 -1
  219. package/dist/types/compositor.d.ts +21 -0
  220. package/dist/types/gesture-tracking.d.ts +2 -2
  221. package/dist/types/hand-tracking.d.ts +3 -3
  222. package/dist/types/holistic.d.ts +121 -0
  223. package/dist/types/index.d.ts +1 -0
  224. package/dist/types/model.d.ts +22 -2
  225. package/dist/types/nodes.d.ts +29 -4
  226. package/dist/types/segmentation.d.ts +19 -0
  227. package/dist/types/session.d.ts +48 -4
  228. package/dist/useFaceInfo-Blf7FCu3.js +303 -0
  229. package/dist/useFaceInfo-C5mO8NQY.cjs +1 -0
  230. package/dist/useXRCapabilities-B1R2ghy-.cjs +1 -0
  231. package/dist/useXRCapabilities-C68v__wB.js +30 -0
  232. package/dist/utils/fns.d.ts +6 -3
  233. package/dist/utils/integrity.d.ts +4 -1
  234. package/dist/utils/updateThrottle.d.ts +2 -0
  235. package/dist/utils/useStableUniforms.d.ts +18 -0
  236. package/docs/SDK_DOCUMENTATION.md +3 -0
  237. package/docs/api/adaptive-quality.mdx +104 -0
  238. package/docs/api/asset-lifecycle.mdx +78 -0
  239. package/docs/api/camera-permission.mdx +142 -0
  240. package/docs/api/capture-recording.mdx +9 -1
  241. package/docs/api/coordinate-spaces.mdx +165 -0
  242. package/docs/api/cross-camera.mdx +88 -0
  243. package/docs/api/diagnostics-calibration.mdx +97 -0
  244. package/docs/api/face-effects.mdx +6 -0
  245. package/docs/api/face-mesh.mdx +35 -0
  246. package/docs/api/gestures.mdx +173 -0
  247. package/docs/api/holistic-tracker.mdx +159 -0
  248. package/docs/api/iris-tracking.mdx +242 -0
  249. package/docs/api/multi-subject.mdx +95 -0
  250. package/docs/api/person-cutout.mdx +136 -0
  251. package/docs/api/product-ar.mdx +111 -0
  252. package/docs/api/product-details.mdx +78 -0
  253. package/docs/api/runtime-3d.mdx +5 -0
  254. package/docs/api/screen-image.mdx +3 -1
  255. package/docs/api/screen-space-coordinates.mdx +35 -1
  256. package/docs/api/segmentation-overlay.mdx +301 -0
  257. package/docs/api/session-lifecycle.mdx +136 -0
  258. package/docs/api/trackers.mdx +18 -0
  259. package/docs/api/tracking-config.mdx +108 -0
  260. package/docs/docs.json +23 -1
  261. package/docs/examples/lip-shaders.mdx +224 -0
  262. package/docs/guides/lip-effect-textures.mdx +292 -0
  263. package/docs/guides/lip-uv-template.png +0 -0
  264. package/docs/guides/lip-uv-template.svg +1 -0
  265. package/package.json +16 -1
  266. package/dist/FaceMeshRenderer-D1WsrTLA.cjs +0 -155
  267. package/dist/FaceMeshRenderer-DSCd1H55.js +0 -1264
  268. package/dist/FaceRetouch-CMJbGThL.js +0 -551
  269. package/dist/FaceRetouch-DsE0Dbw-.cjs +0 -134
  270. package/dist/contexts-h-9TRH0r.cjs +0 -1
  271. package/dist/index-C0-fg1Hk.cjs +0 -1
  272. package/dist/index-CxMEeCRP.cjs +0 -1
  273. package/dist/index-D67hHm44.cjs +0 -1
  274. package/dist/index-DPPpU5By.cjs +0 -17
  275. package/dist/index-DYfY9ljy.js +0 -111
  276. package/dist/index-I4eF5TKT.js +0 -1457
  277. package/dist/internal-MvKrz0Nt.cjs +0 -1
  278. package/dist/renderLayers-lDtL0_43.cjs +0 -1
  279. package/dist/renderLayers-zrK2pv5O.js +0 -13
  280. package/dist/renderPasses-C72MbZ-a.js +0 -1500
  281. package/dist/renderPasses-D-Krl6D0.cjs +0 -21
  282. package/dist/selectors-ByKNTXeA.js +0 -59
  283. package/dist/selectors-CAwmL9VW.cjs +0 -1
  284. package/dist/useFaceInfo-BSZq7AO1.cjs +0 -1
  285. package/dist/useFaceInfo-FVQBBNdY.js +0 -62
  286. package/dist/useFaceResults-C3oZkn2Z.cjs +0 -1
  287. package/dist/useFaceResults-D62STzT1.js +0 -19
package/GROUNDING.md CHANGED
@@ -10,11 +10,12 @@ or spelunk `node_modules` for the API.
10
10
  The API is split into task-domain entry points. Import each symbol from the door
11
11
  this reference shows for it; the imports here are literal.
12
12
 
13
- - `@vincentt-xr/sdk` — **core**: providers, scene, screen-space layout, runtime renderers (`Transform3D`, `MeshRenderer`, `SceneObjectRenderer`), `TextLabel`, `Panel`, `SpriteAnimation`, `VideoBackground`, `AspectRatioContainer`, capture/share (`useFrameCapture`, `useMediaRecorder`, `dataURLtoFile`), audio.
13
+ - `@vincentt-xr/sdk` — **core**: providers, scene, screen-space layout, runtime renderers (`Transform3D`, `MeshRenderer`, `SceneObjectRenderer`), `TextLabel`, `Panel`, `SpriteAnimation`, `VideoBackground`, `AspectRatioContainer`, capture/share (`useFrameCapture`, `useMediaRecorder`, `dataURLtoFile`), audio, capability detection (`useXRCapabilities`, `detectXRCapabilities`, `resolveTrackerSupport`), camera permission (`CameraPermissionGate`, `useCameraPermission`), adaptive quality (`useXRQuality`, `XRQualityController`), persistent tracking config (`useXRTrackingConfig`), asset lifecycle (`useXRAsset`, `XRAssetCache`, `defineTextureAsset` / `defineGltfAsset` / `defineVideoAsset`).
14
14
  - `@vincentt-xr/sdk/scene-object` — **public authoring state**: scene-object/component types, factories, selectors, and immutable store operations.
15
- - `@vincentt-xr/sdk/tracking` — **trackers**: face/hand/gesture/body/segmentation, `TrackingAnchor`, `GestureTrigger`, the bare `FaceMesh`, `useFaceResults`.
15
+ - `@vincentt-xr/sdk/tracking` — **trackers**: face/hand/gesture/body/holistic/segmentation, `TrackingAnchor`, `GestureTrigger`, the bare `FaceMesh`, `useFaceResults`, gestures, iris, device calibration (`useXRCalibration`), multi-subject policy (`selectSubject`, `useFaceInfo({ subject })`).
16
16
  - `@vincentt-xr/sdk/face-effects` — **face deep**: FaceMesh material config, retouch, canonical mesh, head-binding.
17
- - `@vincentt-xr/sdk/low-level` — **escape hatch**: raw model-node reads, selector hooks, custom-tracker plumbing. Reach here only when core + tracking can't express it.
17
+ - `@vincentt-xr/sdk/product` — **placement AR** for product pages: `ProductViewer` (Google `<model-viewer>`, optional peer, lazy-loaded), `useProductAr`. A separate mode from the session: placement AR is model-viewer, face/body AR is the session, never both at once.
18
+ - `@vincentt-xr/sdk/low-level` — **escape hatch**: raw model-node reads, selector hooks (`useXRLifecycle`, `useXRTrackerStatus`, …), tracking diagnostics (`useXRDiagnostics`), custom-tracker plumbing. Reach here only when core + tracking can't express it.
18
19
 
19
20
  Trackers (`<FaceTracker>`, `<GestureTracker>`, `<HandTracker>`, `<BodyTracker>`)
20
21
  **self-register** when mounted — no explicit `registerXRPipeline` call. Per-frame
@@ -94,6 +95,19 @@ import { ScreenSpaceUI, ScreenTransform } from "@vincentt-xr/sdk";
94
95
 
95
96
  Coordinates are normalized: -1 to +1, with +y up. The center of the screen is `(0, 0)`. The anchors rect controls BOTH size and placement of the child — it stretches the child (plane, TextLabel, sprite) to fill the rect. Don't add `scale`/`position`/`planeGeometry args` to size or move a screen-anchored child; set the anchors.
96
97
 
98
+ Canvas screen pixels map across the live viewport on both axes, including
99
+ landscape and square displays. They are not automatically letterboxed. Preserve
100
+ an image or video's own aspect with its fit mode inside a screen rect, not with
101
+ manual point conversion. `ScreenTransform2DSettings`, used by serialized editor
102
+ layouts, is intentionally contain-fitted so text and its transform guide retain
103
+ their authored aspect ratio.
104
+
105
+ For a custom R3F mesh that needs a translated design point, use
106
+ `screenPixelsToScreenSpace({ point, parentSize })`; it applies the same separate
107
+ X/Y mapping as the screen primitives. `screenPixelsToUnits` is scalar conversion
108
+ for uniform distances and is not a substitute for point placement on a
109
+ non-design-aspect viewport.
110
+
97
111
  ```tsx
98
112
  // Full-screen frame
99
113
  <ScreenTransform anchors={{ left: -1, right: 1, top: 1, bottom: -1 }}>
@@ -528,10 +542,47 @@ projection correction. Author a Transform with `position.y = 0` to opt out.
528
542
  Use it only when bridging custom runtime code; ordinary scene-object rendering
529
543
  should use `SceneObjectRenderer`.
530
544
 
545
+ SDK material types and shader presets are convenience defaults, not a closed
546
+ material system. For an advanced effect, pass `materialOverride` to
547
+ `FaceMeshRenderer`. It accepts a material node, a three.js material instance
548
+ (set on the mesh directly, so one instance can serve many meshes), or a factory
549
+ returning either, invoked per visible face geometry with `{ cameraTexture, geometryKey, faceUv, lipEdge }`. `faceUv` is
550
+ the shared live camera-UV `BufferAttribute` and `lipEdge` the static lip band
551
+ position (both `null` until the canonical mesh has loaded), bound to every
552
+ group's geometry as `attribute vec2 faceUv` and `attribute float lipEdge`. The override takes
553
+ precedence over presets and stays runtime-only: do not place `THREE.Material`,
554
+ uniforms, or factories in `FaceMeshRendererSettings` or a persisted
555
+ `SceneObjectStore`.
556
+
557
+ ```tsx
558
+ <FaceMeshRenderer
559
+ value={faceMeshSettings}
560
+ materialOverride={({ cameraTexture, geometryKey }) => (
561
+ <shaderMaterial
562
+ key={geometryKey}
563
+ uniforms={{ cameraTexture: { value: cameraTexture } }}
564
+ vertexShader={vertexShader}
565
+ fragmentShader={fragmentShader}
566
+ transparent
567
+ />
568
+ )}
569
+ />
570
+ ```
571
+
572
+ For serialized scene objects, pass runtime overrides separately to
573
+ `SceneObjectRenderer`, keyed by the FaceMeshRenderer component ID. R3F owns a
574
+ material node's lifecycle; factories must return stable, ordinary React material
575
+ nodes and must not mutate private renderer internals.
576
+
531
577
  `CanonicalFaceMeshRenderer` remains exported from `@vincentt-xr/sdk/face-effects`
532
578
  for backwards compatibility and low-level experiments, but new editor and
533
579
  scene-object work should use `FaceMeshRenderer`.
534
580
 
581
+ Every canonical face mesh (including `FaceMeshRenderer` and `LipEffect`) bridges
582
+ tracker dropouts with `holdMs` (default 250) and `predictMs` (default 0),
583
+ both `CanonicalFaceMeshRendererProps`. The contract, defaults, and limits are
584
+ in "Tracker dropouts — the one bridge to use"; do not add a second hold on top.
585
+
535
586
  ### `FaceRetouch` — legacy landmark skin retouch
536
587
 
537
588
  Use `FaceRetouch` from `@vincentt-xr/sdk/face-effects` for the legacy
@@ -572,6 +623,350 @@ For multiple people, render one `FaceRetouch` per face:
572
623
  optional horizontal UV clipping props in `0..1` space, useful for split-screen
573
624
  comparison demos.
574
625
 
626
+ ### Iris tracking — `useIrisTracking()` and `<IrisEffect>`
627
+
628
+ Iris data comes from the ten refined iris landmarks the shared face tracker
629
+ already emits: `useIrisTracking` (`@vincentt-xr/sdk/tracking`) acquires the
630
+ same `XRModel.FACE_TRACKER` as `FaceTracker` / `useFaceInfo`, so there is no
631
+ second detector. `<IrisEffect>` (`@vincentt-xr/sdk/face-effects`) draws a
632
+ material over each iris.
633
+
634
+ ```tsx
635
+ import { useIrisTracking, IRIS_LANDMARKS } from "@vincentt-xr/sdk/tracking";
636
+ import { IrisEffect } from "@vincentt-xr/sdk/face-effects";
637
+
638
+ <IrisEffect mode="shader" color="#4fa3ff" opacity={0.85} smoothing={15} />;
639
+ <IrisEffect mode="image" textureUrl="/iris.png" scale={1.1} mirrored />;
640
+
641
+ const { result, status } = useIrisTracking({ faceIndex: 0, smoothing: 15 });
642
+ useFrame(() => {
643
+ if (!result.detected) return;
644
+ const { center, radius, offset, openness, confidence } = result.left;
645
+ });
646
+ ```
647
+
648
+ - **Result** (`IrisResult`, stable object mutated per frame): `version`,
649
+ `timestampMs`, `faceIndex`, `detected`, `held`, `confidence`, and `left` /
650
+ `right` (`IrisEye`): `center` + `ring` + `eyeCenter` in MediaPipe image
651
+ space (0..1, top-left, Y down, like `FaceInfo.landmarks`), `trackerCenter`
652
+ in tracker space, `radius` / `eyeWidth` in image-width units, `openness`
653
+ (lid gap / eye width), `offset` ([-1, 1] per axis, +x the person's left,
654
+ +y up, on every source: the horizontal sign is corrected on the mirrored
655
+ webcam feed) and `confidence`. Indices: `IRIS_LANDMARKS` (left center 473, ring
656
+ 474–477; right center 468, ring 469–472; corners and lids per side). Sides
657
+ follow the mesh topology and are anatomical on every source, mirrored
658
+ webcam included; positions need no flip.
659
+ - **`offset` is NOT gaze.** It is where the iris sits inside the eyelid
660
+ opening and ignores head pose and eyeball geometry. The SDK provides no
661
+ gaze estimation; do not present `offset` as one. For blinks and
662
+ eye-look expressions use blendshapes (`useFaceInfo` / `useFaceAction`).
663
+ - **Confidence**: MediaPipe scores no iris point and emits them for closed
664
+ eyes, so `eye.confidence` = `openness / 0.2` clamped to 0..1, scaled by
665
+ the hold decay; `result.confidence` is 1 live, decaying to 0 over
666
+ `holdMs` while `held`. Gate effects on it; the built-in materials fade with
667
+ `irisConfidence`. `status` is `loading` → `ready` / `error` for the model.
668
+ - **Shared contracts**: dropouts use `FaceTrackMemory` (`holdMs` 250,
669
+ `predictMs` 0); `smoothing` is `TrackingAnchor`'s One Euro on centers and
670
+ radius (default 0 = raw; ~10 tames jitter, but it is a 1 Hz-class low-pass
671
+ that lags a 2–3 Hz head shake, so keep it low for fast motion), reset on
672
+ re-acquire; `leadMs` (default 80, 0 off)
673
+ advances the centers along their last sample-to-sample velocity between
674
+ inference samples, so a 24 fps face model reads continuous at 60+ fps
675
+ instead of hold-and-jump (raise the face `targetFps` first when the lag
676
+ itself is the problem); `faceIndex` / `numFaces`
677
+ select faces like `useFaceInfo` (array index, first consumer sets
678
+ `numFaces`). When the face stays lost beyond `holdMs` the effect hides at
679
+ once: no stale eye effects.
680
+ - **`<IrisEffect>`**: flat `IrisEffectSettings` props (`eye` both | left |
681
+ right, `mode` shader | image, `color`, `textureUrl`, `tintImage` (multiply
682
+ the image by `color`; off by default, images show as authored), `scale` × iris
683
+ radius, `opacity`, `targetFps` (face inference cap; 0 = session, first
684
+ face consumer wins, so request the same value everywhere; a later change
685
+ takes effect only once every holder has released the model — a lone
686
+ `<IrisEffect>` re-acquires and re-initializes, a `registerXRPipeline`
687
+ entry pins the first value for the session).
688
+ **_`targetFps: 60` gives the best iris following_** (fast head motion
689
+ reads continuous) **_but use it with caution_**: it runs face inference
690
+ at 2.5× the template's 24 fps on every frame, which heats and throttles
691
+ phones. Ship 60 only when the effect is the point of the experience and
692
+ the target devices have been checked; otherwise keep the session default
693
+ and rely on `leadMs`, or pick 30 as the middle ground. Then `smoothing`,
694
+ `leadMs`, `mirrored` flips the material only, `faceIndex`, `holdMs`) plus `numFaces`, `active`, `predictMs`, `texture`
695
+ (pre-loaded, beats `textureUrl`), `material` (a three material or a
696
+ factory `(IrisMaterialContext) => Material`, called once per eye for each
697
+ `material` identity; the effect disposes what a factory created when the
698
+ factory changes or on unmount, an instance stays yours; read the live
699
+ texture from `uniforms.map`, `context.texture` is only the texture known
700
+ when the factory ran), `renderOrder`, `onTracking(handle)`. Each eye is a quad spanning
701
+ the iris diameter on the z=0 plane; the built-in shader is a procedural
702
+ iris whose pupil follows `offset`; the image material covers the quad
703
+ tinted by `color`. Every material gets the same stable uniforms
704
+ (`IrisUniforms`: `irisCenter`, `irisRadius`, `irisOffset`,
705
+ `irisConfidence`, `irisOpenness`, `time`, `color`, `opacity`, `mirror`,
706
+ `tintImage`, `map`), refreshed in place by `updateIrisUniforms`; the fragment chunks
707
+ `IRIS_SHADER_FRAGMENT` / `IRIS_IMAGE_FRAGMENT` and `IRIS_VERTEX_SHADER`
708
+ are exported as starting points. Debug panel: `IrisEffectSettingsPanel`
709
+ (`@vincentt-xr/sdk/debug-ui`) with `resolveIrisEffectSettings` /
710
+ `patchIrisEffectSettings`.
711
+ - Use iris tracking for eye decorations, pupil overlays and shader inputs;
712
+ blendshapes for blink/wink/eye-look expressions; `PinToMesh` / FaceMesh
713
+ materials for content that must deform with the skin. To hide the iris
714
+ behind the eyelids, draw a `CanonicalFaceMeshRenderer` with
715
+ `showEyeGeometry={false}` at a higher `renderOrder` than the effect and a
716
+ `materialOverride` that samples the camera texture through the mesh's
717
+ live `faceUv` attribute with `transparent: true` and `depthTest: false`
718
+ (the example's "eyelid mask"); it repaints the feed over the face except
719
+ the eye openings. It must be transparent: three draws every opaque object
720
+ before any transparent one, and the iris quads are transparent. Full contract and the
721
+ manual test plan: `docs/api/iris-tracking.mdx`; example:
722
+ `/iris-tracking`.
723
+ - **Low-level iris seam** (`@vincentt-xr/sdk/low-level`): `createIrisResult`,
724
+ `updateIrisResult(result, landmarks, nowMs, { faceIndex, held,
725
+ faceConfidence, mirrored })`, `computeIrisEye`, `createIrisEye`,
726
+ `IRIS_FULL_OPENNESS` and `IrisLead` are the allocation-free helpers behind
727
+ `useIrisTracking`, for bring-your-own-landmarks pipelines (a recorded
728
+ clip, a custom tracker). App code reads `useIrisTracking`; only reach for
729
+ these when you already hold 478-point landmarks.
730
+
731
+ ### `<LipEffect>` — lip tint / lipstick overlay
732
+
733
+ Use `LipEffect` from `@vincentt-xr/sdk/face-effects` to paint the lips of a
734
+ tracked face. It renders only the canonical lip-surface triangles (between the
735
+ MediaPipe outer and inner lip rings) and blends a design texture, or a solid
736
+ colour, over the live camera pixels. Mount a `<FaceTracker>` first.
737
+
738
+ ```tsx
739
+ import { LipEffect } from "@vincentt-xr/sdk/face-effects";
740
+
741
+ <LipEffect preset="gloss" designTexture="/lips/gloss.png" color="#e85d75" />;
742
+ ```
743
+
744
+ Props are `Partial<LipEffectSettings>` plus `faceIndex`, `designTexture` (URL or
745
+ `THREE.Texture`, canonical face UV space; omit it for a solid `color` fill),
746
+ `lowerDesignTexture` (lower lip, used with `splitLips`), `material`,
747
+ `sharedMaterial` (a material instance to use instead of the built-in one),
748
+ `maxUpdateFps` (cap how often the lip mesh follows the landmarks), `holdMs`
749
+ (default 250: keep the lips on the last landmarks across tracker dropouts) and
750
+ `predictMs` (default 0: extrapolate the held landmarks along their last
751
+ velocity), and `renderOrder` (default `30`). `LipEffectSettings` is flat and
752
+ serializable:
753
+
754
+ | Field | Meaning |
755
+ | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
756
+ | `preset` | `"custom"` or one of `lipstick`, `gloss`, `matte`, `tint`, `metallic`, `gradient` |
757
+ | `maskMode` | `"outer-and-inner-lips"` (default, mouth interior cut out) or `"outer-lips"` |
758
+ | `color`, `opacity`, `intensity` | Tint, coverage, and how much the design is multiplied by the tint (0 = as painted) |
759
+ | `shading` | How much the camera's own lip light/shadow modulates the paint (0 = flat) |
760
+ | `gloss`, `roughness`, `highlightColor` | Specular highlight amount, spread, and colour |
761
+ | `colorMode` | `"texture"` (sample the design when given) or `"solid"` (procedural: colour + gradient only) |
762
+ | `gradientColor`, `gradientAmount`, `gradientDirection` | Second colour blended `outward` from the mouth line, or `vertical` / `horizontal` across the lip box |
763
+ | `edgeSoftness` | Feather at the outer lip boundary as a fraction of the lip band (0 = hard edge, default 0.12) |
764
+ | `innerLipColor`, `innerLipAmount`, `innerLipWidth` | Separate tint for the inner lip near the mouth line (and the mouth interior in `outer-lips` mode) |
765
+ | `splitLips`, `lowerLipColor`, `lowerLipOpacity` | Give the lower lip its own colour and opacity; `lowerDesignTexture` prop gives it its own texture |
766
+ | `textureScaleX/Y`, `textureOffsetX/Y` | Design transform about the lip centre; offset is in lip widths/heights |
767
+ | `textureRotation`, `textureFlipX/Y` | Degrees counter-clockwise as seen on the face; mirror across the lip centre |
768
+
769
+ Resolution order is defaults, then the named preset, then every explicit prop.
770
+ Helpers: `resolveLipEffectSettings(partial)`, `patchLipEffectSettings(current, patch)`
771
+ (switching `preset` re-applies that preset), `LIP_EFFECT_PRESETS`,
772
+ `LIP_EFFECT_PRESET_KEYS`, `DEFAULT_LIP_EFFECT_SETTINGS`,
773
+ `LIP_EFFECT_SETTINGS_FIELDS` (typed `SettingsPanel` fields), and
774
+ `createLipTextureTransform(settings, bounds)` (the UV sampling `Matrix3`).
775
+
776
+ **Live controls.** `LipEffectSettingsPanel` from `@vincentt-xr/sdk/debug-ui` edits
777
+ the same settings object that drives rendering; spread what it emits onto the
778
+ component:
779
+
780
+ ```tsx
781
+ import { LipEffectSettingsPanel } from "@vincentt-xr/sdk/debug-ui";
782
+
783
+ const [settings, setSettings] = useState(() => resolveLipEffectSettings({ preset: "lipstick" }));
784
+ <LipEffect {...settings} designTexture={url} />;
785
+ <LipEffectSettingsPanel value={settings} onChange={setSettings} />;
786
+ ```
787
+
788
+ **Design texture contract.** A square image in the canonical face UV atlas (same
789
+ layout as `Canonical_Face_Color.png`). Only pixels under the lip triangles are
790
+ sampled: roughly x 38–62 %, y 65–74 % of the image. Transparent pixels leave the
791
+ skin untouched (alpha drives the blend); RGB is multiplied by `color` at
792
+ `intensity`, so paint white/light for a pure tint. Image-left is the subject's
793
+ right lip corner: screen-right on the mirrored webcam view, screen-left for
794
+ image/video sources. Image-top is `v = 1`. Never remap UVs or assume a lips-only
795
+ canvas; use the texture transform settings to fit a design instead. Generate
796
+ the template instead of drawing it: `createLipUvGuideSvg(mesh)`,
797
+ `createLipUvGuideDataUrl(mesh, { region: "lips" })`, and
798
+ `createLipUvGuidePngBlob(mesh)` (browser) from the `useCanonicalFaceMesh()`
799
+ result; `getLipUvBounds(mesh)` for the numbers (`CANONICAL_LIP_UV_BOUNDS` is the
800
+ shipped mesh's fallback); `lipUvToPixel(u, v, size)` for the `flipY` pixel
801
+ mapping. The shipped `docs/guides/lip-uv-template.svg` / `.png` are regenerated
802
+ with `pnpm dlx tsx scripts/generate-lip-uv-guide.ts --png`; the `/lip-effect`
803
+ example page has a `Texture preview / UV guide` toggle and `Download SVG / PNG`
804
+ buttons. Full guide: `docs/guides/lip-effect-textures.mdx`.
805
+
806
+ **Several faces.** `LipEffects` renders one lip effect per face slot:
807
+
808
+ ```tsx
809
+ import { LipEffects } from "@vincentt-xr/sdk/face-effects";
810
+
811
+ <FaceTracker numFaces={2} />;
812
+ <LipEffects maxFaces={2} faces={{ preset: "gloss", designTexture: url }} />;
813
+ <LipEffects
814
+ maxFaces={2}
815
+ faces={(faceIndex) =>
816
+ faceIndex === 0 ? { preset: "matte" } : { preset: "gloss", designTexture: url }
817
+ }
818
+ />;
819
+ ```
820
+
821
+ `faces` is one config for all faces or a picker per face (`null` skips a
822
+ face); each config takes everything `LipEffect` takes except `faceIndex`. With a
823
+ single config the faces share one built-in material (`shareMaterial`, default
824
+ on for a single config); `maxUpdateFps` and `renderOrder` apply to all faces.
825
+ Match `maxFaces` to the tracker's `numFaces`.
826
+
827
+ **Performance.** The built-in material is a plain three.js object:
828
+ `createLipEffectMaterial()`, `updateLipEffectMaterial(material, inputs)`, and the
829
+ owning hook `useLipEffectMaterial(inputs)` (inputs: `cameraTexture`,
830
+ `designTexture`, `lowerDesignTexture`, `settings`, `lipUvBounds`,
831
+ `subtractMouth`). Pass one instance as `sharedMaterial` to several `LipEffect`s,
832
+ or return a material instance from a `material` factory, and all of them draw
833
+ with the same program and uniforms. `maxUpdateFps` throttles the per-frame
834
+ landmark-to-vertex update (visibility still follows face presence every frame).
835
+ Tracking cost itself is set on the session: `<XRProvider perf={{ targetFps,
836
+ camRes }}>` lowers inference rate and the analysed resolution, and
837
+ `<FaceTracker targetFps>` overrides it per tracker. `useLipDesignTextures(upper,
838
+ lower)` resolves URL-or-texture props through R3F's loader cache, so faces that
839
+ share a URL share a texture.
840
+
841
+ **Custom material.** `material` accepts a React material node, a three.js
842
+ material instance, or a factory that receives a stable
843
+ `LipEffectMaterialContext` and returns either:
844
+
845
+ ```ts
846
+ type LipEffectMaterialContext = {
847
+ cameraTexture: THREE.Texture | null;
848
+ designTexture: THREE.Texture | null;
849
+ faceUv: THREE.BufferAttribute; // live camera UVs, bound as `attribute vec2 faceUv`
850
+ lipEdge: THREE.BufferAttribute; // static band position, `attribute float lipEdge` (0 outer ring, 1 mouth line)
851
+ lowerDesignTexture: THREE.Texture | null;
852
+ faceIndex: number;
853
+ maskMode: LipMaskMode;
854
+ lipUvBounds: LipUvBounds; // lip region of the UV atlas
855
+ settings: LipEffectSettings; // the resolved settings
856
+ };
857
+ ```
858
+
859
+ The factory runs once per lip geometry group (plus the mouth interior in
860
+ `outer-lips` mode) and only after the canonical mesh has loaded. Honour
861
+ `maskMode` with `stencilWrite stencilRef={1}
862
+ stencilFunc={THREE.NotEqualStencilFunc}` plus `KeepStencilOp` for
863
+ `stencilFail` / `stencilZFail` / `stencilZPass` (three.js only runs the
864
+ comparison while `stencilWrite` is true; the keep-ops stop the material from
865
+ altering the mouth stencil it tests). That is what the exported
866
+ `LipEffectMaterial`'s `subtractMouth` prop does; reuse that
867
+ component (`cameraTexture`, `designTexture`, `settings`, `lipUvBounds`,
868
+ `subtractMouth`, `lowerDesignTexture`) when only the settings need to differ.
869
+ `LIP_EFFECT_VERTEX_SHADER` / `LIP_EFFECT_FRAGMENT_SHADER` are the built-in GLSL
870
+ sources, and `docs/examples/lip-shaders.mdx` documents four factories (animated
871
+ gloss, noise texture, iridescence, time-based shimmer) built on the vertex
872
+ shader. The parsed mesh exposes the same band position as `lipEdgeDistance`, and
873
+ `getLipUvBounds(mesh).mouthV` is the mouth line in UV space.
874
+
875
+ ### `<SegmentationOverlay>` — 2D semantic region layers
876
+
877
+ Use `SegmentationOverlay` from `@vincentt-xr/sdk/face-effects` to draw regions
878
+ of a semantic segmentation mask (`lips`, `eyes`, `brows`, `face_skin`, `mouth`,
879
+ `bg`, `person`; or any label an external model emits, such as `beard` / `ears`
880
+ from a face-parsing model, which the SDK itself does not provide)
881
+ as a tinted, screen-aligned 2D layer over the camera. It needs no canonical 3D
882
+ geometry.
883
+
884
+ ```tsx
885
+ import {
886
+ SegmentationOverlay,
887
+ useFaceRegionMask,
888
+ usePersonMask,
889
+ } from "@vincentt-xr/sdk/face-effects";
890
+
891
+ const faces = useFaceRegionMask(); // lips, mouth, eyes, brows, face_skin — from face landmarks
892
+ const person = usePersonMask(); // bg, person — from portrait segmentation
893
+ <SegmentationOverlay mask={faces} label="lips" color="#c0304f" blendMode="multiply" />;
894
+ <SegmentationOverlay mask={person} label="bg" color="#000" opacity={0.6} blendMode="normal" />;
895
+ ```
896
+
897
+ **2D versus 3D.** Pick `SegmentationOverlay` for screen-space tints, cut-outs
898
+ and stamps, and for regions without landmarks (background, person, hair,
899
+ beard, ears, the latter three only with an external mask). Pick `LipEffect` /
900
+ `FaceMeshRenderer` when a design must follow
901
+ the lip or face surface under rotation with real shading. The 2D layer samples
902
+ `texture` in screen space and takes its lighting from the camera pixels via
903
+ `blendMode`; it is one full-screen quad and one small texture.
904
+
905
+ **Mask data.** `SemanticMask = { width, height, indices: Uint8Array (row 0 =
906
+ top), labels: string[], space?: "feed" | "source", version }`. Feed any
907
+ model's per-pixel class ids into it with `createSemanticMask` /
908
+ `clearSemanticMask` (face-effects, also tracking), `SEMANTIC_MASK_LABELS` and
909
+ `FACE_REGION_LABELS` name the built-in labels. Mutate in place and bump
910
+ `version`; the overlay uploads lazily per version, and draws nothing while
911
+ `version` is 0. The rasterizer primitives (`fillPolygon`,
912
+ `fillLandmarkPolygon`, `semanticMaskLabelIndex`, `rasterizeFaceRegions`,
913
+ `FACE_REGION_POLYGONS`) are `@vincentt-xr/sdk/low-level`.
914
+
915
+ **Coordinates.** `"feed"` (default) is the session camera texture — what
916
+ trackers see, cover-cropped and webcam-mirrored — and needs no setup; the
917
+ overlay quad matches `VideoBackground`. `"source"` is the raw media frame:
918
+ the overlay cover-fits it from the mask's aspect and the camera texture size
919
+ (`computeMaskUvTransform`); set `mirrored` when the feed is mirrored relative to
920
+ that source (webcam). Masks are nearest-filtered class indices at their own
921
+ resolution; `feather` (mask texels) softens edges.
922
+
923
+ **Props** are `Partial<SegmentationOverlaySettings>` (`label` — up to four,
924
+ comma-separated; `invert`; `color`; `opacity`; `blendMode` `normal` |
925
+ `multiply` | `screen` | `overlay`; `feather`; `mirrored`) plus `mask`,
926
+ `texture` (screen-space image), `material` (node, `THREE.Material`, or factory
927
+ receiving `{ cameraTexture, maskTexture, maskTransform, labelIndices, mask,
928
+ settings }`), `renderOrder`, `visible`. `SegmentationOverlaySettingsPanel`
929
+ (`@vincentt-xr/sdk/debug-ui`) edits the settings live. The internals are
930
+ `@vincentt-xr/sdk/low-level`: the material object API
931
+ (`createSegmentationOverlayMaterial` / `updateSegmentationOverlayMaterial` /
932
+ `useSegmentationOverlayMaterial`), the shaders
933
+ (`SEGMENTATION_OVERLAY_VERTEX_SHADER` / `_FRAGMENT_SHADER`), mask textures
934
+ (`createSemanticMaskTexture` / `uploadSemanticMask` / `useSemanticMaskTexture`)
935
+ and `computeMaskUvTransform`. `usePersonMask` and `SegmentationOverlay` are
936
+ also exported from `@vincentt-xr/sdk/tracking` for person / background work.
937
+
938
+ **Hair.** `useHairMask({ modelUrl?, quality?, active? })` runs MediaPipe's Hair
939
+ Segmenter through `XRModel.SEMANTIC_SEGMENTATION` and returns `{ mask, status,
940
+ error, labels }` with labels `bg` / `hair`; `status` is `loading` → `ready` |
941
+ `error`, and the mask stays at `version` 0 until the first inference. By
942
+ default the model loads from MediaPipe's published file, integrity-pinned in
943
+ the SDK manifest (verified; a republish fails loudly); the same hash is pinned
944
+ under the SDK CDN path `/assets/xr/models/hair_segmenter.tflite` for when the
945
+ file is uploaded there. `useHairMask()` with no options works. An inference
946
+ error is transient and clears on the next good frame.
947
+ `useSemanticSegmentationMask({ preset: "hair" | "multiclass", … })` is the
948
+ generic form (`multiclass` adds `body_skin`, `face_skin`, `clothes`,
949
+ `others`); one preset per session, first acquirer wins. Hair is neither
950
+ landmark geometry (no hair points) nor the portrait segmenter (person as one
951
+ class): use it for hair tints, recolours, highlights and cut-outs as 2D
952
+ effects. Full contract: `docs/api/segmentation-overlay.mdx` → "Hair
953
+ segmentation".
954
+
955
+ **Faces and performance.** `useFaceRegionMask({ faceIndex: "all" | n,
956
+ regions, resolution = 256, maxUpdateFps, holdMs = 250, predictMs = 0 })`
957
+ merges all faces or isolates one; `holdMs` keeps a dropped face's mask for
958
+ that long (small or distant faces make the tracker blink) and `predictMs`
959
+ extrapolates the held landmarks along their last velocity (`FaceTrackMemory`
960
+ is the reusable bridge);
961
+ make one provider and overlay per face for per-face looks. `usePersonMask({
962
+ maxUpdateFps = 15 })` acquires the segmentation model and reads the mask back
963
+ from the GPU each update, so keep its rate low and share it; it covers every
964
+ person in frame, and its mask stays at `version` 0 (overlay draws nothing)
965
+ until the model has loaded. Use `blendMode="normal"` for `bg` / `person`;
966
+ `multiply` is for tinting skin and is nearly invisible on dark backgrounds. Combine labels in
967
+ one overlay instead of stacking quads. Full reference, including the manual
968
+ visual test plan: `docs/api/segmentation-overlay.mdx`.
969
+
575
970
  ### `<FaceTracker>` — Face tracking (468 landmarks)
576
971
 
577
972
  Detects faces and provides face transform to children via context. Supports
@@ -871,6 +1266,13 @@ import { FaceTracker, FaceMesh } from "@vincentt-xr/sdk/tracking";
871
1266
  </FaceTracker>;
872
1267
  ```
873
1268
 
1269
+ The composite face shader (camera + canonical texture with blend modes) and the
1270
+ `FaceWarp3DHead` shader end with `#include <colorspace_fragment>`, so the face
1271
+ renders at the same brightness as the camera feed and built-in materials;
1272
+ `FaceWarp3DHead` tags its UV reference texture sRGB for the same reason. Any
1273
+ custom `ShaderMaterial` layered over the feed must include that chunk too (see
1274
+ "Custom shaders" under lip effects).
1275
+
874
1276
  ### `<HandTracker>` — Hand skeleton (21 joints)
875
1277
 
876
1278
  The hand-skeleton tracker. Use it for 21-joint hand landmarks — anchoring to `hand.indexTip`, `hand.wrist`, etc. It also exposes a crude open-vs-closed grip on `node.gesture` (only `closed_fist` / `open_palm`), but that is a side effect of the skeleton, not a gesture recognizer. **For any named gesture, use `<GestureTracker>`** (next section) — it is the gesture door; `<HandTracker>` is the skeleton door.
@@ -885,6 +1287,13 @@ Supports multiple detected hands: `hand` selects `"left"` or `"right"` when that
885
1287
  hand is available; `handIndex` falls back to positional selection. Existing
886
1288
  one-hand scenes keep working via the default first-hand selection.
887
1289
 
1290
+ Low-level `node.coordinates` are `TrackerPoint` values: center-origin, X right,
1291
+ Y up, and positive Z toward the viewer. X/Y are in `[-1, 1]`; Z is
1292
+ tracker-relative depth, not a normalized screen axis. For a bespoke cursor or
1293
+ overlay, call `trackerPointToScreenPixels({ point, viewportSize })` from
1294
+ `@vincentt-xr/sdk`. The SDK already owns the mirror and origin conversion; do
1295
+ not repeat it in app code.
1296
+
888
1297
  ### `<GestureTracker>` — Named gestures
889
1298
 
890
1299
  The gesture door — the component that recognizes named gestures. Its `node.gesture` is one of these exact strings (copy verbatim — these are the only valid values):
@@ -920,12 +1329,183 @@ import { BodyTracker, TrackingAnchor } from "@vincentt-xr/sdk/tracking";
920
1329
  </BodyTracker>;
921
1330
  ```
922
1331
 
1332
+ Low-level body landmarks use the same `TrackerPoint` contract as hand and
1333
+ gesture points. `TrackingAnchor` is preferred for attachment; use
1334
+ `trackerPointToScreenPixels` only when rendering a bespoke 2D projection.
1335
+
1336
+ ### `<HolisticTracker>` — Face + hands + pose from one inference
1337
+
1338
+ Opt-in, additive: one MediaPipe Holistic inference per frame for a **single
1339
+ person**, shared by every consumer. Use it when a scene needs face, hands and
1340
+ body together; keep the individual trackers when it needs one stream (the
1341
+ holistic model is heavier than any single one, 13.7 MB). Mounting
1342
+ `<FaceTracker>` beside `<HolisticTracker>` runs two models — pick one per scene.
1343
+
1344
+ ```tsx
1345
+ import { HolisticTracker, TrackingAnchor } from "@vincentt-xr/sdk/tracking";
1346
+
1347
+ <HolisticTracker holdMs={250} hands="both">
1348
+ <HolisticTracker.Face>
1349
+ <TrackingAnchor target="face.noseTip" smoothing={15}>
1350
+ {/* … */}
1351
+ </TrackingAnchor>
1352
+ </HolisticTracker.Face>
1353
+ <HolisticTracker.Hand hand="right">
1354
+ <TrackingAnchor target="hand.indexTip">{/* … */}</TrackingAnchor>
1355
+ </HolisticTracker.Hand>
1356
+ <HolisticTracker.Body>
1357
+ <TrackingAnchor target="body.leftShoulder">{/* … */}</TrackingAnchor>
1358
+ </HolisticTracker.Body>
1359
+ </HolisticTracker>;
1360
+ ```
1361
+
1362
+ - **Sub-trackers** `HolisticTracker.Face` / `.Hand hand="left"|"right"` /
1363
+ `.Body` provide the same tracker context as `FaceTracker` / `HandTracker` /
1364
+ `BodyTracker`: every `face.*`, `hand.*`, `body.*` anchor target (including
1365
+ `body.neck` and `face.custom`) and `smoothing` work unchanged. The group is
1366
+ hidden while its stream is absent; origins are the nose tip, the wrist, the
1367
+ head. The holistic graph has no face transformation matrix, so there is
1368
+ **no head rotation**; use `<FaceTracker>` when children must roll with the
1369
+ head. `FaceMesh`, the face readers, face effects and head binding read
1370
+ `XRModel.FACE_TRACKER`, not holistic.
1371
+ - **Unified result**: `useHolisticTracking()` → `{ node, ready, status,
1372
+ error }`; read `node.getResult()` inside `useFrame` (stable object, mutated
1373
+ in place, `version` bumps every frame). `HolisticResult` = `face`
1374
+ (478 `NormalizedLandmark`s in MediaPipe image space like `FaceTracker`'s
1375
+ `detectionResults`, plus `blendshapes` name → score), `leftHand` /
1376
+ `rightHand` (21 `TrackerPoint`s like `HandTracker`, plus `handedness` and
1377
+ metric `worldLandmarks`), `pose` (33 `TrackerPoint`s like `BodyTracker`,
1378
+ plus `visibility[]` and `worldLandmarks`). Each stream is `null` when
1379
+ absent. Coordinates get no source-dependent mirroring, matching Hand/Body.
1380
+ Hand **labels** are anatomical on every source: holistic assigns hands from
1381
+ the pose, whose left/right follow the image, so the SDK swaps them on the
1382
+ mirrored webcam feed (`leftHand` = the person's left hand, which appears on
1383
+ the viewer's left in the selfie view). `hands="left"` therefore keeps the
1384
+ person's left hand, the same hand `<HandTracker hand="left">` selects. The
1385
+ pose's left/right landmarks (shoulders 11/12, wrists 15/16, …) are swapped
1386
+ the same way, so `pose` and the hand streams always describe the same
1387
+ physical hand and `getHolisticSignals` can pair them; this differs from
1388
+ `BodyTracker`, whose indices follow the image.
1389
+ - **Pre-release asset host**: the default model is MediaPipe's published
1390
+ `holistic_landmarker.task` on `storage.googleapis.com` (SHA-384 pinned), not
1391
+ the SDK CDN, until the CDN copy is published. Pass `modelUrl` to self-host
1392
+ it; the hash for the CDN path is already pinned.
1393
+ - **Confidence**: face/hands `1` while detected, pose = mean visibility.
1394
+ Each stream runs through `FaceTrackMemory` (the one dropout bridge):
1395
+ `holdMs` 250 / `predictMs` 0 by default; while held, `held` is true and
1396
+ `confidence` decays linearly to 0 at `holdMs`. `holdMs={0}` drops at once.
1397
+ - **Delegate**: `delegate` is `"auto"` (default) | `"GPU"` | `"CPU"`.
1398
+ MediaPipe's holistic face-blendshape stage has no WebGL kernel for one of
1399
+ its ops, so with `outputFaceBlendshapes` the GPU graph fails on its first
1400
+ frame; `"auto"` rebuilds the graph on the CPU once (one `console.warn`,
1401
+ status dips to `loading` for the rebuild, no refetch) and
1402
+ `node.getDelegate()` then reports `"CPU"`. CPU inference is several times
1403
+ slower (tens to hundreds of ms per frame on a laptop); when blendshapes
1404
+ are not needed, `outputFaceBlendshapes={false}` keeps the whole graph on
1405
+ the GPU. A fixed `"GPU"` policy reports the failure as `error` instead.
1406
+ - **Options**: model-creation (`modelUrl`, the five `min*Confidence`
1407
+ thresholds, `outputFaceBlendshapes`, `delegate`) come from the first
1408
+ mounted consumer and re-acquire when changed; `targetFps` overrides `perf.targetFps`;
1409
+ `hands` (`both` | `left` | `right` | `none`), `analysisMaxDimension` (768),
1410
+ `holdMs`, `predictMs` apply live (last writer wins); `active` pauses;
1411
+ `onStatusChange(status, error)` reports `loading` → `ready` / `error` (a
1412
+ transient inference error recovers on the next good frame). The default
1413
+ model is MediaPipe's published `holistic_landmarker.task`, SHA-384 pinned.
1414
+ - **Combined signals**: `getHolisticSignals(result)` →
1415
+ `HolisticSignals` with presence per stream, `smile` / `mouthOpen`
1416
+ blendshape scores, `leftHandRaised` / `rightHandRaised` (pose wrist above
1417
+ its shoulder), `leftPalmOpen` / `rightPalmOpen` (three of four fingers
1418
+ extended) and `greeting` (raised open hand while smiling; right wins).
1419
+ `isHandRaised` / `isPalmOpen` are exported alone. Pass an `out` object to
1420
+ avoid per-frame allocation.
1421
+ - **Low-level**: `XRModelNodeHolisticTracking` (`@vincentt-xr/sdk/low-level`)
1422
+ = `{ getResult, getRawResult, getStatus, getError, getDelegate, setOptions }`. Debug
1423
+ panel: `HolisticTrackerSettingsPanel` (`@vincentt-xr/sdk/debug-ui`) with
1424
+ `resolveHolisticTrackerSettings` / `toHolisticTrackerProps`. Full contract
1425
+ and manual test plan: `docs/api/holistic-tracker.mdx`; example:
1426
+ `/holistic-tracking`.
1427
+
923
1428
  ### `<Segmentation type="portrait">` — Background segmentation (declarative)
924
1429
 
925
1430
  Registers the portrait segmentation pipeline. The resulting mask feeds into `<VideoBackground segmentationMask={...} />` for background replacement. Mount it alongside a ref or state that holds the mask texture.
926
1431
 
1432
+ For a **tinted or textured person / background layer** rather than a background
1433
+ swap, use `usePersonMask()` with `<SegmentationOverlay mask={…} label="bg" />`
1434
+ (or `label="person"`). For **hair**, use `useHairMask()` with `label="hair"`
1435
+ (MediaPipe Hair Segmenter via `XRModel.SEMANTIC_SEGMENTATION`). For **background
1436
+ replacement** (person kept, background swapped for any media), use
1437
+ `<PersonCutout>` over a full-screen `ScreenImage`; see "Camera background". Both are exported from this door as well as from
1438
+ `@vincentt-xr/sdk/face-effects`; `usePersonMask` acquires the segmentation model
1439
+ itself, so no `<Segmentation>` is needed for it. See `<SegmentationOverlay>` in
1440
+ the face-effects section for the full contract.
1441
+
927
1442
  ---
928
1443
 
1444
+ ## Multi-subject policy — indices, counts, selection, association
1445
+
1446
+ **An index is a result-array position, not an identity**: `faceIndex` /
1447
+ `handIndex` / `getFace(i)` read position `i` of the current frame; the order
1448
+ can change between frames and nothing promises the same person at the same
1449
+ index later. The SDK never recognises people; the only continuity is spatial
1450
+ (`sticky`). Single-subject defaults (index 0) are unchanged.
1451
+
1452
+ Max subjects (`XR_MAX_SUBJECTS`): face 10 (`numFaces`, default 1, first
1453
+ acquirer of the shared model wins), hand 2 (fixed, `handedness` labels them),
1454
+ gesture 1, body 1, holistic 1 (single person).
1455
+
1456
+ ```tsx
1457
+ import {
1458
+ useFaceInfo,
1459
+ selectSubject,
1460
+ faceCandidates,
1461
+ jointCandidates,
1462
+ associateSubjects,
1463
+ } from "@vincentt-xr/sdk/tracking";
1464
+
1465
+ const face = useFaceInfo({ numFaces: 4, subject: { selection: "largest" } }); // or "nearest-center" | "leftmost" | "rightmost" | "index"
1466
+ const same = useFaceInfo({ numFaces: 4, subject: { selection: "sticky", stickyRadius: 0.2 } }); // follows the previous pick by proximity; fallback "largest"
1467
+ const result = selectSubject(faceCandidates(results.faceLandmarks), policy, previousMemory); // pure: { index | null, reason, memory }; carry result.memory to the next frame
1468
+ const pairs = associateSubjects(faceCandidates(faces), jointCandidates(bodies), 0.35); // nearest-centre pairs, each subject once
1469
+ ```
1470
+
1471
+ Loss: the tracker hold (`holdMs` 250) replays the last result, then drops it;
1472
+ `sticky` keeps its memory through a miss and re-selects a subject that
1473
+ reappears nearby, otherwise the fallback decides and the memory moves with it.
1474
+ Reappearance never restores an index. Full contract: `docs/api/multi-subject.mdx`;
1475
+ example: `/multi-subject`.
1476
+
1477
+ ## Tracker dropouts — the one bridge to use
1478
+
1479
+ The face landmarker loses and re-finds a face for a frame or two at a time
1480
+ when the face is small, distant, fast, or turned hard. Raw detection results
1481
+ show those gaps as `faceLandmarks` momentarily empty. **Do not write your own
1482
+ hold, smoothing, or interpolation for this in app code.** The SDK has one
1483
+ mechanism, used by every face surface, and it is the source of truth:
1484
+
1485
+ - `FaceTrackMemory` (`@vincentt-xr/sdk/face-effects`) remembers each face's
1486
+ last landmarks and a smoothed velocity. A missing face is replayed for
1487
+ `holdMs`, extrapolated along that velocity for the first `predictMs`, then
1488
+ held still, then dropped. Depth (`z`) rides along when present.
1489
+ - Rendered surfaces use it with `holdMs: 250` and `predictMs: 0` by default:
1490
+ `CanonicalFaceMeshRenderer` (so `FaceMeshRenderer` and `LipEffect` /
1491
+ `LipEffects`) and `useFaceRegionMask` for 2D overlays. The face readers
1492
+ (`useFaceInfo` / `useFaceInfos` / `useFaceDetection`) apply the same
1493
+ 250 ms hold to presence and lifecycle events through their own `holdMs`;
1494
+ they report, they do not extrapolate. Set `holdMs: 0` only when immediate
1495
+ disappearance is the point. Keep `predictMs` at or below 300: it
1496
+ extrapolates linearly and will overshoot a sharp reversal for up to that
1497
+ window.
1498
+ - Measured on the SDK's "Person Fullbody" clip, hold 250 with predict 150 cut
1499
+ empty frames from 23 % to 2 %; the remaining gaps are longer than the hold
1500
+ and are correctly dropped rather than shown stale.
1501
+ - The dropouts themselves are a tracking-input problem. When they are
1502
+ frequent, raise the analysed resolution with `perf.camRes` before reaching
1503
+ for a longer hold.
1504
+
1505
+ Custom consumers of raw landmarks (a bespoke mesh, a custom material factory
1506
+ that reads `detectionResults`) should run them through `FaceTrackMemory` with
1507
+ the same defaults so they stay in step with the SDK's own surfaces.
1508
+
929
1509
  ## Tracker performance & lifecycle
930
1510
 
931
1511
  ML tracker models are the heaviest thing in an XR app — each one runs a MediaPipe pipeline on the camera feed. Two rules keep apps from overheating the device and from a scene-transition gesture bug.
@@ -948,7 +1528,7 @@ Scope a tracker to a single scene only when just that scene uses it (e.g. a `<Fa
948
1528
 
949
1529
  ### First load: each tracker you mount is a download
950
1530
 
951
- Before a tracker reports anything, the viewer's phone downloads its model and the shared MediaPipe engine (`vision_wasm_internal.wasm`, served gzip-compressed, ~2.9 MB). The SDK fetches the two in parallel, and trackers mounted together share one engine download. Model sizes: face 3.8 MB, hand 7.8 MB, gesture 8.4 MB, body 5.8 MB. All are cached by the browser for a year, so a returning viewer downloads nothing.
1531
+ Before a tracker reports anything, the viewer's phone downloads its model and the shared MediaPipe engine (`vision_wasm_internal.wasm`, served gzip-compressed, ~2.9 MB). The SDK fetches the two in parallel, and trackers mounted together share one engine download. Model sizes: face 3.8 MB, hand 7.8 MB, gesture 8.4 MB, body 5.8 MB, holistic 13.7 MB (face + hands + pose in one). All are cached by the browser for a year, so a returning viewer downloads nothing.
952
1532
 
953
1533
  So every extra tracker costs a first-time viewer its full model on mobile data. Mount only the trackers the experience actually reads; a face-only effect should not also mount `<HandTracker>`.
954
1534
 
@@ -973,7 +1553,45 @@ Session lifecycle preserves that ownership state: `session.stop()` pauses every
973
1553
  tracker without releasing its MediaPipe resources, and the next `session.start()`
974
1554
  resumes only models that still have at least one active owner. Static-image
975
1555
  detection pulses use the same suspension path, so source switches do not
976
- accidentally reactivate an `active={false}` tracker.
1556
+ accidentally reactivate an `active={false}` tracker. A tracker whose last
1557
+ owner unmounts while the session runs is disposed, and the instance a later
1558
+ mount acquires starts its own inference loop (the run guard is per instance),
1559
+ so remounting a sole-owner tracker mid-session is safe.
1560
+
1561
+ A failed `session.setMediaSource()` (webcam denied, no device, a stream that
1562
+ never produces frames) rejects and leaves the session where it was: an idle
1563
+ session stays idle, a running session clears its swap gate and keeps rendering,
1564
+ and the next `setMediaSource()` binds normally with no stop/start in between.
1565
+ Each bind owns a generation; a webcam denial that lands after a later bind
1566
+ already succeeded is dropped instead of overwriting the healthy session's
1567
+ `error`. Callers therefore recover from a blocked camera by selecting another
1568
+ source once.
1569
+
1570
+ ### Adaptive quality — budget + tiers (on by default)
1571
+
1572
+ `<XRProvider>` mounts an `XRQualityController` unless `quality={false}`. It
1573
+ samples frame time, tracker inference, memory and device signals every
1574
+ second and steps between declared tiers: `targetFps` and `analysisScale`
1575
+ through the session perf config, `renderScale` through `<XRScene>`'s pixel
1576
+ ratio, `camRes` only when a tier sets `rebindCamera: true`.
1577
+
1578
+ ```tsx
1579
+ import { XRProvider, useXRQuality, DEFAULT_QUALITY_TIERS, DEFAULT_PERFORMANCE_BUDGET } from "@vincentt-xr/sdk";
1580
+
1581
+ <XRProvider quality={{ tiers: DEFAULT_QUALITY_TIERS, budget: { maxFrameMs: 33 }, minTier: "medium" }}>…</XRProvider>
1582
+ <XRProvider quality={{ adaptive: false, initialTier: "medium" }}>…</XRProvider> // pinned, still observable
1583
+ <XRProvider quality={false}>…</XRProvider> // no controller
1584
+
1585
+ const { tier, index, tiers, decision, history, signals, locked, setTier, lock, unlock, reset } = useXRQuality();
1586
+ ```
1587
+
1588
+ - Defaults: tiers high / medium / low (`targetFps` 30 / 24 / 15, `renderScale` 1 / 0.8 / 0.6, `analysisScale` 1 / 0.85 / 0.7); budget `maxFrameMs` 1000/24, `maxInferenceMs` 24, `sampleMs` 1000, `downgradeAfter` 2, `upgradeAfter` 6, `upgradeHeadroom` 0.75, `cooldownMs` 4000. All plain JSON; `resolveQualityConfig` validates partials.
1589
+ - Bounded (`minTier` / `maxTier`), hysteretic (streaks), rate-limited (cooldown), reversible (steps back up with headroom). Judges thermal → memory → inference → frame time. At the floor and still over budget it sets `signals.thermal = "suspected"` and changes nothing. Hidden tabs are never judged. A per-tracker `targetFps` prop still beats the tier; the app's `perf` is the base and is restored when the controller unmounts.
1590
+ - Every change is a decision `{ index, tier, reason, at }` (`init`, `frame-time`, `inference`, `memory`, `thermal`, `recovered`, `manual`, `locked`, `reset`) kept in `history`; the state is in the session store (`useXRQualityState()`, low-level) and in `useXRDiagnostics().quality` / the diagnostics panel.
1591
+ - `perf.analysisScale` (0.25..1) is the new knob the tiers drive; set it by hand only with `quality={false}`.
1592
+ - Pure seam (low-level): `stepQualityController`, `judgeSample`, `createQualityState`, `setQualityTier`, `lockQuality`, `resetQuality`, `resolveInitialTierIndex`, `readDeviceSignals`.
1593
+
1594
+ Full contract: `docs/api/adaptive-quality.mdx`; example: `/adaptive-quality`.
977
1595
 
978
1596
  ### `perf` — cap inference rate + camera resolution
979
1597
 
@@ -1005,6 +1623,320 @@ against hashes pinned in the package. A missing hash for
1005
1623
  `https://cdn.vincentt.studio` fails closed; custom consumer-owned asset URLs
1006
1624
  remain best-effort and warn when no pinned hash exists.
1007
1625
 
1626
+ ### Capability detection — decide before the session starts
1627
+
1628
+ Run the check on the landing screen (no `<XRProvider>` needed). It never opens
1629
+ the camera and never downloads a model body: camera = secure context +
1630
+ `getUserMedia` + video-input count + Permissions API state; rendering = WebGL
1631
+ version, OffscreenCanvas, WebAssembly (WebGPU is reported, not required);
1632
+ models = one `HEAD` request per tracker you list.
1633
+
1634
+ ```tsx
1635
+ import { useXRCapabilities, resolveTrackerSupport, XRModel } from "@vincentt-xr/sdk";
1636
+ import type { XRCapabilities, XRFallbackReason } from "@vincentt-xr/sdk";
1637
+
1638
+ const { capabilities, status, refresh } = useXRCapabilities({
1639
+ models: [XRModel.FACE_TRACKER], // optional asset probes
1640
+ });
1641
+ if (status === "ready" && capabilities) {
1642
+ const face = resolveTrackerSupport(capabilities, XRModel.FACE_TRACKER);
1643
+ if (!face.supported) renderFallback(face.reason); // { code, message, recoverable }
1644
+ if (face.needsPermission) renderCameraPrimer(); // first getUserMedia will prompt
1645
+ }
1646
+ ```
1647
+
1648
+ - `capabilities.camera.status`: `"supported" | "permission-prompt" | "permission-denied" | "unsupported" | "unknown"`. Denied and unsupported are distinct; `reason.recoverable` says whether the viewer can fix it (grant permission, close the other tab).
1649
+ - `capabilities.rendering.status` + `webglVersion` (0/1/2), `offscreenCanvas`, `wasm`, `webgpu`.
1650
+ - `capabilities.models[model]`: `status`, `reason`, `url`, `bytes` (`unknown` for models not listed). The `SEMANTIC_SEGMENTATION` probe covers the default `selfie_multiclass_256x256` preset only; the hair preset loads a different file.
1651
+ - `capabilities.canRunTracking`: rendering supported and camera not unsupported.
1652
+ - `detectXRCapabilities(options)` is the same check as a promise. `refresh()` re-runs it (after the viewer granted permission).
1653
+
1654
+ **Fallback vocabulary.** Every degraded state is an `XRFallbackReason =
1655
+ { code, message, recoverable }`. Branch on `code`:
1656
+ `camera-permission-denied`, `camera-in-use`, `no-camera`,
1657
+ `camera-insecure-context`, `camera-unsupported`, `camera-lost`,
1658
+ `webgl-unavailable`, `offscreen-canvas-unavailable`, `wasm-unavailable`,
1659
+ `model-unreachable`, `model-integrity`, `model-init-failed`, `unknown`.
1660
+ `message` is display prose and may be reworded. `resolveTrackerSupport`
1661
+ returns the most fundamental blocker first (rendering → camera → model).
1662
+
1663
+ Inside a session, a component that does not own a tracker reads an actionable
1664
+ status without touching the model:
1665
+
1666
+ ```tsx
1667
+ import { useXRTrackerStatus } from "@vincentt-xr/sdk/low-level";
1668
+
1669
+ const { status, fallback } = useXRTrackerStatus(XRModel.FACE_TRACKER);
1670
+ // status: "unavailable" (nobody acquired it) | "loading" | "ready" |
1671
+ // "blocked" (camera failed; fallback = the session error) |
1672
+ // "error" (the model's initialize failed; fallback = its cause)
1673
+ ```
1674
+
1675
+ `useXRModel(...)` (the owner) also returns `fallback` next to `error`;
1676
+ `useXRModelErrors()` lists every model whose initialize failed.
1677
+
1678
+ ### Camera permission & privacy — gate, request, retry, recover
1679
+
1680
+ Six creator-facing states: `unknown` (never asked; the next request prompts),
1681
+ `requesting`, `granted`, `denied` (declined or dismissed; asking again
1682
+ prompts), `blocked` (persistently denied in the site settings; asking again
1683
+ fails silently), `unavailable` (no camera / insecure context / unsupported /
1684
+ held by another app, see `reason`). `denied` vs `blocked` is decided by
1685
+ reading the Permissions API after the failed request.
1686
+
1687
+ ```tsx
1688
+ import { CameraPermissionGate, useCameraPermission } from "@vincentt-xr/sdk";
1689
+
1690
+ // Gate: children mount only while access is granted; no provider needed.
1691
+ <CameraPermissionGate
1692
+ fallback={({ state, reason, canPrompt, request }) => (
1693
+ <Primer state={state} reason={reason} onAllow={canPrompt ? () => request() : undefined} />
1694
+ )}
1695
+ >
1696
+ <XRProvider>…</XRProvider>
1697
+ </CameraPermissionGate>;
1698
+
1699
+ // Hook: query on mount (no prompt), prompt from a button.
1700
+ const { state, reason, canPrompt, usable, request, refresh } = useCameraPermission();
1701
+ ```
1702
+
1703
+ - `request()` must run from a user gesture; the probe stream is stopped at once (indicator off, nothing captured). `auto` prompts on mount. Both follow site-setting changes live.
1704
+ - The built-in fallback is accessible (`aria-live` status, a real button, disabled + explained when `blocked`); keep those in a custom one.
1705
+ - Inside a session, `useXRSessionCameraPermission()` (`@vincentt-xr/sdk/low-level`) reports the session's own state (`requesting` during its webcam bind, `granted`, the classified failure, `blocked` / `unavailable` after a revoke). Stream/canvas sources leave it `unknown`.
1706
+ - Retry / recover without remounting: `session.setMediaSource({ source: XRMediaSource.WEBCAM })` then `session.start()`; a failed bind leaves the session where it was.
1707
+ - Privacy: `session.stop()` stops the tracks (indicator off); a revoke or lost camera does so automatically; no model runs before `start()`, so no frame is processed before explicit access; frames never leave the device.
1708
+ - Plain helpers (core): `queryCameraPermission`, `requestCameraAccess({ keepStream, constraints })`, `subscribeCameraPermission`, `canPromptForCamera`, `CAMERA_PERMISSION_MESSAGES`. `classifyCameraFailure(reason, permissionState)` + `mapDomExceptionToReason(err)` are `@vincentt-xr/sdk/low-level` (they take an `XRErrorReason`).
1709
+ - Camera choice: see "Cross-camera & orientation" below.
1710
+
1711
+ ### Cross-camera & orientation — front / rear, devices, mirror policy
1712
+
1713
+ ```tsx
1714
+ import { XRMediaSource, listCameras, findCameraByFacing } from "@vincentt-xr/sdk";
1715
+ import { useXRCameraFacing, useXRMirrored, useXROrientation } from "@vincentt-xr/sdk/low-level";
1716
+
1717
+ await session.setMediaSource({ source: XRMediaSource.WEBCAM, facingMode: "environment" }); // or { deviceId }
1718
+ const facing = await session.switchCamera({ facingMode: "environment" }); // live switch; starts a bound/stopped session
1719
+ const cameras = await listCameras(); // [{ deviceId, label, facing }] — labels need permission
1720
+ ```
1721
+
1722
+ - `facingMode` (`"user"` default, `"environment"`) is a soft hint; `deviceId` is exact and wins. `cameraFacing` / `useXRCameraFacing()` report what the track says (a one-camera laptop answers `environment` with `user`). `useXRCameraDeviceId()` reports the device.
1723
+ - Same request = no-op; a different facing / device / mirror policy releases the old tracks once and rebinds under the same source canvas (models keep their input). `running` / `paused` are preserved.
1724
+ - **Mirror policy is keyed on the source kind**, and the kind follows the _effective_ facing the track reports: front camera `"webcam"` mirrored (even when `environment` was requested on a one-camera device); rear camera `"rear-camera"`, images, videos straight; `mirrored: boolean` on the bind pins it. Preview and tracking share the feed, so positions never need flipping; `useXRMirrored()` (= `isMirroredSourceKind(sourceKind)`) is for direction semantics only.
1725
+ - Orientation: `useXROrientation()` → `{ type: "portrait" | "landscape", angle }`; on change the session re-reads the video size so `useXRFeedCrop()` and landmark-anchored content re-align at once. `calibration.orientation` is only for raw sideways captures the browser does not rotate.
1726
+ - Camera-switch lifecycle tests: `lib/hooks/useXRSession/lifecycle.test.ts`. Full contract and phone checklist: `docs/api/cross-camera.mdx`; example: `/camera-switching`.
1727
+
1728
+ Full contract: `docs/api/camera-permission.mdx`; example: `/camera-permission`.
1729
+
1730
+ ### Persistent tracking configuration — `useXRTrackingConfig`
1731
+
1732
+ One serializable, validated, versioned document (`XRTrackingConfig`,
1733
+ `version: 1`) for tracker selection and tuning, projected onto the provider,
1734
+ the tracker components, the camera request and model options. Runtime
1735
+ resources (streams, textures, models, session / permission / quality state,
1736
+ calibration measurements) are excluded by construction.
1737
+
1738
+ ```tsx
1739
+ import {
1740
+ XRProvider,
1741
+ useXRTrackingConfig,
1742
+ resolveTrackingConfig,
1743
+ parseTrackingConfig,
1744
+ } from "@vincentt-xr/sdk";
1745
+ import { FaceTracker } from "@vincentt-xr/sdk/tracking";
1746
+
1747
+ const tracking = useXRTrackingConfig({
1748
+ storageKey: "myapp.tracking",
1749
+ initial: { trackers: { hand: { enabled: true } } },
1750
+ }); // ABOVE the provider
1751
+ if (!tracking.hydrated) return null; // wait for the persisted document: a provider re-init would restart the camera
1752
+ <XRProvider {...tracking.providerProps}>…</XRProvider>; // perf, lifecycle, quality
1753
+ const face = tracking.trackerProps("face"); // { numFaces, targetFps?, holdMs, predictMs, active }
1754
+ {
1755
+ face.active && <FaceTracker numFaces={face.numFaces} targetFps={face.targetFps} />;
1756
+ }
1757
+ await session.switchCamera(tracking.cameraRequest); // { facingMode, deviceId?, mirrored? }
1758
+ tracking.update({ perf: { targetFps: 20 } });
1759
+ tracking.reset();
1760
+ tracking.importJson(text);
1761
+ tracking.json;
1762
+ tracking.warnings;
1763
+ ```
1764
+
1765
+ - Sections: `trackers.{face,hand,gesture,body,holistic,segmentation,semanticSegmentation}` (`enabled`, `targetFps | null` = inherit, face `numFaces 1..10 / holdMs / predictMs / smoothing`, holistic `hands / holdMs / predictMs / analysisMaxDimension / outputFaceBlendshapes / confidence / delegate`, segmentation `quality`, semantic `preset / quality`), `perf { targetFps, camRes, cameraZoom, analysisScale }`, `lifecycle { pauseWhenHidden }`, `quality` (adaptive partial config or `false`), `camera { facingMode, deviceId, mirrored | "auto" }`, `fallbacks { onUnsupported: "block" | "degrade", delegate }`, `assets { holisticModelUrl, semanticSegmentationModelUrl }`, `updatedAt`.
1766
+ - `resolveTrackingConfig(input, base?)` fills from `DEFAULT_TRACKING_CONFIG` (face only, perf 24 fps / 720 / 1.2, adaptive quality on, front camera, degrade), clamps and type-checks every leaf, returns `warnings` instead of throwing; `mergeTrackingConfig` patches; `serializeTrackingConfig` / `parseTrackingConfig` round-trip, drop unknown fields and migrate older `version`s onto the defaults (`migrated: true` + warning; the hook re-saves). `loadTrackingConfig` / `saveTrackingConfig` / `clearTrackingConfig` (`localStorage`, key `vincentt.xr.tracking-config`, injectable storage).
1767
+ - Projections: `toProviderProps`, `toTrackerProps(config, kind)`, `toCameraRequest`, `toModelOptions(config, model)`, `enabledModels(config)` (probe these with `useXRCapabilities`).
1768
+
1769
+ Full contract: `docs/api/tracking-config.mdx`; example: `/tracking-config`.
1770
+
1771
+ ### Product placement AR — `<ProductViewer>` + `useProductAr`
1772
+
1773
+ "View in your space" goes through the device's native path (WebXR on Android
1774
+ Chrome, Scene Viewer on Android, Quick Look on iOS from a USDZ); the SDK's
1775
+ camera session has no world tracking. The SDK wraps Google's
1776
+ `<model-viewer>` (`@google/model-viewer`, optional peer dependency, lazy-loaded
1777
+ on first render, bundles its own three.js ≈ 1 MB — keep it on product routes).
1778
+
1779
+ ```tsx
1780
+ import { ProductViewer, useProductAr } from "@vincentt-xr/sdk/product";
1781
+ import { useXRCapabilities } from "@vincentt-xr/sdk";
1782
+
1783
+ const ar = useProductAr(); // { status, supportedModes, canActivate, reason, loading, error, bind, activate }
1784
+ <ProductViewer
1785
+ src="/hat.glb"
1786
+ iosSrc="/hat.usdz"
1787
+ poster="/hat.webp"
1788
+ alt="Hat"
1789
+ placement="floor"
1790
+ scale="auto"
1791
+ onReady={ar.bind}
1792
+ />;
1793
+ {
1794
+ ar.supportedModes.length > 0 && (
1795
+ <button onClick={() => ar.activate()} disabled={!ar.canActivate}>
1796
+ View in your space
1797
+ </button>
1798
+ );
1799
+ }
1800
+ useXRCapabilities().capabilities?.placement; // { webxr, sceneViewer, quickLook, any, reason } — offer AR only when `any`
1801
+ ```
1802
+
1803
+ - Props map onto model-viewer attributes: `src`, `iosSrc` (USDZ, iOS), `poster`, `alt`, `ar`, `arModes` (preference order, default webxr → scene-viewer → quick-look), `placement` (`floor` | `wall`), `scale` (`auto` | `fixed` = true size), `cameraControls`, `autoRotate`, `loading`, `environmentImage`, `shadowIntensity`, `exposure`, `attributes` (pass-through); events `onLoad`, `onError`, `onArStatus`, `onReady(element)`; children are slotted (`slot="ar-button"`, hotspots).
1804
+ - `activate()` must run from a user gesture. `status`: `not-presenting` → `session-started` → `object-placed` (model-viewer's `ar-status`), `failed`, or `unavailable` (no supported mode; `reason.code` `placement-unsupported`).
1805
+ - **Camera handoff (WebXR only)**: WebXR runs in the page and needs the camera the tracking session holds; Scene Viewer and Quick Look open a native activity and never touch it. Under an `<XRProvider>` with a running / paused webcam session, `activate()` stops the session first when the mode about to be used is `webxr` and restores the same facing and lifecycle (a paused session comes back paused) when AR ends (`handoffCamera: false` opts out; `releaseSessionForAr(session, mode)` is the pure form). Stream / canvas sources are untouched. Keep `arModes` on the viewer and `modes` on the hook identical (both default to `DEFAULT_PRODUCT_AR_MODES`).
1806
+ - Assets: GLB for all platforms (small, Draco / KTX2), USDZ for Quick Look produced at publishing time (never at runtime), both on the CDN.
1807
+
1808
+ Full contract: `docs/api/product-ar.mdx`; example: `/product-ar`.
1809
+
1810
+ **Variants, hotspots, dimensions** (same door):
1811
+
1812
+ ```tsx
1813
+ import { ProductHotspot, ProductDimensions, useProductVariants, useProductHotspots, useProductDimensions } from "@vincentt-xr/sdk/product";
1814
+ import type { ProductHotspotData, ProductViewerElement } from "@vincentt-xr/sdk/product";
1815
+
1816
+ const variants = useProductVariants(el); // KHR_materials_variants: { variants, variant, setVariant } — materials only, carry into WebXR / Scene Viewer; Quick Look shows the USDZ as exported
1817
+ const dims = useProductDimensions(el); // { size: [w, h, d], center } metres, null until load
1818
+ const hotspots = useProductHotspots(el, { initial, onChange }); // { hotspots, placeFromPointer(clientX, clientY, label), add, update, remove, clear, json, importJson }
1819
+ <ProductViewer scale="fixed" onReady={(e) => setEl(e)}>
1820
+ {hotspots.hotspots.map((h) => <ProductHotspot key={h.id} hotspot={h} />)} // { id, position: [x,y,z], normal: [x,y,z], label?, description? } in model metres
1821
+ <ProductDimensions dimensions={dims} unit="metric" /> // W / H / D labels on the bounding box; pair with scale="fixed"
1822
+ </ProductViewer>
1823
+ ```
1824
+
1825
+ Pure helpers: `parseHotspots` / `serializeHotspots` (validated authoring data), `hotspotAttributes`, `nextHotspotId`, `dimensionLines`, `formatLength`. Debug UI: `ProductHotspotPanel` (`@vincentt-xr/sdk/debug-ui`) for click-to-place authoring and JSON export. Full contract: `docs/api/product-details.mdx`; example: `/product-details`.
1826
+
1827
+ ### Device compatibility — matrix, smoke, evidence
1828
+
1829
+ Supported: iOS WebKit 16.4+, Android Chrome 110+, desktop Chrome / Edge /
1830
+ Safari; Firefox best effort; insecure contexts and no-WebGL unsupported.
1831
+ Platform differences the SDK already absorbs: no `video.captureStream` on
1832
+ iOS (feed the camera, not a hosted clip), Permissions API camera descriptor
1833
+ missing on Firefox (`unknown` until the first prompt), `deviceMemory` only on
1834
+ Chromium. Full table: `docs/compatibility.mdx`.
1835
+
1836
+ ```tsx
1837
+ import { XRModel } from "@vincentt-xr/sdk";
1838
+ import {
1839
+ runCompatChecks,
1840
+ useXRContext,
1841
+ COMPAT_SCENARIOS,
1842
+ MANUAL_COMPAT_SCENARIOS,
1843
+ } from "@vincentt-xr/sdk/low-level";
1844
+
1845
+ const { session } = useXRContext();
1846
+ const report = await runCompatChecks(session, {
1847
+ models: [XRModel.FACE_TRACKER],
1848
+ label: "iPhone 13",
1849
+ }); // stops the session afterwards unless keepSessionRunning
1850
+ report.results; // [{ id, status: "pass" | "fail" | "skip", cause: "capability" | "model" | "lifecycle" | "performance" | null, code, detail, durationMs }]
1851
+ report.summary; // { pass, fail, skip, causes }
1852
+ ```
1853
+
1854
+ - Automated scenarios: `capabilities`, `session-start`, `model-ready`, `model-detects`, `pause-resume`, `stop-start`, `performance`. It opens the webcam (or `stream`), acquires and releases the models itself, never throws.
1855
+ - Manual scenarios (`MANUAL_COMPAT_SCENARIOS`): permission prompt / blocked recovery, rear camera, rotation, background-return, camera interrupted, sustained use, low-end device. `/compat` runs both and exports the JSON evidence (`XRCompatReport`, schema `version: 1`).
1856
+ - Failure attribution: `classifyCompatCause(code, layer)` maps SDK codes (`camera-*`, `no-camera`, `webgl-*` → capability; `model-*` → model; `timeout:lifecycle`, `feed-stalled`, `lifecycle:*` → lifecycle; `fps-low`, `inference-high` → performance).
1857
+ - CI: `pnpm smoke` (Playwright, headless Chromium, fake camera) runs `/compat?auto=1` and uploads the evidence; `.github/workflows/compat-smoke.yml`, advisory.
1858
+
1859
+ ### Session lifecycle — start / pause / resume / stop / reset
1860
+
1861
+ ```
1862
+ idle ──setMediaSource──> media-bound ──start──> running ──stop──> stopped
1863
+ ↑ │ ↑ │
1864
+ │ pause │ │ resume │
1865
+ │ ▼ │ │
1866
+ │ paused ─┘ │
1867
+ └────────────reset──────────────────────┘
1868
+ ```
1869
+
1870
+ - `session.pause()` — `running → paused`: feed loop stops, every model pauses, the video element pauses; the camera stream stays open. No-op in any other state. `session.resume()` (or `start()`) continues with no rebind. `setMediaSource()` while paused binds the new media and stays paused.
1871
+ - `session.stop()` releases the stream/tracks and keeps the models initialized; `start()` afterwards needs `setMediaSource()` first. Binding the _same_ source again (the webcam after a privacy-mode stop) re-opens it; the "unchanged source" short-circuit only applies while the media is live. `session.reset()` also releases every model and clears errors.
1872
+ - **Provider unmount disposes the session** (route change away from the AR page): tracks and models are released exactly once and later calls throw `"… called after the provider unmounted"`. Keep one `<XRProvider>` above the pages that share a session.
1873
+ - **Camera loss.** A live video track that ends (unplugged, revoked, taken by another app) or a camera permission that flips to denied releases the media once, suspends the models, sets `useXRError()` to `reason: "camera-lost"` (or `"permission-denied"`) and parks the session in `stopped`. Recover with `setMediaSource()` + `start()`. Tracks the session replaced itself during a source swap never trigger this.
1874
+ - **Camera constraints.** The webcam bind asks for 24–60 fps and `camRes` width; a camera that cannot promise the minimum (cheap, virtual or fake devices) is retried once with a soft frame-rate request, so a slow camera binds instead of failing with `OverconstrainedError` (which now reads as `unsupported` when even the relaxed request fails).
1875
+ - **Visibility.** The session always retries `video.play()` when a hidden tab returns (iOS Safari pauses the camera track). `<XRProvider lifecycle={{ pauseWhenHidden: true }}>` additionally pauses the whole session while hidden and resumes on return; a session the app paused itself stays paused.
1876
+ - `useXRLifecycle()` (`@vincentt-xr/sdk/low-level`) subscribes to the state: `"idle" | "media-bound" | "running" | "paused" | "stopped"`.
1877
+
1878
+ Full contract and manual steps: `docs/api/session-lifecycle.mdx`; example: `/session-health`.
1879
+
1880
+ ### Tracking diagnostics — `useXRDiagnostics()` and `<XRDiagnosticsPanel>`
1881
+
1882
+ Opt-in: sampled on an interval (default 250 ms, never per frame) only while a
1883
+ consumer is mounted, so it costs nothing in production until rendered. Gate it
1884
+ behind a debug flag.
1885
+
1886
+ ```tsx
1887
+ import { useXRDiagnostics } from "@vincentt-xr/sdk/low-level";
1888
+ import { XRDiagnosticsPanel } from "@vincentt-xr/sdk/debug-ui";
1889
+
1890
+ const d = useXRDiagnostics({ intervalMs: 250, models: [XRModel.FACE_TRACKER] });
1891
+ d.session; // lifecycle, ready, error, source/sourceKind, sourceWidth/Height, renderWidth/Height, feedFps, feedFrames
1892
+ d.models[0]; // model, label, state, inferenceMs, inferenceFps, inferences, skippedFrames,
1893
+ // detections, confidence, status, error, paused, targetFps, owners
1894
+ d.inferenceMsTotal; // ms of ML per frame across running models
1895
+
1896
+ <XRDiagnosticsPanel models={[XRModel.FACE_TRACKER, XRModel.HAND_TRACKER]} />; // DOM panel, outside the canvas
1897
+ ```
1898
+
1899
+ `state` per model: `"unavailable"` (nobody acquired it), `"loading"`,
1900
+ `"ready"`, `"paused"` (no active owner, or the session is not running),
1901
+ `"error"`. `models` defaults to every `XRModel`, so unacquired models show as
1902
+ unavailable. `detections`/`confidence`: face = count / none; hand, gesture =
1903
+ count / score; body, holistic = count / mean landmark visibility; segmenters
1904
+ = 1 when a mask was produced / none. `skippedFrames` counts RAF ticks the
1905
+ model did not analyse (its `targetFps` cap, or busy). The legacy
1906
+ `DebuggerProvider` / `DebuggerOverlay` (camera fps, render time, memory)
1907
+ still exists; prefer this hook for tracker health.
1908
+
1909
+ ### Device calibration — `useXRCalibration()`
1910
+
1911
+ Orientation, mirror mode, scale/offset, face distance and body proportions,
1912
+ measured once, persisted (`localStorage`, key `vincentt.xr.calibration`),
1913
+ reset, and applied **only where you call it**. Raw tracker results are never
1914
+ altered.
1915
+
1916
+ ```tsx
1917
+ import { useXRCalibration } from "@vincentt-xr/sdk/tracking";
1918
+
1919
+ const cal = useXRCalibration({ storageKey: "myapp.calibration" }); // persist: false keeps it in memory
1920
+ cal.calibrateFace(face.landmarks); // reference face (feed-space landmarks); false without a face
1921
+ cal.calibrateBody(poseFeedLandmarks); // reference body (33 pose landmarks in feed space)
1922
+ cal.update({ orientation: 90, mirrored: "auto", scale: 1.1, offset: { x: 0, y: 0 } });
1923
+ cal.applyToFeedPoint(face.landmarks[4]); // new point; input untouched
1924
+ cal.faceDistance(face.landmarks); // 1 at the reference distance, > 1 closer, null without a face/reference
1925
+ cal.bodyScale(poseFeedLandmarks); // 1 at the reference, null without a body/reference
1926
+ cal.reset(); // defaults + clears storage
1927
+ cal.calibration; // { version, orientation, mirrored, scale, offset, face, body, updatedAt }
1928
+ ```
1929
+
1930
+ `mirrored: "auto"` follows the session (only the webcam is mirrored), so it
1931
+ never flips a point; `true`/`false` flip only when they disagree with the
1932
+ session. Pure helpers for non-React code: `applyCalibrationToFeedPoint`,
1933
+ `measureFaceCalibration`, `measureBodyCalibration`, `estimateFaceDistance`,
1934
+ `estimateBodyScale`, `updateCalibration`, `resetCalibration`,
1935
+ `loadCalibration`, `saveCalibration`, `clearCalibration` (all
1936
+ `@vincentt-xr/sdk/tracking`). Body landmarks from `getLandmarks2D()` are
1937
+ tracker space; map to feed with `trackerToFeed` before measuring. Full
1938
+ contract: `docs/api/diagnostics-calibration.mdx`; example: `/session-health`.
1939
+
1008
1940
  ---
1009
1941
 
1010
1942
  ## Reading tracker state
@@ -1092,6 +2024,75 @@ useFrame(() => {
1092
2024
  > above; reach for the raw read only when you need the session handle for something the
1093
2025
  > selector can't express.
1094
2026
 
2027
+ ### `useGesture` / `<GestureListener>` — reusable gesture detection (face, hand, body)
2028
+
2029
+ One engine for pinch, grab, open palm, wave, hand raise, nod, head shake,
2030
+ mouth open, smile, blink, brow raise, arm raise and T-pose, plus custom
2031
+ gestures. Import from `@vincentt-xr/sdk/tracking`. Each gesture acquires the
2032
+ shared face, hand or body model it needs (no second detector); mount hooks
2033
+ once above scene switches like any tracker.
2034
+
2035
+ ```tsx
2036
+ import { GestureListener, useGesture } from "@vincentt-xr/sdk/tracking";
2037
+
2038
+ const pinch = useGesture("pinch", { hand: "right", onBegin: () => grab(), onEnd: () => drop() });
2039
+ <GestureListener gesture="headNod" onBegin={() => confirm()} />;
2040
+ ```
2041
+
2042
+ - **Built-ins** (`GESTURE_NAMES`, `GESTURES[name]` with `kind`, `description`,
2043
+ `defaults`): face `mouthOpen`, `smile`, `blink`, `browRaise`, `headNod`,
2044
+ `headShake`; hand `pinch`, `grab`, `openPalm`, `handRaised`, `wave`, and
2045
+ the finger-count signs `oneFinger`, `peaceSign`, `threeFingers`,
2046
+ `fourFingers`, `fist`, `palm`, `thumbUp`, `thumbDown` (the weakest required
2047
+ finger scores, so 1 only when every finger agrees; `grab` / `openPalm` are
2048
+ the thumb-agnostic mean-curl versions of `fist` / `palm`); body
2049
+ `armRaisedLeft`, `armRaisedRight`, `armRaised`, `bothArmsRaised`, `tPose`.
2050
+ Hand measures are scale-free (÷ palm size), body measures are in shoulder
2051
+ widths, body sides are the person's. Nod / shake / wave are oscillations
2052
+ (three turning points above an amplitude within a window) and ship with a
2053
+ 600 ms cooldown so one nod counts once.
2054
+ - **Engine contract** (`GestureDetectorOptions`): per frame the definition
2055
+ yields `{ value, confidence }` or `null` (no subject). Confidence below
2056
+ `minConfidence` (0.5) or no sample = loss; an active gesture survives loss
2057
+ for `lostGraceMs` (250) with its value frozen, then ends with
2058
+ `endReason: "lost"`. Inactive → `begin` at `value >= enterThreshold`
2059
+ (0.5) after `holdMs` (0) of staying above it, never during `cooldownMs`
2060
+ (0) after the last end; active → `end` ("released") at
2061
+ `value <= exitThreshold` (enter − 0.15). A `begin` always gets one `stay`
2062
+ frame. `GestureState`: `active`, `value`, `confidence`, `tracked`, `phase`
2063
+ begin | stay | end | none, `justBegan`, `justEnded`, `endReason`,
2064
+ `startedAtMs`, `durationMs` (as of the last published change, not a
2065
+ per-frame clock; derive a live duration from `startedAtMs`), `count`.
2066
+ Face/hand/body confidence is
2067
+ presence-based (1 while tracked); custom definitions may supply a real
2068
+ score.
2069
+ - **Options** (`UseGestureOptions`): the detector options above, `active`,
2070
+ `faceIndex` / `numFaces`, `hand` / `handIndex`, `bodyIndex`, `targetFps`,
2071
+ `onBegin` / `onEnd` / `onChange` (frame-loop callbacks with that frame's
2072
+ state). The hook returns React state that changes only when the gesture's
2073
+ state changes. `resolveGestureDetectorOptions(overrides, defaults)` is the
2074
+ merge the hook applies.
2075
+ - **Custom**: `defineGesture({ name, kind, description, defaults?,
2076
+ createMemory?, measure })`; `measure(input, memory, nowMs)` gets a
2077
+ `FaceInfo | null` (face) or `TrackerPoint[] | undefined` (hand: the 21
2078
+ joints; body: the 33 pose points). Low-level seam
2079
+ (`@vincentt-xr/sdk/low-level`): `stepGestureDetector`,
2080
+ `createGestureDetectorState`, `snapshotGestureState`,
2081
+ `gestureStatesEqual`, the measures (`measurePinch`, `measureGrab`,
2082
+ `measureOpenPalm`, `measureHandHeight`, `measureArmRaise`,
2083
+ `measureTPose`, `measureFingerPattern({ fingers, thumb })` with `null`
2084
+ for "either", `measureThumb`, `fingerExtensions`, `thumbExtension`,
2085
+ `thumbDirection`), `HAND_JOINTS`, `POSE_JOINTS`, `OscillationTracker`.
2086
+ - **Versus the others**: `<GestureTrigger>` fires once on a named static
2087
+ hand pose from the gesture-recognizer model; `useGesture` measures
2088
+ continuous and temporal gestures from the landmark trackers with tunable
2089
+ thresholds. `useFaceAction` is the face-only predecessor; the face gestures
2090
+ reuse its measurements and add cooldown, confidence, loss grace and the
2091
+ temporal nod / shake. Debug panel: `GestureSettingsPanel`
2092
+ (`@vincentt-xr/sdk/debug-ui`) with `resolveGestureSettings` /
2093
+ `patchGestureSettings` / `GESTURE_SETTINGS_FIELDS`. Full contract and the
2094
+ manual test plan: `docs/api/gestures.mdx`; example: `/gestures`.
2095
+
1095
2096
  ### `<GestureTrigger gestures={["victory"]} onTrigger={(g) => ...}>` — Declarative one-shot gesture handler
1096
2097
 
1097
2098
  Fires once per gesture recognition event. The simplest way to act on a gesture — prefer it over a manual `useFrame` poll. Two modes:
@@ -1131,6 +2132,62 @@ In a v2-template project this is already mounted in `src/App.tsx` — do NOT add
1131
2132
  treat the camera feed as a real scene plane should render a separate editor
1132
2133
  video plane instead of changing `VideoBackground` semantics.
1133
2134
 
2135
+ ### `<PersonCutout>` — background replacement as two screen layers
2136
+
2137
+ The easy path for virtual backgrounds and person cut-outs: any full-screen
2138
+ background surface below, the person-masked live camera above.
2139
+
2140
+ ```tsx
2141
+ import { FULL_SCREEN_ANCHORS, PersonCutout, ScreenImage, ScreenSpaceUI } from "@vincentt-xr/sdk";
2142
+
2143
+ <ScreenSpaceUI>
2144
+ <ScreenImage
2145
+ src="/backgrounds/beach.jpg"
2146
+ fit="cover"
2147
+ anchors={FULL_SCREEN_ANCHORS}
2148
+ renderOrder={0}
2149
+ />
2150
+ <PersonCutout renderOrder={100} />
2151
+ </ScreenSpaceUI>;
2152
+ ```
2153
+
2154
+ - **Background**: a `ScreenImage` with an image, `{ kind: "gif" }`, or
2155
+ `{ kind: "texture", texture: videoTexture }`, or a custom shader plane inside
2156
+ `<ScreenTransform anchors={FULL_SCREEN_ANCHORS}>`; give it a lower
2157
+ `renderOrder` than the cut-out (default `100`). Do not mirror it unless the
2158
+ content must match a mirrored webcam (`flipX`).
2159
+ - **Foreground**: `PersonCutout` is a `ScreenImage` whose source is the session
2160
+ camera texture and whose `maskTexture` is the portrait segmentation mask. The
2161
+ camera texture is already cover-cropped and webcam-mirrored, and the mask is
2162
+ in that same feed space; the mask is sampled through the image's own fit
2163
+ transform, so alignment holds under any aspect ratio, crop or flip. Props:
2164
+ the `ScreenImage` props except `source` / mask props, plus `invert` (show the
2165
+ background only), `fallback` (`"camera"` default: unmasked feed while the
2166
+ model loads or fails; `"hidden"`: nothing), `active`, `onStatusChange(status,
2167
+ error)` with `loading` | `ready` | `error`. It acquires `<Segmentation
2168
+ type="portrait">`'s model itself and shares it with `VideoBackground` and
2169
+ `usePersonMask`.
2170
+ - `usePersonMaskTexture()` returns `{ texture, status, error }` for custom
2171
+ compositions; the mask is white (RGB 255) on the person and black elsewhere
2172
+ with alpha 255 everywhere, so read it as `maskChannel="green"` (what three's
2173
+ `alphaMap` reads too). `ScreenImage` gained `maskTexture`, `maskChannel`
2174
+ (`alpha` | `red` | `green` | `luminance`), `maskInvert`, `maskFeather` and
2175
+ `maskErode` for any image; the mask always samples through the image's fit
2176
+ transform and takes precedence over advanced blend modes.
2177
+ - **Edge quality**: `maskFeather` is a 3×3 blur radius in mask texels (`0` =
2178
+ hard edge); `maskErode` (0..1, needs feather) shrinks the kept region into
2179
+ that soft edge so the source's background fringe (green-screen spill, halo)
2180
+ is trimmed. `PersonCutout` defaults to `maskFeather={4}` / `maskErode={0.7}`,
2181
+ measured on the 720-px segmenter mask; raise erode for spill, lower it when
2182
+ thin features (fingers, hair tips) vanish. Neither fixes mask lag on fast
2183
+ motion, which is the segmenter's temporal error, not an edge problem.
2184
+ - **2D versus 3D**: use this for background replacement and person-in-scene
2185
+ effects (one quad, one mask, covers body, hair and clothes). Use the face
2186
+ mesh when content must stick to the face surface with perspective and
2187
+ shading; use the head occluder when 3D objects must hide behind the head in
2188
+ depth, which a 2D mask cannot do. Full contract and the manual test plan:
2189
+ `docs/api/person-cutout.mdx`; example: `/person-cutout`.
2190
+
1134
2191
  ---
1135
2192
 
1136
2193
  ## Mesh primitives and textures
@@ -1379,6 +2436,56 @@ authoring model into center, size, rotation, pivot offset, and aspect. Do not co
1379
2436
  the default `720x1280` root or `32` reference-pixel constant into another renderer
1380
2437
  or guide.
1381
2438
 
2439
+ ### Coordinate spaces — one toolkit
2440
+
2441
+ Six spaces, named once; every conversion lives in `@vincentt-xr/sdk` and does
2442
+ its origin, axis, crop and mirror change exactly once. Never chain a manual
2443
+ flip, crop or design-canvas conversion after a helper.
2444
+
2445
+ - **source**: raw media pixels (top-left, Y down), never mirrored.
2446
+ - **feed**: the camera texture, 0..1 top-left Y down — the source
2447
+ cover-cropped to the render size, enlarged by `perf.cameraZoom` (1.2)
2448
+ about the centre, mirrored for the **webcam only**. Every landmark and SDK
2449
+ mask lives here. The feed fills the viewport, so feed and viewport agree
2450
+ per axis on every aspect ratio.
2451
+ - **tracker**: feed remapped to center origin, X right, Y up, Z toward the
2452
+ viewer (`TrackerPoint`: hand, body, holistic nodes).
2453
+ - **viewport**: the rendered canvas in CSS pixels; **screen**: 720×1280
2454
+ design pixels, center origin, Y up; **world**: three.js, tracked content on
2455
+ the z = 0 plane.
2456
+
2457
+ `FeedCrop` (`computeFeedCrop({ source, feed, zoom, mirrored })`) is the one
2458
+ description of source → feed: the session's blit loop draws with it,
2459
+ `computeMaskUvTransform` (now with `zoom`) fits source-space masks with it,
2460
+ and the conversions use it. `useXRFeedCrop()` (`/low-level`) returns the live
2461
+ one (`null` until media is bound).
2462
+
2463
+ Conversions: `sourceToFeed` / `feedToSource` (+ `isFeedPointInSource`),
2464
+ `feedToTracker` / `trackerToFeed`, `feedToViewport` / `viewportToFeed`,
2465
+ `feedToScreenPixels` / `screenPixelsToFeed`, `feedToWorld` / `trackerToWorld`
2466
+ (camera, optional `planeZ`, depth-independent), `worldToFeed` /
2467
+ `worldToTracker` (`z` = NDC depth), `computeFitLayout(source, destination,
2468
+ "cover" | "contain", zoom)`. Mirroring: positions never need a flip; direction
2469
+ signs do — use `isMirroredSourceKind(sourceKind)` (webcam or unknown → true),
2470
+ never `sourceKind === "webcam"` inline; `mirrorFeedX` flips a feed x.
2471
+ Orientation: `orientSourcePoint` / `orientedSize` rotate a sideways capture
2472
+ (0 / 90 / 180 / 270, clockwise) before `sourceToFeed`; the browser already
2473
+ orients webcam frames. The trackers (`HandTracker`, `GestureTracker`,
2474
+ `BodyTracker`, `HolisticTracker`, `FaceTracker`, `IrisEffect`), the face
2475
+ model's mirror handling, the viewport layout and the mask fit all run on
2476
+ these helpers; `unprojectToZZero` remains as a thin alias of `feedToWorld`.
2477
+ Diagrams and worked examples: `docs/api/coordinate-spaces.mdx`.
2478
+
2479
+ ```ts
2480
+ import { feedToSource, feedToWorld, trackerToFeed, feedToViewport } from "@vincentt-xr/sdk";
2481
+ import { useXRFeedCrop } from "@vincentt-xr/sdk/low-level";
2482
+
2483
+ const crop = useXRFeedCrop(); // source → feed, live
2484
+ const sourcePx = crop ? feedToSource(face.landmarks[1], crop) : null; // back onto the raw frame
2485
+ const css = feedToViewport(trackerToFeed(hand.coordinates[8]), viewportSize); // DOM overlay
2486
+ feedToWorld(face.landmarks[1], camera, mesh.position); // onto the z = 0 plane
2487
+ ```
2488
+
1382
2489
  ### Screen/world coordinate utilities
1383
2490
 
1384
2491
  Import the `*ScreenPixels` coordinate helpers, `screenPointToRay`, and
@@ -1388,6 +2495,11 @@ design pixels are Three.js world units.
1388
2495
  SDK screen pixels use the Effect House-style `720x1280` design canvas, center
1389
2496
  origin, and +Y up.
1390
2497
 
2498
+ `docs/api/screen-space-coordinates.mdx` is the API source of truth for which
2499
+ helper owns each conversion. Keep this grounding summary and that page aligned;
2500
+ raw MediaPipe face landmarks are normalized image points, while SDK low-level
2501
+ hand, gesture, and body reads are center-origin tracker points.
2502
+
1391
2503
  ```ts
1392
2504
  const units = screenPixelsToUnits(120, { parentSize: screenDimensions });
1393
2505
  const pixels = screenUnitsToPixels(units, { parentSize: screenDimensions });
@@ -1416,8 +2528,8 @@ const placementRay = screenPointToRay({
1416
2528
  - Use `clientToScreenPixels` / `screenPixelsToClient` for pointer events and DOM
1417
2529
  overlays; pass the viewport element's `getBoundingClientRect()` result.
1418
2530
  - Use `normalizedToScreenPixels` / `screenPixelsToNormalized` for normalized
1419
- viewport coordinates with top-left origin. These values are not automatically
1420
- camera-crop adjusted.
2531
+ viewport coordinates with top-left origin — a feed point is one. They do not
2532
+ undo the camera crop; `feedToSource` with the session's `FeedCrop` does.
1421
2533
  - Use `mediaToScreenPixels` / `screenPixelsToMedia` with the shared
1422
2534
  `XRMediaLayout` for camera/image pixels. They apply cover/contain offsets,
1423
2535
  scaling, and optional display mirroring; do not manually repeat that math.
@@ -1677,8 +2789,12 @@ For debug pages that need a camera/image/video picker, import
1677
2789
  applies the selected debug media preset to the current XR session and starts
1678
2790
  the face-tracking media pipeline used by the debug face examples. The older
1679
2791
  `useMediaSourceSession(activeMediaSource, { pipeline })` name remains available
1680
- for custom debug pipelines. Use these only in debug/example UI; production
1681
- runtime should set the session media source directly.
2792
+ for custom debug pipelines. `defaultMediaSources` lists the hosted clips first
2793
+ (so `defaultMediaSources[0]` is a video and a page opens without a camera
2794
+ prompt or a camera at all), then `webcam`, then the stills; pick the webcam
2795
+ entry explicitly when a page must start on the live camera. Use these only in
2796
+ debug/example UI; production runtime should set the session media source
2797
+ directly.
1682
2798
 
1683
2799
  ### Debug UI usage in Harness client apps
1684
2800
 
@@ -2105,8 +3221,10 @@ if (error?.reason === "camera-in-use") {
2105
3221
  The shape is `{ error, message, reason }`:
2106
3222
 
2107
3223
  - **`reason`** — the stable, machine-readable identity. Branch on this. One of
2108
- `"permission-denied"`, `"camera-in-use"`, `"no-camera"`, `"unsupported"`, or
2109
- `"unknown"`.
3224
+ `"permission-denied"`, `"camera-in-use"`, `"no-camera"`, `"unsupported"`,
3225
+ `"camera-lost"` (the camera stopped mid-session; see "Session lifecycle"), or
3226
+ `"unknown"`. `sessionReasonToFallback(reason)` (`@vincentt-xr/sdk/low-level`)
3227
+ maps it onto the `XRFallbackReason` vocabulary.
2110
3228
  - **`message`** — human prose for display. It may be reworded in any release, so
2111
3229
  never match on its text.
2112
3230
  - **`error`** — a legacy constant kept for backwards compatibility. Carries no
@@ -2120,10 +3238,61 @@ request; `"unknown"` is anything unrecognized.
2120
3238
 
2121
3239
  ---
2122
3240
 
3241
+ ## Asset lifecycle & GPU memory — `useXRAsset`, `XRAssetCache`
3242
+
3243
+ One identity, one load, one owner list, one disposal per asset. Own three.js
3244
+ loaders over the SDK's verifying fetch; no drei.
3245
+
3246
+ ```tsx
3247
+ import {
3248
+ XRAssetCacheProvider,
3249
+ createAssetCache,
3250
+ defineGltfAsset,
3251
+ defineTextureAsset,
3252
+ defineVideoAsset,
3253
+ useXRAsset,
3254
+ useXRAssetStats,
3255
+ useXRAssetCache,
3256
+ } from "@vincentt-xr/sdk";
3257
+
3258
+ const HAT = defineGltfAsset(url); // { key, load(signal), dispose, bytes }
3259
+ const { value, status, error } = useXRAsset(HAT); // shared value; reference released on unmount
3260
+ <XRAssetCacheProvider cache={createAssetCache({ budgetBytes: 64 * 1048576 })}>
3261
+ …
3262
+ </XRAssetCacheProvider>;
3263
+ const stats = useXRAssetStats(); // assets, referencedAssets, usageBytes, reclaimableBytes, budgetBytes, overBudget, evictions, hits, misses
3264
+ ```
3265
+
3266
+ - `cache.acquire(asset, ownerId)` / `release(key, ownerId)`: in-flight loads are shared; owners are ids (re-renders and StrictMode never double-count); releasing during a load aborts it.
3267
+ - Unreferenced values stay cached until the budget (default 256 MiB) needs room — LRU, unreferenced only, `dispose` exactly once — or `disposeUnreferenced()` / `disposeAll()`. `setBudget`, `evict(bytes)`, `preload(asset)` (warm without a reference), `get(key)`, `list()`, `subscribe()`.
3268
+ - Bytes are estimates: `estimateTextureBytes`, `estimateGeometryBytes`, `estimateObjectBytes`; `disposeObject3D` releases everything under a scene node once.
3269
+ - Route change: owners release on unmount; `<XRAssetCacheProvider>` disposes unreferenced values when it unmounts (`disposeUnreferencedOnUnmount`, default true). Tracker-owned resources are the session registry's, released once on provider unmount.
3270
+ - Custom assets: write your own `XRAssetDefinition` (`key` + `load` honouring `signal` + `dispose` + `bytes`).
3271
+
3272
+ Full contract: `docs/api/asset-lifecycle.mdx`; example: `/asset-lifecycle`.
3273
+
3274
+ ---
3275
+
2123
3276
  ## Capture & share
2124
3277
 
2125
3278
  Grab a photo of the live render or record the canvas, then save or share it.
2126
3279
 
3280
+ ### Camera, layout, mirror, and capture parity
3281
+
3282
+ One owner composes media pixels before they are displayed, tracked, or captured.
3283
+ For direct session media, the session owns the webcam source and mirrors it once
3284
+ before its cover-cropped camera texture reaches trackers and R3F content. For
3285
+ `XRViewport mediaStrategy="internal"`, the viewport owns source selection,
3286
+ fit, and mirror state; it sends that already composed canvas to the session, so
3287
+ the tracker, visible media surface, and capture target share the same pixels.
3288
+
3289
+ Resize and source changes recompute this composition before the next tracker
3290
+ frame. Do not add a second CSS, canvas, or shader mirror to a session camera
3291
+ texture or viewport media canvas. `captureFrame()` and `captureStream()` capture
3292
+ the active XRScene canvas when present, otherwise the DOM viewport media canvas.
3293
+ They capture canvas composition only; arbitrary DOM HUD elements are not part of
3294
+ the image or recording.
3295
+
2127
3296
  ### `useFrameCapture()` + `dataURLtoFile()` — Single-shot photo
2128
3297
 
2129
3298
  Use `useFrameCapture` for UI/effect logic. It exposes `status`, `capturePhoto`,
@@ -2144,7 +3313,13 @@ await photo.capturePhoto();
2144
3313
  ```
2145
3314
 
2146
3315
  For synchronous low-level code, `session.captureFrame()` remains available via
2147
- `useXRContext` from `@vincentt-xr/sdk/low-level`.
3316
+ `useXRContext` from `@vincentt-xr/sdk/low-level`. It also exposes
3317
+ `session.captureStream(fps?)`, which records the same active canvas:
3318
+
3319
+ ```tsx
3320
+ const rec = useMediaRecorder();
3321
+ await rec.start(session.captureStream(30));
3322
+ ```
2148
3323
 
2149
3324
  ### `useMediaRecorder()` — Record the canvas to a video Blob
2150
3325
 
@@ -2156,8 +3331,7 @@ the first supported codec (mp4 → webm). Pass `audio: true` to mix in the mic,
2156
3331
  import { useMediaRecorder } from "@vincentt-xr/sdk";
2157
3332
 
2158
3333
  const rec = useMediaRecorder();
2159
- const canvas = document.querySelector("#vincentt-xr-canvas canvas");
2160
- await rec.start(canvas.captureStream(30));
3334
+ await rec.start(session.captureStream(30));
2161
3335
  // ...later
2162
3336
  rec.stop(); // -> rec.recordedBlob is the video Blob
2163
3337
  ```