@pose-tracker/react-native-pose-estimation-light 0.1.0

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 (125) hide show
  1. package/LICENSE +45 -0
  2. package/PoseTrackerVision.podspec +28 -0
  3. package/README.md +161 -0
  4. package/THIRD_PARTY_NOTICES.md +19 -0
  5. package/ios/PoseTrackerVision/PoseTrackerBodyPosePlugin.m +18 -0
  6. package/ios/PoseTrackerVision/PoseTrackerBodyPosePlugin.swift +108 -0
  7. package/lib/PoseTrackerProvider.d.ts +102 -0
  8. package/lib/PoseTrackerProvider.js +204 -0
  9. package/lib/api/configure.d.ts +29 -0
  10. package/lib/api/configure.js +64 -0
  11. package/lib/api/skeleton.d.ts +17 -0
  12. package/lib/api/skeleton.js +53 -0
  13. package/lib/api/track.d.ts +61 -0
  14. package/lib/api/track.js +146 -0
  15. package/lib/backends/PoseBackend.d.ts +50 -0
  16. package/lib/backends/PoseBackend.js +11 -0
  17. package/lib/backends/vision/VisionPoseBackend.d.ts +72 -0
  18. package/lib/backends/vision/VisionPoseBackend.js +177 -0
  19. package/lib/backends/vision/mapVisionJoints.d.ts +35 -0
  20. package/lib/backends/vision/mapVisionJoints.js +162 -0
  21. package/lib/backends/vision/optionalVision.d.ts +32 -0
  22. package/lib/backends/vision/optionalVision.js +76 -0
  23. package/lib/backends/webview/WebViewPoseBackend.d.ts +223 -0
  24. package/lib/backends/webview/WebViewPoseBackend.js +382 -0
  25. package/lib/backends/webview/brandAssets.d.ts +5 -0
  26. package/lib/backends/webview/brandAssets.js +8 -0
  27. package/lib/backends/webview/onlineRuntime.d.ts +45 -0
  28. package/lib/backends/webview/onlineRuntime.js +57 -0
  29. package/lib/backends/webview/poseHtml.d.ts +71 -0
  30. package/lib/backends/webview/poseHtml.js +188 -0
  31. package/lib/backends/webview/poseRuntimeSource.d.ts +3 -0
  32. package/lib/backends/webview/poseRuntimeSource.js +4 -0
  33. package/lib/cache/obfuscate.d.ts +18 -0
  34. package/lib/cache/obfuscate.js +90 -0
  35. package/lib/camera/PoseCameraView.d.ts +51 -0
  36. package/lib/camera/PoseCameraView.js +99 -0
  37. package/lib/camera/WebViewPoseView.d.ts +81 -0
  38. package/lib/camera/WebViewPoseView.js +312 -0
  39. package/lib/client.d.ts +361 -0
  40. package/lib/client.js +1046 -0
  41. package/lib/diagnostics/logReport.d.ts +23 -0
  42. package/lib/diagnostics/logReport.js +102 -0
  43. package/lib/engine/EngineLoader.d.ts +95 -0
  44. package/lib/engine/EngineLoader.js +347 -0
  45. package/lib/engine/types.d.ts +83 -0
  46. package/lib/engine/types.js +11 -0
  47. package/lib/events/classicMessage.d.ts +31 -0
  48. package/lib/events/classicMessage.js +259 -0
  49. package/lib/exercises/aliases.d.ts +18 -0
  50. package/lib/exercises/aliases.js +48 -0
  51. package/lib/index.d.ts +72 -0
  52. package/lib/index.js +170 -0
  53. package/lib/models/poseModels.d.ts +39 -0
  54. package/lib/models/poseModels.js +63 -0
  55. package/lib/quality/AdaptiveQualityController.d.ts +120 -0
  56. package/lib/quality/AdaptiveQualityController.js +423 -0
  57. package/lib/quality/RuntimeGuard.d.ts +34 -0
  58. package/lib/quality/RuntimeGuard.js +105 -0
  59. package/lib/quality/captureMode.d.ts +82 -0
  60. package/lib/quality/captureMode.js +75 -0
  61. package/lib/quality/deviceCapability.d.ts +37 -0
  62. package/lib/quality/deviceCapability.js +177 -0
  63. package/lib/quality/profiles.d.ts +125 -0
  64. package/lib/quality/profiles.js +202 -0
  65. package/lib/runtime/RuntimeCache.d.ts +75 -0
  66. package/lib/runtime/RuntimeCache.js +230 -0
  67. package/lib/sdkVersion.d.ts +2 -0
  68. package/lib/sdkVersion.js +5 -0
  69. package/lib/support/optionalModules.d.ts +46 -0
  70. package/lib/support/optionalModules.js +82 -0
  71. package/lib/types/acceleration.d.ts +86 -0
  72. package/lib/types/acceleration.js +15 -0
  73. package/lib/types/events.d.ts +345 -0
  74. package/lib/types/events.js +10 -0
  75. package/lib/types/features.d.ts +90 -0
  76. package/lib/types/features.js +122 -0
  77. package/lib/types/manifest.d.ts +197 -0
  78. package/lib/types/manifest.js +10 -0
  79. package/lib/types/pose.d.ts +36 -0
  80. package/lib/types/pose.js +41 -0
  81. package/lib/types/preload.d.ts +17 -0
  82. package/lib/types/preload.js +10 -0
  83. package/lib/types/skeleton.d.ts +29 -0
  84. package/lib/types/skeleton.js +52 -0
  85. package/package.json +92 -0
  86. package/react-native.config.js +12 -0
  87. package/src/PoseTrackerProvider.tsx +332 -0
  88. package/src/api/configure.ts +82 -0
  89. package/src/api/skeleton.ts +60 -0
  90. package/src/api/track.ts +182 -0
  91. package/src/backends/PoseBackend.ts +59 -0
  92. package/src/backends/vision/VisionPoseBackend.ts +238 -0
  93. package/src/backends/vision/mapVisionJoints.ts +185 -0
  94. package/src/backends/vision/optionalVision.ts +94 -0
  95. package/src/backends/webview/WebViewPoseBackend.ts +582 -0
  96. package/src/backends/webview/brandAssets.ts +5 -0
  97. package/src/backends/webview/onlineRuntime.ts +94 -0
  98. package/src/backends/webview/poseHtml.ts +259 -0
  99. package/src/backends/webview/poseRuntimeSource.d.ts +3 -0
  100. package/src/cache/obfuscate.ts +94 -0
  101. package/src/camera/PoseCameraView.tsx +223 -0
  102. package/src/camera/WebViewPoseView.tsx +466 -0
  103. package/src/client.ts +1295 -0
  104. package/src/diagnostics/logReport.ts +131 -0
  105. package/src/engine/EngineLoader.ts +428 -0
  106. package/src/engine/types.ts +93 -0
  107. package/src/events/classicMessage.ts +271 -0
  108. package/src/exercises/aliases.ts +50 -0
  109. package/src/index.ts +243 -0
  110. package/src/models/poseModels.ts +93 -0
  111. package/src/quality/AdaptiveQualityController.ts +579 -0
  112. package/src/quality/RuntimeGuard.ts +119 -0
  113. package/src/quality/captureMode.ts +97 -0
  114. package/src/quality/deviceCapability.ts +187 -0
  115. package/src/quality/profiles.ts +253 -0
  116. package/src/runtime/RuntimeCache.ts +300 -0
  117. package/src/sdkVersion.ts +2 -0
  118. package/src/support/optionalModules.ts +114 -0
  119. package/src/types/acceleration.ts +94 -0
  120. package/src/types/events.ts +437 -0
  121. package/src/types/features.ts +176 -0
  122. package/src/types/manifest.ts +230 -0
  123. package/src/types/pose.ts +85 -0
  124. package/src/types/preload.ts +19 -0
  125. package/src/types/skeleton.ts +77 -0
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Inference backend abstraction.
3
+ *
4
+ * v1 ships a single implementation (`TfjsMoveNetBackend`, TF.js +
5
+ * bundleResourceIO). The interface is deliberately minimal so a native
6
+ * TFLite backend (react-native-fast-tflite + custom models downloaded from
7
+ * the manifest) can be plugged in later without touching the engine or the
8
+ * provider: anything that turns an input frame into a `Pose` qualifies.
9
+ */
10
+
11
+ import type { Pose } from '../types/pose';
12
+ import type { ModelDescriptor } from '../types/manifest';
13
+ import type { AccelerationDiagnostics } from '../types/acceleration';
14
+
15
+ /**
16
+ * Input frame handed to the backend. For the TF.js backend this is a
17
+ * `tf.Tensor3D` ([height, width, 3], int32/float32). Kept opaque here so the
18
+ * interface does not force a TF.js dependency on future native backends
19
+ * (which would receive a native frame/buffer reference instead).
20
+ */
21
+ export type PoseInputFrame = unknown;
22
+
23
+ export interface PoseBackendInitOptions {
24
+ /** Model resolved from the manifest for the active profile. */
25
+ model: ModelDescriptor;
26
+ /**
27
+ * Local filesystem path of a downloaded model archive, when the manifest
28
+ * provided one and it passed integrity validation. Undefined = use the
29
+ * model bundled in the SDK package.
30
+ */
31
+ localModelPath?: string;
32
+ }
33
+
34
+ export interface PoseBackend {
35
+ readonly name: string;
36
+
37
+ /** Prepare the runtime (TF.js ready + backend selection for tfjs). */
38
+ init(options: PoseBackendInitOptions): Promise<void>;
39
+
40
+ /**
41
+ * Run 1–2 dummy inferences so shaders/kernels are compiled before the
42
+ * first real camera frame. No-op if already warm.
43
+ */
44
+ warmup(): Promise<void>;
45
+
46
+ /** Estimate a single pose from an input frame. */
47
+ estimatePose(frame: PoseInputFrame): Promise<Pose | null>;
48
+
49
+ /** Release model and GPU resources. */
50
+ dispose(): Promise<void>;
51
+
52
+ /**
53
+ * Optional: GPU-acceleration verdict + diagnostics collected during
54
+ * `warmup()` (see docs/ANDROID_GL_ACCELERATION.md). Backends that cannot
55
+ * introspect their accelerator (or a future native TFLite backend using
56
+ * GPU/NNAPI delegates) may omit it or return null before warm-up.
57
+ */
58
+ getAcceleration?(): AccelerationDiagnostics | null;
59
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Apple Vision implementation of `PoseBackend` (iOS only).
3
+ *
4
+ * Uses `VNDetectHumanBodyPoseRequest` via a VisionCamera frame-processor
5
+ * plugin shipped in this package's `ios/` folder. Up to 19 anatomical
6
+ * joints (COCO-17 + `neck` + `root`). Primary fast path is
7
+ * `PoseCameraView` → worklet → native plugin (CVPixelBuffer, no RGB copy).
8
+ *
9
+ * Never loaded in Expo Go (`isExpoGo()` guard). On Android,
10
+ * `init()` throws {@link VisionUnavailableError} so the client can fall
11
+ * back to TFLite / TF.js when selection is `'auto'`.
12
+ */
13
+
14
+ import { Platform } from 'react-native';
15
+
16
+ import type { PoseBackend, PoseBackendInitOptions, PoseInputFrame } from '../PoseBackend';
17
+ import type { Pose } from '../../types/pose';
18
+ import type {
19
+ AccelerationDiagnostics,
20
+ AccelerationState,
21
+ DiagnosticListener,
22
+ } from '../../types/acceleration';
23
+ import { isExpoGo } from '../../support/optionalModules';
24
+ import {
25
+ getVisionBodyPosePlugin,
26
+ isVisionBackendAvailable,
27
+ type VisionFrameProcessorPlugin,
28
+ } from './optionalVision';
29
+ import {
30
+ poseFromVisionPluginResult,
31
+ type VisionPluginResult,
32
+ } from './mapVisionJoints';
33
+
34
+ const DEFAULT_MAX_ACCEPTABLE_INFERENCE_MS = 50;
35
+
36
+ /** Thrown when Apple Vision cannot run (wrong platform, Expo Go, missing pod). */
37
+ export class VisionUnavailableError extends Error {
38
+ constructor(message: string) {
39
+ super(message);
40
+ this.name = 'VisionUnavailableError';
41
+ }
42
+ }
43
+
44
+ export interface VisionPoseBackendOptions {
45
+ maxAcceptableInferenceMs?: number;
46
+ onDiagnostic?: DiagnosticListener;
47
+ onAccelerationChange?: (state: AccelerationState) => void;
48
+ }
49
+
50
+ /**
51
+ * Opaque frame accepted on the JS path: either a VisionCamera `Frame`
52
+ * (preferred — passed through to the plugin) or a pre-decoded
53
+ * {@link VisionPluginResult} (for tests / hosts that already ran Vision).
54
+ */
55
+ export type VisionPoseInput = VisionPluginResult | { __visionCameraFrame: true };
56
+
57
+ export class VisionPoseBackend implements PoseBackend {
58
+ readonly name = 'apple-vision';
59
+
60
+ private plugin: VisionFrameProcessorPlugin | null = null;
61
+ private warm = false;
62
+ private acceleration: AccelerationState = 'unknown';
63
+ private inferenceTimesMs: number[] = [];
64
+ private reasons: string[] = [];
65
+
66
+ private readonly maxAcceptableInferenceMs: number;
67
+ private readonly onDiagnostic: DiagnosticListener | undefined;
68
+ private readonly onAccelerationChange: ((state: AccelerationState) => void) | undefined;
69
+
70
+ constructor(private readonly options: VisionPoseBackendOptions = {}) {
71
+ this.maxAcceptableInferenceMs =
72
+ options.maxAcceptableInferenceMs ?? DEFAULT_MAX_ACCEPTABLE_INFERENCE_MS;
73
+ this.onDiagnostic = options.onDiagnostic;
74
+ this.onAccelerationChange = options.onAccelerationChange;
75
+ }
76
+
77
+ async init(_options: PoseBackendInitOptions): Promise<void> {
78
+ if (this.plugin) {
79
+ return;
80
+ }
81
+ if (Platform.OS !== 'ios') {
82
+ throw new VisionUnavailableError(
83
+ 'Apple Vision body pose is iOS-only. Use preferredBackend "tflite" or "tfjs" on Android.',
84
+ );
85
+ }
86
+ if (isExpoGo()) {
87
+ throw new VisionUnavailableError(
88
+ 'Apple Vision requires a native iOS build — not available in Expo Go.',
89
+ );
90
+ }
91
+ if (!isVisionBackendAvailable()) {
92
+ throw new VisionUnavailableError(
93
+ 'Apple Vision frame-processor plugin is not linked. Rebuild with the SDK ios/ pod ' +
94
+ '(VisionCamera + PoseTrackerVision) and ensure react-native-vision-camera + worklets are installed.',
95
+ );
96
+ }
97
+ this.plugin = getVisionBodyPosePlugin();
98
+ if (!this.plugin) {
99
+ throw new VisionUnavailableError('Failed to initialize detectBodyPose frame-processor plugin.');
100
+ }
101
+ this.onDiagnostic?.(
102
+ '[posetracker] apple-vision backend ready (VNDetectHumanBodyPoseRequest, up to 19 joints)',
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Vision has no separate model load — warm-up records the accelerator
108
+ * verdict. Real timings come from the first camera frames via
109
+ * {@link recordInferenceTime}.
110
+ */
111
+ async warmup(): Promise<void> {
112
+ this.requirePlugin();
113
+ if (this.warm) {
114
+ return;
115
+ }
116
+ // Neural Engine / GPU path — Vision does not expose a CPU-only mode.
117
+ this.setAcceleration('gpu');
118
+ this.warm = true;
119
+ this.onDiagnostic?.(
120
+ `[posetracker] apple-vision warm-up done: acceleration=${this.acceleration} ` +
121
+ '(per-frame ms reported from PoseCameraView)',
122
+ );
123
+ }
124
+
125
+ /**
126
+ * JS-path estimation. Accepts:
127
+ * - a VisionCamera `Frame` (has `.width`/`.height` and is callable via plugin);
128
+ * - a precomputed {@link VisionPluginResult} `{ joints }`.
129
+ *
130
+ * Prefer `PoseCameraView` (worklet) for production — this path is for
131
+ * TensorCamera-style hosts and unit tests.
132
+ */
133
+ async estimatePose(frame: PoseInputFrame): Promise<Pose | null> {
134
+ const plugin = this.requirePlugin();
135
+ const asResult = frame as VisionPluginResult;
136
+ if (asResult && Array.isArray(asResult.joints)) {
137
+ return poseFromVisionPluginResult(asResult);
138
+ }
139
+
140
+ // VisionCamera Frame — structural duck-type (worklet Frame has width/height).
141
+ const anyFrame = frame as { width?: number; height?: number };
142
+ if (
143
+ anyFrame &&
144
+ typeof anyFrame.width === 'number' &&
145
+ typeof anyFrame.height === 'number' &&
146
+ typeof (plugin as VisionFrameProcessorPlugin).call === 'function'
147
+ ) {
148
+ const start = Date.now();
149
+ const raw = plugin.call(frame) as VisionPluginResult | null;
150
+ const elapsed = Date.now() - start;
151
+ this.recordInferenceTime(elapsed);
152
+ return poseFromVisionPluginResult(raw ?? undefined);
153
+ }
154
+
155
+ throw new Error(
156
+ 'VisionPoseBackend: pass a VisionCamera Frame, a { joints } plugin result, ' +
157
+ 'or use PoseCameraView (worklet path).',
158
+ );
159
+ }
160
+
161
+ getAcceleration(): AccelerationDiagnostics | null {
162
+ if (!this.plugin && this.acceleration === 'unknown') {
163
+ return null;
164
+ }
165
+ return {
166
+ state: this.acceleration,
167
+ tfjsBackend: null,
168
+ runtime: 'vision',
169
+ delegate: 'apple-vision',
170
+ medianInferenceMs: median(this.inferenceTimesMs),
171
+ inferenceTimesMs: [...this.inferenceTimesMs],
172
+ maxAcceptableInferenceMs: this.maxAcceptableInferenceMs,
173
+ capabilities: null,
174
+ flags: {},
175
+ contextLossCount: 0,
176
+ reasons: [...this.reasons],
177
+ };
178
+ }
179
+
180
+ async dispose(): Promise<void> {
181
+ this.plugin = null;
182
+ this.warm = false;
183
+ this.acceleration = 'unknown';
184
+ this.inferenceTimesMs = [];
185
+ this.reasons = [];
186
+ }
187
+
188
+ /** Plugin handle for PoseCameraView worklets. */
189
+ getFrameProcessorPlugin(): VisionFrameProcessorPlugin | null {
190
+ return this.plugin;
191
+ }
192
+
193
+ /** Feed live inference timings from the camera worklet into diagnostics. */
194
+ recordInferenceTime(ms: number): void {
195
+ this.inferenceTimesMs.push(ms);
196
+ if (this.inferenceTimesMs.length > 30) {
197
+ this.inferenceTimesMs.shift();
198
+ }
199
+ const med = median(this.inferenceTimesMs);
200
+ if (med !== null && med > this.maxAcceptableInferenceMs && this.acceleration === 'gpu') {
201
+ this.noteReason(
202
+ `apple-vision median ${med.toFixed(0)} ms exceeds ${this.maxAcceptableInferenceMs} ms budget`,
203
+ );
204
+ this.setAcceleration('cpu-fallback');
205
+ }
206
+ }
207
+
208
+ private setAcceleration(state: AccelerationState): void {
209
+ if (this.acceleration !== state) {
210
+ this.acceleration = state;
211
+ this.onAccelerationChange?.(state);
212
+ }
213
+ }
214
+
215
+ private noteReason(reason: string): void {
216
+ this.reasons.push(reason);
217
+ this.onDiagnostic?.(`[posetracker] ${reason}`);
218
+ }
219
+
220
+ private requirePlugin(): VisionFrameProcessorPlugin {
221
+ if (!this.plugin) {
222
+ throw new Error('VisionPoseBackend not initialized — call init() first.');
223
+ }
224
+ return this.plugin;
225
+ }
226
+ }
227
+
228
+ function median(values: number[]): number | null {
229
+ if (values.length === 0) {
230
+ return null;
231
+ }
232
+ const sorted = [...values].sort((a, b) => a - b);
233
+ const mid = Math.floor(sorted.length / 2);
234
+ if (sorted.length % 2 === 0) {
235
+ return ((sorted[mid - 1] as number) + (sorted[mid] as number)) / 2;
236
+ }
237
+ return sorted[mid] as number;
238
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Apple Vision body-pose joint → SDK keypoint mapping.
3
+ *
4
+ * `VNDetectHumanBodyPoseRequest` exposes 19 joints. Seventeen align 1:1 with
5
+ * the COCO / MoveNet topology; `neck` and `root` (mid-hip) are Vision extras
6
+ * kept under their Vision names so A/B overlays can show the full set.
7
+ *
8
+ * Vision normalized points use a **bottom-left** origin — the native plugin
9
+ * already flips Y (`y = 1 - visionY`) before returning joints here.
10
+ *
11
+ * `poseFromVisionPluginResult` carries a `'worklet'` directive so
12
+ * PoseCameraView can call it on the frame-processor thread.
13
+ */
14
+
15
+ import type { Keypoint, KeypointName, Pose } from '../../types/pose';
16
+
17
+ /** Vision `VNHumanBodyPoseObservation.JointName` camelCase → SDK name. */
18
+ export const VISION_JOINT_TO_SDK: Readonly<Record<string, KeypointName>> = {
19
+ nose: 'nose',
20
+ leftEye: 'left_eye',
21
+ rightEye: 'right_eye',
22
+ leftEar: 'left_ear',
23
+ rightEar: 'right_ear',
24
+ leftShoulder: 'left_shoulder',
25
+ rightShoulder: 'right_shoulder',
26
+ leftElbow: 'left_elbow',
27
+ rightElbow: 'right_elbow',
28
+ leftWrist: 'left_wrist',
29
+ rightWrist: 'right_wrist',
30
+ leftHip: 'left_hip',
31
+ rightHip: 'right_hip',
32
+ leftKnee: 'left_knee',
33
+ rightKnee: 'right_knee',
34
+ leftAnkle: 'left_ankle',
35
+ rightAnkle: 'right_ankle',
36
+ neck: 'neck',
37
+ root: 'root',
38
+ };
39
+
40
+ /** One joint as returned by the native frame-processor plugin. */
41
+ export interface VisionPluginJoint {
42
+ name: string;
43
+ x: number;
44
+ y: number;
45
+ score: number;
46
+ }
47
+
48
+ /** Payload of `detectBodyPose` frame-processor plugin (already Y-flipped). */
49
+ export interface VisionPluginResult {
50
+ joints: VisionPluginJoint[];
51
+ /** Wall time of the Vision request on the frame-processor thread, ms. */
52
+ inferenceTimeMs?: number;
53
+ }
54
+
55
+ /**
56
+ * Build a SDK `Pose` from the native plugin result. Unknown joint names are
57
+ * dropped; missing joints are filled with score 0 so overlays stay
58
+ * topology-stable when a joint is absent.
59
+ */
60
+ export function poseFromVisionPluginResult(
61
+ result: VisionPluginResult | null | undefined,
62
+ timestampMs: number = Date.now(),
63
+ ): Pose | null {
64
+ 'worklet';
65
+ // Inlined — worklets cannot capture module-level imports reliably.
66
+ const ORDER: KeypointName[] = [
67
+ 'nose',
68
+ 'left_eye',
69
+ 'right_eye',
70
+ 'left_ear',
71
+ 'right_ear',
72
+ 'left_shoulder',
73
+ 'right_shoulder',
74
+ 'left_elbow',
75
+ 'right_elbow',
76
+ 'left_wrist',
77
+ 'right_wrist',
78
+ 'left_hip',
79
+ 'right_hip',
80
+ 'left_knee',
81
+ 'right_knee',
82
+ 'left_ankle',
83
+ 'right_ankle',
84
+ 'neck',
85
+ 'root',
86
+ ];
87
+ const ALIAS: Record<string, KeypointName> = {
88
+ nose: 'nose',
89
+ leftEye: 'left_eye',
90
+ rightEye: 'right_eye',
91
+ leftEar: 'left_ear',
92
+ rightEar: 'right_ear',
93
+ leftShoulder: 'left_shoulder',
94
+ rightShoulder: 'right_shoulder',
95
+ leftElbow: 'left_elbow',
96
+ rightElbow: 'right_elbow',
97
+ leftWrist: 'left_wrist',
98
+ rightWrist: 'right_wrist',
99
+ leftHip: 'left_hip',
100
+ rightHip: 'right_hip',
101
+ leftKnee: 'left_knee',
102
+ rightKnee: 'right_knee',
103
+ leftAnkle: 'left_ankle',
104
+ rightAnkle: 'right_ankle',
105
+ neck: 'neck',
106
+ root: 'root',
107
+ left_eye: 'left_eye',
108
+ right_eye: 'right_eye',
109
+ left_ear: 'left_ear',
110
+ right_ear: 'right_ear',
111
+ left_shoulder: 'left_shoulder',
112
+ right_shoulder: 'right_shoulder',
113
+ left_elbow: 'left_elbow',
114
+ right_elbow: 'right_elbow',
115
+ left_wrist: 'left_wrist',
116
+ right_wrist: 'right_wrist',
117
+ left_hip: 'left_hip',
118
+ right_hip: 'right_hip',
119
+ left_knee: 'left_knee',
120
+ right_knee: 'right_knee',
121
+ left_ankle: 'left_ankle',
122
+ right_ankle: 'right_ankle',
123
+ };
124
+
125
+ if (!result || !result.joints || result.joints.length === 0) {
126
+ return null;
127
+ }
128
+
129
+ const byName: Partial<Record<KeypointName, Keypoint>> = {};
130
+ for (let i = 0; i < result.joints.length; i++) {
131
+ const joint = result.joints[i];
132
+ if (!joint) {
133
+ continue;
134
+ }
135
+ const mapped = ALIAS[joint.name];
136
+ if (!mapped) {
137
+ continue;
138
+ }
139
+ const x = clamp01(joint.x);
140
+ const y = clamp01(joint.y);
141
+ const score = clamp01(joint.score);
142
+ byName[mapped] = { name: mapped, x, y, score };
143
+ }
144
+
145
+ const keypoints: Keypoint[] = [];
146
+ let scoreSum = 0;
147
+ let scoreCount = 0;
148
+ for (let i = 0; i < ORDER.length; i++) {
149
+ const name = ORDER[i] as KeypointName;
150
+ const existing = byName[name];
151
+ if (existing) {
152
+ keypoints.push(existing);
153
+ if (existing.score > 0) {
154
+ scoreSum += existing.score;
155
+ scoreCount += 1;
156
+ }
157
+ } else {
158
+ keypoints.push({ name, x: 0, y: 0, score: 0 });
159
+ }
160
+ }
161
+
162
+ if (scoreCount === 0) {
163
+ return null;
164
+ }
165
+
166
+ return {
167
+ keypoints,
168
+ score: scoreSum / scoreCount,
169
+ timestampMs,
170
+ };
171
+ }
172
+
173
+ function clamp01(n: number): number {
174
+ 'worklet';
175
+ if (!Number.isFinite(n)) {
176
+ return 0;
177
+ }
178
+ if (n < 0) {
179
+ return 0;
180
+ }
181
+ if (n > 1) {
182
+ return 1;
183
+ }
184
+ return n;
185
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Guarded access to the Apple Vision body-pose frame-processor plugin.
3
+ *
4
+ * The plugin lives in this package's `ios/` folder (VisionCamera Swift
5
+ * FrameProcessorPlugin + `VNDetectHumanBodyPoseRequest`). It is only linked
6
+ * in native iOS builds — never in Expo Go. All requires are gated by
7
+ * `Platform.OS === 'ios' && !isExpoGo()` so Metro/Expo Go never evaluate
8
+ * Nitro/VisionCamera code paths unintentionally.
9
+ */
10
+
11
+ import { Platform } from 'react-native';
12
+
13
+ import { getVisionCamera, getWorkletsCore, isExpoGo } from '../../support/optionalModules';
14
+
15
+ /* eslint-disable @typescript-eslint/no-explicit-any */
16
+ declare function require(name: string): unknown;
17
+
18
+ /** JS name registered via `VISION_EXPORT_SWIFT_FRAME_PROCESSOR(..., detectBodyPose)`. */
19
+ export const VISION_BODY_POSE_PLUGIN_NAME = 'detectBodyPose';
20
+
21
+ export interface VisionFrameProcessorPlugin {
22
+ call(frame: unknown, args?: Record<string, unknown>): unknown;
23
+ }
24
+
25
+ interface VisionCameraProxyModule {
26
+ VisionCameraProxy: {
27
+ initFrameProcessorPlugin(
28
+ name: string,
29
+ options?: Record<string, unknown>,
30
+ ): VisionFrameProcessorPlugin | null | undefined;
31
+ };
32
+ }
33
+
34
+ let cachedPlugin: VisionFrameProcessorPlugin | null | undefined;
35
+
36
+ /**
37
+ * True when we are on a native iOS build (not Expo Go) where the Vision
38
+ * frame-processor plugin can be loaded. Does not require Nitro / TFLite.
39
+ */
40
+ export function isVisionBackendAvailable(): boolean {
41
+ if (Platform.OS !== 'ios' || isExpoGo()) {
42
+ return false;
43
+ }
44
+ if (getVisionCamera() === null || getWorkletsCore() === null) {
45
+ return false;
46
+ }
47
+ return getVisionBodyPosePlugin() !== null;
48
+ }
49
+
50
+ /**
51
+ * vision-camera + worklets present on iOS native build — enough to mount the
52
+ * Vision path of `PoseCameraView` once the backend has initialized.
53
+ */
54
+ export function isVisionPoseCameraAvailable(): boolean {
55
+ return (
56
+ Platform.OS === 'ios' &&
57
+ !isExpoGo() &&
58
+ getVisionCamera() !== null &&
59
+ getWorkletsCore() !== null
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Lazily init the VisionCamera frame-processor plugin. Cached; `null` when
65
+ * the native binary does not include our Swift plugin (Expo Go, Android,
66
+ * or a host that excluded the pod).
67
+ */
68
+ export function getVisionBodyPosePlugin(): VisionFrameProcessorPlugin | null {
69
+ if (cachedPlugin !== undefined) {
70
+ return cachedPlugin;
71
+ }
72
+ if (Platform.OS !== 'ios' || isExpoGo()) {
73
+ cachedPlugin = null;
74
+ return cachedPlugin;
75
+ }
76
+ try {
77
+ const mod = require('react-native-vision-camera') as VisionCameraProxyModule;
78
+ const proxy = mod.VisionCameraProxy;
79
+ if (!proxy || typeof proxy.initFrameProcessorPlugin !== 'function') {
80
+ cachedPlugin = null;
81
+ return cachedPlugin;
82
+ }
83
+ const plugin = proxy.initFrameProcessorPlugin(VISION_BODY_POSE_PLUGIN_NAME, {});
84
+ cachedPlugin = plugin ?? null;
85
+ } catch {
86
+ cachedPlugin = null;
87
+ }
88
+ return cachedPlugin;
89
+ }
90
+
91
+ /** Test helper — clears the plugin cache between availability probes. */
92
+ export function resetVisionPluginCacheForTests(): void {
93
+ cachedPlugin = undefined;
94
+ }