react-native-nitro-pose-exercises 1.0.9 → 1.1.2

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.
@@ -19,13 +19,14 @@ Pod::Spec.new do |s|
19
19
  "cpp/**/*.{hpp,cpp}",
20
20
  ]
21
21
 
22
+ s.frameworks = ["AVFoundation", "Vision"]
23
+
22
24
  s.dependency 'React-jsi'
23
25
  s.dependency 'React-callinvoker'
24
26
 
25
27
  load 'nitrogen/generated/ios/NitroPoseExercises+autolinking.rb'
26
28
  add_nitrogen_files(s)
27
29
 
28
- s.dependency 'MediaPipeTasksVision', '~> 0.10.0'
29
30
  s.dependency "VisionCamera"
30
31
 
31
32
  install_modules_dependencies(s)
package/README.md CHANGED
@@ -6,21 +6,22 @@
6
6
 
7
7
  # react-native-nitro-pose-exercises
8
8
 
9
- A **React Native Nitro Module** for real-time, on-device exercise tracking using pose estimation. Built on **MediaPipe Pose Landmarker** and **VisionCamera v5**.
9
+ A **React Native Nitro Module** for real-time, on-device exercise tracking using pose estimation. Uses **OS-native pose detection** — Apple Vision on iOS and Google ML Kit on Android — with **VisionCamera v5**.
10
10
 
11
- - 🏋️ **Rep Counting** — Automatic rep detection with configurable state machines
12
- - 🧘 **Hold Tracking** — Duration and stability tracking for planks, yoga poses, and isometric holds
13
- - 📐 **Form Validation** — Real-time form feedback with configurable angle-based rules
14
- - 💀 **Skeleton Overlay** — Optional Skia-powered skeleton rendering over the camera feed
15
- - ⚡ **Fully Native** — MediaPipe runs on-device via Nitro Modules, zero JS bridge overhead
11
+ * 🏋️ **Rep Counting** — Automatic rep detection with configurable state machines
12
+ * 🧘 **Hold Tracking** — Duration and stability tracking for planks, yoga poses, and isometric holds
13
+ * 📐 **Form Validation** — Real-time form feedback with configurable angle-based rules
14
+ * 💀 **Skeleton Overlay** — Optional Skia-powered skeleton rendering over the camera feed
15
+ * ⚡ **Fully Native** — OS-level pose detection via Nitro Modules, zero JS bridge overhead
16
+ * 📦 **Zero Model Bundling** — No ML model files to download or ship with your app
16
17
 
17
18
  ---
18
19
 
19
20
  > [!IMPORTANT]
20
21
  >
21
- > - Requires React Native **0.76+** with Nitro Modules and VisionCamera **v5**.
22
- > - Must be tested on a **physical device** — camera + ML inference don't work on simulators.
23
- > - MediaPipe Pose Landmarker model file (`pose_landmarker_lite.task`) must be bundled with the app.
22
+ > * Requires React Native **0.76+** with Nitro Modules and VisionCamera **v5**.
23
+ > * Must be tested on a **physical device** — camera + ML inference don't work on simulators.
24
+ > * iOS requires **iOS 14+** (Vision body pose API). Android requires **API 23+** (ML Kit).
24
25
 
25
26
  ---
26
27
 
@@ -44,9 +45,10 @@ cd ios && pod install
44
45
  ```
45
46
 
46
47
  > [!NOTE]
47
- > This package uses **MediaPipe Pose Landmarker** natively on both platforms.
48
- > iOS uses `MediaPipeTasksVision` via CocoaPods.
49
- > Android uses `com.google.mediapipe:tasks-vision` via Gradle.
48
+ > This package uses **OS-native pose detection** on both platforms.
49
+ > iOS uses Apple's **Vision framework** (`VNDetectHumanBodyPoseRequest`) built into iOS, no extra dependencies.
50
+ > Android uses **Google ML Kit Pose Detection** (`com.google.mlkit:pose-detection:18.0.0-beta5`) — model auto-managed via Play Services.
51
+ > **No model files to bundle, no extra downloads, no color format conversions.**
50
52
 
51
53
  ---
52
54
 
@@ -71,30 +73,25 @@ cd ios && pod install
71
73
 
72
74
  ## 🧠 Overview
73
75
 
74
- | Feature | Description |
75
- | ------------------------ | ------------------------------------------------------------------------- |
76
- | **Rep-Based Exercises** | Cyclic state machine (UP → DOWN → UP = 1 rep). Push-ups, squats, curls. |
76
+ | Feature | Description |
77
+ | --- | --- |
78
+ | **Rep-Based Exercises** | Cyclic state machine (UP → DOWN → UP = 1 rep). Push-ups, squats, curls. |
77
79
  | **Hold-Based Exercises** | Single target pose with duration tracking. Planks, wall sits, yoga poses. |
78
- | **Flow-Based Exercises** | Ordered sequence of poses. Sun salutation, yoga flows. _(coming soon)_ |
79
- | **Form Feedback** | Angle-based rules with throttled real-time callbacks. |
80
- | **Skeleton Overlay** | 33-point body skeleton drawn over camera via Skia. |
81
- | **Bilateral Tracking** | Left and right side angles tracked independently. |
80
+ | **Flow-Based Exercises** | Ordered sequence of poses. Sun salutation, yoga flows. *(coming soon)* |
81
+ | **Form Feedback** | Angle-based rules with throttled real-time callbacks. |
82
+ | **Skeleton Overlay** | Body skeleton drawn over camera via Skia (19 joints iOS, 33 joints Android). |
83
+ | **Bilateral Tracking** | Left and right side angles tracked independently. |
82
84
 
83
85
  ---
84
86
 
85
87
  ## 🔧 Setup
86
88
 
87
- ### Model File
89
+ ### No Model File Needed
88
90
 
89
- Download the MediaPipe Pose Landmarker model:
91
+ Unlike MediaPipe-based solutions, this library uses OS-native APIs. There is **no model file to download or bundle**.
90
92
 
91
- ```
92
- https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/latest/pose_landmarker_lite.task
93
- ```
94
-
95
- **iOS:** Drag `pose_landmarker_lite.task` into your Xcode project (Copy items if needed, add to app target).
96
-
97
- **Android:** Place at `android/app/src/main/assets/pose_landmarker_lite.task`
93
+ * **iOS:** Apple Vision is a system framework — it's already on every iPhone running iOS 14+.
94
+ * **Android:** ML Kit manages its own model via Google Play Services — it downloads and updates automatically.
98
95
 
99
96
  ### Permissions
100
97
 
@@ -125,6 +122,26 @@ module.exports = {
125
122
  };
126
123
  ```
127
124
 
125
+ ### Podspec (for library authors)
126
+
127
+ The iOS podspec needs the Vision and AVFoundation system frameworks:
128
+
129
+ ```ruby
130
+ s.frameworks = ["AVFoundation", "Vision"]
131
+ ```
132
+
133
+ No CocoaPods dependencies required — Vision is built into iOS.
134
+
135
+ ### Android Gradle (for library authors)
136
+
137
+ Add ML Kit Pose Detection to `android/build.gradle`:
138
+
139
+ ```groovy
140
+ dependencies {
141
+ implementation 'com.google.mlkit:pose-detection:18.0.0-beta5'
142
+ }
143
+ ```
144
+
128
145
  ---
129
146
 
130
147
  ## ⚙️ Usage
@@ -159,10 +176,10 @@ export default function App() {
159
176
  if (!hasPermission) requestPermission();
160
177
  }, [hasPermission]);
161
178
 
162
- // Initialize pose engine
179
+ // Initialize pose engine — modelPath is ignored (OS-native, no model file)
163
180
  useEffect(() => {
164
181
  async function init() {
165
- await nitroPoseExercises.initialize('pose_landmarker_lite.task');
182
+ await nitroPoseExercises.initialize('');
166
183
  nitroPoseExercises.loadExercise(PUSHUP_CONFIG);
167
184
 
168
185
  nitroPoseExercises.onRepComplete = (data: RepData) => {
@@ -310,7 +327,7 @@ const SKELETON_CONNECTIONS: [number, number][] = [
310
327
  ### Lifecycle
311
328
 
312
329
  ```ts
313
- // Initialize MediaPipe with model file path
330
+ // Initialize the pose engine (modelPath is ignored — OS-native, no model file needed)
314
331
  initialize(modelPath: string): Promise<void>
315
332
 
316
333
  // Clean up resources
@@ -346,7 +363,7 @@ processFrame(frame: Frame): void
346
363
  readonly status: SessionStatus // 'idle' | 'countdown' | 'active' | 'paused' | 'completed'
347
364
  readonly currentPhase: ExercisePhase // 'up' | 'down' | 'hold' | 'transition' | 'unknown'
348
365
  readonly repCount: number
349
- readonly landmarks: Landmark[] // 33 body landmarks from MediaPipe
366
+ readonly landmarks: Landmark[] // Body landmarks (mapped to MediaPipe indices)
350
367
  ```
351
368
 
352
369
  ### Callbacks
@@ -380,9 +397,9 @@ onSessionComplete: ((result: SessionResult) => void) | undefined
380
397
 
381
398
  ```ts
382
399
  {
383
- ruleName: string; // e.g. 'hipSag'
384
- message: string; // e.g. 'Keep your hips up'
385
- severity: FormSeverity; // 'info' | 'warning' | 'error'
400
+ ruleName: string // e.g. 'hipSag'
401
+ message: string // e.g. 'Keep your hips up'
402
+ severity: FormSeverity // 'info' | 'warning' | 'error'
386
403
  }
387
404
  ```
388
405
 
@@ -403,62 +420,88 @@ onSessionComplete: ((result: SessionResult) => void) | undefined
403
420
 
404
421
  ## 🏋️ Built-In Exercise Configs
405
422
 
406
- ### Push-Up (`PUSHUP_CONFIG`)
423
+ ### Rep-Based
407
424
 
408
- | Parameter | Value |
409
- | ------------- | ------------------------------------- |
410
- | Type | `rep` |
411
- | Primary Angle | Left elbow (shoulder elbow → wrist) |
412
- | UP Phase | Elbow angle 140°–180° |
413
- | DOWN Phase | Elbow angle 30°–110° |
414
- | Rep Sequence | UP DOWN UP |
415
- | Form Rules | Hip sag detection, hip pike detection |
425
+ | Config | Exercise | Primary Angle | Form Rules |
426
+ | --- | --- | --- | --- |
427
+ | `PUSHUP_CONFIG` | Push-Up | Elbow (140°–180° up, 30°–110° down) | Hip sag, hip pike |
428
+ | `SQUAT_CONFIG` | Squat | Knee (155°–180° up, 50°–105° down) | Knees caving, leaning forward |
429
+ | `BICEP_CURL_CONFIG` | Bicep Curl | Elbow (150°–180° down, 25°–70° up) | Elbow flare, swinging |
430
+ | `SHOULDER_PRESS_CONFIG` | Shoulder Press | Elbow (155°–180° up, 60°–100° down) | Back arch |
431
+ | `LUNGE_CONFIG` | Lunge | Front knee (155°–180° up, 70°–110° down) | Knee over toe, torso lean |
432
+ | `SITUP_CONFIG` | Sit-Up | Hip (130°–180° down, 40°–90° up) | Neck strain |
433
+ | `TRICEP_DIP_CONFIG` | Tricep Dip | Elbow (150°–180° up, 60°–100° down) | Going too deep |
434
+
435
+ ### Hold-Based
436
+
437
+ | Config | Exercise | Hold Angle | Duration | Form Rules |
438
+ | --- | --- | --- | --- | --- |
439
+ | `PLANK_CONFIG` | Plank | Hip 155°–180° | 60s | Hip sag, hip pike |
440
+ | `WALL_SIT_CONFIG` | Wall Sit | Knee 80°–110° | 45s | Too high, leaning forward |
441
+
442
+ ### Yoga Poses
443
+
444
+ | Config | Exercise | Hold Angle | Duration | Form Rules |
445
+ | --- | --- | --- | --- | --- |
446
+ | `TREE_POSE_CONFIG` | Tree Pose (Vrksasana) | Standing leg 165°–180° | 30s | Standing leg bent, leaning torso |
447
+ | `WARRIOR_I_CONFIG` | Warrior I (Virabhadrasana I) | Front knee 80°–110° | 30s | Knee too straight, back leg bent, arms not extended, torso leaning |
448
+ | `WARRIOR_II_CONFIG` | Warrior II (Virabhadrasana II) | Front knee 80°–110° | 30s | Knee too straight, back leg bent, arms drooping |
449
+ | `DOWNWARD_DOG_CONFIG` | Downward Dog (Adho Mukha Svanasana) | Hip 55°–100° | 30s | Arms bent, legs bent, hips too low |
450
+ | `CHAIR_POSE_CONFIG` | Chair Pose (Utkatasana) | Knee 90°–130° | 30s | Knees too straight, leaning forward, arms not up |
451
+ | `COBRA_POSE_CONFIG` | Cobra Pose (Bhujangasana) | Hip extension 120°–170° | 30s | Shoulders tensed, legs bending |
416
452
 
417
453
  ### Custom Exercise Config
418
454
 
419
455
  ```ts
420
456
  import type { ExerciseConfig } from 'react-native-nitro-pose-exercises';
421
457
 
422
- const SQUAT_CONFIG: ExerciseConfig = {
423
- name: 'Squat',
424
- type: 'rep',
458
+ const MY_EXERCISE: ExerciseConfig = {
459
+ name: 'Custom Exercise',
460
+ type: 'rep', // 'rep' | 'hold'
425
461
  angles: [
426
- { name: 'leftKnee', landmarkA: 23, landmarkB: 25, landmarkC: 27 },
427
- { name: 'rightKnee', landmarkA: 24, landmarkB: 26, landmarkC: 28 },
462
+ { name: 'myAngle', landmarkA: 11, landmarkB: 13, landmarkC: 15 },
428
463
  ],
429
464
  phases: [
430
- { phase: 'up', angleName: 'leftKnee', minAngle: 160, maxAngle: 180 },
431
- { phase: 'down', angleName: 'leftKnee', minAngle: 50, maxAngle: 110 },
465
+ { phase: 'up', angleName: 'myAngle', minAngle: 150, maxAngle: 180 },
466
+ { phase: 'down', angleName: 'myAngle', minAngle: 30, maxAngle: 100 },
432
467
  ],
433
468
  repSequence: ['up', 'down', 'up'],
434
- formRules: [
435
- {
436
- name: 'kneesCaving',
437
- message: 'Push your knees out over your toes',
438
- severity: 'warning',
439
- angleName: 'leftKnee',
440
- minAngle: 50,
441
- maxAngle: 180,
442
- },
443
- ],
469
+ formRules: [],
444
470
  holdDurationMs: 0,
445
471
  };
446
472
  ```
447
473
 
448
474
  ---
449
475
 
450
- ## 📐 MediaPipe Landmark Index Reference
476
+ ## 📐 Landmark Index Reference
477
+
478
+ Landmarks are mapped to MediaPipe-compatible indices on both platforms. iOS Vision provides 19 joints (all exercise-critical joints covered), Android ML Kit provides the full 33.
451
479
 
452
- | Index | Landmark | Index | Landmark |
453
- | ----- | -------------- | ----- | ----------- |
454
- | 0 | Nose | 16 | Right wrist |
455
- | 11 | Left shoulder | 23 | Left hip |
456
- | 12 | Right shoulder | 24 | Right hip |
457
- | 13 | Left elbow | 25 | Left knee |
458
- | 14 | Right elbow | 26 | Right knee |
459
- | 15 | Left wrist | 27 | Left ankle |
480
+ | Index | Landmark | Index | Landmark |
481
+ | --- | --- | --- | --- |
482
+ | 0 | Nose | 16 | Right wrist |
483
+ | 11 | Left shoulder | 23 | Left hip |
484
+ | 12 | Right shoulder | 24 | Right hip |
485
+ | 13 | Left elbow | 25 | Left knee |
486
+ | 14 | Right elbow | 26 | Right knee |
487
+ | 15 | Left wrist | 27 | Left ankle |
460
488
 
461
- Full 33-point reference: [MediaPipe Pose Landmarks](https://ai.google.dev/edge/mediapipe/solutions/vision/pose_landmarker#pose_landmarker_model)
489
+ **iOS note:** Vision provides 19 joints. Indices not available from Vision (face details 1-10, hands 17-22, feet 29-32) are filled with `visibility: 0` and skipped by the skeleton overlay.
490
+
491
+ **Android note:** ML Kit provides all 33 landmarks matching MediaPipe indices exactly.
492
+
493
+ ---
494
+
495
+ ## 🏗️ Architecture — OS-Native vs MediaPipe
496
+
497
+ | | OS-Native (current) | MediaPipe (previous) |
498
+ | --- | --- | --- |
499
+ | **iOS** | Apple Vision framework (built-in) | MediaPipeTasksVision (CocoaPod) |
500
+ | **Android** | Google ML Kit (Play Services) | com.google.mediapipe:tasks-vision |
501
+ | **Model file** | None needed | ~3 MB bundled `.task` file |
502
+ | **Color conversion** | None — takes CVPixelBuffer/ImageProxy directly | BGRA required (iOS), NV21→RGB (Android) |
503
+ | **App size impact** | ~200 KB (Nitro module code only) | ~11-15 MB (SDK + model) |
504
+ | **Updates** | OS/Play Services updates | Manual model file replacement |
462
505
 
463
506
  ---
464
507
 
@@ -466,33 +509,35 @@ Full 33-point reference: [MediaPipe Pose Landmarks](https://ai.google.dev/edge/m
466
509
 
467
510
  For best results, the camera should see the exerciser from a **side profile**:
468
511
 
469
- | ✅ Good | ❌ Bad |
470
- | ---------------------------------- | ------------------------ |
471
- | Side view, full body visible | Front-facing view |
472
- | Phone at waist height, 6-8 ft away | Ground-level angle |
473
- | Well-lit environment | Heavy glare or backlight |
512
+ | ✅ Good | ❌ Bad |
513
+ | --- | --- |
514
+ | Side view, full body visible | Front-facing view |
515
+ | Phone at waist height, 6-8 ft away | Ground-level angle |
516
+ | Well-lit environment | Heavy glare or backlight |
474
517
 
475
518
  ---
476
519
 
477
520
  ## 🧩 Supported Platforms
478
521
 
479
- | Platform | Status | Notes |
480
- | -------------------- | ---------------- | --------------------------------- |
481
- | **iOS** | ✅ Supported | Requires physical device, iOS 14+ |
482
- | **Android** | ✅ Supported | Min SDK 24 (Android 7.0) |
483
- | **iOS Simulator** | ❌ Not supported | No camera access |
484
- | **Android Emulator** | ❌ Not supported | No real camera feed |
522
+ | Platform | Status | Notes |
523
+ | --- | --- | --- |
524
+ | **iOS** | ✅ Supported | Requires physical device, iOS 14+ (Vision body pose) |
525
+ | **Android** | ✅ Supported | API 23+ (ML Kit), Google Play Services required |
526
+ | **iOS Simulator** | ❌ Not supported | No camera access |
527
+ | **Android Emulator** | ❌ Not supported | No real camera feed |
485
528
 
486
529
  ---
487
530
 
488
531
  ## 📊 App Size Impact
489
532
 
490
- | Component | Size |
491
- | ---------------------------- | ------------- |
492
- | Pose model (Lite) | ~3 MB |
493
- | MediaPipe SDK (per platform) | ~8–12 MB |
494
- | Nitro module code | ~200 KB |
495
- | **Total new addition** | **~11–15 MB** |
533
+ | Component | Size |
534
+ | --- | --- |
535
+ | Nitro module code (Swift + Kotlin) | ~200 KB |
536
+ | ML Kit (Android, via Play Services) | ~0 KB (managed externally) |
537
+ | Vision framework (iOS, built-in) | ~0 KB (system framework) |
538
+ | **Total new addition** | **~200 KB** |
539
+
540
+ Compared to the MediaPipe approach (~11-15 MB), the OS-native approach adds virtually zero app size.
496
541
 
497
542
  ---
498
543
 
@@ -500,9 +545,9 @@ For best results, the camera should see the exerciser from a **side profile**:
500
545
 
501
546
  PRs welcome!
502
547
 
503
- - [Development Workflow](CONTRIBUTING.md#development-workflow)
504
- - [Sending a PR](CONTRIBUTING.md#sending-a-pull-request)
505
- - [Code of Conduct](CODE_OF_CONDUCT.md)
548
+ * [Development Workflow](CONTRIBUTING.md#development-workflow)
549
+ * [Sending a PR](CONTRIBUTING.md#sending-a-pull-request)
550
+ * [Code of Conduct](CODE_OF_CONDUCT.md)
506
551
 
507
552
  ---
508
553
 
@@ -512,4 +557,4 @@ MIT © [**Gautham Vijayan**](https://gauthamvijay.com)
512
557
 
513
558
  ---
514
559
 
515
- Made with ❤️ and [**Nitro Modules**](https://nitro.margelo.com) + [**VisionCamera**](https://visioncamera.margelo.com) + [**MediaPipe**](https://ai.google.dev/edge/mediapipe)
560
+ Made with ❤️ and [**Nitro Modules**](https://nitro.margelo.com) + [**VisionCamera**](https://visioncamera.margelo.com) + [**Apple Vision**](https://developer.apple.com/documentation/vision) + [**ML Kit**](https://developers.google.com/ml-kit/vision/pose-detection)
@@ -120,7 +120,7 @@ repositories {
120
120
  dependencies {
121
121
  implementation "com.facebook.react:react-android"
122
122
  implementation project(":react-native-nitro-modules")
123
- implementation 'com.google.mediapipe:tasks-vision:0.10.29'
123
+ implementation 'com.google.mlkit:pose-detection:18.0.0-beta5'
124
124
  implementation project(":react-native-vision-camera")
125
125
  }
126
126
 
@@ -1,17 +1,18 @@
1
1
  package com.margelo.nitro.nitroposeexercises
2
2
 
3
3
  import android.graphics.Bitmap
4
+ import android.graphics.Matrix
5
+ import android.media.Image
4
6
  import androidx.annotation.Keep
5
7
  import com.facebook.proguard.annotations.DoNotStrip
6
- import com.google.mediapipe.framework.image.BitmapImageBuilder
7
- import com.google.mediapipe.tasks.core.BaseOptions
8
- import com.google.mediapipe.tasks.vision.core.RunningMode
9
- import com.google.mediapipe.tasks.vision.poselandmarker.PoseLandmarker
10
- import com.google.mediapipe.tasks.vision.poselandmarker.PoseLandmarker.PoseLandmarkerOptions
8
+ import com.google.mlkit.vision.common.InputImage
9
+ import com.google.mlkit.vision.pose.PoseDetection
10
+ import com.google.mlkit.vision.pose.PoseDetector
11
+ import com.google.mlkit.vision.pose.PoseLandmark
12
+ import com.google.mlkit.vision.pose.defaults.PoseDetectorOptions
11
13
  import com.margelo.nitro.NitroModules
12
14
  import com.margelo.nitro.core.Promise
13
15
  import com.margelo.nitro.camera.HybridFrameSpec
14
- import com.margelo.nitro.camera.public.NativeFrame
15
16
  import kotlin.math.acos
16
17
  import kotlin.math.max
17
18
  import kotlin.math.min
@@ -19,12 +20,54 @@ import kotlin.math.sqrt
19
20
 
20
21
  @Keep
21
22
  @DoNotStrip
22
- class NitroPoseExercises : HybridNitroPoseExercisesSpec() {
23
+ class HybridPoseExercise : HybridNitroPoseExercisesSpec() {
23
24
 
24
- // ─── MediaPipe ──────────────────────────────────────────────
25
- private var poseLandmarker: PoseLandmarker? = null
25
+ // ─── ML Kit ─────────────────────────────────────────────────
26
+ private var poseDetector: PoseDetector? = null
26
27
  private var isInitialized = false
27
28
 
29
+ // ─── Cached Landmarks (ML Kit is async, we cache last result) ──
30
+ private var cachedLandmarks: Array<Landmark> = emptyArray()
31
+ private val landmarkLock = Any()
32
+
33
+ // ─── Landmark Index Mapping ─────────────────────────────────
34
+ // ML Kit PoseLandmark type → MediaPipe index that JS configs expect
35
+ private val mlKitToMediaPipeMap = mapOf(
36
+ PoseLandmark.NOSE to 0,
37
+ PoseLandmark.LEFT_EYE_INNER to 1,
38
+ PoseLandmark.LEFT_EYE to 2,
39
+ PoseLandmark.LEFT_EYE_OUTER to 3,
40
+ PoseLandmark.RIGHT_EYE_INNER to 4,
41
+ PoseLandmark.RIGHT_EYE to 5,
42
+ PoseLandmark.RIGHT_EYE_OUTER to 6,
43
+ PoseLandmark.LEFT_EAR to 7,
44
+ PoseLandmark.RIGHT_EAR to 8,
45
+ PoseLandmark.LEFT_MOUTH to 9,
46
+ PoseLandmark.RIGHT_MOUTH to 10,
47
+ PoseLandmark.LEFT_SHOULDER to 11,
48
+ PoseLandmark.RIGHT_SHOULDER to 12,
49
+ PoseLandmark.LEFT_ELBOW to 13,
50
+ PoseLandmark.RIGHT_ELBOW to 14,
51
+ PoseLandmark.LEFT_WRIST to 15,
52
+ PoseLandmark.RIGHT_WRIST to 16,
53
+ PoseLandmark.LEFT_PINKY to 17,
54
+ PoseLandmark.RIGHT_PINKY to 18,
55
+ PoseLandmark.LEFT_INDEX to 19,
56
+ PoseLandmark.RIGHT_INDEX to 20,
57
+ PoseLandmark.LEFT_THUMB to 21,
58
+ PoseLandmark.RIGHT_THUMB to 22,
59
+ PoseLandmark.LEFT_HIP to 23,
60
+ PoseLandmark.RIGHT_HIP to 24,
61
+ PoseLandmark.LEFT_KNEE to 25,
62
+ PoseLandmark.RIGHT_KNEE to 26,
63
+ PoseLandmark.LEFT_ANKLE to 27,
64
+ PoseLandmark.RIGHT_ANKLE to 28,
65
+ PoseLandmark.LEFT_HEEL to 29,
66
+ PoseLandmark.RIGHT_HEEL to 30,
67
+ PoseLandmark.LEFT_FOOT_INDEX to 31,
68
+ PoseLandmark.RIGHT_FOOT_INDEX to 32,
69
+ )
70
+
28
71
  // ─── Exercise Config ────────────────────────────────────────
29
72
  private var exerciseConfig: ExerciseConfig? = null
30
73
 
@@ -59,7 +102,7 @@ class NitroPoseExercises : HybridNitroPoseExercisesSpec() {
59
102
  // ─── Pose Tracking ──────────────────────────────────────────
60
103
  private var poseWasLost = false
61
104
 
62
- // ─── Frame Skip ─────────────────────────────────────────────
105
+ // ─── Frame Throttle ─────────────────────────────────────────
63
106
  private var frameCount: Int = 0
64
107
  private val processEveryNFrames: Int = 3
65
108
 
@@ -80,31 +123,21 @@ class NitroPoseExercises : HybridNitroPoseExercisesSpec() {
80
123
  // ═══════════════════════════════════════════════════════════
81
124
 
82
125
  override fun initialize(modelPath: String): Promise<Unit> {
126
+ // No model file needed — ML Kit downloads/bundles its own model
83
127
  return Promise.async {
84
- val context = NitroModules.applicationContext
85
- ?: throw Error("No ApplicationContext set!")
86
-
87
- val baseOptions = BaseOptions.builder()
88
- .setModelAssetPath(modelPath)
128
+ val options = PoseDetectorOptions.Builder()
129
+ .setDetectorMode(PoseDetectorOptions.STREAM_MODE)
89
130
  .build()
90
131
 
91
- val options = PoseLandmarkerOptions.builder()
92
- .setBaseOptions(baseOptions)
93
- .setRunningMode(RunningMode.IMAGE)
94
- .setNumPoses(1)
95
- .setMinPoseDetectionConfidence(0.5f)
96
- .setMinPosePresenceConfidence(0.5f)
97
- .setMinTrackingConfidence(0.5f)
98
- .build()
99
-
100
- poseLandmarker = PoseLandmarker.createFromOptions(context, options)
132
+ poseDetector = PoseDetection.getClient(options)
101
133
  isInitialized = true
134
+ println("[PoseExercise] Initialized with ML Kit Pose Detection (no model file needed)")
102
135
  }
103
136
  }
104
137
 
105
138
  override fun release() {
106
- poseLandmarker?.close()
107
- poseLandmarker = null
139
+ poseDetector?.close()
140
+ poseDetector = null
108
141
  isInitialized = false
109
142
  _status = SessionStatus.IDLE
110
143
  resetSession()
@@ -154,61 +187,98 @@ class NitroPoseExercises : HybridNitroPoseExercisesSpec() {
154
187
  }
155
188
 
156
189
  // ═══════════════════════════════════════════════════════════
157
- // Frame Processing
190
+ // Frame Processing (ML Kit — async with cached results)
158
191
  // ═══════════════════════════════════════════════════════════
159
192
 
160
- override fun processFrame(frame: HybridFrameSpec) {
193
+ override fun processFrame(frame: HybridFrameSpec) {
161
194
  if (_status != SessionStatus.ACTIVE && _status != SessionStatus.COUNTDOWN) return
162
- if (!isInitialized || poseLandmarker == null) return
195
+ if (!isInitialized || poseDetector == null) return
163
196
 
197
+ // Frame throttle
164
198
  frameCount++
165
199
  if (frameCount % processEveryNFrames != 0) return
166
200
 
167
201
  try {
168
- // Get the native buffer from VisionCamera frame
202
+ // Get raw buffer from VisionCamera frame
169
203
  val nativeBuffer = frame.getNativeBuffer()
170
- val w = frame.width.toInt()
171
- val h = frame.height.toInt()
172
-
173
- // On Android, the pointer is an AHardwareBuffer*
174
- // Create a Bitmap and use MediaPipe's BitmapImageBuilder
175
- val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
176
- val mpImage = BitmapImageBuilder(bitmap).build()
177
- val result = poseLandmarker!!.detect(mpImage)
178
-
179
- if (result.landmarks().isNotEmpty()) {
180
- val poseLandmarks = result.landmarks()[0]
181
-
182
- if (poseWasLost) {
183
- poseWasLost = false
184
- onPoseRegained?.invoke()
204
+ val width = frame.getWidth()
205
+ val height = frame.getHeight()
206
+ val bytesPerRow = frame.getBytesPerRow()
207
+
208
+ // Create Bitmap from the native buffer
209
+ val buffer = java.nio.ByteBuffer.allocateDirect(height * bytesPerRow)
210
+ val pointer = nativeBuffer.pointer
211
+ // Copy from native pointer to ByteBuffer
212
+ NativeBufferHelper.copyToByteBuffer(pointer, buffer, height * bytesPerRow)
213
+
214
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
215
+ buffer.rewind()
216
+ bitmap.copyPixelsFromBuffer(buffer)
217
+
218
+ val inputImage = InputImage.fromBitmap(bitmap, 0)
219
+
220
+ // ML Kit is async — fire detection, cache result
221
+ poseDetector!!.process(inputImage)
222
+ .addOnSuccessListener { pose ->
223
+ val poseLandmarks = pose.allPoseLandmarks
224
+
225
+ if (poseLandmarks.isNotEmpty()) {
226
+ if (poseWasLost) {
227
+ poseWasLost = false
228
+ onPoseRegained?.invoke()
229
+ }
230
+
231
+ val landmarkArray = Array(34) { Landmark(x = 0.0, y = 0.0, z = 0.0, visibility = 0.0) }
232
+
233
+ val imageWidth = width.toDouble()
234
+ val imageHeight = height.toDouble()
235
+
236
+ for (poseLandmark in poseLandmarks) {
237
+ val mediaPipeIndex = mlKitToMediaPipeMap[poseLandmark.landmarkType] ?: continue
238
+ if (mediaPipeIndex >= 34) continue
239
+
240
+ landmarkArray[mediaPipeIndex] = Landmark(
241
+ x = poseLandmark.position.x.toDouble() / imageWidth,
242
+ y = poseLandmark.position.y.toDouble() / imageHeight,
243
+ z = poseLandmark.position3D.z.toDouble(),
244
+ visibility = poseLandmark.inFrameLikelihood.toDouble()
245
+ )
246
+ }
247
+
248
+ synchronized(landmarkLock) {
249
+ cachedLandmarks = landmarkArray
250
+ }
251
+ } else {
252
+ if (!poseWasLost) {
253
+ poseWasLost = true
254
+ onPoseLost?.invoke()
255
+ }
256
+ synchronized(landmarkLock) {
257
+ cachedLandmarks = emptyArray()
258
+ }
259
+ }
260
+
261
+ bitmap.recycle()
185
262
  }
186
-
187
- _landmarks = poseLandmarks.map { lm ->
188
- Landmark(
189
- x = lm.x().toDouble(),
190
- y = lm.y().toDouble(),
191
- z = lm.z().toDouble(),
192
- visibility = (lm.visibility().orElse(0f)).toDouble()
193
- )
194
- }.toTypedArray()
195
-
196
- if (_status == SessionStatus.ACTIVE) {
197
- processExerciseLogic()
263
+ .addOnFailureListener { e ->
264
+ println("[PoseExercise] ML Kit error: ${e.message}")
265
+ bitmap.recycle()
198
266
  }
199
- } else {
200
- if (!poseWasLost) {
201
- poseWasLost = true
202
- onPoseLost?.invoke()
203
- }
204
- _landmarks = emptyArray()
267
+
268
+ // Use cached landmarks for exercise logic
269
+ val currentLandmarks: Array<Landmark>
270
+ synchronized(landmarkLock) {
271
+ currentLandmarks = cachedLandmarks.copyOf()
205
272
  }
206
273
 
207
- bitmap.recycle()
208
- nativeBuffer.release()
274
+ _landmarks = currentLandmarks
275
+
276
+ if (currentLandmarks.isNotEmpty() && _status == SessionStatus.ACTIVE) {
277
+ processExerciseLogic()
278
+ }
209
279
 
210
280
  } catch (e: Exception) {
211
- // MediaPipe detection failed — skip this frame
281
+ println("[PoseExercise] Frame processing error: ${e.message}")
212
282
  }
213
283
  }
214
284
 
@@ -230,6 +300,9 @@ class NitroPoseExercises : HybridNitroPoseExercisesSpec() {
230
300
 
231
301
  if (a >= _landmarks.size || b >= _landmarks.size || c >= _landmarks.size) continue
232
302
 
303
+ // Only calculate if all three landmarks have reasonable confidence
304
+ if (_landmarks[a].visibility < 0.3 || _landmarks[b].visibility < 0.3 || _landmarks[c].visibility < 0.3) continue
305
+
233
306
  val angle = calculateAngle(_landmarks[a], _landmarks[b], _landmarks[c])
234
307
  currentAngles[angleDef.name] = angle
235
308
  angleSnapshots.add(AngleSnapshot(name = angleDef.name, value = angle))
@@ -365,6 +438,7 @@ class NitroPoseExercises : HybridNitroPoseExercisesSpec() {
365
438
 
366
439
  for (rule in config.formRules) {
367
440
  val angle = currentAngles[rule.angleName] ?: continue
441
+
368
442
  val isViolating = angle < rule.minAngle || angle > rule.maxAngle
369
443
 
370
444
  if (isViolating) {
@@ -407,18 +481,24 @@ class NitroPoseExercises : HybridNitroPoseExercisesSpec() {
407
481
  }
408
482
 
409
483
  if (inPosition) {
410
- if (holdStartTime == null) holdStartTime = System.currentTimeMillis()
484
+ if (holdStartTime == null) {
485
+ holdStartTime = System.currentTimeMillis()
486
+ }
411
487
 
412
488
  val elapsed = (System.currentTimeMillis() - holdStartTime!!).toDouble()
413
489
  val stability = min(100.0, max(0.0, repFormScore))
414
490
 
415
- onHoldProgress?.invoke(HoldProgress(
491
+ val progress = HoldProgress(
416
492
  elapsedMs = elapsed,
417
493
  targetMs = config.holdDurationMs,
418
494
  stability = stability
419
- ))
495
+ )
420
496
 
421
- if (elapsed >= config.holdDurationMs) completeSession()
497
+ onHoldProgress?.invoke(progress)
498
+
499
+ if (elapsed >= config.holdDurationMs) {
500
+ completeSession()
501
+ }
422
502
  } else {
423
503
  holdStartTime = null
424
504
  }
@@ -435,14 +515,16 @@ class NitroPoseExercises : HybridNitroPoseExercisesSpec() {
435
515
  val avgRepDuration = if (allRepDurations.isEmpty()) 0.0 else allRepDurations.average()
436
516
  val avgFormScore = if (allRepFormScores.isEmpty()) 100.0 else allRepFormScores.average()
437
517
 
438
- onSessionComplete?.invoke(SessionResult(
518
+ val result = SessionResult(
439
519
  totalReps = _repCount,
440
520
  totalDurationMs = totalDuration,
441
521
  averageRepDurationMs = avgRepDuration,
442
522
  averageFormScore = avgFormScore,
443
523
  formViolations = sessionFormViolations.toTypedArray(),
444
524
  angleHistory = repAngleSnapshots
445
- ))
525
+ )
526
+
527
+ onSessionComplete?.invoke(result)
446
528
  }
447
529
 
448
530
  // ═══════════════════════════════════════════════════════════
@@ -482,5 +564,9 @@ class NitroPoseExercises : HybridNitroPoseExercisesSpec() {
482
564
  poseWasLost = false
483
565
  targetReps = 0.0
484
566
  countdownSeconds = 0.0
567
+ frameCount = 0
568
+ synchronized(landmarkLock) {
569
+ cachedLandmarks = emptyArray()
570
+ }
485
571
  }
486
572
  }
@@ -1,15 +1,43 @@
1
+ // ios/HybridPoseExercise.swift
2
+
1
3
  import Foundation
2
4
  import NitroModules
3
- import MediaPipeTasksVision
4
5
  import VisionCamera
5
6
  import AVFoundation
7
+ import Vision
6
8
 
7
- class NitroPoseExercises: HybridNitroPoseExercisesSpec {
9
+ class HybridPoseExercise: HybridNitroPoseExercisesSpec {
8
10
 
9
- // ─── MediaPipe ──────────────────────────────────────────────
10
- private var poseLandmarker: PoseLandmarker?
11
+ // ─── Vision Framework ───────────────────────────────────────
11
12
  private var isInitialized = false
12
13
 
14
+ // ─── Landmark Index Mapping ─────────────────────────────────
15
+ // Maps Apple Vision joint names to MediaPipe landmark indices
16
+ // that our JS configs expect
17
+ private static let visionToMediaPipeMap: [(VNHumanBodyPoseObservation.JointName, Int)] = [
18
+ (.nose, 0),
19
+ (.leftShoulder, 11),
20
+ (.rightShoulder, 12),
21
+ (.leftElbow, 13),
22
+ (.rightElbow, 14),
23
+ (.leftWrist, 15),
24
+ (.rightWrist, 16),
25
+ (.leftHip, 23),
26
+ (.rightHip, 24),
27
+ (.leftKnee, 25),
28
+ (.rightKnee, 26),
29
+ (.leftAnkle, 27),
30
+ (.rightAnkle, 28),
31
+ // Vision also provides these but they map to non-standard indices
32
+ // We include them for skeleton drawing
33
+ (.neck, 10), // approximate — MediaPipe doesn't have neck
34
+ (.root, 33), // hip center — not in MediaPipe, we skip
35
+ (.leftEar, 7),
36
+ (.rightEar, 8),
37
+ (.leftEye, 2),
38
+ (.rightEye, 5),
39
+ ]
40
+
13
41
  // ─── Exercise Config ────────────────────────────────────────
14
42
  private var exerciseConfig: ExerciseConfig?
15
43
 
@@ -34,9 +62,6 @@ class NitroPoseExercises: HybridNitroPoseExercisesSpec {
34
62
  private var countdownSeconds: Double = 0
35
63
  private var countdownTimer: Timer?
36
64
 
37
- private var frameCount: Int = 0
38
- private let processEveryNFrames: Int = 3 // Only process every 3rd frame
39
-
40
65
  // ─── Form Tracking ──────────────────────────────────────────
41
66
  private var lastFormFeedbackTime: [String: Date] = [:]
42
67
  private var sessionFormViolations: [FormFeedback] = []
@@ -48,6 +73,10 @@ class NitroPoseExercises: HybridNitroPoseExercisesSpec {
48
73
  // ─── Pose Tracking ──────────────────────────────────────────
49
74
  private var poseWasLost = false
50
75
 
76
+ // ─── Frame Throttle ─────────────────────────────────────────
77
+ private var frameCount: Int = 0
78
+ private let processEveryNFrames: Int = 3
79
+
51
80
  // ─── Callbacks ──────────────────────────────────────────────
52
81
  var onRepComplete: ((_ data: RepData) -> Void)?
53
82
  var onPhaseChange: ((_ phase: ExercisePhase) -> Void)?
@@ -65,28 +94,15 @@ class NitroPoseExercises: HybridNitroPoseExercisesSpec {
65
94
  // ═══════════════════════════════════════════════════════════
66
95
 
67
96
  func initialize(modelPath: String) throws -> Promise<Void> {
68
- print("[PoseExercise] initialize called with path: \(modelPath)")
69
-
97
+ // No model loading needed — Vision framework is built into iOS
70
98
  return Promise.async { [weak self] in
71
99
  guard let self = self else { return }
72
-
73
- let options = PoseLandmarkerOptions()
74
- options.baseOptions.modelAssetPath = modelPath
75
- options.runningMode = .image
76
- options.numPoses = 1
77
- options.minPoseDetectionConfidence = 0.5
78
- options.minPosePresenceConfidence = 0.5
79
- options.minTrackingConfidence = 0.5
80
-
81
- self.poseLandmarker = try PoseLandmarker(options: options)
82
100
  self.isInitialized = true
83
-
84
- print("[PoseExercise] MediaPipe initialized successfully")
101
+ print("[PoseExercise] Initialized with Apple Vision (no model file needed)")
85
102
  }
86
103
  }
87
104
 
88
105
  func release() throws {
89
- poseLandmarker = nil
90
106
  isInitialized = false
91
107
  _status = .idle
92
108
  resetSession()
@@ -106,8 +122,6 @@ class NitroPoseExercises: HybridNitroPoseExercisesSpec {
106
122
  // ═══════════════════════════════════════════════════════════
107
123
 
108
124
  func startSession(targetReps: Double, countdownSeconds: Double) throws {
109
- print("[PoseExercise] startSession called - target: \(targetReps), countdown: \(countdownSeconds)")
110
-
111
125
  resetSession()
112
126
  self.targetReps = targetReps
113
127
  self.countdownSeconds = countdownSeconds
@@ -138,75 +152,90 @@ class NitroPoseExercises: HybridNitroPoseExercisesSpec {
138
152
  }
139
153
 
140
154
  // ═══════════════════════════════════════════════════════════
141
- // MARK: - Frame Processing
155
+ // MARK: - Frame Processing (Apple Vision)
142
156
  // ═══════════════════════════════════════════════════════════
143
157
 
144
- func processFrame(frame: any HybridFrameSpec) throws {
145
- print("[PoseExercise] processFrame called, status: \(_status)")
158
+ func processFrame(frame: any HybridFrameSpec) throws {
159
+ guard _status == .active || _status == .countdown else { return }
160
+ guard isInitialized else { return }
146
161
 
147
- frameCount += 1
148
- if frameCount % processEveryNFrames != 0 { return }
162
+ // Frame throttle
163
+ frameCount += 1
164
+ if frameCount % processEveryNFrames != 0 { return }
149
165
 
150
- guard _status == .active || _status == .countdown else {
151
- print("[PoseExercise] Skipping - status is \(_status)")
152
- return
153
- }
154
- guard isInitialized, let landmarker = poseLandmarker else {
155
- print("[PoseExercise] Skipping - not initialized: \(isInitialized)")
156
- return
157
- }
166
+ // Get CMSampleBuffer from VisionCamera frame
167
+ guard let nativeFrame = frame as? any NativeFrame,
168
+ let sampleBuffer = nativeFrame.sampleBuffer else { return }
158
169
 
159
- guard let nativeFrame = frame as? any NativeFrame else {
160
- print("[PoseExercise] Failed to cast to NativeFrame")
161
- return
162
- }
163
- guard let sampleBuffer = nativeFrame.sampleBuffer else {
164
- print("[PoseExercise] sampleBuffer is nil")
165
- return
166
- }
170
+ // Get pixel buffer Vision takes CVPixelBuffer directly, no color conversion needed
171
+ guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
167
172
 
168
- print("[PoseExercise] Got sampleBuffer, running detection...")
173
+ // Create Vision request
174
+ let request = VNDetectHumanBodyPoseRequest()
169
175
 
170
- do {
171
- let mpImage = try MPImage(sampleBuffer: sampleBuffer)
172
- let result = try landmarker.detect(image: mpImage)
176
+ // Run synchronously on this frame processor thread
177
+ let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, options: [:])
173
178
 
174
- print("[PoseExercise] Detection done, landmarks count: \(result.landmarks.count)")
179
+ do {
180
+ try handler.perform([request])
181
+
182
+ guard let observation = request.results?.first else {
183
+ // No pose detected
184
+ if !poseWasLost {
185
+ poseWasLost = true
186
+ onPoseLost?()
187
+ }
188
+ _landmarks = []
189
+ return
190
+ }
175
191
 
176
- if let poseLandmarks = result.landmarks.first {
192
+ // Pose detected
177
193
  if poseWasLost {
178
194
  poseWasLost = false
179
195
  onPoseRegained?()
180
196
  }
181
197
 
182
- _landmarks = poseLandmarks.map { lm in
183
- Landmark(
184
- x: Double(lm.x),
185
- y: Double(lm.y),
186
- z: Double(lm.z),
187
- visibility: Double(lm.visibility ?? 0)
188
- )
198
+ // Map Vision joints to MediaPipe landmark array (34 slots, indices 0-33)
199
+ // Fill all slots with zero-visibility first
200
+ var landmarkArray = [Landmark](repeating: Landmark(x: 0, y: 0, z: 0, visibility: 0), count: 34)
201
+
202
+ for (jointName, mediaPipeIndex) in HybridPoseExercise.visionToMediaPipeMap {
203
+ guard mediaPipeIndex < 34 else { continue }
204
+
205
+ do {
206
+ let point = try observation.recognizedPoint(jointName)
207
+
208
+ // Vision uses bottom-left origin (0,0 = bottom-left)
209
+ // MediaPipe uses top-left origin (0,0 = top-left)
210
+ // Flip Y axis
211
+ let confidence = Double(point.confidence)
212
+
213
+ landmarkArray[mediaPipeIndex] = Landmark(
214
+ x: Double(point.location.x),
215
+ y: 1.0 - Double(point.location.y), // flip Y
216
+ z: 0, // Vision doesn't provide Z depth
217
+ visibility: confidence
218
+ )
219
+
220
+ // Debug logging — uncomment to verify mapping
221
+ // print("[PoseExercise] \(jointName.rawValue.rawValue) → index \(mediaPipeIndex): x=\(String(format: "%.3f", point.location.x)) y=\(String(format: "%.3f", 1.0 - Double(point.location.y))) conf=\(String(format: "%.2f", confidence))")
222
+
223
+ } catch {
224
+ // Joint not detected — leave as zero visibility
225
+ continue
226
+ }
189
227
  }
190
228
 
191
- print("[PoseExercise] Landmarks detected: \(_landmarks.count)")
229
+ _landmarks = landmarkArray
192
230
 
193
231
  if _status == .active {
194
232
  processExerciseLogic()
195
233
  }
196
234
 
197
- } else {
198
- print("[PoseExercise] No pose detected in frame")
199
- if !poseWasLost {
200
- poseWasLost = true
201
- onPoseLost?()
202
- }
203
- _landmarks = []
235
+ } catch {
236
+ print("[PoseExercise] Vision error: \(error.localizedDescription)")
204
237
  }
205
-
206
- } catch {
207
- print("[PoseExercise] MediaPipe error: \(error.localizedDescription)")
208
238
  }
209
- }
210
239
 
211
240
  // ═══════════════════════════════════════════════════════════
212
241
  // MARK: - Exercise Logic Engine
@@ -216,7 +245,6 @@ func processFrame(frame: any HybridFrameSpec) throws {
216
245
  guard let config = exerciseConfig else { return }
217
246
  guard !_landmarks.isEmpty else { return }
218
247
 
219
- // 1. Calculate all angles defined in the config
220
248
  var currentAngles: [String: Double] = [:]
221
249
  var angleSnapshots: [AngleSnapshot] = []
222
250
 
@@ -227,6 +255,11 @@ func processFrame(frame: any HybridFrameSpec) throws {
227
255
 
228
256
  guard a < _landmarks.count, b < _landmarks.count, c < _landmarks.count else { continue }
229
257
 
258
+ // Only calculate if all three landmarks have reasonable confidence
259
+ guard _landmarks[a].visibility > 0.3,
260
+ _landmarks[b].visibility > 0.3,
261
+ _landmarks[c].visibility > 0.3 else { continue }
262
+
230
263
  let angle = calculateAngle(
231
264
  pointA: _landmarks[a],
232
265
  vertex: _landmarks[b],
@@ -239,31 +272,25 @@ func processFrame(frame: any HybridFrameSpec) throws {
239
272
 
240
273
  repAngleSnapshots = angleSnapshots
241
274
 
242
- // 2. Determine current phase from angle thresholds
275
+ // Debug logging uncomment to see angles
276
+ // for (name, angle) in currentAngles {
277
+ // print("[PoseExercise] Angle \(name): \(String(format: "%.1f", angle))°")
278
+ // }
279
+
243
280
  let detectedPhase = determinePhase(from: currentAngles, config: config)
244
281
 
245
282
  if detectedPhase != _currentPhase && detectedPhase != .unknown {
246
283
  let previousPhase = _currentPhase
247
284
  _currentPhase = detectedPhase
248
285
  onPhaseChange?(detectedPhase)
249
-
250
- // 3. Update phase history for rep counting
251
286
  handlePhaseTransition(from: previousPhase, to: detectedPhase, config: config)
252
287
  }
253
288
 
254
- // 4. Check form rules
255
289
  checkFormRules(currentAngles: currentAngles, config: config)
256
290
 
257
- // 5. Handle hold-based exercises
258
291
  if config.type == .hold {
259
292
  handleHoldProgress(currentAngles: currentAngles, config: config)
260
293
  }
261
-
262
- // Temporary debug logging — remove after testing
263
- for (name, angle) in currentAngles {
264
- print("[PoseExercise] Angle \(name): \(String(format: "%.1f", angle))°")
265
- }
266
- print("[PoseExercise] Detected phase: \(detectedPhase), Current phase: \(_currentPhase)")
267
294
  }
268
295
 
269
296
  // ═══════════════════════════════════════════════════════════
@@ -284,9 +311,7 @@ print("[PoseExercise] Detected phase: \(detectedPhase), Current phase: \(_curren
284
311
 
285
312
  let cosAngle = max(-1.0, min(1.0, dot / (magA * magC)))
286
313
  let angleRad = acos(cosAngle)
287
- let angleDeg = angleRad * (180.0 / .pi)
288
-
289
- return angleDeg
314
+ return angleRad * (180.0 / .pi)
290
315
  }
291
316
 
292
317
  // ═══════════════════════════════════════════════════════════
@@ -296,7 +321,6 @@ print("[PoseExercise] Detected phase: \(detectedPhase), Current phase: \(_curren
296
321
  private func determinePhase(from angles: [String: Double], config: ExerciseConfig) -> ExercisePhase {
297
322
  for phaseThreshold in config.phases {
298
323
  guard let angle = angles[phaseThreshold.angleName] else { continue }
299
-
300
324
  if angle >= phaseThreshold.minAngle && angle <= phaseThreshold.maxAngle {
301
325
  return phaseThreshold.phase
302
326
  }
@@ -308,7 +332,7 @@ print("[PoseExercise] Detected phase: \(detectedPhase), Current phase: \(_curren
308
332
  // MARK: - Rep Counting State Machine
309
333
  // ═══════════════════════════════════════════════════════════
310
334
 
311
- private func handlePhaseTransition(from previousPhase: ExercisePhase, to newPhase: ExercisePhase, config: ExerciseConfig) {
335
+ private func handlePhaseTransition(from previousPhase: ExercisePhase, to newPhase: ExercisePhase, config: ExerciseConfig) {
312
336
  guard config.type == .rep else { return }
313
337
 
314
338
  phaseHistory.append(newPhase)
@@ -523,5 +547,6 @@ private func handlePhaseTransition(from previousPhase: ExercisePhase, to newPhas
523
547
  countdownSeconds = 0
524
548
  countdownTimer?.invalidate()
525
549
  countdownTimer = nil
550
+ frameCount = 0
526
551
  }
527
- }
552
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "react-native-nitro-pose-exercises",
3
- "version": "1.0.9",
4
- "description": "Real-time on-device exercise tracking for React Native. Rep counting, form validation, and skeleton overlay powered by MediaPipe Pose Landmarker and VisionCamera v5 via Nitro Modules.",
3
+ "version": "1.1.2",
4
+ "description": "Real-time on-device exercise tracking for React Native. Rep counting, form validation, and skeleton overlay powered by Apple Vision (iOS) and Google ML Kit (Android) with VisionCamera v5 via Nitro Modules.",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
7
7
  "exports": {
@@ -69,9 +69,9 @@
69
69
  "@eslint/eslintrc": "^3.3.1",
70
70
  "@eslint/js": "^9.35.0",
71
71
  "@jest/globals": "^30.0.0",
72
- "@react-native/babel-preset": "0.85.0",
73
- "@react-native/eslint-config": "0.85.0",
74
- "@react-native/jest-preset": "0.85.0",
72
+ "@react-native/babel-preset": "0.85.3",
73
+ "@react-native/eslint-config": "0.85.3",
74
+ "@react-native/jest-preset": "0.85.3",
75
75
  "@release-it/conventional-changelog": "^10.0.6",
76
76
  "@types/react": "^19.2.0",
77
77
  "commitlint": "^20.5.0",
@@ -82,16 +82,16 @@
82
82
  "eslint-plugin-prettier": "^5.5.4",
83
83
  "jest": "^30.3.0",
84
84
  "lefthook": "^2.1.4",
85
- "nitrogen": "^0.35.7",
85
+ "nitrogen": "^0.35.9",
86
86
  "prettier": "^3.8.1",
87
87
  "react": "19.2.3",
88
- "react-native": "0.85.0",
88
+ "react-native": "0.85.3",
89
89
  "react-native-builder-bob": "^0.41.0",
90
- "react-native-nitro-modules": "^0.35.7",
90
+ "react-native-nitro-modules": "^0.35.9",
91
91
  "react-native-svg": "^15.15.5",
92
- "react-native-vision-camera": "^5.0.10",
93
- "react-native-vision-camera-worklets": "^5.0.10",
94
- "react-native-worklets": "^0.8.3",
92
+ "react-native-vision-camera": "^5.0.11",
93
+ "react-native-vision-camera-worklets": "^5.0.11",
94
+ "react-native-worklets": "^0.9.1",
95
95
  "release-it": "^19.2.4",
96
96
  "turbo": "^2.8.21",
97
97
  "typescript": "^6.0.2"