@pose-tracker/react-native-pose-estimation-light 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +45 -0
- package/PoseTrackerVision.podspec +28 -0
- package/README.md +161 -0
- package/THIRD_PARTY_NOTICES.md +19 -0
- package/ios/PoseTrackerVision/PoseTrackerBodyPosePlugin.m +18 -0
- package/ios/PoseTrackerVision/PoseTrackerBodyPosePlugin.swift +108 -0
- package/lib/PoseTrackerProvider.d.ts +102 -0
- package/lib/PoseTrackerProvider.js +204 -0
- package/lib/api/configure.d.ts +29 -0
- package/lib/api/configure.js +64 -0
- package/lib/api/skeleton.d.ts +17 -0
- package/lib/api/skeleton.js +53 -0
- package/lib/api/track.d.ts +61 -0
- package/lib/api/track.js +146 -0
- package/lib/backends/PoseBackend.d.ts +50 -0
- package/lib/backends/PoseBackend.js +11 -0
- package/lib/backends/vision/VisionPoseBackend.d.ts +72 -0
- package/lib/backends/vision/VisionPoseBackend.js +177 -0
- package/lib/backends/vision/mapVisionJoints.d.ts +35 -0
- package/lib/backends/vision/mapVisionJoints.js +162 -0
- package/lib/backends/vision/optionalVision.d.ts +32 -0
- package/lib/backends/vision/optionalVision.js +76 -0
- package/lib/backends/webview/WebViewPoseBackend.d.ts +223 -0
- package/lib/backends/webview/WebViewPoseBackend.js +382 -0
- package/lib/backends/webview/brandAssets.d.ts +5 -0
- package/lib/backends/webview/brandAssets.js +8 -0
- package/lib/backends/webview/onlineRuntime.d.ts +45 -0
- package/lib/backends/webview/onlineRuntime.js +57 -0
- package/lib/backends/webview/poseHtml.d.ts +71 -0
- package/lib/backends/webview/poseHtml.js +188 -0
- package/lib/backends/webview/poseRuntimeSource.d.ts +3 -0
- package/lib/backends/webview/poseRuntimeSource.js +4 -0
- package/lib/cache/obfuscate.d.ts +18 -0
- package/lib/cache/obfuscate.js +90 -0
- package/lib/camera/PoseCameraView.d.ts +51 -0
- package/lib/camera/PoseCameraView.js +99 -0
- package/lib/camera/WebViewPoseView.d.ts +81 -0
- package/lib/camera/WebViewPoseView.js +312 -0
- package/lib/client.d.ts +361 -0
- package/lib/client.js +1046 -0
- package/lib/diagnostics/logReport.d.ts +23 -0
- package/lib/diagnostics/logReport.js +102 -0
- package/lib/engine/EngineLoader.d.ts +95 -0
- package/lib/engine/EngineLoader.js +347 -0
- package/lib/engine/types.d.ts +83 -0
- package/lib/engine/types.js +11 -0
- package/lib/events/classicMessage.d.ts +31 -0
- package/lib/events/classicMessage.js +259 -0
- package/lib/exercises/aliases.d.ts +18 -0
- package/lib/exercises/aliases.js +48 -0
- package/lib/index.d.ts +72 -0
- package/lib/index.js +170 -0
- package/lib/models/poseModels.d.ts +39 -0
- package/lib/models/poseModels.js +63 -0
- package/lib/quality/AdaptiveQualityController.d.ts +120 -0
- package/lib/quality/AdaptiveQualityController.js +423 -0
- package/lib/quality/RuntimeGuard.d.ts +34 -0
- package/lib/quality/RuntimeGuard.js +105 -0
- package/lib/quality/captureMode.d.ts +82 -0
- package/lib/quality/captureMode.js +75 -0
- package/lib/quality/deviceCapability.d.ts +37 -0
- package/lib/quality/deviceCapability.js +177 -0
- package/lib/quality/profiles.d.ts +125 -0
- package/lib/quality/profiles.js +202 -0
- package/lib/runtime/RuntimeCache.d.ts +75 -0
- package/lib/runtime/RuntimeCache.js +230 -0
- package/lib/sdkVersion.d.ts +2 -0
- package/lib/sdkVersion.js +5 -0
- package/lib/support/optionalModules.d.ts +46 -0
- package/lib/support/optionalModules.js +82 -0
- package/lib/types/acceleration.d.ts +86 -0
- package/lib/types/acceleration.js +15 -0
- package/lib/types/events.d.ts +345 -0
- package/lib/types/events.js +10 -0
- package/lib/types/features.d.ts +90 -0
- package/lib/types/features.js +122 -0
- package/lib/types/manifest.d.ts +197 -0
- package/lib/types/manifest.js +10 -0
- package/lib/types/pose.d.ts +36 -0
- package/lib/types/pose.js +41 -0
- package/lib/types/preload.d.ts +17 -0
- package/lib/types/preload.js +10 -0
- package/lib/types/skeleton.d.ts +29 -0
- package/lib/types/skeleton.js +52 -0
- package/package.json +92 -0
- package/react-native.config.js +12 -0
- package/src/PoseTrackerProvider.tsx +332 -0
- package/src/api/configure.ts +82 -0
- package/src/api/skeleton.ts +60 -0
- package/src/api/track.ts +182 -0
- package/src/backends/PoseBackend.ts +59 -0
- package/src/backends/vision/VisionPoseBackend.ts +238 -0
- package/src/backends/vision/mapVisionJoints.ts +185 -0
- package/src/backends/vision/optionalVision.ts +94 -0
- package/src/backends/webview/WebViewPoseBackend.ts +582 -0
- package/src/backends/webview/brandAssets.ts +5 -0
- package/src/backends/webview/onlineRuntime.ts +94 -0
- package/src/backends/webview/poseHtml.ts +259 -0
- package/src/backends/webview/poseRuntimeSource.d.ts +3 -0
- package/src/cache/obfuscate.ts +94 -0
- package/src/camera/PoseCameraView.tsx +223 -0
- package/src/camera/WebViewPoseView.tsx +466 -0
- package/src/client.ts +1295 -0
- package/src/diagnostics/logReport.ts +131 -0
- package/src/engine/EngineLoader.ts +428 -0
- package/src/engine/types.ts +93 -0
- package/src/events/classicMessage.ts +271 -0
- package/src/exercises/aliases.ts +50 -0
- package/src/index.ts +243 -0
- package/src/models/poseModels.ts +93 -0
- package/src/quality/AdaptiveQualityController.ts +579 -0
- package/src/quality/RuntimeGuard.ts +119 -0
- package/src/quality/captureMode.ts +97 -0
- package/src/quality/deviceCapability.ts +187 -0
- package/src/quality/profiles.ts +253 -0
- package/src/runtime/RuntimeCache.ts +300 -0
- package/src/sdkVersion.ts +2 -0
- package/src/support/optionalModules.ts +114 -0
- package/src/types/acceleration.ts +94 -0
- package/src/types/events.ts +437 -0
- package/src/types/features.ts +176 -0
- package/src/types/manifest.ts +230 -0
- package/src/types/pose.ts +85 -0
- package/src/types/preload.ts +19 -0
- package/src/types/skeleton.ts +77 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1046 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Framework-agnostic orchestrator. `PoseTrackerProvider` is a thin React
|
|
4
|
+
* wrapper around this class.
|
|
5
|
+
*
|
|
6
|
+
* Lifecycle (exposed as `PoseTrackerStatus`):
|
|
7
|
+
* idle → configuring (handshake attempt) → downloading (engine bundle)
|
|
8
|
+
* → warming (TF runtime + model + dummy inferences) → ready | error
|
|
9
|
+
*
|
|
10
|
+
* Operating modes (commercial boundary, see ARCHITECTURE.md §Modes):
|
|
11
|
+
* - 'keypoints-only': always reachable — online MoveNet (CDN TF.js + model
|
|
12
|
+
* URL) + warm-up + camera pipeline, raw `keypoints` events only. A failed
|
|
13
|
+
* handshake (offline API, missing/invalid token, quota) NEVER blocks
|
|
14
|
+
* `ready`: it degrades to this mode with a non-fatal `error` event.
|
|
15
|
+
* `error` status is reserved for unrecoverable local failures (model load).
|
|
16
|
+
* - 'full-engine': requires a validated handshake — live, or replayed from
|
|
17
|
+
* the encrypted session cache written after a previous successful
|
|
18
|
+
* handshake (Sency-style offline cold start). The npm package itself
|
|
19
|
+
* ships ZERO movement intelligence.
|
|
20
|
+
*
|
|
21
|
+
* The mode upgrades at runtime without restarting the camera pipeline:
|
|
22
|
+
* `configure()` can be called at any time (e.g. when the network comes back
|
|
23
|
+
* or the host obtains a token) — the inference backend is untouched, and
|
|
24
|
+
* business events simply start flowing once the engine is up.
|
|
25
|
+
*/
|
|
26
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
|
+
exports.PoseTrackerClient = void 0;
|
|
28
|
+
const configure_1 = require("./api/configure");
|
|
29
|
+
const track_1 = require("./api/track");
|
|
30
|
+
const EngineLoader_1 = require("./engine/EngineLoader");
|
|
31
|
+
const EngineLoader_2 = require("./engine/EngineLoader");
|
|
32
|
+
const onlineRuntime_1 = require("./backends/webview/onlineRuntime");
|
|
33
|
+
const obfuscate_1 = require("./cache/obfuscate");
|
|
34
|
+
const VisionPoseBackend_1 = require("./backends/vision/VisionPoseBackend");
|
|
35
|
+
const WebViewPoseBackend_1 = require("./backends/webview/WebViewPoseBackend");
|
|
36
|
+
const aliases_1 = require("./exercises/aliases");
|
|
37
|
+
const skeleton_1 = require("./api/skeleton");
|
|
38
|
+
const logReport_1 = require("./diagnostics/logReport");
|
|
39
|
+
const AdaptiveQualityController_1 = require("./quality/AdaptiveQualityController");
|
|
40
|
+
const profiles_1 = require("./quality/profiles");
|
|
41
|
+
const classicMessage_1 = require("./events/classicMessage");
|
|
42
|
+
const features_1 = require("./types/features");
|
|
43
|
+
const MANIFEST_CACHE_KEY = 'session.sealed';
|
|
44
|
+
const ENGINE_VERSION_KEY = 'engine.version';
|
|
45
|
+
/** Consecutive engine `processPose` failures before the session is stopped. */
|
|
46
|
+
const SESSION_ERROR_STREAK_LIMIT = 30;
|
|
47
|
+
/**
|
|
48
|
+
* Fallback descriptor when no manifest is available. Light SDK loads the
|
|
49
|
+
* graph model from {@link PoseTrackerClientOptions.modelUrl} (or the product
|
|
50
|
+
* default URL); this descriptor only feeds backend init metadata.
|
|
51
|
+
*/
|
|
52
|
+
const DEFAULT_MOVENET = {
|
|
53
|
+
modelId: 'movenet-singlepose-lightning',
|
|
54
|
+
format: 'tfjs-graph-model',
|
|
55
|
+
inputSize: 192,
|
|
56
|
+
version: '4',
|
|
57
|
+
};
|
|
58
|
+
class PoseTrackerClient {
|
|
59
|
+
constructor(apiToken, options = {}) {
|
|
60
|
+
this.status = 'idle';
|
|
61
|
+
this.mode = 'keypoints-only';
|
|
62
|
+
this.lastError = null;
|
|
63
|
+
this.manifest = null;
|
|
64
|
+
this.engine = null;
|
|
65
|
+
this.engineSource = null;
|
|
66
|
+
this.session = null;
|
|
67
|
+
this.currentExerciseId = null;
|
|
68
|
+
this.listeners = new Set();
|
|
69
|
+
this.messageListeners = new Set();
|
|
70
|
+
this.stateListeners = new Set();
|
|
71
|
+
this.preloadPromise = null;
|
|
72
|
+
/** Last requested cold-start mode (default basic — no getUserMedia). */
|
|
73
|
+
this.coldStartMode = 'basic';
|
|
74
|
+
this.configurePromise = null;
|
|
75
|
+
/** One handshake per configuration cycle, shared by runtime warm + engine load. */
|
|
76
|
+
this.handshakePromise = null;
|
|
77
|
+
/** Online pose-runtime descriptor (CDN TF.js + model URL + thin page runtime). */
|
|
78
|
+
this.runtimePromise = null;
|
|
79
|
+
/**
|
|
80
|
+
* Metered-session gate: with an API key, the `camera_start` track call must
|
|
81
|
+
* succeed online before key-gated features run ('refused' = offline/quota).
|
|
82
|
+
*/
|
|
83
|
+
this.meteredSessionState = 'idle';
|
|
84
|
+
/** Last camera_start payload — lets configure() retry a refused metered gate. */
|
|
85
|
+
this.lastCameraStartInfo = null;
|
|
86
|
+
/** Consecutive engine processPose failures (see SESSION_ERROR_STREAK_LIMIT). */
|
|
87
|
+
this.sessionErrorStreak = 0;
|
|
88
|
+
/** One-shot flags so plan-gating errors are not re-emitted on every retry. */
|
|
89
|
+
this.featureGateReported = { unsupported: false, freeBlock: false, missingToken: false };
|
|
90
|
+
this.keypointsSuppressionLogged = false;
|
|
91
|
+
// Default: dump every diagnostic line to Metro / logcat so Android FPS
|
|
92
|
+
// issues (CPU fallback) are visible without any host wiring.
|
|
93
|
+
this.options = {
|
|
94
|
+
...options,
|
|
95
|
+
onDiagnostic: options.onDiagnostic ?? logReport_1.defaultDiagnosticLogger,
|
|
96
|
+
};
|
|
97
|
+
this.apiToken = apiToken ?? null;
|
|
98
|
+
const resolved = (0, features_1.resolveFeatures)(options.features);
|
|
99
|
+
this.features = resolved.features;
|
|
100
|
+
this.unsupportedFeatureKeys = resolved.unsupportedKeys;
|
|
101
|
+
(0, logReport_1.logPlatformBanner)();
|
|
102
|
+
this.options.onDiagnostic?.(`[posetracker] client created preferredBackend=${this.options.preferredBackend ?? 'auto'} ` +
|
|
103
|
+
`qualityChoice=${this.options.qualityChoice ?? 'AdaptiveChoice'} ` +
|
|
104
|
+
`capturePriority=${this.options.capturePriority ?? 'performance'} ` +
|
|
105
|
+
`hasApiToken=${Boolean(this.apiToken)} ` +
|
|
106
|
+
`features=${JSON.stringify(this.features)}` +
|
|
107
|
+
(this.unsupportedFeatureKeys.length > 0
|
|
108
|
+
? ` unsupportedFeatureKeys=${this.unsupportedFeatureKeys.join(',')}`
|
|
109
|
+
: ''));
|
|
110
|
+
this.quality = new AdaptiveQualityController_1.AdaptiveQualityController({
|
|
111
|
+
choice: this.options.qualityChoice ?? 'AdaptiveChoice',
|
|
112
|
+
capturePriority: this.options.capturePriority ?? 'performance',
|
|
113
|
+
onDiagnostic: this.options.onDiagnostic,
|
|
114
|
+
onQualityChanged: (event) => {
|
|
115
|
+
// Typed: quality_changed. Classic onMessage maps it to type "warning".
|
|
116
|
+
this.emit(event);
|
|
117
|
+
this.notifyState();
|
|
118
|
+
},
|
|
119
|
+
onPerformanceWarning: (event) => {
|
|
120
|
+
this.emit(event);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
this.backend = options.backend ?? this.createBackend();
|
|
124
|
+
this.engineLoader =
|
|
125
|
+
options.engineLoader ??
|
|
126
|
+
new EngineLoader_1.EngineLoader({
|
|
127
|
+
fileStore: options.fileStore,
|
|
128
|
+
onDiagnostic: this.options.onDiagnostic,
|
|
129
|
+
});
|
|
130
|
+
this.files =
|
|
131
|
+
options.fileStore !== undefined ? options.fileStore : (0, EngineLoader_2.createNativeFileStore)('posetracker-engine');
|
|
132
|
+
this.tracker = options.usageTracker ?? new track_1.UsageTracker({ baseUrl: options.baseUrl });
|
|
133
|
+
this.options.onDiagnostic?.(`[posetracker-light] selected backend=${this.backend.name} ` +
|
|
134
|
+
`onlinePoseRuntime=${(0, onlineRuntime_1.getOnlineRuntimeVersion)()} ` +
|
|
135
|
+
`fileStore=${this.files ? 'native' : 'none'}`);
|
|
136
|
+
}
|
|
137
|
+
/** Backend selection — see {@link PreferredBackend}. */
|
|
138
|
+
createBackend() {
|
|
139
|
+
const shared = {
|
|
140
|
+
onDiagnostic: this.options.onDiagnostic,
|
|
141
|
+
// Surface mid-session downgrades to the provider/hook without polling.
|
|
142
|
+
onAccelerationChange: () => this.notifyState(),
|
|
143
|
+
};
|
|
144
|
+
const preferred = this.options.preferredBackend ?? 'auto';
|
|
145
|
+
if (preferred === 'vision') {
|
|
146
|
+
// Explicit opt-in only (iOS native build). On Android / Expo Go,
|
|
147
|
+
// init() throws a clear error — no silent swap, the host asked for
|
|
148
|
+
// Vision. The WebView runtime remains available as a manual retry.
|
|
149
|
+
return new VisionPoseBackend_1.VisionPoseBackend(shared);
|
|
150
|
+
}
|
|
151
|
+
// 'auto' | 'webview': the offline WebView MoveNet runtime, both platforms.
|
|
152
|
+
return new WebViewPoseBackend_1.WebViewPoseBackend({
|
|
153
|
+
...shared,
|
|
154
|
+
onWarmupEstimate: (info) => {
|
|
155
|
+
const pageProfile = (0, profiles_1.isQualityProfileId)(info.profileId) ? info.profileId : null;
|
|
156
|
+
void this.quality.onWarmupEstimate({
|
|
157
|
+
medianInferenceMs: info.medianInferenceMs,
|
|
158
|
+
glRenderer: info.glRenderer,
|
|
159
|
+
pageSelectedProfile: pageProfile,
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
onReady: (info) => {
|
|
163
|
+
const pageProfile = info.profileId && (0, profiles_1.isQualityProfileId)(info.profileId) ? info.profileId : null;
|
|
164
|
+
void this.quality.onRuntimeReady({
|
|
165
|
+
glRenderer: info.glRenderer,
|
|
166
|
+
medianInferenceMs: info.medianInferenceMs,
|
|
167
|
+
pageSelectedProfile: pageProfile,
|
|
168
|
+
});
|
|
169
|
+
// Usage metering happens HERE: camera started with the model ready
|
|
170
|
+
// (not at handshake, not at preload).
|
|
171
|
+
void this.handleCameraStart({
|
|
172
|
+
backend: info.backend,
|
|
173
|
+
profileId: info.profileId ?? null,
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
onStats: (stats) => {
|
|
177
|
+
void this.quality.onStats({
|
|
178
|
+
fps: stats.fps,
|
|
179
|
+
medianInferenceMs: stats.medianInferenceMs,
|
|
180
|
+
videoSize: stats.videoSize,
|
|
181
|
+
backend: stats.backend,
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
onTrackerEvent: (event) => {
|
|
185
|
+
this.emit(event);
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
// -------------------------------------------------------------------------
|
|
190
|
+
// Introspection & events
|
|
191
|
+
// -------------------------------------------------------------------------
|
|
192
|
+
getStatus() {
|
|
193
|
+
return this.status;
|
|
194
|
+
}
|
|
195
|
+
getMode() {
|
|
196
|
+
return this.mode;
|
|
197
|
+
}
|
|
198
|
+
/** Last non-fatal or fatal error (also emitted as an `error` event). */
|
|
199
|
+
getError() {
|
|
200
|
+
return this.lastError;
|
|
201
|
+
}
|
|
202
|
+
getManifest() {
|
|
203
|
+
return this.manifest;
|
|
204
|
+
}
|
|
205
|
+
/** Plan type from the manifest ('free', 'developer', …) or null (keyless/offline). */
|
|
206
|
+
getPlanType() {
|
|
207
|
+
return this.manifest?.plan?.plan ?? null;
|
|
208
|
+
}
|
|
209
|
+
/** Requested tracking features with WebView-parity defaults applied. */
|
|
210
|
+
getFeatures() {
|
|
211
|
+
return { ...this.features };
|
|
212
|
+
}
|
|
213
|
+
/** 'remote-cache' | 'remote-download' | null (keypoints-only). */
|
|
214
|
+
getEngineSource() {
|
|
215
|
+
return this.engineSource;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* GPU-acceleration verdict from the warm-up health check ('unknown' until
|
|
219
|
+
* preload/warmup completes). See docs/ANDROID_GL_ACCELERATION.md.
|
|
220
|
+
*/
|
|
221
|
+
getAcceleration() {
|
|
222
|
+
return this.getAccelerationDiagnostics()?.state ?? 'unknown';
|
|
223
|
+
}
|
|
224
|
+
/** Full diagnostics (backend, timings, GL renderer, flags, downgrade trail). */
|
|
225
|
+
getAccelerationDiagnostics() {
|
|
226
|
+
return this.backend.getAcceleration?.() ?? null;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Adaptive camera-quality state (profile, capability score, mean FPS).
|
|
230
|
+
* See docs/ADAPTIVE_QUALITY.md.
|
|
231
|
+
*/
|
|
232
|
+
getQualityState() {
|
|
233
|
+
return this.quality.getState();
|
|
234
|
+
}
|
|
235
|
+
/** Resolve (and cache) the initial quality profile before mounting the WebView. */
|
|
236
|
+
resolveQualityProfile() {
|
|
237
|
+
return this.quality.resolveInitialProfile();
|
|
238
|
+
}
|
|
239
|
+
/** Mark the active profile as PROBING (crash-loop guard) before page boot. */
|
|
240
|
+
beginQualitySession() {
|
|
241
|
+
return this.quality.beginSession();
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Wire the live WebView injector so auto-downgrades can restart getUserMedia.
|
|
245
|
+
* Called by {@link WebViewPoseView}.
|
|
246
|
+
*/
|
|
247
|
+
setQualityApplyHandler(fn) {
|
|
248
|
+
this.quality.setApplyProfile(fn);
|
|
249
|
+
}
|
|
250
|
+
addEventListener(listener) {
|
|
251
|
+
this.listeners.add(listener);
|
|
252
|
+
return () => this.listeners.delete(listener);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Classic PoseTracker WebView parity: receive `sendDataToNative`-shaped JSON
|
|
256
|
+
* (`{ type: 'keypoints', data: [...] }`, `{ type: 'initialization', message, ready }`, …).
|
|
257
|
+
* Prefer typed {@link addEventListener} for new apps; use this to migrate
|
|
258
|
+
* existing `onMessage` parsers with minimal changes.
|
|
259
|
+
*/
|
|
260
|
+
addMessageListener(listener) {
|
|
261
|
+
this.messageListeners.add(listener);
|
|
262
|
+
return () => this.messageListeners.delete(listener);
|
|
263
|
+
}
|
|
264
|
+
/** Fired on any status/mode/manifest change. */
|
|
265
|
+
onStateChange(listener) {
|
|
266
|
+
this.stateListeners.add(listener);
|
|
267
|
+
return () => this.stateListeners.delete(listener);
|
|
268
|
+
}
|
|
269
|
+
// -------------------------------------------------------------------------
|
|
270
|
+
// Preload / warmup
|
|
271
|
+
// -------------------------------------------------------------------------
|
|
272
|
+
/**
|
|
273
|
+
* Idempotent warm-up: bundled pose runtime, optional engine handshake,
|
|
274
|
+
* then backend cold-start. Does **not** run when the provider mounts
|
|
275
|
+
* (unless `autoPreload`). For WebView, mount a `WebViewPoseView` so the
|
|
276
|
+
* page can load MoveNet — see docs/PRELOAD.md.
|
|
277
|
+
*
|
|
278
|
+
* Cold-start modes (`options.coldStart`):
|
|
279
|
+
* - `basic` (**default**): model + WebGL zeros only — **no getUserMedia**,
|
|
280
|
+
* so lobby / home preload never prompts for camera permission.
|
|
281
|
+
* - `full`: also open the camera (legacy). Pair with
|
|
282
|
+
* `<WebViewPoseView coldStart="full" />` or call after basic ready to
|
|
283
|
+
* upgrade via `__PT_OPEN_CAMERA`.
|
|
284
|
+
*
|
|
285
|
+
* Always reaches `ready` unless the local model path fails; a failed
|
|
286
|
+
* handshake degrades to keypoints-only mode.
|
|
287
|
+
*/
|
|
288
|
+
preload(options) {
|
|
289
|
+
const mode = options?.coldStart === 'full' ? 'full' : 'basic';
|
|
290
|
+
this.coldStartMode = mode;
|
|
291
|
+
// Already model-ready: only upgrade to full if requested.
|
|
292
|
+
if (this.status === 'ready' && this.preloadPromise) {
|
|
293
|
+
return this.preloadPromise.then(() => this.ensureColdStartMode(mode));
|
|
294
|
+
}
|
|
295
|
+
if (!this.preloadPromise) {
|
|
296
|
+
this.preloadPromise = this.doPreload()
|
|
297
|
+
.then(() => this.ensureColdStartMode(mode))
|
|
298
|
+
.catch((err) => {
|
|
299
|
+
// Allow a retry after a failed preload (model failure).
|
|
300
|
+
this.preloadPromise = null;
|
|
301
|
+
throw err;
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
else if (mode === 'full') {
|
|
305
|
+
// Upgrade an in-flight basic preload once it finishes.
|
|
306
|
+
return this.preloadPromise.then(() => this.ensureColdStartMode('full'));
|
|
307
|
+
}
|
|
308
|
+
return this.preloadPromise;
|
|
309
|
+
}
|
|
310
|
+
/** Alias of preload(), matching the Sency-style naming. */
|
|
311
|
+
warmup(options) {
|
|
312
|
+
return this.preload(options);
|
|
313
|
+
}
|
|
314
|
+
/** Current preferred cold-start mode (for hosts / WebView wiring). */
|
|
315
|
+
getColdStartMode() {
|
|
316
|
+
return this.coldStartMode;
|
|
317
|
+
}
|
|
318
|
+
async doPreload() {
|
|
319
|
+
// 1. Online runtime descriptor (thin page runtime + CDN/model URLs).
|
|
320
|
+
// Network fetch of TF.js/model happens inside the WebView at warm-up.
|
|
321
|
+
await this.getRuntimeParts();
|
|
322
|
+
// 2. Handshake — optional for keypoints-only; required path for engine.
|
|
323
|
+
// Never fatal by itself (offline API / no key → keypoints-only).
|
|
324
|
+
this.setStatus('configuring');
|
|
325
|
+
await this.resolveManifestOnce();
|
|
326
|
+
// 3. Engine (API key path, never fatal — degrades to keypoints-only) ---
|
|
327
|
+
this.setStatus('downloading');
|
|
328
|
+
await this.tryConfigure({ silentStatus: true });
|
|
329
|
+
// 4. Flush queued anonymous usage events (best-effort) -----------------
|
|
330
|
+
void this.tracker.flushQueue();
|
|
331
|
+
// 5. WebView backend warm-up (fatal on failure) ------------------------
|
|
332
|
+
// Waits for page `ready` after CDN TF.js + remote model load.
|
|
333
|
+
try {
|
|
334
|
+
this.setStatus('warming');
|
|
335
|
+
await this.initAndWarmupBackend();
|
|
336
|
+
}
|
|
337
|
+
catch (err) {
|
|
338
|
+
this.reportError({
|
|
339
|
+
type: 'error',
|
|
340
|
+
code: 'model_load_failed',
|
|
341
|
+
message: err instanceof Error ? err.message : String(err),
|
|
342
|
+
});
|
|
343
|
+
this.setStatus('error');
|
|
344
|
+
throw err;
|
|
345
|
+
}
|
|
346
|
+
this.setStatus('ready');
|
|
347
|
+
this.emit({
|
|
348
|
+
type: 'initialization',
|
|
349
|
+
step: 'ready',
|
|
350
|
+
message: 'running',
|
|
351
|
+
ready: true,
|
|
352
|
+
mode: this.mode,
|
|
353
|
+
acceleration: this.getAcceleration(),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* After model ready, optionally open the camera for `coldStart: 'full'`.
|
|
358
|
+
* No-op for basic, or when the WebView already booted with coldStart=full.
|
|
359
|
+
*/
|
|
360
|
+
async ensureColdStartMode(mode) {
|
|
361
|
+
if (mode !== 'full')
|
|
362
|
+
return;
|
|
363
|
+
const backend = this.backend;
|
|
364
|
+
if (!(backend instanceof WebViewPoseBackend_1.WebViewPoseBackend))
|
|
365
|
+
return;
|
|
366
|
+
if (backend.isCameraOpened())
|
|
367
|
+
return;
|
|
368
|
+
await backend.openCamera();
|
|
369
|
+
}
|
|
370
|
+
// -------------------------------------------------------------------------
|
|
371
|
+
// Pose-runtime (online — CDN TF.js + remote model URL)
|
|
372
|
+
// -------------------------------------------------------------------------
|
|
373
|
+
/**
|
|
374
|
+
* Online runtime descriptor used by {@link WebViewPoseView} / {@link buildPoseHtml}.
|
|
375
|
+
* Resolves model URL + CDN script list synchronously; the WebView performs
|
|
376
|
+
* the actual network fetches at boot.
|
|
377
|
+
*/
|
|
378
|
+
getRuntimeParts() {
|
|
379
|
+
if (!this.runtimePromise) {
|
|
380
|
+
this.runtimePromise = Promise.resolve(this.loadOnlineRuntime());
|
|
381
|
+
}
|
|
382
|
+
return this.runtimePromise;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Load a custom skeleton overlay by Strapi `api_uuid`
|
|
386
|
+
* (WebView `?skeleton=<uuid>`). Pass the result to
|
|
387
|
+
* `<WebViewPoseView skeletonDef={…} />` or let the view fetch via
|
|
388
|
+
* `skeletonUuid`.
|
|
389
|
+
*/
|
|
390
|
+
fetchSkeleton(uuid) {
|
|
391
|
+
return (0, skeleton_1.fetchSkeletonDefinition)(uuid, { baseUrl: this.options.baseUrl });
|
|
392
|
+
}
|
|
393
|
+
loadOnlineRuntime() {
|
|
394
|
+
const parts = (0, onlineRuntime_1.getOnlineRuntimeParts)({
|
|
395
|
+
model: this.options.model,
|
|
396
|
+
modelUrl: this.options.modelUrl,
|
|
397
|
+
tfjsCdnBase: this.options.tfjsCdnBase,
|
|
398
|
+
tfjsVersion: this.options.tfjsVersion,
|
|
399
|
+
});
|
|
400
|
+
this.options.onDiagnostic?.(`[posetracker-light] pose-runtime online version=${parts.version} ` +
|
|
401
|
+
`modelId=${parts.modelId} modelUrl=${parts.modelUrl}`);
|
|
402
|
+
return parts;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* One handshake per configuration cycle — shared by the pose-runtime warm
|
|
406
|
+
* and the engine load, with or without API key. Never throws; resolves to
|
|
407
|
+
* null when unreachable AND no sealed session cache can be replayed.
|
|
408
|
+
*/
|
|
409
|
+
resolveManifestOnce() {
|
|
410
|
+
if (!this.handshakePromise) {
|
|
411
|
+
this.handshakePromise = this.doResolveManifestOnce().then((manifest) => {
|
|
412
|
+
if (!manifest) {
|
|
413
|
+
// Allow later retries (network may come back).
|
|
414
|
+
this.handshakePromise = null;
|
|
415
|
+
}
|
|
416
|
+
return manifest;
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
return this.handshakePromise;
|
|
420
|
+
}
|
|
421
|
+
async doResolveManifestOnce() {
|
|
422
|
+
const localVersions = {
|
|
423
|
+
poseRuntime: (0, onlineRuntime_1.getOnlineRuntimeVersion)(),
|
|
424
|
+
engine: (await this.files?.read(ENGINE_VERSION_KEY).catch(() => null)) ?? null,
|
|
425
|
+
};
|
|
426
|
+
if (this.apiToken) {
|
|
427
|
+
const manifest = await this.resolveManifest(this.apiToken, localVersions);
|
|
428
|
+
if (manifest) {
|
|
429
|
+
this.manifest = manifest;
|
|
430
|
+
if (manifest.revoked === true) {
|
|
431
|
+
await this.handleRevocation('Access revoked by the backend.');
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return manifest;
|
|
435
|
+
}
|
|
436
|
+
// Keyless handshake: public manifest (pose-runtime descriptor only).
|
|
437
|
+
try {
|
|
438
|
+
const manifest = await (0, configure_1.configure)(null, { ...this.options, localVersions });
|
|
439
|
+
this.manifest = manifest;
|
|
440
|
+
return manifest;
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
// Offline keyless: the runtime cache decides what is possible.
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Revocation signal: purge the sealed engine artifacts and downgrade to
|
|
449
|
+
* keypoints-only. The public pose-runtime cache is NOT purged (public
|
|
450
|
+
* payload, keypoints-only keeps working).
|
|
451
|
+
*/
|
|
452
|
+
async handleRevocation(message) {
|
|
453
|
+
const engineVersion = await this.files?.read(ENGINE_VERSION_KEY).catch(() => null);
|
|
454
|
+
if (engineVersion) {
|
|
455
|
+
await this.files?.remove(`engine-${engineVersion}.sealed`).catch(() => { });
|
|
456
|
+
await this.files?.remove(ENGINE_VERSION_KEY).catch(() => { });
|
|
457
|
+
}
|
|
458
|
+
await this.files?.remove(MANIFEST_CACHE_KEY).catch(() => { });
|
|
459
|
+
this.engine = null;
|
|
460
|
+
this.engineSource = null;
|
|
461
|
+
this.stopExercise();
|
|
462
|
+
this.setMode('keypoints-only');
|
|
463
|
+
this.reportError({ type: 'error', code: 'invalid_token', message });
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Usage metering — fired when the WebView reports the camera started with
|
|
467
|
+
* the model ready. With an API key the call MUST succeed (quota check +
|
|
468
|
+
* counter increment + usage row): offline metered sessions are refused.
|
|
469
|
+
* Without a key the event is fire-and-forget with a local retry queue.
|
|
470
|
+
*/
|
|
471
|
+
async handleCameraStart(info) {
|
|
472
|
+
this.lastCameraStartInfo = info;
|
|
473
|
+
const params = {
|
|
474
|
+
backend: info.backend,
|
|
475
|
+
profileId: info.profileId,
|
|
476
|
+
poseModelProfile: this.options.poseModelProfile ?? 'AdaptiveChoice',
|
|
477
|
+
qualityChoice: this.options.qualityChoice ?? 'AdaptiveChoice',
|
|
478
|
+
mode: this.mode,
|
|
479
|
+
exercise: this.currentExerciseId,
|
|
480
|
+
runtimeVersion: (0, onlineRuntime_1.getOnlineRuntimeVersion)(),
|
|
481
|
+
model: this.options.model ?? 'movenet',
|
|
482
|
+
modelUrl: this.options.modelUrl ?? null,
|
|
483
|
+
};
|
|
484
|
+
if (!this.apiToken) {
|
|
485
|
+
void this.tracker.trackAnonymous({ event: 'camera_start', params });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
this.meteredSessionState = 'pending';
|
|
489
|
+
try {
|
|
490
|
+
await this.tracker.trackMetered({ event: 'camera_start', apiToken: this.apiToken, params });
|
|
491
|
+
this.meteredSessionState = 'validated';
|
|
492
|
+
this.options.onDiagnostic?.('[posetracker] camera_start tracked (metered session validated)');
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
this.meteredSessionState = 'refused';
|
|
496
|
+
if (err instanceof track_1.TrackError && err.code === 'network') {
|
|
497
|
+
this.reportError({
|
|
498
|
+
type: 'error',
|
|
499
|
+
code: 'offline_metered',
|
|
500
|
+
message: 'API-key features are not available offline: PoseTracker cannot ' +
|
|
501
|
+
'count their usage. Keypoints-only keeps running from the cache; ' +
|
|
502
|
+
'reconnect to start a metered session.',
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
else if (err instanceof track_1.TrackError && err.code === 'invalid_token') {
|
|
506
|
+
await this.handleRevocation('API key invalid or revoked — engine cache purged.');
|
|
507
|
+
}
|
|
508
|
+
else if (err instanceof track_1.TrackError && err.code === 'quota_exceeded') {
|
|
509
|
+
this.reportError({ type: 'error', code: 'quota_exceeded', message: err.message });
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
this.reportError({
|
|
513
|
+
type: 'error',
|
|
514
|
+
code: 'internal',
|
|
515
|
+
message: `Usage tracking failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
/** Backend init + warm-up. An init failure surfaces as status 'error'. */
|
|
521
|
+
async initAndWarmupBackend() {
|
|
522
|
+
try {
|
|
523
|
+
await this.backend.init({ model: this.resolveModel() });
|
|
524
|
+
await this.backend.warmup();
|
|
525
|
+
}
|
|
526
|
+
catch (err) {
|
|
527
|
+
(0, logReport_1.logAccelerationReport)(this.getAccelerationDiagnostics(), {
|
|
528
|
+
phase: 'init-failed',
|
|
529
|
+
error: err instanceof Error ? err.message : String(err),
|
|
530
|
+
});
|
|
531
|
+
throw err;
|
|
532
|
+
}
|
|
533
|
+
// Always dump the full report to Metro after warm-up.
|
|
534
|
+
(0, logReport_1.logAccelerationReport)(this.getAccelerationDiagnostics(), {
|
|
535
|
+
phase: 'warmup-complete',
|
|
536
|
+
backend: this.backend.name,
|
|
537
|
+
mode: this.mode,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Runtime (re)configuration — the keypoints-only → full-engine upgrade
|
|
542
|
+
* path. Can be called before or after `ready`; when the camera pipeline
|
|
543
|
+
* is already running it keeps running, business events simply start once
|
|
544
|
+
* the engine is loaded. Resolves to true when full-engine mode is active.
|
|
545
|
+
*/
|
|
546
|
+
configure(apiToken) {
|
|
547
|
+
if (apiToken !== undefined && apiToken !== this.apiToken) {
|
|
548
|
+
this.apiToken = apiToken;
|
|
549
|
+
this.configurePromise = null;
|
|
550
|
+
// New credentials: re-handshake (the previous one may be keyless).
|
|
551
|
+
this.handshakePromise = null;
|
|
552
|
+
// The plan may change with the token: re-arm the feature gating errors.
|
|
553
|
+
this.featureGateReported.freeBlock = false;
|
|
554
|
+
this.featureGateReported.missingToken = false;
|
|
555
|
+
}
|
|
556
|
+
// Explicit configure() (e.g. Test key) must be allowed to retry even when
|
|
557
|
+
// a previous attempt failed or the crash-guard blocked the bundle.
|
|
558
|
+
if (this.mode !== 'full-engine') {
|
|
559
|
+
this.configurePromise = null;
|
|
560
|
+
}
|
|
561
|
+
if (!this.configurePromise) {
|
|
562
|
+
this.configurePromise = this.tryConfigure({
|
|
563
|
+
silentStatus: this.status === 'ready',
|
|
564
|
+
forceEngineRetry: true,
|
|
565
|
+
})
|
|
566
|
+
.then((ok) => {
|
|
567
|
+
if (!ok) {
|
|
568
|
+
// Allow retries (network may come back).
|
|
569
|
+
this.configurePromise = null;
|
|
570
|
+
}
|
|
571
|
+
// Network is (possibly) back: a metered session refused at camera
|
|
572
|
+
// start (offline) can now be validated without restarting the camera.
|
|
573
|
+
this.retryMeteredSessionIfRefused();
|
|
574
|
+
return ok;
|
|
575
|
+
})
|
|
576
|
+
.catch(() => {
|
|
577
|
+
this.configurePromise = null;
|
|
578
|
+
return false;
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
return this.configurePromise;
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Re-run the `camera_start` metered gate after a configure() retry. A
|
|
585
|
+
* session refused offline becomes usable as soon as the network is back —
|
|
586
|
+
* WebView parity: the front revalidates on reload, the SDK on configure().
|
|
587
|
+
*/
|
|
588
|
+
retryMeteredSessionIfRefused() {
|
|
589
|
+
if (this.meteredSessionState === 'refused' && this.apiToken && this.lastCameraStartInfo) {
|
|
590
|
+
this.options.onDiagnostic?.('[posetracker] retrying metered camera_start after configure() (was refused)');
|
|
591
|
+
void this.handleCameraStart(this.lastCameraStartInfo);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Handshake + engine load. Never throws; returns whether full-engine mode
|
|
596
|
+
* was reached. `silentStatus` avoids status regressions (e.g. hot upgrade
|
|
597
|
+
* while `ready` and the camera is streaming).
|
|
598
|
+
*/
|
|
599
|
+
async tryConfigure({ silentStatus, forceEngineRetry = false, }) {
|
|
600
|
+
if (this.mode === 'full-engine') {
|
|
601
|
+
return true;
|
|
602
|
+
}
|
|
603
|
+
if (!this.apiToken) {
|
|
604
|
+
// No API key: keypoints-only, by design. Not an error — unless the
|
|
605
|
+
// host requested key-gated features (WebView parity: token required).
|
|
606
|
+
this.validateRequestedFeatures();
|
|
607
|
+
this.setMode('keypoints-only');
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
const manifest = await this.resolveManifestOnce();
|
|
611
|
+
if (!manifest || manifest.revoked === true) {
|
|
612
|
+
this.setMode('keypoints-only');
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
this.manifest = manifest;
|
|
616
|
+
// Plan is now known: replicate the TrackingAppV3 load-time gating.
|
|
617
|
+
this.validateRequestedFeatures();
|
|
618
|
+
if (!silentStatus) {
|
|
619
|
+
this.setStatus('downloading');
|
|
620
|
+
}
|
|
621
|
+
const secret = (0, obfuscate_1.deriveCacheSecret)(this.apiToken);
|
|
622
|
+
if (forceEngineRetry && manifest.engine) {
|
|
623
|
+
await this.engineLoader.clearGuard(manifest.engine);
|
|
624
|
+
}
|
|
625
|
+
const result = await this.engineLoader.load(manifest.engine ?? null, secret, {
|
|
626
|
+
forceRetry: forceEngineRetry,
|
|
627
|
+
});
|
|
628
|
+
if (!result) {
|
|
629
|
+
const detail = this.engineLoader.lastError;
|
|
630
|
+
this.reportError({
|
|
631
|
+
type: 'error',
|
|
632
|
+
code: 'engine_load_failed',
|
|
633
|
+
message: detail
|
|
634
|
+
? `Engine bundle unavailable — ${detail}`
|
|
635
|
+
: 'Engine bundle unavailable (offline without cache, or integrity/evaluation failure) — running keypoints-only.',
|
|
636
|
+
});
|
|
637
|
+
this.setMode('keypoints-only');
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
this.engine = result.engine;
|
|
641
|
+
this.engineSource = result.source;
|
|
642
|
+
// Remember the sealed engine version so a revocation can purge it.
|
|
643
|
+
if (manifest.engine?.version) {
|
|
644
|
+
await this.files?.write(ENGINE_VERSION_KEY, manifest.engine.version).catch(() => { });
|
|
645
|
+
}
|
|
646
|
+
this.setMode('full-engine');
|
|
647
|
+
return true;
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Handshake with the session-cache fallback:
|
|
651
|
+
* - live call → cache the manifest, sealed with the token-derived secret;
|
|
652
|
+
* - network/server failure → replay the sealed cached manifest (only
|
|
653
|
+
* readable with the same token: the "already configured once" proof);
|
|
654
|
+
* - invalid token → purge the cache (a revoked key must not keep the
|
|
655
|
+
* engine alive) and report a non-fatal error;
|
|
656
|
+
* - quota exceeded → non-fatal error, cache kept (transient condition)
|
|
657
|
+
* but not replayed this session.
|
|
658
|
+
*/
|
|
659
|
+
async resolveManifest(apiToken, localVersions) {
|
|
660
|
+
const secret = (0, obfuscate_1.deriveCacheSecret)(apiToken);
|
|
661
|
+
try {
|
|
662
|
+
const manifest = await (0, configure_1.configure)(apiToken, { ...this.options, localVersions });
|
|
663
|
+
await this.files?.write(MANIFEST_CACHE_KEY, (0, obfuscate_1.sealString)(JSON.stringify(manifest), secret)).catch(() => { });
|
|
664
|
+
return manifest;
|
|
665
|
+
}
|
|
666
|
+
catch (err) {
|
|
667
|
+
if (err instanceof configure_1.ConfigureError && err.code === 'invalid_token') {
|
|
668
|
+
// Revoked/invalid key: purge the sealed session AND engine caches —
|
|
669
|
+
// a revoked key must not keep the business logic alive.
|
|
670
|
+
await this.handleRevocation(err.message);
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
if (err instanceof configure_1.ConfigureError && err.code === 'quota_exceeded') {
|
|
674
|
+
this.reportError({ type: 'error', code: 'quota_exceeded', message: err.message });
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
// Network/server failure: replay the encrypted session cache.
|
|
678
|
+
const sealed = await this.files?.read(MANIFEST_CACHE_KEY);
|
|
679
|
+
if (sealed) {
|
|
680
|
+
const plain = (0, obfuscate_1.openString)(sealed, secret);
|
|
681
|
+
if (plain) {
|
|
682
|
+
try {
|
|
683
|
+
return JSON.parse(plain);
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
await this.files?.remove(MANIFEST_CACHE_KEY).catch(() => { });
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
this.reportError({
|
|
691
|
+
type: 'error',
|
|
692
|
+
code: 'network',
|
|
693
|
+
message: 'Handshake unreachable and no cached session — running keypoints-only.',
|
|
694
|
+
});
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* WebView parity — the load-time gating of `TrackingAppV3`:
|
|
700
|
+
* - `blazepose` / `poseEngine` / other WebView-only keys → clear error
|
|
701
|
+
* (this SDK ships MoveNet Lightning only);
|
|
702
|
+
* - developer features requested WITHOUT an API key → the front's exact
|
|
703
|
+
* "Invalid params… token=YOUR API_KEY…" message;
|
|
704
|
+
* - plan `free` + angles/recommendations/progression → the front's exact
|
|
705
|
+
* "You cannot use developer features." message (keypoints alone stays
|
|
706
|
+
* allowed here: pose-only mode is free; the +exercise case is enforced
|
|
707
|
+
* in {@link startExercise}).
|
|
708
|
+
* All non-fatal: keypoints-only pose estimation keeps running. One-shot
|
|
709
|
+
* per condition so `configure()` retries don't spam the host.
|
|
710
|
+
*/
|
|
711
|
+
validateRequestedFeatures() {
|
|
712
|
+
if (this.unsupportedFeatureKeys.length > 0 && !this.featureGateReported.unsupported) {
|
|
713
|
+
this.featureGateReported.unsupported = true;
|
|
714
|
+
this.reportError({
|
|
715
|
+
type: 'error',
|
|
716
|
+
code: 'feature_not_supported',
|
|
717
|
+
message: (0, features_1.featureNotSupportedMessage)(this.unsupportedFeatureKeys[0]),
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
const f = this.features;
|
|
721
|
+
const requestsDevFeatures = f.angles || f.recommendations || f.progression || f.keypoints;
|
|
722
|
+
if (!requestsDevFeatures) {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (!this.apiToken) {
|
|
726
|
+
if (!this.featureGateReported.missingToken) {
|
|
727
|
+
this.featureGateReported.missingToken = true;
|
|
728
|
+
this.reportError({ type: 'error', code: 'invalid_token', message: features_1.INVALID_TOKEN_MESSAGE });
|
|
729
|
+
}
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (this.getPlanType() === 'free') {
|
|
733
|
+
const blocked = (0, features_1.freeBlockedFeatures)(f, { withExercise: false });
|
|
734
|
+
if (blocked.length > 0 && !this.featureGateReported.freeBlock) {
|
|
735
|
+
this.featureGateReported.freeBlock = true;
|
|
736
|
+
this.options.onDiagnostic?.(`[posetracker] free plan blocked features: ${blocked.join(', ')}`);
|
|
737
|
+
this.reportError({
|
|
738
|
+
type: 'error',
|
|
739
|
+
code: 'free_plan_feature_blocked',
|
|
740
|
+
message: features_1.FREE_PLAN_FEATURES_MESSAGE,
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
resolveModel() {
|
|
746
|
+
if (this.manifest) {
|
|
747
|
+
const model = this.manifest.models[this.manifest.resolvedProfile];
|
|
748
|
+
if (model) {
|
|
749
|
+
return model;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return DEFAULT_MOVENET;
|
|
753
|
+
}
|
|
754
|
+
// -------------------------------------------------------------------------
|
|
755
|
+
// Exercise sessions (full-engine mode only)
|
|
756
|
+
// -------------------------------------------------------------------------
|
|
757
|
+
getAvailableExercises() {
|
|
758
|
+
return this.mode === 'full-engine' ? this.manifest?.exercises ?? [] : [];
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Custom exercises shipped inside the engine bundle (jump_analysis,
|
|
762
|
+
* air_time_jump — WebView `customHandlers.js` parity). Empty in
|
|
763
|
+
* keypoints-only mode or with an engine bundle older than 1.2.0.
|
|
764
|
+
*/
|
|
765
|
+
getAvailableCustomExercises() {
|
|
766
|
+
if (this.mode !== 'full-engine' || !this.engine?.listCustomExercises) {
|
|
767
|
+
return [];
|
|
768
|
+
}
|
|
769
|
+
return this.engine.listCustomExercises();
|
|
770
|
+
}
|
|
771
|
+
startExercise(exerciseId, options = {}) {
|
|
772
|
+
if (this.status !== 'ready') {
|
|
773
|
+
throw new Error(`Cannot start exercise while status is '${this.status}' — call preload() first.`);
|
|
774
|
+
}
|
|
775
|
+
if (this.mode !== 'full-engine' || !this.engine) {
|
|
776
|
+
throw new Error("Exercises require full-engine mode (validated API key). The SDK is running keypoints-only — call configure(apiToken) first.");
|
|
777
|
+
}
|
|
778
|
+
if (this.meteredSessionState === 'refused') {
|
|
779
|
+
throw new Error('API-key features are not available offline: PoseTracker could not ' +
|
|
780
|
+
'track this session (camera_start failed — no network or quota ' +
|
|
781
|
+
'exceeded). Reconnect and retry.');
|
|
782
|
+
}
|
|
783
|
+
if (this.unsupportedFeatureKeys.length > 0) {
|
|
784
|
+
const message = (0, features_1.featureNotSupportedMessage)(this.unsupportedFeatureKeys[0]);
|
|
785
|
+
this.reportError({ type: 'error', code: 'feature_not_supported', message });
|
|
786
|
+
throw new Error(message);
|
|
787
|
+
}
|
|
788
|
+
// WebView parity: `free` cannot run developer features — and keypoints
|
|
789
|
+
// combined with an exercise counts as one (pose-only keypoints stay free).
|
|
790
|
+
if (this.getPlanType() === 'free') {
|
|
791
|
+
const blocked = (0, features_1.freeBlockedFeatures)(this.features, { withExercise: true });
|
|
792
|
+
if (blocked.length > 0) {
|
|
793
|
+
this.reportError({
|
|
794
|
+
type: 'error',
|
|
795
|
+
code: 'free_plan_feature_blocked',
|
|
796
|
+
message: features_1.FREE_PLAN_FEATURES_MESSAGE,
|
|
797
|
+
});
|
|
798
|
+
throw new Error(features_1.FREE_PLAN_FEATURES_MESSAGE);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
const available = this.getAvailableExercises();
|
|
802
|
+
const exercise = (0, aliases_1.findExerciseByIdOrAlias)(exerciseId, available);
|
|
803
|
+
if (!exercise) {
|
|
804
|
+
// Custom exercises live in the engine bundle, not the movement manifest
|
|
805
|
+
// (WebView customHandlers.js parity: jump_analysis, air_time_jump).
|
|
806
|
+
const customs = this.getAvailableCustomExercises();
|
|
807
|
+
const custom = customs.find((e) => e.id === exerciseId) ??
|
|
808
|
+
(0, aliases_1.findExerciseByIdOrAlias)(exerciseId, customs);
|
|
809
|
+
if (custom) {
|
|
810
|
+
this.startCustomExercise(custom, options);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
// Front literal (CameraFeedV3): error `invalid_exercise`.
|
|
814
|
+
const message = `Exercise '${exerciseId}' is not available in V3 engine`;
|
|
815
|
+
this.reportError({ type: 'error', code: 'invalid_exercise', message });
|
|
816
|
+
throw new Error(message);
|
|
817
|
+
}
|
|
818
|
+
this.stopExercise();
|
|
819
|
+
this.currentExerciseId = exerciseId;
|
|
820
|
+
this.keypointsSuppressionLogged = false;
|
|
821
|
+
this.session = this.engine.createSession({
|
|
822
|
+
exercise,
|
|
823
|
+
locale: this.options.locale ?? 'en',
|
|
824
|
+
difficulty: options.difficulty,
|
|
825
|
+
minGrade: this.features.minGrade ?? undefined,
|
|
826
|
+
features: {
|
|
827
|
+
angles: this.features.angles,
|
|
828
|
+
recommendations: this.features.recommendations,
|
|
829
|
+
progression: this.features.progression,
|
|
830
|
+
},
|
|
831
|
+
}, (event) => this.emitFromSession(event));
|
|
832
|
+
}
|
|
833
|
+
/** Custom engine session (jump_analysis / air_time_jump). */
|
|
834
|
+
startCustomExercise(custom, options) {
|
|
835
|
+
if (!this.engine?.createCustomSession) {
|
|
836
|
+
throw new Error(`The cached engine bundle is too old for custom exercise '${custom.id}' — reconnect so the SDK can update it.`);
|
|
837
|
+
}
|
|
838
|
+
// Front literal (CameraFeedV3): error `jump_analysis_missing_height`.
|
|
839
|
+
if (custom.id === 'jump_analysis' && (!options.userHeightCm || options.userHeightCm <= 0)) {
|
|
840
|
+
const message = 'User height (userHeightCm) must be provided for jump_analysis exercise';
|
|
841
|
+
this.reportError({ type: 'error', code: 'jump_analysis_missing_height', message });
|
|
842
|
+
throw new Error(message);
|
|
843
|
+
}
|
|
844
|
+
this.stopExercise();
|
|
845
|
+
this.currentExerciseId = custom.id;
|
|
846
|
+
this.keypointsSuppressionLogged = false;
|
|
847
|
+
this.session = this.engine.createCustomSession({
|
|
848
|
+
exerciseId: custom.id,
|
|
849
|
+
locale: this.options.locale ?? 'en',
|
|
850
|
+
userHeightCm: options.userHeightCm,
|
|
851
|
+
devicePitchDeg: options.devicePitchDeg,
|
|
852
|
+
}, (event) => this.emitFromSession(event));
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Engine → host emission gate (WebView parity): `angles`,
|
|
856
|
+
* `recommendations` and `progression` only stream when their flag is on.
|
|
857
|
+
* Double safety on top of the engine-side flags — a cached engine bundle
|
|
858
|
+
* predating features support still gets filtered here.
|
|
859
|
+
*/
|
|
860
|
+
emitFromSession(event) {
|
|
861
|
+
if (event.type === 'angles' && !this.features.angles)
|
|
862
|
+
return;
|
|
863
|
+
if (event.type === 'recommendations' && !this.features.recommendations)
|
|
864
|
+
return;
|
|
865
|
+
if (event.type === 'progression' && !this.features.progression)
|
|
866
|
+
return;
|
|
867
|
+
this.emit(event);
|
|
868
|
+
}
|
|
869
|
+
/** Ends the active session (emits a final `exercise_summary`). */
|
|
870
|
+
stopExercise() {
|
|
871
|
+
// end() runs remotely-delivered engine code — a throw must not leave the
|
|
872
|
+
// client stuck with a dead session (startExercise calls stopExercise).
|
|
873
|
+
try {
|
|
874
|
+
this.session?.end();
|
|
875
|
+
}
|
|
876
|
+
catch (err) {
|
|
877
|
+
this.options.onDiagnostic?.('[posetracker] engine session end() threw: ' +
|
|
878
|
+
(err instanceof Error ? err.message : String(err)));
|
|
879
|
+
}
|
|
880
|
+
this.session = null;
|
|
881
|
+
this.currentExerciseId = null;
|
|
882
|
+
this.sessionErrorStreak = 0;
|
|
883
|
+
}
|
|
884
|
+
getCurrentExerciseId() {
|
|
885
|
+
return this.currentExerciseId;
|
|
886
|
+
}
|
|
887
|
+
// -------------------------------------------------------------------------
|
|
888
|
+
// Frame pipeline (both modes)
|
|
889
|
+
// -------------------------------------------------------------------------
|
|
890
|
+
/** Active inference backend (auto-selected, forced, or injected). */
|
|
891
|
+
getBackend() {
|
|
892
|
+
return this.backend;
|
|
893
|
+
}
|
|
894
|
+
/** Raw pose estimation, no engine involvement. */
|
|
895
|
+
estimatePose(frame) {
|
|
896
|
+
return this.backend.estimatePose(frame);
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Full pipeline for one camera frame: pose estimation + `keypoints` event
|
|
900
|
+
* (both modes), then engine processing when a session is active
|
|
901
|
+
* (full-engine mode). Mode upgrades take effect transparently here.
|
|
902
|
+
*/
|
|
903
|
+
async processFrame(frame) {
|
|
904
|
+
const pose = await this.estimatePose(frame);
|
|
905
|
+
if (!pose) {
|
|
906
|
+
return null;
|
|
907
|
+
}
|
|
908
|
+
this.ingestPose(pose);
|
|
909
|
+
return pose;
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Feed an externally-estimated pose into the pipeline: `keypoints` event
|
|
913
|
+
* (both modes) + engine processing when a session is active. This is how
|
|
914
|
+
* the vision-camera path (`PoseCameraView`) delivers poses computed
|
|
915
|
+
* synchronously inside the frame-processor worklet.
|
|
916
|
+
*/
|
|
917
|
+
ingestPose(pose) {
|
|
918
|
+
// WebView parity: DURING an exercise session, raw keypoints only stream
|
|
919
|
+
// when the `keypoints` feature is on (paid plans — free + keypoints +
|
|
920
|
+
// exercise is rejected in startExercise). Pose-only mode (no session)
|
|
921
|
+
// always streams: that is the SDK's free offline base.
|
|
922
|
+
if (!this.session || this.features.keypoints) {
|
|
923
|
+
this.emit({
|
|
924
|
+
type: 'keypoints',
|
|
925
|
+
keypoints: pose.keypoints,
|
|
926
|
+
score: pose.score,
|
|
927
|
+
timestampMs: pose.timestampMs,
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
else if (!this.keypointsSuppressionLogged) {
|
|
931
|
+
this.keypointsSuppressionLogged = true;
|
|
932
|
+
this.options.onDiagnostic?.('[posetracker] keypoints events paused during the exercise session ' +
|
|
933
|
+
'(features.keypoints=false, WebView parity) — they resume on stopExercise().');
|
|
934
|
+
}
|
|
935
|
+
if (this.session) {
|
|
936
|
+
// The engine is remotely-delivered code: one bad frame must not crash
|
|
937
|
+
// the ingest path, and a session that fails on EVERY frame must not
|
|
938
|
+
// keep throwing at camera rate (battery). After a streak of failures
|
|
939
|
+
// the session is terminated with an error event.
|
|
940
|
+
try {
|
|
941
|
+
this.session.processPose(pose);
|
|
942
|
+
this.sessionErrorStreak = 0;
|
|
943
|
+
}
|
|
944
|
+
catch (err) {
|
|
945
|
+
this.sessionErrorStreak += 1;
|
|
946
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
947
|
+
if (this.sessionErrorStreak === 1) {
|
|
948
|
+
this.options.onDiagnostic?.(`[posetracker] engine session processPose threw: ${message}`);
|
|
949
|
+
}
|
|
950
|
+
if (this.sessionErrorStreak >= SESSION_ERROR_STREAK_LIMIT) {
|
|
951
|
+
const exerciseId = this.currentExerciseId;
|
|
952
|
+
this.session = null; // skip end(): the session is already broken
|
|
953
|
+
this.currentExerciseId = null;
|
|
954
|
+
this.sessionErrorStreak = 0;
|
|
955
|
+
this.reportError({
|
|
956
|
+
type: 'error',
|
|
957
|
+
code: 'internal',
|
|
958
|
+
message: `Exercise session '${exerciseId ?? '?'}' failed repeatedly ` +
|
|
959
|
+
`(${message}) — session stopped, keypoints keep streaming.`,
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
async dispose() {
|
|
966
|
+
this.stopExercise();
|
|
967
|
+
this.quality.setApplyProfile(undefined);
|
|
968
|
+
await this.backend.dispose();
|
|
969
|
+
this.listeners.clear();
|
|
970
|
+
this.messageListeners.clear();
|
|
971
|
+
this.stateListeners.clear();
|
|
972
|
+
this.preloadPromise = null;
|
|
973
|
+
this.configurePromise = null;
|
|
974
|
+
this.handshakePromise = null;
|
|
975
|
+
this.runtimePromise = null;
|
|
976
|
+
this.meteredSessionState = 'idle';
|
|
977
|
+
this.lastCameraStartInfo = null;
|
|
978
|
+
this.featureGateReported = { unsupported: false, freeBlock: false, missingToken: false };
|
|
979
|
+
this.keypointsSuppressionLogged = false;
|
|
980
|
+
this.engine = null;
|
|
981
|
+
this.engineSource = null;
|
|
982
|
+
this.manifest = null;
|
|
983
|
+
this.mode = 'keypoints-only';
|
|
984
|
+
this.setStatus('idle');
|
|
985
|
+
}
|
|
986
|
+
// -------------------------------------------------------------------------
|
|
987
|
+
setStatus(status) {
|
|
988
|
+
this.status = status;
|
|
989
|
+
if (status === 'configuring' || status === 'downloading' || status === 'warming') {
|
|
990
|
+
this.emit({
|
|
991
|
+
type: 'initialization',
|
|
992
|
+
step: status,
|
|
993
|
+
message: status === 'configuring'
|
|
994
|
+
// Front / GitBook literal (sic): hosts may string-match this.
|
|
995
|
+
? 'checking you plan and access'
|
|
996
|
+
: status === 'downloading'
|
|
997
|
+
? 'downloading engine'
|
|
998
|
+
: 'loading pose model',
|
|
999
|
+
ready: false,
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
this.notifyState();
|
|
1003
|
+
}
|
|
1004
|
+
setMode(mode) {
|
|
1005
|
+
if (this.mode !== mode) {
|
|
1006
|
+
this.mode = mode;
|
|
1007
|
+
this.notifyState();
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
reportError(error) {
|
|
1011
|
+
this.lastError = error;
|
|
1012
|
+
this.emit(error);
|
|
1013
|
+
this.notifyState();
|
|
1014
|
+
}
|
|
1015
|
+
notifyState() {
|
|
1016
|
+
this.stateListeners.forEach((l) => l());
|
|
1017
|
+
}
|
|
1018
|
+
emit(event) {
|
|
1019
|
+
// A throwing host listener must never break other listeners or the frame
|
|
1020
|
+
// pipeline (emit is called from the per-frame ingest path).
|
|
1021
|
+
this.listeners.forEach((l) => {
|
|
1022
|
+
try {
|
|
1023
|
+
l(event);
|
|
1024
|
+
}
|
|
1025
|
+
catch (err) {
|
|
1026
|
+
this.options.onDiagnostic?.(`[posetracker] event listener threw on '${event.type}': ` +
|
|
1027
|
+
(err instanceof Error ? err.message : String(err)));
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
if (this.messageListeners.size === 0) {
|
|
1031
|
+
// Skip the classic-message conversion entirely (per-frame allocation).
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
const classic = (0, classicMessage_1.toClassicNativeMessage)(event);
|
|
1035
|
+
this.messageListeners.forEach((l) => {
|
|
1036
|
+
try {
|
|
1037
|
+
l(classic);
|
|
1038
|
+
}
|
|
1039
|
+
catch (err) {
|
|
1040
|
+
this.options.onDiagnostic?.(`[posetracker] message listener threw on '${event.type}': ` +
|
|
1041
|
+
(err instanceof Error ? err.message : String(err)));
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
exports.PoseTrackerClient = PoseTrackerClient;
|