@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 +56 -9
- package/lib/PoseTrackerProvider.d.ts +17 -1
- package/lib/PoseTrackerProvider.js +36 -2
- package/lib/backends/webview/WebViewPoseBackend.d.ts +30 -3
- package/lib/backends/webview/WebViewPoseBackend.js +79 -3
- package/lib/backends/webview/poseRuntimeSource.js +2 -2
- package/lib/camera/WebViewPoseView.d.ts +2 -1
- package/lib/camera/WebViewPoseView.js +21 -12
- package/lib/client.d.ts +29 -3
- package/lib/client.js +76 -3
- package/lib/index.d.ts +2 -1
- package/lib/index.js +1 -0
- package/lib/sdkVersion.d.ts +1 -1
- package/lib/sdkVersion.js +1 -1
- package/lib/types/externalFrame.d.ts +41 -0
- package/lib/types/externalFrame.js +16 -0
- package/package.json +2 -1
- package/src/PoseTrackerProvider.tsx +74 -3
- package/src/backends/webview/WebViewPoseBackend.ts +100 -3
- package/src/camera/WebViewPoseView.tsx +38 -17
- package/src/client.ts +98 -4
- package/src/index.ts +6 -1
- package/src/sdkVersion.ts +1 -1
- package/src/types/externalFrame.ts +53 -0
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);
|
package/lib/sdkVersion.d.ts
CHANGED
|
@@ -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.
|
|
2
|
+
export declare const SDK_VERSION = "0.3.6";
|
package/lib/sdkVersion.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
520
|
+
placementDim: {
|
|
521
|
+
position: 'absolute',
|
|
522
|
+
backgroundColor: PLACEMENT_DIM,
|
|
523
|
+
},
|
|
502
524
|
placementBox: {
|
|
503
525
|
position: 'absolute',
|
|
504
|
-
borderWidth:
|
|
505
|
-
borderColor:
|
|
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
|
-
*
|
|
1247
|
-
*
|
|
1248
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
2
|
+
export const SDK_VERSION = '0.3.6';
|