@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.
Files changed (125) hide show
  1. package/LICENSE +45 -0
  2. package/PoseTrackerVision.podspec +28 -0
  3. package/README.md +161 -0
  4. package/THIRD_PARTY_NOTICES.md +19 -0
  5. package/ios/PoseTrackerVision/PoseTrackerBodyPosePlugin.m +18 -0
  6. package/ios/PoseTrackerVision/PoseTrackerBodyPosePlugin.swift +108 -0
  7. package/lib/PoseTrackerProvider.d.ts +102 -0
  8. package/lib/PoseTrackerProvider.js +204 -0
  9. package/lib/api/configure.d.ts +29 -0
  10. package/lib/api/configure.js +64 -0
  11. package/lib/api/skeleton.d.ts +17 -0
  12. package/lib/api/skeleton.js +53 -0
  13. package/lib/api/track.d.ts +61 -0
  14. package/lib/api/track.js +146 -0
  15. package/lib/backends/PoseBackend.d.ts +50 -0
  16. package/lib/backends/PoseBackend.js +11 -0
  17. package/lib/backends/vision/VisionPoseBackend.d.ts +72 -0
  18. package/lib/backends/vision/VisionPoseBackend.js +177 -0
  19. package/lib/backends/vision/mapVisionJoints.d.ts +35 -0
  20. package/lib/backends/vision/mapVisionJoints.js +162 -0
  21. package/lib/backends/vision/optionalVision.d.ts +32 -0
  22. package/lib/backends/vision/optionalVision.js +76 -0
  23. package/lib/backends/webview/WebViewPoseBackend.d.ts +223 -0
  24. package/lib/backends/webview/WebViewPoseBackend.js +382 -0
  25. package/lib/backends/webview/brandAssets.d.ts +5 -0
  26. package/lib/backends/webview/brandAssets.js +8 -0
  27. package/lib/backends/webview/onlineRuntime.d.ts +45 -0
  28. package/lib/backends/webview/onlineRuntime.js +57 -0
  29. package/lib/backends/webview/poseHtml.d.ts +71 -0
  30. package/lib/backends/webview/poseHtml.js +188 -0
  31. package/lib/backends/webview/poseRuntimeSource.d.ts +3 -0
  32. package/lib/backends/webview/poseRuntimeSource.js +4 -0
  33. package/lib/cache/obfuscate.d.ts +18 -0
  34. package/lib/cache/obfuscate.js +90 -0
  35. package/lib/camera/PoseCameraView.d.ts +51 -0
  36. package/lib/camera/PoseCameraView.js +99 -0
  37. package/lib/camera/WebViewPoseView.d.ts +81 -0
  38. package/lib/camera/WebViewPoseView.js +312 -0
  39. package/lib/client.d.ts +361 -0
  40. package/lib/client.js +1046 -0
  41. package/lib/diagnostics/logReport.d.ts +23 -0
  42. package/lib/diagnostics/logReport.js +102 -0
  43. package/lib/engine/EngineLoader.d.ts +95 -0
  44. package/lib/engine/EngineLoader.js +347 -0
  45. package/lib/engine/types.d.ts +83 -0
  46. package/lib/engine/types.js +11 -0
  47. package/lib/events/classicMessage.d.ts +31 -0
  48. package/lib/events/classicMessage.js +259 -0
  49. package/lib/exercises/aliases.d.ts +18 -0
  50. package/lib/exercises/aliases.js +48 -0
  51. package/lib/index.d.ts +72 -0
  52. package/lib/index.js +170 -0
  53. package/lib/models/poseModels.d.ts +39 -0
  54. package/lib/models/poseModels.js +63 -0
  55. package/lib/quality/AdaptiveQualityController.d.ts +120 -0
  56. package/lib/quality/AdaptiveQualityController.js +423 -0
  57. package/lib/quality/RuntimeGuard.d.ts +34 -0
  58. package/lib/quality/RuntimeGuard.js +105 -0
  59. package/lib/quality/captureMode.d.ts +82 -0
  60. package/lib/quality/captureMode.js +75 -0
  61. package/lib/quality/deviceCapability.d.ts +37 -0
  62. package/lib/quality/deviceCapability.js +177 -0
  63. package/lib/quality/profiles.d.ts +125 -0
  64. package/lib/quality/profiles.js +202 -0
  65. package/lib/runtime/RuntimeCache.d.ts +75 -0
  66. package/lib/runtime/RuntimeCache.js +230 -0
  67. package/lib/sdkVersion.d.ts +2 -0
  68. package/lib/sdkVersion.js +5 -0
  69. package/lib/support/optionalModules.d.ts +46 -0
  70. package/lib/support/optionalModules.js +82 -0
  71. package/lib/types/acceleration.d.ts +86 -0
  72. package/lib/types/acceleration.js +15 -0
  73. package/lib/types/events.d.ts +345 -0
  74. package/lib/types/events.js +10 -0
  75. package/lib/types/features.d.ts +90 -0
  76. package/lib/types/features.js +122 -0
  77. package/lib/types/manifest.d.ts +197 -0
  78. package/lib/types/manifest.js +10 -0
  79. package/lib/types/pose.d.ts +36 -0
  80. package/lib/types/pose.js +41 -0
  81. package/lib/types/preload.d.ts +17 -0
  82. package/lib/types/preload.js +10 -0
  83. package/lib/types/skeleton.d.ts +29 -0
  84. package/lib/types/skeleton.js +52 -0
  85. package/package.json +92 -0
  86. package/react-native.config.js +12 -0
  87. package/src/PoseTrackerProvider.tsx +332 -0
  88. package/src/api/configure.ts +82 -0
  89. package/src/api/skeleton.ts +60 -0
  90. package/src/api/track.ts +182 -0
  91. package/src/backends/PoseBackend.ts +59 -0
  92. package/src/backends/vision/VisionPoseBackend.ts +238 -0
  93. package/src/backends/vision/mapVisionJoints.ts +185 -0
  94. package/src/backends/vision/optionalVision.ts +94 -0
  95. package/src/backends/webview/WebViewPoseBackend.ts +582 -0
  96. package/src/backends/webview/brandAssets.ts +5 -0
  97. package/src/backends/webview/onlineRuntime.ts +94 -0
  98. package/src/backends/webview/poseHtml.ts +259 -0
  99. package/src/backends/webview/poseRuntimeSource.d.ts +3 -0
  100. package/src/cache/obfuscate.ts +94 -0
  101. package/src/camera/PoseCameraView.tsx +223 -0
  102. package/src/camera/WebViewPoseView.tsx +466 -0
  103. package/src/client.ts +1295 -0
  104. package/src/diagnostics/logReport.ts +131 -0
  105. package/src/engine/EngineLoader.ts +428 -0
  106. package/src/engine/types.ts +93 -0
  107. package/src/events/classicMessage.ts +271 -0
  108. package/src/exercises/aliases.ts +50 -0
  109. package/src/index.ts +243 -0
  110. package/src/models/poseModels.ts +93 -0
  111. package/src/quality/AdaptiveQualityController.ts +579 -0
  112. package/src/quality/RuntimeGuard.ts +119 -0
  113. package/src/quality/captureMode.ts +97 -0
  114. package/src/quality/deviceCapability.ts +187 -0
  115. package/src/quality/profiles.ts +253 -0
  116. package/src/runtime/RuntimeCache.ts +300 -0
  117. package/src/sdkVersion.ts +2 -0
  118. package/src/support/optionalModules.ts +114 -0
  119. package/src/types/acceleration.ts +94 -0
  120. package/src/types/events.ts +437 -0
  121. package/src/types/features.ts +176 -0
  122. package/src/types/manifest.ts +230 -0
  123. package/src/types/pose.ts +85 -0
  124. package/src/types/preload.ts +19 -0
  125. package/src/types/skeleton.ts +77 -0
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Human-readable diagnostic dumps for Metro / adb logcat.
3
+ * Designed so a host can paste the terminal output into a support chat.
4
+ */
5
+
6
+ import { Platform } from 'react-native';
7
+
8
+ import type { AccelerationDiagnostics } from '../types/acceleration';
9
+
10
+ const PREFIX = '[PoseTracker]';
11
+
12
+ /** Always-on console sink — shows up in `npx expo start` / Metro. */
13
+ export function defaultDiagnosticLogger(message: string): void {
14
+ // Keep a single prefix so hosts can filter: `npx expo start | grep PoseTracker`
15
+ console.log(PREFIX, message.startsWith('[posetracker]') ? message : message);
16
+ }
17
+
18
+ export function logPlatformBanner(): void {
19
+ console.log(
20
+ PREFIX,
21
+ `platform=${Platform.OS} version=${String(Platform.Version)} ` +
22
+ `constants=${JSON.stringify({
23
+ isPad: (Platform as { isPad?: boolean }).isPad ?? null,
24
+ isTV: Platform.isTV,
25
+ })}`,
26
+ );
27
+ }
28
+
29
+ /**
30
+ * Multi-line dump of the warm-up / acceleration verdict. Call after
31
+ * preload()/warmup() so Android CPU-fallback reasons are visible in Metro.
32
+ */
33
+ export function logAccelerationReport(
34
+ diag: AccelerationDiagnostics | null,
35
+ extra?: Record<string, unknown>,
36
+ ): void {
37
+ const lines: string[] = [];
38
+ lines.push('======== ACCELERATION REPORT ========');
39
+ lines.push(`platform: ${Platform.OS} ${String(Platform.Version)}`);
40
+ if (!diag) {
41
+ lines.push('diagnostics: null (warm-up not finished)');
42
+ } else {
43
+ lines.push(`state: ${diag.state}`);
44
+ lines.push(`runtime: ${diag.runtime ?? 'webview'}`);
45
+ lines.push(`tfjsBackend: ${diag.tfjsBackend ?? 'n/a'}`);
46
+ if (diag.delegate) {
47
+ lines.push(`delegate: ${diag.delegate}`);
48
+ }
49
+ lines.push(
50
+ `medianInferenceMs: ${diag.medianInferenceMs != null ? diag.medianInferenceMs.toFixed(1) : 'n/a'}`,
51
+ );
52
+ lines.push(
53
+ `warmUpRunsMs: [${diag.inferenceTimesMs.map((ms) => Math.round(ms)).join(', ')}]`,
54
+ );
55
+ lines.push(`maxAcceptableInferenceMs: ${diag.maxAcceptableInferenceMs}`);
56
+ lines.push(`contextLossCount: ${diag.contextLossCount}`);
57
+
58
+ const caps = diag.capabilities;
59
+ if (caps) {
60
+ lines.push(`GL.renderer: ${caps.renderer ?? 'n/a'}`);
61
+ lines.push(`GL.vendor: ${caps.vendor ?? 'n/a'}`);
62
+ lines.push(`GL.version: ${caps.glVersion ?? 'n/a'}`);
63
+ lines.push(`GL.maxTextureSize: ${caps.maxTextureSize ?? 'n/a'}`);
64
+ lines.push(
65
+ `GL.extensions(shimmed=${caps.extensionQueriesShimmed}): ` +
66
+ `float=${caps.textureFloat} halfFloat=${caps.textureHalfFloat} ` +
67
+ `colorBufferFloat=${caps.colorBufferFloat} colorBufferHalfFloat=${caps.colorBufferHalfFloat}`,
68
+ );
69
+ } else {
70
+ lines.push('GL.capabilities: null (no GL context — typically means tfjs backend=cpu)');
71
+ }
72
+
73
+ lines.push(`tfjsFlags: ${JSON.stringify(diag.flags)}`);
74
+ if (diag.reasons.length === 0) {
75
+ lines.push('reasons: (none)');
76
+ } else {
77
+ lines.push('reasons:');
78
+ for (const reason of diag.reasons) {
79
+ lines.push(` - ${reason}`);
80
+ }
81
+ }
82
+
83
+ if (diag.state === 'gpu' && diag.runtime === 'vision') {
84
+ lines.push(
85
+ 'INTERPRETATION: Apple Vision (VNDetectHumanBodyPoseRequest) — Neural Engine / GPU. Up to 19 joints.',
86
+ );
87
+ } else if (diag.runtime === 'webview' || diag.runtime === undefined) {
88
+ lines.push(
89
+ 'INTERPRETATION: offline WebView MoveNet Lightning (ANGLE WebGL, bundled model) — ' +
90
+ 'same stack as the PoseTracker WebView product. Expected real-time on both platforms.',
91
+ );
92
+ } else if (diag.state === 'cpu-fallback') {
93
+ lines.push(
94
+ 'INTERPRETATION: non-accelerated path (cpu-fallback). Expect low FPS.',
95
+ );
96
+ } else if (diag.state === 'gpu') {
97
+ lines.push('INTERPRETATION: GPU path accepted by health check.');
98
+ } else if (diag.state === 'unavailable') {
99
+ lines.push('INTERPRETATION: no usable backend — model could not run.');
100
+ }
101
+ }
102
+
103
+ if (extra) {
104
+ lines.push(`extra: ${JSON.stringify(extra)}`);
105
+ }
106
+ lines.push('======== END ACCELERATION REPORT ========');
107
+
108
+ for (const line of lines) {
109
+ console.log(PREFIX, line);
110
+ }
111
+ }
112
+
113
+ /** Throttled FPS / latency line for the camera loop (Metro-friendly). */
114
+ export function logFrameStats(stats: {
115
+ fps: number;
116
+ medianLatencyMs: number | null;
117
+ backend: string;
118
+ acceleration: string;
119
+ frames: number;
120
+ keypointsAbove03: number;
121
+ meanScore: number;
122
+ }): void {
123
+ console.log(
124
+ PREFIX,
125
+ `FRAMESTATS fps=${stats.fps} ` +
126
+ `medianMs=${stats.medianLatencyMs != null ? Math.round(stats.medianLatencyMs) : 'n/a'} ` +
127
+ `backend=${stats.backend} acceleration=${stats.acceleration} ` +
128
+ `frames=${stats.frames} kp≥0.3=${stats.keypointsAbove03}/17 ` +
129
+ `meanScore=${stats.meanScore.toFixed(2)}`,
130
+ );
131
+ }
@@ -0,0 +1,428 @@
1
+ /**
2
+ * Remote engine distribution (Sency-style model distribution, applied to the
3
+ * business-logic bundle):
4
+ *
5
+ * 1. The handshake manifest carries a signed, token-gated URL of a versioned
6
+ * JS engine bundle plus its SHA-256 digest.
7
+ * 2. The bundle is cached on the local filesystem, sealed with a secret
8
+ * derived from the API token (see `cache/obfuscate.ts`): a device without
9
+ * the key cannot read the cached business logic. Integrity is re-validated
10
+ * against the manifest digest on every load (corrupted/partial downloads
11
+ * are purged, like Sency's `TFL3` magic check on cached TFLite files).
12
+ * 3. A crash-loop guard (analog of Sency's `TfliteRuntimeGuard`
13
+ * PROBING→PASSED/FAILED state machine) marks the bundle as "probing"
14
+ * before evaluation; if the app died while probing, the next launch
15
+ * skips that exact bundle.
16
+ * 4. NO business logic ships in the npm package: when no engine can be
17
+ * obtained (no network, no valid cache, crash-looping bundle), `load`
18
+ * returns null and the SDK runs in keypoints-only mode.
19
+ */
20
+
21
+ import { sha256 } from 'js-sha256';
22
+
23
+ import type { EngineBundleDescriptor } from '../types/manifest';
24
+ import type { EngineModuleExports, PoseTrackerEngine } from './types';
25
+ import { openString, sealString } from '../cache/obfuscate';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Pluggable primitives (kept injectable for tests and non-Expo hosts)
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export interface FileStore {
32
+ read(key: string): Promise<string | null>;
33
+ write(key: string, contents: string): Promise<void>;
34
+ remove(key: string): Promise<void>;
35
+ }
36
+
37
+ export interface KeyValueStore {
38
+ getItem(key: string): Promise<string | null>;
39
+ setItem(key: string, value: string): Promise<void>;
40
+ removeItem(key: string): Promise<void>;
41
+ }
42
+
43
+ type ExpoFileSystemLegacy = {
44
+ cacheDirectory: string | null;
45
+ readAsStringAsync(uri: string): Promise<string>;
46
+ writeAsStringAsync(uri: string, contents: string): Promise<void>;
47
+ deleteAsync(uri: string, options?: { idempotent?: boolean }): Promise<void>;
48
+ makeDirectoryAsync(uri: string, options?: { intermediates?: boolean }): Promise<void>;
49
+ };
50
+
51
+ function asExpoFileSystemLegacy(mod: unknown): ExpoFileSystemLegacy | null {
52
+ const raw = mod as Partial<ExpoFileSystemLegacy> & {
53
+ default?: Partial<ExpoFileSystemLegacy>;
54
+ };
55
+ const fs = (raw?.cacheDirectory != null ? raw : raw?.default) as
56
+ | Partial<ExpoFileSystemLegacy>
57
+ | undefined;
58
+ if (
59
+ fs &&
60
+ typeof fs.cacheDirectory === 'string' &&
61
+ typeof fs.readAsStringAsync === 'function' &&
62
+ typeof fs.writeAsStringAsync === 'function' &&
63
+ typeof fs.deleteAsync === 'function' &&
64
+ typeof fs.makeDirectoryAsync === 'function'
65
+ ) {
66
+ return fs as ExpoFileSystemLegacy;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Resolve the Expo FS API that still exposes the classic helpers.
73
+ * Expo SDK 54+ moved them to `expo-file-system/legacy` — the root export's
74
+ * `readAsStringAsync` / `writeAsStringAsync` now THROW at runtime.
75
+ *
76
+ * Requires MUST be static string literals (Metro rejects `require(id)`).
77
+ */
78
+ function requireExpoFileSystemLegacy(): ExpoFileSystemLegacy | null {
79
+ try {
80
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
81
+ const legacy = asExpoFileSystemLegacy(require('expo-file-system/legacy'));
82
+ if (legacy) return legacy;
83
+ } catch {
84
+ /* not installed / old expo-file-system without /legacy */
85
+ }
86
+ try {
87
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
88
+ const root = asExpoFileSystemLegacy(require('expo-file-system'));
89
+ if (root) return root;
90
+ } catch {
91
+ /* expo-file-system not installed */
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /** Filesystem-backed store using expo-file-system (optional peer dependency). */
97
+ export function createExpoFileStore(dirName: string = 'posetracker-engine'): FileStore | null {
98
+ const FileSystem = requireExpoFileSystemLegacy();
99
+ if (!FileSystem || !FileSystem.cacheDirectory) {
100
+ return null;
101
+ }
102
+ const dir = `${FileSystem.cacheDirectory}${dirName}/`;
103
+ const ensureDir = FileSystem.makeDirectoryAsync(dir, { intermediates: true }).catch(() => {});
104
+ return {
105
+ async read(key) {
106
+ try {
107
+ return await FileSystem.readAsStringAsync(dir + key);
108
+ } catch {
109
+ return null;
110
+ }
111
+ },
112
+ async write(key, contents) {
113
+ await ensureDir;
114
+ await FileSystem.writeAsStringAsync(dir + key, contents);
115
+ },
116
+ async remove(key) {
117
+ await FileSystem.deleteAsync(dir + key, { idempotent: true });
118
+ },
119
+ };
120
+ }
121
+
122
+ /** Filesystem-backed store using react-native-fs (bare RN, optional). */
123
+ export function createRnfsFileStore(dirName: string): FileStore | null {
124
+ let RNFS: {
125
+ CachesDirectoryPath: string;
126
+ readFile(path: string, encoding: string): Promise<string>;
127
+ writeFile(path: string, contents: string, encoding: string): Promise<void>;
128
+ unlink(path: string): Promise<void>;
129
+ mkdir(path: string): Promise<void>;
130
+ };
131
+ try {
132
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
133
+ RNFS = require('react-native-fs');
134
+ if (!RNFS || typeof RNFS.CachesDirectoryPath !== 'string') return null;
135
+ } catch {
136
+ return null;
137
+ }
138
+ const dir = `${RNFS.CachesDirectoryPath}/${dirName}`;
139
+ const ensureDir = RNFS.mkdir(dir).catch(() => {});
140
+ return {
141
+ async read(key) {
142
+ try {
143
+ return await RNFS.readFile(`${dir}/${key}`, 'utf8');
144
+ } catch {
145
+ return null;
146
+ }
147
+ },
148
+ async write(key, contents) {
149
+ await ensureDir;
150
+ await RNFS.writeFile(`${dir}/${key}`, contents, 'utf8');
151
+ },
152
+ async remove(key) {
153
+ await RNFS.unlink(`${dir}/${key}`).catch(() => {});
154
+ },
155
+ };
156
+ }
157
+
158
+ /** Filesystem-backed store using react-native-blob-util (bare RN, optional). */
159
+ export function createBlobUtilFileStore(dirName: string): FileStore | null {
160
+ let fs: {
161
+ dirs: { CacheDir: string };
162
+ readFile(path: string, encoding: string): Promise<string>;
163
+ writeFile(path: string, data: string, encoding: string): Promise<void>;
164
+ unlink(path: string): Promise<void>;
165
+ mkdir(path: string): Promise<void>;
166
+ exists(path: string): Promise<boolean>;
167
+ };
168
+ try {
169
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
170
+ const mod = require('react-native-blob-util');
171
+ fs = (mod.default ?? mod).fs;
172
+ if (!fs || !fs.dirs || typeof fs.dirs.CacheDir !== 'string') return null;
173
+ } catch {
174
+ return null;
175
+ }
176
+ const dir = `${fs.dirs.CacheDir}/${dirName}`;
177
+ const ensureDir = fs.mkdir(dir).catch(() => {});
178
+ return {
179
+ async read(key) {
180
+ try {
181
+ return await fs.readFile(`${dir}/${key}`, 'utf8');
182
+ } catch {
183
+ return null;
184
+ }
185
+ },
186
+ async write(key, contents) {
187
+ await ensureDir;
188
+ await fs.writeFile(`${dir}/${key}`, contents, 'utf8');
189
+ },
190
+ async remove(key) {
191
+ await fs.unlink(`${dir}/${key}`).catch(() => {});
192
+ },
193
+ };
194
+ }
195
+
196
+ /**
197
+ * In-memory store — last-resort fallback when no filesystem module is
198
+ * installed (bare RN without expo-file-system / react-native-fs /
199
+ * react-native-blob-util). The SDK stays fully functional but nothing
200
+ * persists across app launches: every cold start needs the network again.
201
+ */
202
+ export function createMemoryFileStore(): FileStore {
203
+ const entries = new Map<string, string>();
204
+ return {
205
+ async read(key) {
206
+ return entries.has(key) ? (entries.get(key) as string) : null;
207
+ },
208
+ async write(key, contents) {
209
+ entries.set(key, contents);
210
+ },
211
+ async remove(key) {
212
+ entries.delete(key);
213
+ },
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Best available persistent file store for the host app:
219
+ * expo-file-system (Expo / Expo Go) → react-native-fs (bare RN) →
220
+ * react-native-blob-util (bare RN). Returns null when none is installed —
221
+ * callers may fall back to {@link createMemoryFileStore}.
222
+ */
223
+ export function createNativeFileStore(dirName: string): FileStore | null {
224
+ return (
225
+ createExpoFileStore(dirName) ??
226
+ createRnfsFileStore(dirName) ??
227
+ createBlobUtilFileStore(dirName)
228
+ );
229
+ }
230
+
231
+ /** AsyncStorage-backed store (already a peer dep via tfjs-react-native). */
232
+ export function createAsyncKeyValueStore(): KeyValueStore | null {
233
+ try {
234
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
235
+ const AsyncStorage = require('@react-native-async-storage/async-storage').default;
236
+ return AsyncStorage as KeyValueStore;
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Loader
244
+ // ---------------------------------------------------------------------------
245
+
246
+ const GUARD_KEY_PREFIX = 'posetracker.engine.guard.';
247
+ type GuardState = 'probing' | 'passed' | 'failed';
248
+
249
+ export interface EngineLoadResult {
250
+ engine: PoseTrackerEngine;
251
+ source: 'remote-cache' | 'remote-download';
252
+ }
253
+
254
+ export interface EngineLoaderOptions {
255
+ fileStore?: FileStore | null;
256
+ keyValueStore?: KeyValueStore | null;
257
+ /** Injectable for tests. */
258
+ fetchFn?: typeof fetch;
259
+ /** Diagnostic trail (Metro / logcat) — why a load returned null. */
260
+ onDiagnostic?: (message: string) => void;
261
+ }
262
+
263
+ export class EngineLoader {
264
+ private readonly files: FileStore | null;
265
+ private readonly kv: KeyValueStore | null;
266
+ private readonly fetchFn: typeof fetch;
267
+ private readonly onDiagnostic: ((message: string) => void) | undefined;
268
+ /** Last load failure detail (surfaced by the client error event). */
269
+ lastError: string | null = null;
270
+
271
+ constructor(options: EngineLoaderOptions = {}) {
272
+ this.files =
273
+ options.fileStore !== undefined
274
+ ? options.fileStore
275
+ : createNativeFileStore('posetracker-engine');
276
+ this.kv =
277
+ options.keyValueStore !== undefined ? options.keyValueStore : createAsyncKeyValueStore();
278
+ this.fetchFn = options.fetchFn ?? fetch;
279
+ this.onDiagnostic = options.onDiagnostic;
280
+ this.onDiagnostic?.(
281
+ `[posetracker] EngineLoader fileStore=${this.files ? 'native' : 'none'} ` +
282
+ `kv=${this.kv ? 'async-storage' : 'none'}`,
283
+ );
284
+ }
285
+
286
+ /**
287
+ * Clear a crash-guard mark so the next {@link load} can retry this bundle
288
+ * (e.g. after the host taps "Test key").
289
+ */
290
+ async clearGuard(descriptor: EngineBundleDescriptor | null): Promise<void> {
291
+ if (!descriptor || !this.kv) return;
292
+ const guardKey = `${GUARD_KEY_PREFIX}${descriptor.version}.${descriptor.sha256}`;
293
+ await this.kv.removeItem(guardKey).catch(() => {});
294
+ }
295
+
296
+ /**
297
+ * Load the engine described by the manifest. Returns null when no engine
298
+ * can be obtained (the caller then runs keypoints-only).
299
+ *
300
+ * @param cacheSecret secret derived from the API token, used to seal/open
301
+ * the cached bundle (`deriveCacheSecret`).
302
+ */
303
+ async load(
304
+ descriptor: EngineBundleDescriptor | null,
305
+ cacheSecret: string,
306
+ options?: { forceRetry?: boolean },
307
+ ): Promise<EngineLoadResult | null> {
308
+ this.lastError = null;
309
+ if (!descriptor) {
310
+ this.lastError = 'manifest has no engine descriptor';
311
+ return null;
312
+ }
313
+
314
+ const guardKey = `${GUARD_KEY_PREFIX}${descriptor.version}.${descriptor.sha256}`;
315
+ if (options?.forceRetry) {
316
+ await this.kv?.removeItem(guardKey).catch(() => {});
317
+ }
318
+ const guardState = (await this.kv?.getItem(guardKey)) as GuardState | null | undefined;
319
+ if (guardState === 'probing' || guardState === 'failed') {
320
+ // 'probing' left over = the app crashed while evaluating this exact
321
+ // bundle on a previous launch. Do not retry it automatically.
322
+ await this.kv?.setItem(guardKey, 'failed');
323
+ this.lastError =
324
+ `engine crash-guard blocked version=${descriptor.version} ` +
325
+ `(state=${guardState}) — tap Test key to force retry`;
326
+ this.onDiagnostic?.(`[posetracker] ${this.lastError}`);
327
+ return null;
328
+ }
329
+
330
+ const cacheKey = `engine-${descriptor.version}.sealed`;
331
+
332
+ // 1. Cache hit: unseal + integrity check.
333
+ const sealed = await this.files?.read(cacheKey);
334
+ if (sealed !== null && sealed !== undefined) {
335
+ const code = openString(sealed, cacheSecret);
336
+ if (code !== null && sha256(code) === descriptor.sha256) {
337
+ const engine = await this.evaluate(code, guardKey);
338
+ if (engine) {
339
+ this.onDiagnostic?.(
340
+ `[posetracker] engine loaded from cache version=${descriptor.version}`,
341
+ );
342
+ return { engine, source: 'remote-cache' };
343
+ }
344
+ } else {
345
+ // Corrupted, partial, or sealed with another token: purge it.
346
+ this.onDiagnostic?.(
347
+ `[posetracker] engine cache purged (unseal/integrity mismatch) key=${cacheKey}`,
348
+ );
349
+ await this.files?.remove(cacheKey);
350
+ }
351
+ }
352
+
353
+ // 2. Download → integrity → evaluate. Cache write is best-effort AFTER
354
+ // evaluate so a broken FS API (Expo 54 root export) cannot block the
355
+ // engine when the download itself succeeded.
356
+ try {
357
+ this.onDiagnostic?.(
358
+ `[posetracker] downloading engine version=${descriptor.version}…`,
359
+ );
360
+ const response = await this.fetchFn(descriptor.signedUrl);
361
+ if (!response.ok) {
362
+ throw new Error(`HTTP ${response.status}`);
363
+ }
364
+ const code = await response.text();
365
+ const digest = sha256(code);
366
+ if (digest !== descriptor.sha256) {
367
+ throw new Error(
368
+ `integrity check failed (got ${digest.slice(0, 12)}… expected ${descriptor.sha256.slice(0, 12)}…, ` +
369
+ `bytes=${code.length})`,
370
+ );
371
+ }
372
+ const engine = await this.evaluate(code, guardKey);
373
+ if (engine) {
374
+ try {
375
+ await this.files?.write(cacheKey, sealString(code, cacheSecret));
376
+ } catch (err) {
377
+ // Non-fatal: next cold start will re-download.
378
+ this.onDiagnostic?.(
379
+ `[posetracker] engine cache write failed (non-fatal): ` +
380
+ (err instanceof Error ? err.message : String(err)),
381
+ );
382
+ }
383
+ this.onDiagnostic?.(
384
+ `[posetracker] engine loaded from download version=${descriptor.version} bytes=${code.length}`,
385
+ );
386
+ return { engine, source: 'remote-download' };
387
+ }
388
+ } catch (err) {
389
+ this.lastError = err instanceof Error ? err.message : String(err);
390
+ this.onDiagnostic?.(`[posetracker] engine download/load failed: ${this.lastError}`);
391
+ }
392
+
393
+ if (!this.lastError) {
394
+ this.lastError = 'engine evaluate returned null';
395
+ }
396
+ return null;
397
+ }
398
+
399
+ /**
400
+ * Evaluate an engine bundle (CommonJS-style: the bundle assigns
401
+ * `module.exports.createEngine`). Wrapped by the crash-loop guard.
402
+ * Hermes and JSC both support runtime evaluation via `new Function`.
403
+ */
404
+ private async evaluate(code: string, guardKey: string): Promise<PoseTrackerEngine | null> {
405
+ await this.kv?.setItem(guardKey, 'probing');
406
+ try {
407
+ const moduleRef: { exports: Partial<EngineModuleExports> } = { exports: {} };
408
+ // eslint-disable-next-line no-new-func
409
+ const run = new Function('module', 'exports', code);
410
+ run(moduleRef, moduleRef.exports);
411
+ const factory = moduleRef.exports.createEngine;
412
+ if (typeof factory !== 'function') {
413
+ throw new Error(
414
+ `engine bundle does not export createEngine() (got ${typeof factory}, keys=${Object.keys(moduleRef.exports).join(',')})`,
415
+ );
416
+ }
417
+ const engine = factory();
418
+ await this.kv?.setItem(guardKey, 'passed');
419
+ return engine;
420
+ } catch (err) {
421
+ this.lastError =
422
+ 'engine evaluate failed: ' + (err instanceof Error ? err.message : String(err));
423
+ this.onDiagnostic?.(`[posetracker] ${this.lastError}`);
424
+ await this.kv?.setItem(guardKey, 'failed');
425
+ return null;
426
+ }
427
+ }
428
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Contract between the SDK shell and the engine (business logic: angles,
3
+ * rep counting, form scoring, posture, progression, recommendations).
4
+ *
5
+ * The engine is EXCLUSIVELY distributed remotely as a versioned JS bundle
6
+ * (signed URL in the handshake manifest, SHA-256 integrity check, sealed
7
+ * local cache) — the npm package ships zero business logic. Without a
8
+ * loaded engine the SDK runs in keypoints-only mode.
9
+ */
10
+
11
+ import type { Pose } from '../types/pose';
12
+ import type { MinGrade, PoseTrackerEvent } from '../types/events';
13
+ import type { ExerciseConfig } from '../types/manifest';
14
+
15
+ /**
16
+ * Feature flags forwarded to the engine session (WebView query-param
17
+ * parity). The client already filters emissions by plan/flags; passing
18
+ * them to the engine is a second gate AND lets the engine skip work.
19
+ */
20
+ export interface EngineSessionFeatures {
21
+ angles?: boolean;
22
+ recommendations?: boolean;
23
+ progression?: boolean;
24
+ }
25
+
26
+ export interface EngineSessionOptions {
27
+ exercise: ExerciseConfig;
28
+ /** Locale used to resolve localized hints/recommendations. */
29
+ locale: string;
30
+ /** Difficulty key into the movement `scale_acceptance` maps. Default: 'medium'. */
31
+ difficulty?: string;
32
+ /** Parsed reference-movement signature, when comparing against a reference. */
33
+ referenceSignature?: unknown;
34
+ /**
35
+ * Only count reps whose form grade is at or above this letter (`A` best).
36
+ * WebView `minGrade` parity: reps below the bar emit neither `counter`
37
+ * nor `form_score` and are excluded from the summary.
38
+ */
39
+ minGrade?: MinGrade;
40
+ /** Emission flags — engines older than 1.1.0 ignore them (client re-filters). */
41
+ features?: EngineSessionFeatures;
42
+ }
43
+
44
+ export interface EngineSession {
45
+ /** Feed one estimated pose; the session emits events through the sink. */
46
+ processPose(pose: Pose): void;
47
+ /** Finish the session; emits a final `exercise_summary` event. */
48
+ end(): void;
49
+ }
50
+
51
+ /**
52
+ * Custom (non-FSM) exercise shipped inside the engine bundle, e.g. the jump
53
+ * analysis handlers (WebView `customHandlers.js` parity). Not present in the
54
+ * Strapi movement manifest — discovered via `listCustomExercises()`.
55
+ */
56
+ export interface CustomExerciseDescriptor {
57
+ /** Exercise id used with `startExercise()` (e.g. 'jump_analysis'). */
58
+ id: string;
59
+ displayName: string;
60
+ type: 'custom';
61
+ description?: string;
62
+ /** Option keys that MUST be passed to `startExercise` (e.g. 'userHeightCm'). */
63
+ requiredParams: string[];
64
+ optionalParams: string[];
65
+ }
66
+
67
+ /** Options for engine custom sessions (jump_analysis / air_time_jump). */
68
+ export interface CustomSessionOptions {
69
+ exerciseId: string;
70
+ locale: string;
71
+ /** Athlete height in cm — required by jump_analysis (cm/pixel calibration). */
72
+ userHeightCm?: number;
73
+ /** Device pitch in degrees, used to compensate camera tilt. */
74
+ devicePitchDeg?: number;
75
+ }
76
+
77
+ export type EventSink = (event: PoseTrackerEvent) => void;
78
+
79
+ export interface PoseTrackerEngine {
80
+ readonly version: string;
81
+ createSession(options: EngineSessionOptions, emit: EventSink): EngineSession;
82
+ /** Engine >= 1.2.0: custom exercises shipped in the bundle (jump analysis…). */
83
+ listCustomExercises?(): CustomExerciseDescriptor[];
84
+ /** Engine >= 1.2.0: start a custom exercise session (placement + jump_* events). */
85
+ createCustomSession?(options: CustomSessionOptions, emit: EventSink): EngineSession;
86
+ }
87
+
88
+ /** What an engine bundle's `module.exports` must expose. */
89
+ export interface EngineModuleExports {
90
+ createEngine(): PoseTrackerEngine;
91
+ }
92
+
93
+ export type EngineFactory = () => PoseTrackerEngine;