@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/lib/index.d.ts CHANGED
@@ -33,7 +33,7 @@ export { DEFAULT_SKELETON_DEFINITION } from './types/skeleton';
33
33
  export { fetchSkeletonDefinition, SkeletonFetchError } from './api/skeleton';
34
34
  export type { FetchSkeletonOptions } from './api/skeleton';
35
35
  export { PoseTrackerProvider, usePoseTracker } from './PoseTrackerProvider';
36
- export type { PoseTrackerContextValue, PoseTrackerProviderProps } from './PoseTrackerProvider';
36
+ export type { PoseTrackerContextValue, PoseTrackerProviderProps, ProcessFrameFn, } from './PoseTrackerProvider';
37
37
  export type { PoseBackend, PoseBackendInitOptions, PoseInputFrame } from './backends/PoseBackend';
38
38
  export { WebViewPoseBackend, isWebViewPoseBackend } from './backends/webview/WebViewPoseBackend';
39
39
  export type { WebViewPoseBackendOptions, WebViewPoseMessage } from './backends/webview/WebViewPoseBackend';
@@ -72,3 +72,4 @@ export type { PoseTrackerFeatures, ResolvedFeatures } from './types/features';
72
72
  export * from './types/events';
73
73
  export * from './types/manifest';
74
74
  export * from './types/pose';
75
+ export * from './types/externalFrame';
package/lib/index.js CHANGED
@@ -181,3 +181,4 @@ Object.defineProperty(exports, "resolveFeatures", { enumerable: true, get: funct
181
181
  __exportStar(require("./types/events"), exports);
182
182
  __exportStar(require("./types/manifest"), exports);
183
183
  __exportStar(require("./types/pose"), exports);
184
+ __exportStar(require("./types/externalFrame"), exports);
@@ -1,2 +1,2 @@
1
1
  /** AUTO-GENERATED by scripts/sync-sdk-version.mjs — do not edit. */
2
- export declare const SDK_VERSION = "0.3.4";
2
+ export declare const SDK_VERSION = "0.3.6";
package/lib/sdkVersion.js CHANGED
@@ -2,4 +2,4 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SDK_VERSION = void 0;
4
4
  /** AUTO-GENERATED by scripts/sync-sdk-version.mjs — do not edit. */
5
- exports.SDK_VERSION = '0.3.4';
5
+ exports.SDK_VERSION = '0.3.6';
@@ -0,0 +1,41 @@
1
+ /**
2
+ * External frames: the host app owns the camera and the preview, and pushes
3
+ * frames to {@link PoseTrackerClient.processFrame}. The SDK infers, runs the
4
+ * active exercise engine, and returns data. It draws nothing.
5
+ */
6
+ import type { PoseTrackerEvent } from './events';
7
+ import type { Pose } from './pose';
8
+ export interface ExternalFrame {
9
+ /** Encoded image (JPEG / PNG) without the `data:` prefix. */
10
+ base64?: string;
11
+ /** `file://`, `data:` or `https:` URL the WebView can load. */
12
+ uri?: string;
13
+ /** Width of the frame you send. Keep the longest side at 256 px or less. */
14
+ width: number;
15
+ height: number;
16
+ /** Capture time of the frame. The exercise engine uses it for timing. */
17
+ timestampMs: number;
18
+ /**
19
+ * Mirror keypoints horizontally, as for a selfie preview. Default `true`.
20
+ * Pass `false` for a back camera or an unmirrored preview.
21
+ */
22
+ mirrored?: boolean;
23
+ /** MIME type of `base64`. Default `image/jpeg`. */
24
+ mime?: string;
25
+ }
26
+ export interface ExternalFrameResult {
27
+ /** True when a previous frame was still running. Nothing was inferred. */
28
+ dropped: boolean;
29
+ /**
30
+ * Pose for this frame, or the last pose when `dropped`. Keypoints are
31
+ * normalized 0–1 to the frame you sent.
32
+ */
33
+ pose: Pose | null;
34
+ /**
35
+ * Events produced by this frame (keypoints, counter, posture, angles,
36
+ * form score, …), in emission order. They are also delivered to the
37
+ * existing listeners. Empty when `dropped`.
38
+ */
39
+ events: PoseTrackerEvent[];
40
+ }
41
+ export declare function isExternalFrame(frame: unknown): frame is ExternalFrame;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ /**
3
+ * External frames: the host app owns the camera and the preview, and pushes
4
+ * frames to {@link PoseTrackerClient.processFrame}. The SDK infers, runs the
5
+ * active exercise engine, and returns data. It draws nothing.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.isExternalFrame = isExternalFrame;
9
+ function isExternalFrame(frame) {
10
+ if (frame == null || typeof frame !== 'object')
11
+ return false;
12
+ const f = frame;
13
+ return ((typeof f.base64 === 'string' || typeof f.uri === 'string') &&
14
+ typeof f.width === 'number' &&
15
+ typeof f.height === 'number');
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pose-tracker/react-native-pose-estimation-light",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Human pose estimation SDK for React Native (light / online) — MoveNet Lightning via CDN + model URL each session, no bundled weights. iOS & Android (Expo Go). Optional API-key exercise engine.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -20,6 +20,7 @@
20
20
  "scripts": {
21
21
  "build": "node scripts/sync-sdk-version.mjs && node scripts/embed-pose-runtime.mjs && tsc -p tsconfig.json && node scripts/copy-pose-runtime-source.mjs",
22
22
  "typecheck": "tsc -p tsconfig.json --noEmit",
23
+ "test": "node --test \"test/*.test.cjs\"",
23
24
  "embed:runtime": "node scripts/embed-pose-runtime.mjs",
24
25
  "prepack": "npm run build",
25
26
  "pack:dry": "npm pack --dry-run"
@@ -25,8 +25,13 @@ import React, {
25
25
  useState,
26
26
  } from 'react';
27
27
 
28
+ import { StyleSheet, View } from 'react-native';
29
+
28
30
  import { PoseTrackerClient, type PoseTrackerClientOptions, type StartExerciseOptions } from './client';
29
31
  import type { PoseInputFrame } from './backends/PoseBackend';
32
+ import { WebViewPoseBackend } from './backends/webview/WebViewPoseBackend';
33
+ import type { WebViewPoseViewProps } from './camera/WebViewPoseView';
34
+ import type { ExternalFrame, ExternalFrameResult } from './types/externalFrame';
30
35
  import type { ExerciseConfig, SdkManifest } from './types/manifest';
31
36
  import type { PreloadOptions } from './types/preload';
32
37
  import type {
@@ -90,7 +95,18 @@ export interface PoseTrackerContextValue {
90
95
  startExercise: (exerciseId: string, options?: StartExerciseOptions) => void;
91
96
  stopExercise: () => void;
92
97
  estimatePose: (frame: PoseInputFrame) => Promise<Pose | null>;
93
- processFrame: (frame: PoseInputFrame) => Promise<Pose | null>;
98
+ /**
99
+ * Pass `{ base64 | uri, width, height, timestampMs }` from your own camera
100
+ * after {@link warmupExternal}: resolves `{ dropped, pose, events }`.
101
+ * Any other frame keeps the original pose + engine behaviour.
102
+ */
103
+ processFrame: ProcessFrameFn;
104
+ /**
105
+ * Opt-in: load the model for frames from your own camera. Mounts a hidden
106
+ * 1×1 basic WebView (no camera, nothing drawn). Not needed with
107
+ * `WebViewPoseView`.
108
+ */
109
+ warmupExternal: () => Promise<void>;
94
110
  addEventListener: (listener: PoseTrackerEventListener) => () => void;
95
111
  /**
96
112
  * Classic PoseTracker WebView JSON stream (`sendDataToNative` shape).
@@ -101,6 +117,11 @@ export interface PoseTrackerContextValue {
101
117
  ) => () => void;
102
118
  }
103
119
 
120
+ export interface ProcessFrameFn {
121
+ (frame: ExternalFrame): Promise<ExternalFrameResult>;
122
+ (frame: PoseInputFrame): Promise<Pose | null>;
123
+ }
124
+
104
125
  const PoseTrackerContext = createContext<PoseTrackerContextValue | null>(null);
105
126
 
106
127
  export interface PoseTrackerProviderProps {
@@ -143,9 +164,21 @@ export function PoseTrackerProvider({
143
164
  const [accelerationDiagnostics, setAccelerationDiagnostics] =
144
165
  useState<AccelerationDiagnostics | null>(client.getAccelerationDiagnostics());
145
166
  const [quality, setQuality] = useState<QualityState>(client.getQualityState());
167
+ /** Hidden warmer for external frames; false unless warmupExternal() ran. */
168
+ const [mountExternalWarmer, setMountExternalWarmer] = useState(false);
146
169
 
147
170
  useEffect(() => {
148
171
  const offState = client.onStateChange(() => {
172
+ if (client.isExternalWarmerRequested()) {
173
+ setMountExternalWarmer((mounted) => {
174
+ if (mounted) return true;
175
+ const backend = client.getBackend();
176
+ // Reuse a WebViewPoseView the host already mounted.
177
+ return backend instanceof WebViewPoseBackend && !backend.isAttached();
178
+ });
179
+ } else {
180
+ setMountExternalWarmer(false);
181
+ }
149
182
  setStatus(client.getStatus());
150
183
  setMode(client.getMode());
151
184
  setError(client.getError());
@@ -186,9 +219,10 @@ export function PoseTrackerProvider({
186
219
  [client],
187
220
  );
188
221
  const processFrame = useCallback(
189
- (frame: PoseInputFrame) => client.processFrame(frame),
222
+ ((frame: PoseInputFrame) => client.processFrame(frame)) as ProcessFrameFn,
190
223
  [client],
191
224
  );
225
+ const warmupExternal = useCallback(() => client.warmupExternal(), [client]);
192
226
  const addEventListener = useCallback(
193
227
  (listener: PoseTrackerEventListener) => client.addEventListener(listener),
194
228
  [client],
@@ -217,6 +251,7 @@ export function PoseTrackerProvider({
217
251
  stopExercise,
218
252
  estimatePose,
219
253
  processFrame,
254
+ warmupExternal,
220
255
  addEventListener,
221
256
  addMessageListener,
222
257
  }),
@@ -236,14 +271,50 @@ export function PoseTrackerProvider({
236
271
  stopExercise,
237
272
  estimatePose,
238
273
  processFrame,
274
+ warmupExternal,
239
275
  addEventListener,
240
276
  addMessageListener,
241
277
  ],
242
278
  );
243
279
 
244
- return <PoseTrackerContext.Provider value={value}>{children}</PoseTrackerContext.Provider>;
280
+ return (
281
+ <PoseTrackerContext.Provider value={value}>
282
+ {children}
283
+ {mountExternalWarmer ? <ExternalFramesWarmer /> : null}
284
+ </PoseTrackerContext.Provider>
285
+ );
245
286
  }
246
287
 
288
+ declare function require(name: string): unknown;
289
+
290
+ /** Hidden basic page for external frames: no camera, nothing drawn. */
291
+ function ExternalFramesWarmer(): React.JSX.Element {
292
+ // Required lazily: WebViewPoseView itself imports this module.
293
+ const { WebViewPoseView } = require('./camera/WebViewPoseView') as {
294
+ WebViewPoseView: React.ComponentType<WebViewPoseViewProps>;
295
+ };
296
+ return (
297
+ <View pointerEvents="none" style={styles.externalWarmer}>
298
+ <WebViewPoseView
299
+ coldStart="basic"
300
+ drawSkeleton={false}
301
+ drawPlacementBox={false}
302
+ showWatermark={false}
303
+ />
304
+ </View>
305
+ );
306
+ }
307
+
308
+ const styles = StyleSheet.create({
309
+ externalWarmer: {
310
+ position: 'absolute',
311
+ width: 1,
312
+ height: 1,
313
+ opacity: 0,
314
+ overflow: 'hidden',
315
+ },
316
+ });
317
+
247
318
  /**
248
319
  * Access the PoseTracker pipeline and subscribe to typed events.
249
320
  *
@@ -7,9 +7,10 @@
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
 
15
16
  import type { PoseBackend, PoseBackendInitOptions, PoseInputFrame } from '../PoseBackend';
@@ -20,6 +21,7 @@ import type {
20
21
  DiagnosticListener,
21
22
  } from '../../types/acceleration';
22
23
  import type { PoseTrackerEvent } from '../../types/events';
24
+ import type { ExternalFrame } from '../../types/externalFrame';
23
25
 
24
26
  export type WebViewPoseMessage =
25
27
  | {
@@ -55,7 +57,10 @@ export type WebViewPoseMessage =
55
57
  score: number;
56
58
  inferenceTimeMs: number;
57
59
  timestampMs: number;
60
+ /** Set when the pose answers a pushed external frame. */
61
+ frameId?: number;
58
62
  }
63
+ | { type: 'frame_result'; id: number; dropped: boolean; error?: string }
59
64
  | {
60
65
  type: 'stats';
61
66
  fps: number;
@@ -155,6 +160,15 @@ type ReadyResolver = {
155
160
  reject: (err: Error) => void;
156
161
  };
157
162
 
163
+ type PendingFrame = {
164
+ pose: Pose | null;
165
+ resolve: (result: { dropped: boolean; pose: Pose | null }) => void;
166
+ reject: (err: Error) => void;
167
+ timer: ReturnType<typeof setTimeout>;
168
+ };
169
+
170
+ const PUSH_FRAME_TIMEOUT_MS = 10_000;
171
+
158
172
  export class WebViewPoseBackend implements PoseBackend {
159
173
  readonly name = 'webview-movenet';
160
174
 
@@ -165,6 +179,9 @@ export class WebViewPoseBackend implements PoseBackend {
165
179
  /** True after the page successfully opened getUserMedia. */
166
180
  private cameraOpened = false;
167
181
  private openCameraHandler: (() => void) | undefined;
182
+ private pushFrameHandler: ((payload: string) => void) | undefined;
183
+ private frameSeq = 0;
184
+ private readonly pendingFrames = new Map<number, PendingFrame>();
168
185
  private lastPose: Pose | null = null;
169
186
  private inferenceTimesMs: number[] = [];
170
187
  private medianInferenceMs: number | null = null;
@@ -207,9 +224,24 @@ export class WebViewPoseBackend implements PoseBackend {
207
224
  // Drop stale GPU verdict from a previous warmer/page so the host does
208
225
  // not show acceleration=gpu while the next WebView is still booting.
209
226
  this.setAcceleration('unknown');
227
+ this.pushFrameHandler = undefined;
228
+ this.rejectPendingFrames('WebView detached while a frame was in flight.');
210
229
  }
211
230
  }
212
231
 
232
+ isAttached(): boolean {
233
+ return this.attached;
234
+ }
235
+
236
+ /** Model warm in the attached page (basic or full cold-start). */
237
+ isWarm(): boolean {
238
+ return this.warm;
239
+ }
240
+
241
+ getLastPose(): Pose | null {
242
+ return this.lastPose;
243
+ }
244
+
213
245
  setOnPose(handler: ((pose: Pose, inferenceTimeMs: number) => void) | undefined): void {
214
246
  this.onPose = handler;
215
247
  }
@@ -401,8 +433,71 @@ export class WebViewPoseBackend implements PoseBackend {
401
433
  timestampMs: msg.timestampMs,
402
434
  };
403
435
  this.lastPose = pose;
436
+ if (msg.frameId != null) {
437
+ const pending = this.pendingFrames.get(msg.frameId);
438
+ if (pending) pending.pose = pose;
439
+ }
404
440
  this.onPose?.(pose, msg.inferenceTimeMs);
441
+ return;
442
+ }
443
+
444
+ if (msg.type === 'frame_result') {
445
+ const pending = this.pendingFrames.get(msg.id);
446
+ if (!pending) return;
447
+ this.pendingFrames.delete(msg.id);
448
+ clearTimeout(pending.timer);
449
+ if (msg.error) {
450
+ pending.reject(new Error(`processFrame: ${msg.error}`));
451
+ } else {
452
+ pending.resolve({ dropped: msg.dropped === true, pose: pending.pose });
453
+ }
454
+ }
455
+ }
456
+
457
+ /** Host view injects `__PT_PUSH_FRAME` with the JSON payload. */
458
+ setPushFrameHandler(handler: ((payload: string) => void) | undefined): void {
459
+ this.pushFrameHandler = handler;
460
+ }
461
+
462
+ /**
463
+ * Infer one host-provided frame in the attached page. Resolves after the
464
+ * page posted the pose (already forwarded to `onPose`, hence the engine).
465
+ */
466
+ pushFrame(frame: ExternalFrame): Promise<{ dropped: boolean; pose: Pose | null }> {
467
+ const handler = this.pushFrameHandler;
468
+ if (!handler) {
469
+ return Promise.reject(
470
+ new Error('processFrame: no WebViewPoseView is attached. Call warmupExternal() first.'),
471
+ );
472
+ }
473
+ const id = ++this.frameSeq;
474
+ return new Promise((resolve, reject) => {
475
+ const timer = setTimeout(() => {
476
+ this.pendingFrames.delete(id);
477
+ reject(new Error('processFrame: timed out waiting for the pose runtime.'));
478
+ }, PUSH_FRAME_TIMEOUT_MS);
479
+ this.pendingFrames.set(id, { pose: null, resolve, reject, timer });
480
+ handler(
481
+ JSON.stringify({
482
+ id,
483
+ base64: frame.base64 ?? null,
484
+ uri: frame.uri ?? null,
485
+ mime: frame.mime ?? null,
486
+ width: frame.width,
487
+ height: frame.height,
488
+ timestampMs: frame.timestampMs,
489
+ mirrored: frame.mirrored !== false,
490
+ }),
491
+ );
492
+ });
493
+ }
494
+
495
+ private rejectPendingFrames(message: string): void {
496
+ for (const pending of this.pendingFrames.values()) {
497
+ clearTimeout(pending.timer);
498
+ pending.reject(new Error(`processFrame: ${message}`));
405
499
  }
500
+ this.pendingFrames.clear();
406
501
  }
407
502
 
408
503
  async init(_options: PoseBackendInitOptions): Promise<void> {
@@ -503,6 +598,8 @@ export class WebViewPoseBackend implements PoseBackend {
503
598
  this.cameraOpened = false;
504
599
  this.attached = false;
505
600
  this.openCameraHandler = undefined;
601
+ this.pushFrameHandler = undefined;
602
+ this.rejectPendingFrames('client disposed.');
506
603
  this.lastPose = null;
507
604
  this.acceleration = 'unknown';
508
605
  this.readyWaiters = [];
@@ -76,8 +76,9 @@ export interface WebViewPoseViewProps {
76
76
  */
77
77
  drawSkeleton?: boolean;
78
78
  /**
79
- * Show a placement guide box (WebView `postureBox` parity) while the
79
+ * Show the placement zone (iframe `placementOverlay` parity) while the
80
80
  * active exercise reports `posture.ready === false`. Default `true`.
81
+ * Gray masks sit outside the box; the frame is the PoseTracker stroke.
81
82
  * Drawn as an RN sibling overlay (reliable on iOS; Android WebView may
82
83
  * composite above it — hosts can also render from `onPosture`).
83
84
  */
@@ -364,6 +365,12 @@ export function WebViewPoseView(props: WebViewPoseViewProps): React.ReactElement
364
365
  'window.__PT_OPEN_CAMERA && window.__PT_OPEN_CAMERA(); true;',
365
366
  );
366
367
  });
368
+ backend.setPushFrameHandler((payload) => {
369
+ const safe = payload.replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
370
+ webRef.current?.injectJavaScript?.(
371
+ `window.__PT_PUSH_FRAME && window.__PT_PUSH_FRAME(${safe}); true;`,
372
+ );
373
+ });
367
374
  // Live downgrades restart getUserMedia without remounting the WebView.
368
375
  client.setQualityApplyHandler((next) => {
369
376
  injectQuality(webRef, next);
@@ -371,6 +378,7 @@ export function WebViewPoseView(props: WebViewPoseViewProps): React.ReactElement
371
378
  return () => {
372
379
  backend.setOnPose(undefined);
373
380
  backend.setOpenCameraHandler(undefined);
381
+ backend.setPushFrameHandler(undefined);
374
382
  backend.setAttached(false);
375
383
  client.setQualityApplyHandler(undefined);
376
384
  };
@@ -475,18 +483,7 @@ export function WebViewPoseView(props: WebViewPoseViewProps): React.ReactElement
475
483
  }}
476
484
  />
477
485
  {showPlacementBox ? (
478
- <View
479
- pointerEvents="none"
480
- style={[
481
- styles.placementBox,
482
- {
483
- top: `${placementPaddingPercent}%`,
484
- bottom: `${placementPaddingPercent}%`,
485
- left: `${placementPaddingPercent}%`,
486
- right: `${placementPaddingPercent}%`,
487
- },
488
- ]}
489
- />
486
+ <PlacementZone paddingPercent={placementPaddingPercent} />
490
487
  ) : null}
491
488
  {/* Optional RN overlay (works on iOS; usually hidden under Android WebView surface). */}
492
489
  {props.children}
@@ -494,16 +491,40 @@ export function WebViewPoseView(props: WebViewPoseViewProps): React.ReactElement
494
491
  );
495
492
  }
496
493
 
494
+ /** Iframe V4 placement: outside dim `#000` at 0.55, demo frame `#4DD21D`. */
495
+ const PLACEMENT_DIM = 'rgba(0,0,0,0.55)';
496
+ const PLACEMENT_STROKE = '#4DD21D';
497
+
498
+ function PlacementZone({ paddingPercent }: { paddingPercent: number }) {
499
+ const pad = `${paddingPercent}%` as `${number}%`;
500
+ return (
501
+ <View pointerEvents="none" style={StyleSheet.absoluteFill}>
502
+ <View style={[styles.placementDim, { top: 0, left: 0, right: 0, height: pad }]} />
503
+ <View style={[styles.placementDim, { bottom: 0, left: 0, right: 0, height: pad }]} />
504
+ <View style={[styles.placementDim, { top: pad, bottom: pad, left: 0, width: pad }]} />
505
+ <View style={[styles.placementDim, { top: pad, bottom: pad, right: 0, width: pad }]} />
506
+ <View
507
+ style={[
508
+ styles.placementBox,
509
+ { top: pad, bottom: pad, left: pad, right: pad },
510
+ ]}
511
+ />
512
+ </View>
513
+ );
514
+ }
515
+
497
516
  const styles = StyleSheet.create({
498
517
  fill: { flex: 1, backgroundColor: '#000' },
499
518
  center: { alignItems: 'center', justifyContent: 'center', padding: 16 },
500
519
  error: { color: '#FE8370', textAlign: 'center', fontSize: 13 },
501
- /** Front drawPostureBox stroke `#FE8370`. */
520
+ placementDim: {
521
+ position: 'absolute',
522
+ backgroundColor: PLACEMENT_DIM,
523
+ },
502
524
  placementBox: {
503
525
  position: 'absolute',
504
- borderWidth: 3,
505
- borderColor: '#FE8370',
506
- borderRadius: 8,
526
+ borderWidth: 2,
527
+ borderColor: PLACEMENT_STROKE,
507
528
  backgroundColor: 'transparent',
508
529
  },
509
530
  });
package/src/client.ts CHANGED
@@ -58,6 +58,11 @@ import type {
58
58
  PoseTrackerStatus,
59
59
  } from './types/events';
60
60
  import type { Pose } from './types/pose';
61
+ import {
62
+ isExternalFrame,
63
+ type ExternalFrame,
64
+ type ExternalFrameResult,
65
+ } from './types/externalFrame';
61
66
  import type { AccelerationDiagnostics, AccelerationState } from './types/acceleration';
62
67
  import {
63
68
  defaultDiagnosticLogger,
@@ -237,6 +242,16 @@ export class PoseTrackerClient {
237
242
  /** Consecutive engine processPose failures (see SESSION_ERROR_STREAK_LIMIT). */
238
243
  private sessionErrorStreak = 0;
239
244
 
245
+ /**
246
+ * External frames (opt-in): the provider mounts a hidden basic warmer only
247
+ * when this is true. Never set on the default camera path.
248
+ */
249
+ private externalWarmerRequested = false;
250
+ private externalReady = false;
251
+ private externalInFlight = false;
252
+ /** Events emitted while one external frame is in flight. */
253
+ private externalCollector: PoseTrackerEvent[] | null = null;
254
+
240
255
  /** Requested tracking features with WebView-parity defaults applied. */
241
256
  private readonly features: ResolvedFeatures;
242
257
  /** WebView-only keys passed by untyped hosts (blazepose, poseEngine, …). */
@@ -1242,12 +1257,55 @@ export class PoseTrackerClient {
1242
1257
  return this.backend.estimatePose(frame);
1243
1258
  }
1244
1259
 
1260
+ // -------------------------------------------------------------------------
1261
+ // External frames (opt-in: the host app owns the camera)
1262
+ // -------------------------------------------------------------------------
1263
+
1264
+ /** True once {@link warmupExternal} was called (provider mounts the warmer). */
1265
+ isExternalWarmerRequested(): boolean {
1266
+ return this.externalWarmerRequested;
1267
+ }
1268
+
1269
+ /**
1270
+ * Load the pose model for {@link processFrame} with external frames. Never
1271
+ * opens the camera. Idempotent. With `PoseTrackerProvider`, a hidden 1×1
1272
+ * basic WebView is mounted for this, unless a `WebViewPoseView` is already
1273
+ * attached, in which case that view is reused.
1274
+ */
1275
+ async warmupExternal(): Promise<void> {
1276
+ const backend = this.backend;
1277
+ if (!(backend instanceof WebViewPoseBackend)) {
1278
+ throw new Error('warmupExternal() requires the WebView backend (preferredBackend auto or webview).');
1279
+ }
1280
+ if (!this.externalWarmerRequested) {
1281
+ this.externalWarmerRequested = true;
1282
+ this.notifyState();
1283
+ }
1284
+ if (this.preloadPromise) {
1285
+ await this.preloadPromise;
1286
+ } else {
1287
+ await this.preload({ coldStart: 'basic' });
1288
+ }
1289
+ await backend.warmup();
1290
+ this.externalReady = true;
1291
+ }
1292
+
1245
1293
  /**
1246
- * Full pipeline for one camera frame: pose estimation + `keypoints` event
1247
- * (both modes), then engine processing when a session is active
1248
- * (full-engine mode). Mode upgrades take effect transparently here.
1294
+ * External frame (`{ base64 | uri, width, height, timestampMs }`): infer it
1295
+ * on the warm session and run the active exercise engine. Resolves with
1296
+ * the pose and the events this frame produced; those events also reach
1297
+ * the regular listeners. Nothing is drawn. One frame in flight: a call
1298
+ * made while one is running resolves `{ dropped: true }` at once.
1299
+ *
1300
+ * Any other argument keeps the original behaviour: pose estimation +
1301
+ * engine for a backend-specific frame.
1249
1302
  */
1250
- async processFrame(frame: PoseInputFrame): Promise<Pose | null> {
1303
+ processFrame(frame: ExternalFrame): Promise<ExternalFrameResult>;
1304
+ processFrame(frame: PoseInputFrame): Promise<Pose | null>;
1305
+ async processFrame(frame: PoseInputFrame | ExternalFrame): Promise<Pose | null | ExternalFrameResult> {
1306
+ if (isExternalFrame(frame)) {
1307
+ return this.processExternalFrame(frame);
1308
+ }
1251
1309
  const pose = await this.estimatePose(frame);
1252
1310
  if (!pose) {
1253
1311
  return null;
@@ -1256,6 +1314,37 @@ export class PoseTrackerClient {
1256
1314
  return pose;
1257
1315
  }
1258
1316
 
1317
+ private async processExternalFrame(frame: ExternalFrame): Promise<ExternalFrameResult> {
1318
+ const backend = this.backend;
1319
+ if (!(backend instanceof WebViewPoseBackend)) {
1320
+ throw new Error('processFrame: external frames require the WebView backend.');
1321
+ }
1322
+ if (!this.externalReady || !backend.isWarm()) {
1323
+ throw new Error('processFrame: call warmupExternal() and wait for it to resolve first.');
1324
+ }
1325
+ if (backend.isCameraOpened()) {
1326
+ throw new Error(
1327
+ 'processFrame: the SDK camera is open on this client. External frames and the SDK camera cannot run together.',
1328
+ );
1329
+ }
1330
+ if (this.externalInFlight) {
1331
+ return { dropped: true, pose: backend.getLastPose(), events: [] };
1332
+ }
1333
+ this.externalInFlight = true;
1334
+ const events: PoseTrackerEvent[] = [];
1335
+ this.externalCollector = events;
1336
+ try {
1337
+ const result = await backend.pushFrame(frame);
1338
+ if (result.dropped) {
1339
+ return { dropped: true, pose: backend.getLastPose(), events: [] };
1340
+ }
1341
+ return { dropped: false, pose: result.pose, events };
1342
+ } finally {
1343
+ this.externalCollector = null;
1344
+ this.externalInFlight = false;
1345
+ }
1346
+ }
1347
+
1259
1348
  /**
1260
1349
  * Feed an externally-estimated pose into the pipeline: `keypoints` event
1261
1350
  * (both modes) + engine processing when a session is active. This is how
@@ -1329,6 +1418,10 @@ export class PoseTrackerClient {
1329
1418
  this.lastCameraStartInfo = null;
1330
1419
  this.featureGateReported = { unsupported: false, freeBlock: false, missingToken: false };
1331
1420
  this.keypointsSuppressionLogged = false;
1421
+ this.externalWarmerRequested = false;
1422
+ this.externalReady = false;
1423
+ this.externalInFlight = false;
1424
+ this.externalCollector = null;
1332
1425
  this.engine = null;
1333
1426
  this.engineSource = null;
1334
1427
  this.manifest = null;
@@ -1375,6 +1468,7 @@ export class PoseTrackerClient {
1375
1468
  }
1376
1469
 
1377
1470
  private emit(event: PoseTrackerEvent): void {
1471
+ this.externalCollector?.push(event);
1378
1472
  // A throwing host listener must never break other listeners or the frame
1379
1473
  // pipeline (emit is called from the per-frame ingest path).
1380
1474
  this.listeners.forEach((l) => {
package/src/index.ts CHANGED
@@ -81,7 +81,11 @@ export { DEFAULT_SKELETON_DEFINITION } from './types/skeleton';
81
81
  export { fetchSkeletonDefinition, SkeletonFetchError } from './api/skeleton';
82
82
  export type { FetchSkeletonOptions } from './api/skeleton';
83
83
  export { PoseTrackerProvider, usePoseTracker } from './PoseTrackerProvider';
84
- export type { PoseTrackerContextValue, PoseTrackerProviderProps } from './PoseTrackerProvider';
84
+ export type {
85
+ PoseTrackerContextValue,
86
+ PoseTrackerProviderProps,
87
+ ProcessFrameFn,
88
+ } from './PoseTrackerProvider';
85
89
 
86
90
  // Inference backends
87
91
  export type { PoseBackend, PoseBackendInitOptions, PoseInputFrame } from './backends/PoseBackend';
@@ -256,3 +260,4 @@ export type { PoseTrackerFeatures, ResolvedFeatures } from './types/features';
256
260
  export * from './types/events';
257
261
  export * from './types/manifest';
258
262
  export * from './types/pose';
263
+ export * from './types/externalFrame';
package/src/sdkVersion.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** AUTO-GENERATED by scripts/sync-sdk-version.mjs — do not edit. */
2
- export const SDK_VERSION = '0.3.4';
2
+ export const SDK_VERSION = '0.3.6';