@pose-tracker/react-native-pose-estimation-light 0.3.4 → 0.3.6

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.
package/README.md CHANGED
@@ -36,10 +36,10 @@ package stays tiny.
36
36
  download at session start.
37
37
  **Choose light** when app install / OTA size matters and devices are online.
38
38
 
39
- Full comparison: [LIGHT_SDK.md](../../docs/LIGHT_SDK.md).
39
+ Full comparison: [LIGHT_SDK.md](docs/LIGHT_SDK.md).
40
40
 
41
41
  **Agents:** shared UX/API/bugfixes → mirror to offline (or ask first). See
42
- [`DUAL_SDK_CHANGES.md`](../../docs/DUAL_SDK_CHANGES.md).
42
+ [`DUAL_SDK_CHANGES.md`](docs/DUAL_SDK_CHANGES.md).
43
43
 
44
44
  ## Install
45
45
 
@@ -54,10 +54,10 @@ npx expo install react-native-webview expo-camera
54
54
  > **Offline sibling:** [`@pose-tracker/react-native-pose-estimation`](https://www.npmjs.com/package/@pose-tracker/react-native-pose-estimation)
55
55
 
56
56
  **Required:** host app must declare camera permissions — see
57
- [PERMISSIONS.md](../../docs/PERMISSIONS.md).
57
+ [PERMISSIONS.md](docs/PERMISSIONS.md).
58
58
 
59
59
  **Media inputs (v0.2):** camera (default), uploaded video, still image — host
60
- picks the file. See [MEDIA_SOURCES.md](../../docs/MEDIA_SOURCES.md) and
60
+ picks the file. See [MEDIA_SOURCES.md](docs/MEDIA_SOURCES.md) and
61
61
  https://docs.posetracker.com/media-sources.
62
62
 
63
63
  ## Quick start (keypoints — needs network, no API key)
@@ -133,6 +133,50 @@ Same contract as the offline SDK / web tracking URL:
133
133
  </PoseTrackerProvider>
134
134
  ```
135
135
 
136
+ ## External frames (your camera, our data)
137
+
138
+ Full guide, including Vision Camera and expo-camera: https://docs.posetracker.com/external-frames
139
+
140
+ Optional. Use it when your app already owns the camera (VisionCamera, a
141
+ custom pipeline, a recorded file) and you only need the data. PoseTracker
142
+ runs pose estimation and the exercise engine on every frame you push, and
143
+ returns the same events as the camera flow: keypoints, posture/placement,
144
+ counter, form score, progression. **Nothing is drawn**, so you render what you want.
145
+
146
+ ```tsx
147
+ const { warmupExternal, startExercise, processFrame } = usePoseTracker({
148
+ onCounter: (e) => setReps(e.count), // listeners still fire
149
+ });
150
+
151
+ await warmupExternal(); // no camera permission, no getUserMedia
152
+ startExercise('squat');
153
+
154
+ // For each frame from your camera:
155
+ const { dropped, pose, events } = await processFrame({
156
+ base64: jpegBase64, // or uri: 'file:///…' / 'data:image/jpeg;base64,…'
157
+ width: 256,
158
+ height: 192,
159
+ timestampMs: Date.now(),
160
+ mirrored: true, // front camera (default). false for the back camera.
161
+ });
162
+ if (!dropped) {
163
+ // pose.keypoints are normalized (0..1) to the frame you sent
164
+ // events: everything emitted for this frame (counter, posture, …)
165
+ }
166
+ ```
167
+
168
+ - The Provider mounts a hidden 1×1 warmer after `warmupExternal()`. It is not
169
+ needed if a `<WebViewPoseView />` is already on screen. The light package
170
+ still needs network for the model.
171
+ - One frame in flight at a time: an overlapping call resolves at once with
172
+ `{ dropped: true, pose: lastPose, events: [] }`. Await before pushing the next one.
173
+ - Keep the longest side of each frame at about 256 px or less. A 1080p JPEG per
174
+ frame is not real-time over the bridge.
175
+ - The engine is temporal: push frames in order and keep the same session
176
+ across frames.
177
+ - Don't mix the two flows. `processFrame` throws while the SDK camera is open.
178
+ - `processFrame` before `warmupExternal()` throws.
179
+
136
180
  ## Cold-start
137
181
 
138
182
  | Mode | API | Camera permission |
@@ -144,11 +188,11 @@ Same contract as the offline SDK / web tracking URL:
144
188
 
145
189
  | Doc | Topic |
146
190
  |-----|--------|
147
- | [LIGHT_SDK.md](../../docs/LIGHT_SDK.md) | Offline vs light, sizes, model URL |
148
- | [PERMISSIONS.md](../../docs/PERMISSIONS.md) | Camera permission setup (required) |
149
- | [PRELOAD.md](../../docs/PRELOAD.md) | Preload / warm-up / lifecycle |
150
- | [FEATURES.md](../../docs/FEATURES.md) | Plan gating, watermark, loading text |
151
- | [EVENTS.md](../../docs/EVENTS.md) | Typed events + classic `onMessage` |
191
+ | [LIGHT_SDK.md](docs/LIGHT_SDK.md) | Offline vs light, sizes, model URL |
192
+ | [PERMISSIONS.md](docs/PERMISSIONS.md) | Camera permission setup (required) |
193
+ | [PRELOAD.md](docs/PRELOAD.md) | Preload / warm-up / lifecycle |
194
+ | [FEATURES.md](docs/FEATURES.md) | Plan gating, watermark, loading text |
195
+ | [EVENTS.md](docs/EVENTS.md) | Typed events + classic `onMessage` |
152
196
 
153
197
  ## FAQ
154
198
 
@@ -169,6 +213,9 @@ offline MoveNet.
169
213
  **Who sees the watermark?**
170
214
  Keyless and free plans. Hidden for paid plans.
171
215
 
216
+ **How does Claude or Cursor integrate this SDK?**
217
+ Add the connector `https://mcp.posetracker.com/api/mcp`, sign in, and click Allow at `https://app.posetracker.com/oauth/authorize`. The agent writes the snippet on your account. Docs: https://docs.posetracker.com/ai/mcp-connector.
218
+
172
219
  ## License
173
220
 
174
221
  **Proprietary** — Movelytics SAS / PoseTracker. See [`LICENSE`](./LICENSE).
@@ -17,6 +17,7 @@
17
17
  import React from 'react';
18
18
  import { PoseTrackerClient, type PoseTrackerClientOptions, type StartExerciseOptions } from './client';
19
19
  import type { PoseInputFrame } from './backends/PoseBackend';
20
+ import type { ExternalFrame, ExternalFrameResult } from './types/externalFrame';
20
21
  import type { ExerciseConfig, SdkManifest } from './types/manifest';
21
22
  import type { PreloadOptions } from './types/preload';
22
23
  import type { ErrorEvent, PoseTrackerCallbacks, PoseTrackerEventListener, PoseTrackerMode, PoseTrackerStatus } from './types/events';
@@ -72,7 +73,18 @@ export interface PoseTrackerContextValue {
72
73
  startExercise: (exerciseId: string, options?: StartExerciseOptions) => void;
73
74
  stopExercise: () => void;
74
75
  estimatePose: (frame: PoseInputFrame) => Promise<Pose | null>;
75
- processFrame: (frame: PoseInputFrame) => Promise<Pose | null>;
76
+ /**
77
+ * Pass `{ base64 | uri, width, height, timestampMs }` from your own camera
78
+ * after {@link warmupExternal}: resolves `{ dropped, pose, events }`.
79
+ * Any other frame keeps the original pose + engine behaviour.
80
+ */
81
+ processFrame: ProcessFrameFn;
82
+ /**
83
+ * Opt-in: load the model for frames from your own camera. Mounts a hidden
84
+ * 1×1 basic WebView (no camera, nothing drawn). Not needed with
85
+ * `WebViewPoseView`.
86
+ */
87
+ warmupExternal: () => Promise<void>;
76
88
  addEventListener: (listener: PoseTrackerEventListener) => () => void;
77
89
  /**
78
90
  * Classic PoseTracker WebView JSON stream (`sendDataToNative` shape).
@@ -80,6 +92,10 @@ export interface PoseTrackerContextValue {
80
92
  */
81
93
  addMessageListener: (listener: (message: import('./events/classicMessage').ClassicNativeMessage) => void) => () => void;
82
94
  }
95
+ export interface ProcessFrameFn {
96
+ (frame: ExternalFrame): Promise<ExternalFrameResult>;
97
+ (frame: PoseInputFrame): Promise<Pose | null>;
98
+ }
83
99
  export interface PoseTrackerProviderProps {
84
100
  /** Optional: without a token the SDK runs in keypoints-only mode. */
85
101
  apiToken?: string;
@@ -20,7 +20,9 @@ const jsx_runtime_1 = require("react/jsx-runtime");
20
20
  * full-engine without restarting the camera pipeline.
21
21
  */
22
22
  const react_1 = require("react");
23
+ const react_native_1 = require("react-native");
23
24
  const client_1 = require("./client");
25
+ const WebViewPoseBackend_1 = require("./backends/webview/WebViewPoseBackend");
24
26
  const PoseTrackerContext = (0, react_1.createContext)(null);
25
27
  function PoseTrackerProvider({ apiToken, engine, options, autoPreload = false, children, }) {
26
28
  const clientRef = (0, react_1.useRef)(null);
@@ -39,8 +41,22 @@ function PoseTrackerProvider({ apiToken, engine, options, autoPreload = false, c
39
41
  const [acceleration, setAcceleration] = (0, react_1.useState)(client.getAcceleration());
40
42
  const [accelerationDiagnostics, setAccelerationDiagnostics] = (0, react_1.useState)(client.getAccelerationDiagnostics());
41
43
  const [quality, setQuality] = (0, react_1.useState)(client.getQualityState());
44
+ /** Hidden warmer for external frames; false unless warmupExternal() ran. */
45
+ const [mountExternalWarmer, setMountExternalWarmer] = (0, react_1.useState)(false);
42
46
  (0, react_1.useEffect)(() => {
43
47
  const offState = client.onStateChange(() => {
48
+ if (client.isExternalWarmerRequested()) {
49
+ setMountExternalWarmer((mounted) => {
50
+ if (mounted)
51
+ return true;
52
+ const backend = client.getBackend();
53
+ // Reuse a WebViewPoseView the host already mounted.
54
+ return backend instanceof WebViewPoseBackend_1.WebViewPoseBackend && !backend.isAttached();
55
+ });
56
+ }
57
+ else {
58
+ setMountExternalWarmer(false);
59
+ }
44
60
  setStatus(client.getStatus());
45
61
  setMode(client.getMode());
46
62
  setError(client.getError());
@@ -69,7 +85,8 @@ function PoseTrackerProvider({ apiToken, engine, options, autoPreload = false, c
69
85
  const startExercise = (0, react_1.useCallback)((id, exerciseOptions) => client.startExercise(id, exerciseOptions), [client]);
70
86
  const stopExercise = (0, react_1.useCallback)(() => client.stopExercise(), [client]);
71
87
  const estimatePose = (0, react_1.useCallback)((frame) => client.estimatePose(frame), [client]);
72
- const processFrame = (0, react_1.useCallback)((frame) => client.processFrame(frame), [client]);
88
+ const processFrame = (0, react_1.useCallback)(((frame) => client.processFrame(frame)), [client]);
89
+ const warmupExternal = (0, react_1.useCallback)(() => client.warmupExternal(), [client]);
73
90
  const addEventListener = (0, react_1.useCallback)((listener) => client.addEventListener(listener), [client]);
74
91
  const addMessageListener = (0, react_1.useCallback)((listener) => client.addMessageListener(listener), [client]);
75
92
  const value = (0, react_1.useMemo)(() => ({
@@ -89,6 +106,7 @@ function PoseTrackerProvider({ apiToken, engine, options, autoPreload = false, c
89
106
  stopExercise,
90
107
  estimatePose,
91
108
  processFrame,
109
+ warmupExternal,
92
110
  addEventListener,
93
111
  addMessageListener,
94
112
  }), [
@@ -107,11 +125,27 @@ function PoseTrackerProvider({ apiToken, engine, options, autoPreload = false, c
107
125
  stopExercise,
108
126
  estimatePose,
109
127
  processFrame,
128
+ warmupExternal,
110
129
  addEventListener,
111
130
  addMessageListener,
112
131
  ]);
113
- return (0, jsx_runtime_1.jsx)(PoseTrackerContext.Provider, { value: value, children: children });
132
+ return ((0, jsx_runtime_1.jsxs)(PoseTrackerContext.Provider, { value: value, children: [children, mountExternalWarmer ? (0, jsx_runtime_1.jsx)(ExternalFramesWarmer, {}) : null] }));
133
+ }
134
+ /** Hidden basic page for external frames: no camera, nothing drawn. */
135
+ function ExternalFramesWarmer() {
136
+ // Required lazily: WebViewPoseView itself imports this module.
137
+ const { WebViewPoseView } = require('./camera/WebViewPoseView');
138
+ return ((0, jsx_runtime_1.jsx)(react_native_1.View, { pointerEvents: "none", style: styles.externalWarmer, children: (0, jsx_runtime_1.jsx)(WebViewPoseView, { coldStart: "basic", drawSkeleton: false, drawPlacementBox: false, showWatermark: false }) }));
114
139
  }
140
+ const styles = react_native_1.StyleSheet.create({
141
+ externalWarmer: {
142
+ position: 'absolute',
143
+ width: 1,
144
+ height: 1,
145
+ opacity: 0,
146
+ overflow: 'hidden',
147
+ },
148
+ });
115
149
  /**
116
150
  * Access the PoseTracker pipeline and subscribe to typed events.
117
151
  *
@@ -7,14 +7,16 @@
7
7
  * only one real-time across the whole Android park in Expo Go. Fully
8
8
  * offline: TF.js and the model ship inside the npm package (poseHtml.ts).
9
9
  *
10
- * The WebView owns the camera (`getUserMedia`). Frames are NOT pushed from
11
- * React Native — `estimatePose()` returns the latest pose posted by the page.
12
- * Mount {@link WebViewPoseView} (or a 1×1 warmer) to attach the runtime.
10
+ * By default the WebView owns the camera (`getUserMedia`) and
11
+ * `estimatePose()` returns the latest pose posted by the page. Mount
12
+ * {@link WebViewPoseView} (or a 1×1 warmer) to attach the runtime.
13
+ * Opt-in: {@link pushFrame} infers host-provided frames on a basic warmer.
13
14
  */
14
15
  import type { PoseBackend, PoseBackendInitOptions, PoseInputFrame } from '../PoseBackend';
15
16
  import type { Pose } from '../../types/pose';
16
17
  import type { AccelerationDiagnostics, AccelerationState, DiagnosticListener } from '../../types/acceleration';
17
18
  import type { PoseTrackerEvent } from '../../types/events';
19
+ import type { ExternalFrame } from '../../types/externalFrame';
18
20
  export type WebViewPoseMessage = {
19
21
  type: 'ready';
20
22
  backend: string;
@@ -59,6 +61,13 @@ export type WebViewPoseMessage = {
59
61
  score: number;
60
62
  inferenceTimeMs: number;
61
63
  timestampMs: number;
64
+ /** Set when the pose answers a pushed external frame. */
65
+ frameId?: number;
66
+ } | {
67
+ type: 'frame_result';
68
+ id: number;
69
+ dropped: boolean;
70
+ error?: string;
62
71
  } | {
63
72
  type: 'stats';
64
73
  fps: number;
@@ -172,6 +181,9 @@ export declare class WebViewPoseBackend implements PoseBackend {
172
181
  /** True after the page successfully opened getUserMedia. */
173
182
  private cameraOpened;
174
183
  private openCameraHandler;
184
+ private pushFrameHandler;
185
+ private frameSeq;
186
+ private readonly pendingFrames;
175
187
  private lastPose;
176
188
  private inferenceTimesMs;
177
189
  private medianInferenceMs;
@@ -192,11 +204,26 @@ export declare class WebViewPoseBackend implements PoseBackend {
192
204
  constructor(options?: WebViewPoseBackendOptions);
193
205
  /** Host view calls this when the WebView mounts / unmounts. */
194
206
  setAttached(attached: boolean): void;
207
+ isAttached(): boolean;
208
+ /** Model warm in the attached page (basic or full cold-start). */
209
+ isWarm(): boolean;
210
+ getLastPose(): Pose | null;
195
211
  setOnPose(handler: ((pose: Pose, inferenceTimeMs: number) => void) | undefined): void;
196
212
  setOnReady(handler: WebViewPoseBackendOptions['onReady']): void;
197
213
  setOnStats(handler: WebViewPoseBackendOptions['onStats']): void;
198
214
  /** Feed a message from the WebView `onMessage` handler. */
199
215
  handleMessage(raw: string): void;
216
+ /** Host view injects `__PT_PUSH_FRAME` with the JSON payload. */
217
+ setPushFrameHandler(handler: ((payload: string) => void) | undefined): void;
218
+ /**
219
+ * Infer one host-provided frame in the attached page. Resolves after the
220
+ * page posted the pose (already forwarded to `onPose`, hence the engine).
221
+ */
222
+ pushFrame(frame: ExternalFrame): Promise<{
223
+ dropped: boolean;
224
+ pose: Pose | null;
225
+ }>;
226
+ private rejectPendingFrames;
200
227
  init(_options: PoseBackendInitOptions): Promise<void>;
201
228
  warmup(): Promise<void>;
202
229
  /** Whether getUserMedia has already run in the attached page. */
@@ -8,13 +8,15 @@
8
8
  * only one real-time across the whole Android park in Expo Go. Fully
9
9
  * offline: TF.js and the model ship inside the npm package (poseHtml.ts).
10
10
  *
11
- * The WebView owns the camera (`getUserMedia`). Frames are NOT pushed from
12
- * React Native — `estimatePose()` returns the latest pose posted by the page.
13
- * Mount {@link WebViewPoseView} (or a 1×1 warmer) to attach the runtime.
11
+ * By default the WebView owns the camera (`getUserMedia`) and
12
+ * `estimatePose()` returns the latest pose posted by the page. Mount
13
+ * {@link WebViewPoseView} (or a 1×1 warmer) to attach the runtime.
14
+ * Opt-in: {@link pushFrame} infers host-provided frames on a basic warmer.
14
15
  */
15
16
  Object.defineProperty(exports, "__esModule", { value: true });
16
17
  exports.WebViewPoseBackend = void 0;
17
18
  exports.isWebViewPoseBackend = isWebViewPoseBackend;
19
+ const PUSH_FRAME_TIMEOUT_MS = 10000;
18
20
  class WebViewPoseBackend {
19
21
  constructor(options = {}) {
20
22
  this.name = 'webview-movenet';
@@ -24,6 +26,8 @@ class WebViewPoseBackend {
24
26
  this.warm = false;
25
27
  /** True after the page successfully opened getUserMedia. */
26
28
  this.cameraOpened = false;
29
+ this.frameSeq = 0;
30
+ this.pendingFrames = new Map();
27
31
  this.lastPose = null;
28
32
  this.inferenceTimesMs = [];
29
33
  this.medianInferenceMs = null;
@@ -54,8 +58,20 @@ class WebViewPoseBackend {
54
58
  // Drop stale GPU verdict from a previous warmer/page so the host does
55
59
  // not show acceleration=gpu while the next WebView is still booting.
56
60
  this.setAcceleration('unknown');
61
+ this.pushFrameHandler = undefined;
62
+ this.rejectPendingFrames('WebView detached while a frame was in flight.');
57
63
  }
58
64
  }
65
+ isAttached() {
66
+ return this.attached;
67
+ }
68
+ /** Model warm in the attached page (basic or full cold-start). */
69
+ isWarm() {
70
+ return this.warm;
71
+ }
72
+ getLastPose() {
73
+ return this.lastPose;
74
+ }
59
75
  setOnPose(handler) {
60
76
  this.onPose = handler;
61
77
  }
@@ -222,8 +238,66 @@ class WebViewPoseBackend {
222
238
  timestampMs: msg.timestampMs,
223
239
  };
224
240
  this.lastPose = pose;
241
+ if (msg.frameId != null) {
242
+ const pending = this.pendingFrames.get(msg.frameId);
243
+ if (pending)
244
+ pending.pose = pose;
245
+ }
225
246
  this.onPose?.(pose, msg.inferenceTimeMs);
247
+ return;
248
+ }
249
+ if (msg.type === 'frame_result') {
250
+ const pending = this.pendingFrames.get(msg.id);
251
+ if (!pending)
252
+ return;
253
+ this.pendingFrames.delete(msg.id);
254
+ clearTimeout(pending.timer);
255
+ if (msg.error) {
256
+ pending.reject(new Error(`processFrame: ${msg.error}`));
257
+ }
258
+ else {
259
+ pending.resolve({ dropped: msg.dropped === true, pose: pending.pose });
260
+ }
261
+ }
262
+ }
263
+ /** Host view injects `__PT_PUSH_FRAME` with the JSON payload. */
264
+ setPushFrameHandler(handler) {
265
+ this.pushFrameHandler = handler;
266
+ }
267
+ /**
268
+ * Infer one host-provided frame in the attached page. Resolves after the
269
+ * page posted the pose (already forwarded to `onPose`, hence the engine).
270
+ */
271
+ pushFrame(frame) {
272
+ const handler = this.pushFrameHandler;
273
+ if (!handler) {
274
+ return Promise.reject(new Error('processFrame: no WebViewPoseView is attached. Call warmupExternal() first.'));
275
+ }
276
+ const id = ++this.frameSeq;
277
+ return new Promise((resolve, reject) => {
278
+ const timer = setTimeout(() => {
279
+ this.pendingFrames.delete(id);
280
+ reject(new Error('processFrame: timed out waiting for the pose runtime.'));
281
+ }, PUSH_FRAME_TIMEOUT_MS);
282
+ this.pendingFrames.set(id, { pose: null, resolve, reject, timer });
283
+ handler(JSON.stringify({
284
+ id,
285
+ base64: frame.base64 ?? null,
286
+ uri: frame.uri ?? null,
287
+ mime: frame.mime ?? null,
288
+ width: frame.width,
289
+ height: frame.height,
290
+ timestampMs: frame.timestampMs,
291
+ mirrored: frame.mirrored !== false,
292
+ }));
293
+ });
294
+ }
295
+ rejectPendingFrames(message) {
296
+ for (const pending of this.pendingFrames.values()) {
297
+ clearTimeout(pending.timer);
298
+ pending.reject(new Error(`processFrame: ${message}`));
226
299
  }
300
+ this.pendingFrames.clear();
227
301
  }
228
302
  async init(_options) {
229
303
  this.onDiagnostic?.('[posetracker] WebViewPoseBackend.init — waiting for Chromium WebView to mount (WebViewPoseView)');
@@ -312,6 +386,8 @@ class WebViewPoseBackend {
312
386
  this.cameraOpened = false;
313
387
  this.attached = false;
314
388
  this.openCameraHandler = undefined;
389
+ this.pushFrameHandler = undefined;
390
+ this.rejectPendingFrames('client disposed.');
315
391
  this.lastPose = null;
316
392
  this.acceleration = 'unknown';
317
393
  this.readyWaiters = [];