@pose-tracker/react-native-pose-estimation-light 0.1.0 → 0.2.1
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 +14 -2
- package/lib/PoseTrackerProvider.js +3 -0
- package/lib/backends/webview/WebViewPoseBackend.d.ts +1 -1
- package/lib/backends/webview/onlineRuntime.d.ts +14 -4
- package/lib/backends/webview/onlineRuntime.js +22 -4
- package/lib/backends/webview/poseHtml.d.ts +12 -4
- package/lib/backends/webview/poseHtml.js +15 -6
- package/lib/backends/webview/poseRuntimeSource.js +2 -2
- package/lib/camera/WebViewPoseView.d.ts +12 -0
- package/lib/camera/WebViewPoseView.js +29 -0
- package/lib/client.d.ts +13 -2
- package/lib/client.js +9 -1
- package/lib/engine/types.d.ts +5 -0
- package/lib/events/classicMessage.d.ts +2 -2
- package/lib/events/classicMessage.js +4 -1
- package/lib/index.d.ts +2 -2
- package/lib/index.js +4 -2
- package/lib/models/poseModels.d.ts +8 -6
- package/lib/models/poseModels.js +9 -8
- package/lib/sdkVersion.d.ts +1 -1
- package/lib/sdkVersion.js +1 -1
- package/lib/types/events.d.ts +37 -2
- package/lib/types/features.d.ts +2 -2
- package/lib/types/features.js +8 -9
- package/package.json +1 -1
- package/src/PoseTrackerProvider.tsx +3 -0
- package/src/backends/webview/WebViewPoseBackend.ts +1 -1
- package/src/backends/webview/onlineRuntime.ts +36 -5
- package/src/backends/webview/poseHtml.ts +23 -6
- package/src/camera/WebViewPoseView.tsx +43 -0
- package/src/client.ts +25 -5
- package/src/engine/types.ts +5 -0
- package/src/events/classicMessage.ts +7 -2
- package/src/index.ts +3 -0
- package/src/models/poseModels.ts +14 -11
- package/src/sdkVersion.ts +1 -1
- package/src/types/events.ts +35 -0
- package/src/types/features.ts +8 -9
package/README.md
CHANGED
|
@@ -29,6 +29,9 @@ download at session start.
|
|
|
29
29
|
|
|
30
30
|
Full comparison: [LIGHT_SDK.md](../../docs/LIGHT_SDK.md).
|
|
31
31
|
|
|
32
|
+
**Agents:** shared UX/API/bugfixes → mirror to offline (or ask first). See
|
|
33
|
+
[`DUAL_SDK_CHANGES.md`](../../docs/DUAL_SDK_CHANGES.md).
|
|
34
|
+
|
|
32
35
|
## Install
|
|
33
36
|
|
|
34
37
|
```bash
|
|
@@ -44,6 +47,10 @@ npx expo install react-native-webview expo-camera
|
|
|
44
47
|
**Required:** host app must declare camera permissions — see
|
|
45
48
|
[PERMISSIONS.md](../../docs/PERMISSIONS.md).
|
|
46
49
|
|
|
50
|
+
**Media inputs (v0.2):** camera (default), uploaded video, still image — host
|
|
51
|
+
picks the file. See [MEDIA_SOURCES.md](../../docs/MEDIA_SOURCES.md) and
|
|
52
|
+
https://docs.posetracker.com/media-sources.
|
|
53
|
+
|
|
47
54
|
## Quick start (keypoints — needs network, no API key)
|
|
48
55
|
|
|
49
56
|
```tsx
|
|
@@ -79,6 +86,9 @@ function CameraScreen() {
|
|
|
79
86
|
drawSkeleton
|
|
80
87
|
loadingText="AI Loading"
|
|
81
88
|
coldStart="full"
|
|
89
|
+
// Default source="camera". Host-picked file:
|
|
90
|
+
// source="image" sourceUri={fileUri}
|
|
91
|
+
// source="video" sourceUri={fileUri}
|
|
82
92
|
/>
|
|
83
93
|
);
|
|
84
94
|
}
|
|
@@ -140,8 +150,10 @@ Yes for keypoints — but a network is still required to load TF.js + MoveNet.
|
|
|
140
150
|
Yes (`react-native-webview` peer).
|
|
141
151
|
|
|
142
152
|
**BlazePose?**
|
|
143
|
-
`model: 'blazepose'`
|
|
144
|
-
|
|
153
|
+
Pass `model: 'blazepose'` on `PoseTrackerProvider` options. Loads
|
|
154
|
+
`@tensorflow-models/pose-detection` from jsDelivr in the WebView (lite /
|
|
155
|
+
TF.js). Keypoints stay COCO-17. Heavier than MoveNet — expect lower FPS on
|
|
156
|
+
mid-range Android. Offline SDK does **not** ship BlazePose.
|
|
145
157
|
|
|
146
158
|
**Who sees the watermark?**
|
|
147
159
|
Keyless and free plans. Hidden for paid plans.
|
|
@@ -91,7 +91,7 @@ export type WebViewPoseMessage = {
|
|
|
91
91
|
type: 'initialization';
|
|
92
92
|
message: string;
|
|
93
93
|
ready?: boolean;
|
|
94
|
-
step?: 'accessing_webcam' | 'loading_pose_model' | 'ready';
|
|
94
|
+
step?: 'accessing_webcam' | 'loading_media' | 'loading_pose_model' | 'ready';
|
|
95
95
|
} | {
|
|
96
96
|
type: 'warning';
|
|
97
97
|
message: string;
|
|
@@ -5,12 +5,17 @@
|
|
|
5
5
|
* TF.js + WASM backends load from a CDN; MoveNet (or a custom graph model)
|
|
6
6
|
* loads from a URL on every WebView boot — same idea as the historical
|
|
7
7
|
* PoseTracker Front tracking page.
|
|
8
|
+
*
|
|
9
|
+
* BlazePose: appends `@tensorflow-models/pose-detection` CDN after TF.js
|
|
10
|
+
* (no graph `modelUrl`); the page runtime calls `createDetector`.
|
|
8
11
|
*/
|
|
9
|
-
import { type PoseModelAlias } from '../../models/poseModels';
|
|
12
|
+
import { type PoseModelAlias, type PoseModelKind } from '../../models/poseModels';
|
|
10
13
|
/** TF.js version pinned for CDN scripts (matches offline SDK build). */
|
|
11
14
|
export declare const ONLINE_TFJS_VERSION = "4.22.0";
|
|
15
|
+
/** pose-detection UMD pin (BlazePose). */
|
|
16
|
+
export declare const ONLINE_POSE_DETECTION_VERSION = "2.1.3";
|
|
12
17
|
export interface OnlineRuntimeUrls {
|
|
13
|
-
/** Ordered <script src> list (core → converter → webgl → wasm). */
|
|
18
|
+
/** Ordered <script src> list (core → converter → webgl → wasm [→ pose-detection]). */
|
|
14
19
|
tfjsScriptUrls: string[];
|
|
15
20
|
/** Directory passed to `tf.wasm.setWasmPaths`. */
|
|
16
21
|
tfjsWasmPath: string;
|
|
@@ -19,7 +24,9 @@ export interface OnlineRuntimeParts {
|
|
|
19
24
|
version: string;
|
|
20
25
|
delivery: 'online';
|
|
21
26
|
modelId: string;
|
|
22
|
-
|
|
27
|
+
/** Graph kinds only; null for BlazePose. */
|
|
28
|
+
modelUrl: string | null;
|
|
29
|
+
modelKind: Exclude<PoseModelKind, 'unsupported'>;
|
|
23
30
|
tfjsScriptUrls: string[];
|
|
24
31
|
tfjsWasmPath: string;
|
|
25
32
|
/** Thin page runtime shipped in the package (~50 KB). */
|
|
@@ -35,11 +42,14 @@ export interface GetOnlineRuntimeOptions {
|
|
|
35
42
|
tfjsCdnBase?: string;
|
|
36
43
|
/** Override TF.js version segment in CDN URLs. */
|
|
37
44
|
tfjsVersion?: string;
|
|
45
|
+
/** Override pose-detection version (BlazePose CDN). */
|
|
46
|
+
poseDetectionVersion?: string;
|
|
38
47
|
}
|
|
39
48
|
export declare function defaultOnlineRuntimeUrls(cdnBase?: string, tfjsVersion?: string): OnlineRuntimeUrls;
|
|
49
|
+
export declare function defaultPoseDetectionCdnUrl(cdnBase?: string, version?: string): string;
|
|
40
50
|
/**
|
|
41
51
|
* Build the online runtime descriptor used by {@link buildPoseHtml}.
|
|
42
|
-
* Throws when the selected model cannot be loaded
|
|
52
|
+
* Throws when the selected model cannot be loaded.
|
|
43
53
|
*/
|
|
44
54
|
export declare function getOnlineRuntimeParts(options?: GetOnlineRuntimeOptions): OnlineRuntimeParts;
|
|
45
55
|
export declare function getOnlineRuntimeVersion(): string;
|
|
@@ -6,16 +6,22 @@
|
|
|
6
6
|
* TF.js + WASM backends load from a CDN; MoveNet (or a custom graph model)
|
|
7
7
|
* loads from a URL on every WebView boot — same idea as the historical
|
|
8
8
|
* PoseTracker Front tracking page.
|
|
9
|
+
*
|
|
10
|
+
* BlazePose: appends `@tensorflow-models/pose-detection` CDN after TF.js
|
|
11
|
+
* (no graph `modelUrl`); the page runtime calls `createDetector`.
|
|
9
12
|
*/
|
|
10
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
-
exports.ONLINE_TFJS_VERSION = void 0;
|
|
14
|
+
exports.ONLINE_POSE_DETECTION_VERSION = exports.ONLINE_TFJS_VERSION = void 0;
|
|
12
15
|
exports.defaultOnlineRuntimeUrls = defaultOnlineRuntimeUrls;
|
|
16
|
+
exports.defaultPoseDetectionCdnUrl = defaultPoseDetectionCdnUrl;
|
|
13
17
|
exports.getOnlineRuntimeParts = getOnlineRuntimeParts;
|
|
14
18
|
exports.getOnlineRuntimeVersion = getOnlineRuntimeVersion;
|
|
15
19
|
const poseRuntimeSource_1 = require("./poseRuntimeSource");
|
|
16
20
|
const poseModels_1 = require("../../models/poseModels");
|
|
17
21
|
/** TF.js version pinned for CDN scripts (matches offline SDK build). */
|
|
18
22
|
exports.ONLINE_TFJS_VERSION = '4.22.0';
|
|
23
|
+
/** pose-detection UMD pin (BlazePose). */
|
|
24
|
+
exports.ONLINE_POSE_DETECTION_VERSION = '2.1.3';
|
|
19
25
|
const JSDELIVR = 'https://cdn.jsdelivr.net/npm';
|
|
20
26
|
function defaultOnlineRuntimeUrls(cdnBase = JSDELIVR, tfjsVersion = exports.ONLINE_TFJS_VERSION) {
|
|
21
27
|
const base = cdnBase.replace(/\/$/, '');
|
|
@@ -29,25 +35,37 @@ function defaultOnlineRuntimeUrls(cdnBase = JSDELIVR, tfjsVersion = exports.ONLI
|
|
|
29
35
|
tfjsWasmPath: `${base}/@tensorflow/tfjs-backend-wasm@${tfjsVersion}/dist/`,
|
|
30
36
|
};
|
|
31
37
|
}
|
|
38
|
+
function defaultPoseDetectionCdnUrl(cdnBase = JSDELIVR, version = exports.ONLINE_POSE_DETECTION_VERSION) {
|
|
39
|
+
const base = cdnBase.replace(/\/$/, '');
|
|
40
|
+
return `${base}/@tensorflow-models/pose-detection@${version}/dist/pose-detection.min.js`;
|
|
41
|
+
}
|
|
32
42
|
/**
|
|
33
43
|
* Build the online runtime descriptor used by {@link buildPoseHtml}.
|
|
34
|
-
* Throws when the selected model cannot be loaded
|
|
44
|
+
* Throws when the selected model cannot be loaded.
|
|
35
45
|
*/
|
|
36
46
|
function getOnlineRuntimeParts(options = {}) {
|
|
37
47
|
const resolved = (0, poseModels_1.resolvePoseModel)({
|
|
38
48
|
model: options.model,
|
|
39
49
|
modelUrl: options.modelUrl,
|
|
40
50
|
});
|
|
41
|
-
if (
|
|
51
|
+
if (resolved.kind === 'unsupported') {
|
|
52
|
+
throw new Error(resolved.unsupportedReason ?? `Model "${resolved.modelId}" is unavailable.`);
|
|
53
|
+
}
|
|
54
|
+
if (resolved.kind !== 'blazepose' && !resolved.modelUrl) {
|
|
42
55
|
throw new Error(resolved.unsupportedReason ?? `Model "${resolved.modelId}" is unavailable.`);
|
|
43
56
|
}
|
|
44
57
|
const urls = defaultOnlineRuntimeUrls(options.tfjsCdnBase, options.tfjsVersion);
|
|
58
|
+
const scriptUrls = [...urls.tfjsScriptUrls];
|
|
59
|
+
if (resolved.kind === 'blazepose') {
|
|
60
|
+
scriptUrls.push(defaultPoseDetectionCdnUrl(options.tfjsCdnBase, options.poseDetectionVersion));
|
|
61
|
+
}
|
|
45
62
|
return {
|
|
46
63
|
version: poseRuntimeSource_1.POSE_RUNTIME_VERSION,
|
|
47
64
|
delivery: 'online',
|
|
48
65
|
modelId: resolved.modelId,
|
|
49
66
|
modelUrl: resolved.modelUrl,
|
|
50
|
-
|
|
67
|
+
modelKind: resolved.kind,
|
|
68
|
+
tfjsScriptUrls: scriptUrls,
|
|
51
69
|
tfjsWasmPath: urls.tfjsWasmPath,
|
|
52
70
|
runtimeJs: poseRuntimeSource_1.POSE_RUNTIME_JS,
|
|
53
71
|
};
|
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
* WebView HTML assembler (light / online).
|
|
3
3
|
*
|
|
4
4
|
* Loads TF.js from CDN script tags and MoveNet (or a custom graph model)
|
|
5
|
-
* from {@link OnlineRuntimeParts.modelUrl}.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* from {@link OnlineRuntimeParts.modelUrl}. For BlazePose, also loads
|
|
6
|
+
* `@tensorflow-models/pose-detection` (no graph URL). The thin page runtime
|
|
7
|
+
* is still inlined from the package. Same camera / adaptive quality /
|
|
8
|
+
* postMessage events as the offline SDK.
|
|
8
9
|
*/
|
|
9
10
|
import type { CapturePriority } from '../../quality/profiles';
|
|
10
11
|
import type { SkeletonDefinition } from '../../types/skeleton';
|
|
11
12
|
import type { OnlineRuntimeParts } from './onlineRuntime';
|
|
12
13
|
/** Bumped on every assembler-path change — appears in WebView diag logs. */
|
|
13
|
-
export declare const POSE_HTML_BUILD = "
|
|
14
|
+
export declare const POSE_HTML_BUILD = "20260812-onlineLight-mediaSources";
|
|
14
15
|
/** Default boot overlay copy (WebView `loading_message` parity). */
|
|
15
16
|
export declare const DEFAULT_LOADING_TEXT = "AI Loading";
|
|
16
17
|
export interface PoseHtmlOptions {
|
|
@@ -63,6 +64,13 @@ export interface PoseHtmlOptions {
|
|
|
63
64
|
showWatermark?: boolean;
|
|
64
65
|
/** Show the technical `#hud` overlay (FPS / backend). Default false. */
|
|
65
66
|
debugHud?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Input source mode. Default `camera`.
|
|
69
|
+
* For `video` / `image`, pass a URI the WebView can load (`sourceUrl`).
|
|
70
|
+
*/
|
|
71
|
+
sourceType?: 'camera' | 'video' | 'image';
|
|
72
|
+
/** file://, content://, https://, or data: URL for video/image modes. */
|
|
73
|
+
sourceUrl?: string;
|
|
66
74
|
}
|
|
67
75
|
/**
|
|
68
76
|
* Assemble the pose page for online delivery: CDN TF.js + remote model URL
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* WebView HTML assembler (light / online).
|
|
4
4
|
*
|
|
5
5
|
* Loads TF.js from CDN script tags and MoveNet (or a custom graph model)
|
|
6
|
-
* from {@link OnlineRuntimeParts.modelUrl}.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* from {@link OnlineRuntimeParts.modelUrl}. For BlazePose, also loads
|
|
7
|
+
* `@tensorflow-models/pose-detection` (no graph URL). The thin page runtime
|
|
8
|
+
* is still inlined from the package. Same camera / adaptive quality /
|
|
9
|
+
* postMessage events as the offline SDK.
|
|
9
10
|
*/
|
|
10
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
12
|
exports.DEFAULT_LOADING_TEXT = exports.POSE_HTML_BUILD = void 0;
|
|
@@ -14,17 +15,17 @@ const react_native_1 = require("react-native");
|
|
|
14
15
|
const captureMode_1 = require("../../quality/captureMode");
|
|
15
16
|
const brandAssets_1 = require("./brandAssets");
|
|
16
17
|
/** Bumped on every assembler-path change — appears in WebView diag logs. */
|
|
17
|
-
exports.POSE_HTML_BUILD = '
|
|
18
|
+
exports.POSE_HTML_BUILD = '20260812-onlineLight-mediaSources';
|
|
18
19
|
/** Default boot overlay copy (WebView `loading_message` parity). */
|
|
19
20
|
exports.DEFAULT_LOADING_TEXT = 'AI Loading';
|
|
20
21
|
const PAGE_CSS = `
|
|
21
22
|
html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }
|
|
22
23
|
#wrap { position:relative; width:100%; height:100%; background:#000; }
|
|
23
|
-
video, canvas {
|
|
24
|
+
video, img#still, canvas {
|
|
24
25
|
position:absolute; inset:0; width:100%; height:100%;
|
|
25
26
|
object-fit:cover; opacity:0; transition:opacity 180ms ease-out;
|
|
26
27
|
}
|
|
27
|
-
video { z-index:1; background:#000; }
|
|
28
|
+
video, img#still { z-index:1; background:#000; }
|
|
28
29
|
canvas { z-index:2; pointer-events:none; }
|
|
29
30
|
#boot {
|
|
30
31
|
position:absolute; inset:0; z-index:4;
|
|
@@ -137,11 +138,16 @@ function buildPoseHtml(parts, options) {
|
|
|
137
138
|
loadingText,
|
|
138
139
|
showWatermark: watermarkOn,
|
|
139
140
|
debugHud,
|
|
141
|
+
sourceType: options?.sourceType === 'video' || options?.sourceType === 'image'
|
|
142
|
+
? options.sourceType
|
|
143
|
+
: 'camera',
|
|
144
|
+
sourceUrl: typeof options?.sourceUrl === 'string' ? options.sourceUrl : null,
|
|
140
145
|
captureConstraintMode: captureMode_1.CAPTURE_CONSTRAINT_MODE,
|
|
141
146
|
capturePriority,
|
|
142
147
|
platform: react_native_1.Platform.OS,
|
|
143
148
|
modelUrl: parts.modelUrl,
|
|
144
149
|
modelId: parts.modelId,
|
|
150
|
+
modelKind: parts.modelKind,
|
|
145
151
|
tfjsWasmPath: parts.tfjsWasmPath,
|
|
146
152
|
inferFrameSkip: isAndroid && captureMode_1.ANDROID_INFER_FRAME_SKIP > 0 ? captureMode_1.ANDROID_INFER_FRAME_SKIP : 0,
|
|
147
153
|
preprocessPath: isAndroid ? captureMode_1.ANDROID_PREPROCESS_PATH : 'imagebitmap',
|
|
@@ -157,6 +163,7 @@ function buildPoseHtml(parts, options) {
|
|
|
157
163
|
'<style>', PAGE_CSS, '</style></head><body>',
|
|
158
164
|
'<div id="wrap">',
|
|
159
165
|
'<video id="video" playsinline muted autoplay></video>',
|
|
166
|
+
'<img id="still" alt="" draggable="false" />',
|
|
160
167
|
'<canvas id="overlay"></canvas>',
|
|
161
168
|
'<div id="wm">',
|
|
162
169
|
'<div class="wm-powered">powered by</div>',
|
|
@@ -176,6 +183,8 @@ function buildPoseHtml(parts, options) {
|
|
|
176
183
|
'<script>window.__PT_BUILD=', JSON.stringify(`${exports.POSE_HTML_BUILD}/${parts.version}`),
|
|
177
184
|
';window.__PT_CONFIG=', config,
|
|
178
185
|
';window.__PT_MODEL_URL=', JSON.stringify(parts.modelUrl),
|
|
186
|
+
';window.__PT_MODEL_ID=', JSON.stringify(parts.modelId),
|
|
187
|
+
';window.__PT_MODEL_KIND=', JSON.stringify(parts.modelKind),
|
|
179
188
|
';window.__PT_WASM_PATH=', JSON.stringify(parts.tfjsWasmPath),
|
|
180
189
|
';window.__PT_MODEL_JSON=null',
|
|
181
190
|
';window.__PT_WEIGHTS_B64=null',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** AUTO-GENERATED by scripts/embed-pose-runtime.mjs — do not edit. */
|
|
2
2
|
'use strict';
|
|
3
|
-
exports.POSE_RUNTIME_VERSION = "online-
|
|
4
|
-
exports.POSE_RUNTIME_JS = "/**\n * PoseTracker pose-runtime (light / online) — WebView page runtime (camera,\n * capture presets, MoveNet inference loop, skeleton, events).\n *\n * Shipped thin inside the light npm package; TF.js + model stay remote.\n *\n * Expects globals injected by the SDK HTML assembler:\n * __PT_BUILD, __PT_CONFIG\n * __PT_MODEL_URL (preferred) — TF.js graph model URL (Front product path)\n * OR __PT_MODEL_JSON + __PT_WEIGHTS_B64 — legacy in-memory artifacts\n * __PT_WASM_PATH — CDN directory for tfjs-backend-wasm (online)\n * OR __PT_WASM_B64 — embedded XNNPACK binaries (legacy)\n * __PT_PIPELINE_WASM_B64 — optional proprietary pipeline (may be null)\n */\n(function () {\n var CFG = window.__PT_CONFIG;\n var FACING = CFG.facingMode;\n var MIN_SCORE = CFG.minScore;\n var INPUT_SIZE = 192;\n var KEYPOINT_NAMES = [\n 'nose', 'left_eye', 'right_eye', 'left_ear', 'right_ear',\n 'left_shoulder', 'right_shoulder', 'left_elbow', 'right_elbow',\n 'left_wrist', 'right_wrist', 'left_hip', 'right_hip',\n 'left_knee', 'right_knee', 'left_ankle', 'right_ankle'\n ];\n\n /**\n * Default skeleton — same document as PoseTrackerFront\n * `lib/drawing_on_canvas.js` DEFAULT_SKELETON (navy + gold, body edges).\n * Overridable via CFG.skeletonDef / __PT_SET_SKELETON (custom Strapi skeleton).\n */\n var DEFAULT_SKELETON = {\n keypoints: [\n 'nose', 'left_eye', 'right_eye', 'left_ear', 'right_ear',\n 'left_shoulder', 'right_shoulder', 'left_elbow', 'right_elbow',\n 'left_wrist', 'right_wrist', 'left_hip', 'right_hip',\n 'left_knee', 'right_knee', 'left_ankle', 'right_ankle'\n ],\n keypoint_lines: [\n 'right_shoulder||left_shoulder',\n 'right_hip||left_hip',\n 'right_hip||right_shoulder',\n 'right_hip||right_knee',\n 'right_ankle||right_knee',\n 'right_shoulder||right_elbow',\n 'right_wrist||right_elbow',\n 'left_hip||left_shoulder',\n 'left_hip||left_knee',\n 'left_ankle||left_knee',\n 'left_shoulder||left_elbow',\n 'left_wrist||left_elbow'\n ],\n keypoint_angles: [],\n angles: { fontColor: '#000000', strokeColor: '#FFC300' },\n circles: {\n circleFillColor: '#010A73',\n circleStrokeColor: '#FFC300',\n strokeWidth: '4',\n radius: '8'\n },\n lines: { lineStrokeColor: '#010A73', strokeWidth: '4' }\n };\n /** V3 drawOnCanvas threshold (not the older drawing_on_canvas 0.5). */\n var SKELETON_SCORE = 0.3;\n\n var video = document.getElementById('video');\n var canvas = document.getElementById('overlay');\n var hud = document.getElementById('hud');\n var bootCover = document.getElementById('boot');\n var watermarkEl = document.getElementById('wm');\n var ctx = canvas.getContext('2d');\n var cameraRevealed = false;\n var loadingText =\n (CFG.loadingText && String(CFG.loadingText).trim()) || 'AI Loading';\n var showWatermark = CFG.showWatermark !== false;\n var debugHud = !!CFG.debugHud || !!CFG.perfDebug;\n\n function applyWatermarkVisibility() {\n if (!watermarkEl) return;\n // Only on live full camera surfaces once revealed (skip 1×1 basic warmers).\n var on = showWatermark && CFG.coldStart === 'full' && cameraRevealed;\n if (on) watermarkEl.classList.add('show');\n else watermarkEl.classList.remove('show');\n }\n\n function setBootMessage(text, isError) {\n if (!bootCover) return;\n var msg = bootCover.querySelector('.boot-msg');\n if (!msg) return;\n msg.textContent = text;\n if (isError) msg.classList.add('is-error');\n else msg.classList.remove('is-error');\n }\n\n function resetBootLoadingText() {\n setBootMessage(loadingText, false);\n }\n\n /**\n * Keep video/canvas hidden until metadata + layout settle. Showing the\n * <video> at 0×0 (or before aspect is known) makes object-fit:cover flash\n * a tiny or over-zoomed frame for a few frames.\n */\n function setCameraVisible(visible) {\n video.style.opacity = visible ? '1' : '0';\n canvas.style.opacity = visible ? '1' : '0';\n if (bootCover) {\n if (visible) {\n bootCover.classList.add('hide');\n } else {\n bootCover.classList.remove('hide');\n resetBootLoadingText();\n }\n }\n applyWatermarkVisibility();\n }\n\n function revealCameraWhenReady() {\n if (cameraRevealed) return;\n if (!(video.videoWidth > 0 && video.videoHeight > 0)) return;\n // Two rAFs: let the browser apply object-fit:cover with real intrinsic size.\n requestAnimationFrame(function () {\n requestAnimationFrame(function () {\n if (cameraRevealed) return;\n if (!(video.videoWidth > 0 && video.videoHeight > 0)) return;\n cameraRevealed = true;\n setCameraVisible(true);\n applyWatermarkVisibility();\n });\n });\n }\n\n /**\n * Some WebViews deliver metadata late (or after a 0×0 first frame). Keep\n * retrying reveal so the branded \"AI Loading\" overlay cannot stick forever\n * after a successful getUserMedia.\n */\n function scheduleRevealRetries(maxMs) {\n var started = Date.now();\n function tick() {\n if (cameraRevealed) return;\n revealCameraWhenReady();\n if (cameraRevealed) return;\n if (Date.now() - started >= maxMs) {\n if (video.srcObject && !(video.videoWidth > 0)) {\n post({\n type: 'diag',\n message: 'camera stream open but video still 0x0 after ' + maxMs + 'ms'\n });\n setBootMessage('camera started but preview is empty — check permissions', true);\n }\n return;\n }\n setTimeout(tick, 120);\n }\n setTimeout(tick, 60);\n }\n var model = null;\n var running = false;\n var busy = false;\n // Android experiment: skip N ready rAF ticks between inferences (iOS = 0).\n // Injected from captureMode.ANDROID_INFER_FRAME_SKIP via poseHtml.\n var INFER_FRAME_SKIP = (CFG.inferFrameSkip != null && CFG.inferFrameSkip > 0)\n ? (CFG.inferFrameSkip | 0)\n : 0;\n var skipCountdown = 0;\n var PREPROCESS_PATH = CFG.preprocessPath === 'canvas-direct'\n ? 'canvas-direct'\n : 'imagebitmap';\n var SOFT_CAP_PROFILE = CFG.softCapProfile || null;\n var CAPTURE_PRIORITY = CFG.capturePriority === 'quality' ? 'quality' : 'performance';\n var PERF_DEBUG = !!CFG.perfDebug;\n var lastInferMs = [];\n var frameCount = 0;\n var fpsWindow = 0;\n var fpsTimer = 0;\n var skipTicksWindow = 0;\n var inferTicksWindow = 0;\n var smoothed = null;\n var SMOOTH_ALPHA = 0.5;\n var stageMs = { bitmap: [], pixels: [], exec: [], total: [] };\n var activeFlags = 'default';\n var activeBackend = null;\n // Letterbox params for the last pose input (contain into 192×192).\n // Needed to map MoveNet [0,1] square coords → video pixels → object-fit:cover display.\n var letterbox = { offsetX: 0, offsetY: 0, drawW: INPUT_SIZE, drawH: INPUT_SIZE, vw: 1, vh: 1 };\n\n function post(msg) {\n try {\n if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {\n window.ReactNativeWebView.postMessage(JSON.stringify(msg));\n }\n } catch (e) {}\n }\n\n /**\n * Technical status line for the optional debug HUD only.\n * Boot overlay keeps the branded loadingText — progress is emitted via\n * `initialization` / `diag` events for the host developer.\n */\n function setHud(t) {\n if (!hud) return;\n if (debugHud) {\n hud.classList.add('debug');\n hud.textContent = t;\n }\n }\n\n function median(arr) {\n if (!arr.length) return null;\n var s = arr.slice().sort(function (a, b) { return a - b; });\n return s[Math.floor(s.length / 2)];\n }\n\n function pushStage(k, v) {\n var a = stageMs[k];\n a.push(v);\n if (a.length > 30) a.shift();\n }\n\n function applyMirrorCss() {\n var t = FACING === 'user' ? 'scaleX(-1)' : 'none';\n video.style.transform = t;\n canvas.style.transform = t;\n }\n\n function base64ToArrayBuffer(b64) {\n var bin = atob(b64);\n var bytes = new Uint8Array(bin.length);\n for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);\n return bytes.buffer;\n }\n\n function wasmBlobUrl(b64) {\n return URL.createObjectURL(\n new Blob([base64ToArrayBuffer(b64)], { type: 'application/wasm' })\n );\n }\n\n // -------------------------------------------------------------------------\n // Proprietary pose pipeline (WASM): decode + letterbox/cover mapping + EMA.\n // Falls back to the equivalent JS path below when instantiation fails.\n // -------------------------------------------------------------------------\n var pipeline = null;\n\n async function initPipeline() {\n try {\n var b64 = window.__PT_PIPELINE_WASM_B64;\n if (!b64 || typeof WebAssembly === 'undefined') {\n post({ type: 'diag', message: 'pose-pipeline: js path (no wasm payload)' });\n return;\n }\n var res = await WebAssembly.instantiate(base64ToArrayBuffer(b64), {\n env: { abort: function () {} }\n });\n var ex = res.instance.exports;\n pipeline = {\n memory: ex.memory,\n inputPtr: ex.inputPtr(),\n outputPtr: ex.outputPtr(),\n setFrame: ex.setFrame,\n process: ex.process,\n reset: ex.reset\n };\n post({ type: 'diag', message: 'pose-pipeline.wasm active' });\n } catch (e) {\n pipeline = null;\n post({\n type: 'diag',\n message: 'pose-pipeline.wasm unavailable: ' +\n (e && e.message ? e.message : String(e)) + ' — js path'\n });\n }\n window.__PT_PIPELINE_WASM_B64 = null;\n }\n\n function buildModelArtifacts() {\n var json = JSON.parse(window.__PT_MODEL_JSON);\n var shards = window.__PT_WEIGHTS_B64.map(base64ToArrayBuffer);\n var total = 0;\n shards.forEach(function (b) { total += b.byteLength; });\n var weightData = new Uint8Array(total);\n var offset = 0;\n shards.forEach(function (b) {\n weightData.set(new Uint8Array(b), offset);\n offset += b.byteLength;\n });\n var weightSpecs = [];\n json.weightsManifest.forEach(function (group) {\n weightSpecs = weightSpecs.concat(group.weights);\n });\n return {\n modelTopology: json.modelTopology,\n weightSpecs: weightSpecs,\n weightData: weightData.buffer,\n format: json.format,\n generatedBy: json.generatedBy,\n convertedBy: json.convertedBy,\n signature: json.signature,\n userDefinedMetadata: json.userDefinedMetadata\n };\n }\n\n function glInfo() {\n try {\n var c = document.createElement('canvas');\n var gl = c.getContext('webgl') || c.getContext('experimental-webgl');\n if (!gl) return null;\n var dbg = gl.getExtension('WEBGL_debug_renderer_info');\n return {\n renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null,\n vendor: dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : null,\n version: gl.getParameter(gl.VERSION)\n };\n } catch (e) { return null; }\n }\n\n function isMali(renderer) {\n return !!(renderer && /mali/i.test(renderer));\n }\n\n // Product flags (poseWorker.js) — must be set BEFORE the first setBackend('webgl').\n // Never call tf.removeBackend('webgl'): that deletes the factory from the\n // registry and setBackend then fails with \"webgl not found in registry\".\n function applyProductFlags() {\n try { tf.env().set('WEBGL_CPU_FORWARD', false); } catch (e) {}\n try { tf.env().set('WEBGL_PACK', false); } catch (e) {}\n try { tf.env().set('WEBGL_FORCE_F16_TEXTURES', false); } catch (e) {}\n try { tf.env().set('WEBGL_RENDER_FLOAT32_ENABLED', false); } catch (e) {}\n try { tf.env().set('WEBGL_FLUSH_THRESHOLD', 1.75); } catch (e) {}\n try { tf.env().set('WEBGL_DISJOINT_QUERY_TIMER_EXTENSION_VERSION', 0); } catch (e) {}\n try { tf.env().set('WEBGL_AUTO_SQUARIFY_NARROW_TEXTURE_SHAPE', true); } catch (e) {}\n }\n\n // dataSync (not await data): exact product parity — pose-detection's MoveNet\n // detector uses dataSync on WebGL. Async data() waits on a GPU fence polled\n // via setTimeout, which adds big per-frame latency in an Android WebView.\n function readOutput(out) {\n return out.dataSync();\n }\n\n async function benchZeros(n) {\n var times = [];\n for (var i = 0; i < n; i++) {\n var z = tf.zeros([1, INPUT_SIZE, INPUT_SIZE, 3], 'int32');\n var t0 = performance.now();\n var out = model.execute(z);\n readOutput(out);\n var dt = performance.now() - t0;\n z.dispose(); out.dispose();\n if (i > 0) times.push(dt);\n }\n return times;\n }\n\n async function loadModel() {\n if (model && model.dispose) try { model.dispose(); } catch (e) {}\n var modelUrl =\n (typeof window.__PT_MODEL_URL === 'string' && window.__PT_MODEL_URL) ||\n (CFG.modelUrl && String(CFG.modelUrl)) ||\n null;\n if (modelUrl) {\n post({\n type: 'diag',\n message: 'loadGraphModel url=' + modelUrl\n });\n model = await tf.loadGraphModel(modelUrl);\n return;\n }\n if (!window.__PT_MODEL_JSON || !window.__PT_WEIGHTS_B64) {\n throw new Error('no modelUrl and no embedded model artifacts');\n }\n model = await tf.loadGraphModel({\n load: function () { return Promise.resolve(buildModelArtifacts()); }\n });\n }\n\n async function initWasmFallback() {\n if (!tf.wasm || typeof WebAssembly === 'undefined') {\n throw new Error('wasm unavailable');\n }\n var b = window.__PT_WASM_B64;\n var wasmPath =\n (typeof window.__PT_WASM_PATH === 'string' && window.__PT_WASM_PATH) ||\n (CFG.tfjsWasmPath && String(CFG.tfjsWasmPath)) ||\n null;\n if (b && b.plain) {\n tf.wasm.setWasmPaths({\n 'tfjs-backend-wasm.wasm': wasmBlobUrl(b.plain),\n 'tfjs-backend-wasm-simd.wasm': wasmBlobUrl(b.simd),\n 'tfjs-backend-wasm-threaded-simd.wasm': wasmBlobUrl(b.threadedSimd)\n });\n } else if (wasmPath) {\n var base = wasmPath.charAt(wasmPath.length - 1) === '/' ? wasmPath : wasmPath + '/';\n tf.wasm.setWasmPaths(base);\n } else {\n throw new Error('wasm unavailable (no __PT_WASM_B64 and no __PT_WASM_PATH)');\n }\n var ok = await tf.setBackend('wasm');\n await tf.ready();\n if (ok === false || tf.getBackend() !== 'wasm') throw new Error('wasm setBackend failed');\n }\n\n /**\n * Init WebGL once (product path). WASM only if WebGL truly fails.\n */\n async function pickFastestBackend() {\n var info = glInfo();\n var mali = isMali(info && info.renderer);\n var registered = [];\n try {\n if (tf.engine && tf.engine().registryFactory) {\n registered = Object.keys(tf.engine().registryFactory);\n }\n } catch (e) {}\n post({\n type: 'diag',\n message: 'build=' + (window.__PT_BUILD || '?') +\n ' gl renderer=' + ((info && info.renderer) || '?') +\n ' mali=' + mali +\n ' backends=[' + registered.join(',') + ']'\n });\n\n try {\n post({\n type: 'initialization',\n step: 'init_webgl',\n message: 'init webgl (product flags)',\n ready: false\n });\n setHud('init webgl (product flags)…');\n applyProductFlags();\n var ok = await tf.setBackend('webgl');\n await tf.ready();\n if (ok === false || tf.getBackend() !== 'webgl') {\n throw new Error('setBackend webgl returned ' + String(tf.getBackend()));\n }\n await loadModel();\n var times = await benchZeros(5);\n var med = median(times);\n post({\n type: 'diag',\n message: 'bench webgl/stable medianMs=' +\n (med != null ? med.toFixed(1) : '?') +\n ' runs=[' + times.map(function (t) { return Math.round(t); }).join(',') + ']'\n });\n activeFlags = 'stable';\n activeBackend = 'webgl';\n return { name: 'stable', med: med, times: times, backend: 'webgl' };\n } catch (e) {\n post({\n type: 'diag',\n message: 'webgl init failed: ' + (e && e.message ? e.message : String(e)) +\n ' — falling back to wasm'\n });\n }\n\n post({\n type: 'initialization',\n step: 'init_wasm_fallback',\n message: 'webgl failed — trying wasm',\n ready: false\n });\n setHud('webgl failed — trying wasm…');\n await initWasmFallback();\n await loadModel();\n var wtimes = await benchZeros(4);\n activeFlags = 'wasm';\n activeBackend = 'wasm';\n return { name: 'wasm', med: median(wtimes), times: wtimes, backend: 'wasm' };\n }\n\n function poseCanvasCtx() {\n if (!window.__PT_FC) {\n window.__PT_FC = document.createElement('canvas');\n window.__PT_FC.width = INPUT_SIZE;\n window.__PT_FC.height = INPUT_SIZE;\n window.__PT_FCTX = window.__PT_FC.getContext('2d', { willReadFrequently: true });\n }\n return window.__PT_FCTX;\n }\n\n /**\n * Letterbox video → 192×192 canvas (contain). Stretching with\n * resizeWidth/Height=192 broke aspect ratio → wrong skeleton.\n * Returns either an ImageBitmap or the canvas itself for fromPixels.\n */\n async function preparePoseInput() {\n var t0 = performance.now();\n var vw = video.videoWidth || 1;\n var vh = video.videoHeight || 1;\n var scale = Math.min(INPUT_SIZE / vw, INPUT_SIZE / vh);\n var drawW = vw * scale;\n var drawH = vh * scale;\n var offsetX = (INPUT_SIZE - drawW) / 2;\n var offsetY = (INPUT_SIZE - drawH) / 2;\n letterbox = { offsetX: offsetX, offsetY: offsetY, drawW: drawW, drawH: drawH, vw: vw, vh: vh };\n\n var c2d = poseCanvasCtx();\n c2d.fillStyle = '#000';\n c2d.fillRect(0, 0, INPUT_SIZE, INPUT_SIZE);\n c2d.drawImage(video, 0, 0, vw, vh, offsetX, offsetY, drawW, drawH);\n\n if (PREPROCESS_PATH === 'canvas-direct') {\n pushStage('bitmap', performance.now() - t0);\n return { kind: 'canvas', source: window.__PT_FC };\n }\n\n var bmp = await createImageBitmap(window.__PT_FC);\n pushStage('bitmap', performance.now() - t0);\n return { kind: 'bitmap', source: bmp };\n }\n\n /** MoveNet norm (0–1 over the 192 square) → video pixel coords. (JS fallback) */\n function modelNormToVideo(xNorm, yNorm) {\n var xSq = xNorm * INPUT_SIZE;\n var ySq = yNorm * INPUT_SIZE;\n return {\n x: (xSq - letterbox.offsetX) * (letterbox.vw / letterbox.drawW),\n y: (ySq - letterbox.offsetY) * (letterbox.vh / letterbox.drawH)\n };\n }\n\n /** Video pixels → CSS object-fit:cover display pixels. (JS fallback) */\n function videoToCover(vx, vy, dispW, dispH) {\n var scale = Math.max(dispW / letterbox.vw, dispH / letterbox.vh);\n var ox = (dispW - letterbox.vw * scale) / 2;\n var oy = (dispH - letterbox.vh * scale) / 2;\n return { x: vx * scale + ox, y: vy * scale + oy };\n }\n\n async function infer() {\n var t0 = performance.now();\n var prepared = await preparePoseInput();\n var t1 = performance.now();\n var input = tf.tidy(function () {\n return tf.expandDims(tf.browser.fromPixels(prepared.source), 0);\n });\n if (prepared.kind === 'bitmap' && prepared.source && prepared.source.close) {\n prepared.source.close();\n }\n var t2 = performance.now();\n var out = model.execute(input);\n var data = readOutput(out);\n var t3 = performance.now();\n input.dispose();\n out.dispose();\n pushStage('pixels', t2 - t1);\n pushStage('exec', t3 - t2);\n // Full pipeline (draw + fromPixels + execute) — adaptive quality + debug\n // must see preprocess cost, especially on Android HD capture.\n var totalMs = t3 - t0;\n pushStage('total', totalMs);\n return { data: data, totalMs: totalMs };\n }\n\n /** Rank which stage dominates the frame (for leverHints). */\n function dominantStage(drawMs, pixelsMs, execMs) {\n var entries = [\n { stage: 'bitmap', ms: drawMs || 0 },\n { stage: 'fromPixels', ms: pixelsMs || 0 },\n { stage: 'execute', ms: execMs || 0 }\n ];\n entries.sort(function (a, b) { return b.ms - a.ms; });\n return entries[0];\n }\n\n /**\n * Heuristic hints: which experiment lever is likely helping / wasted.\n * Purely diagnostic — never changes behaviour.\n */\n function buildLeverHints(medTotal, drawMs, pixelsMs, execMs, poseFps) {\n var dom = dominantStage(drawMs, pixelsMs, execMs);\n var hints = [];\n var total = medTotal > 0 ? medTotal : (drawMs || 0) + (pixelsMs || 0) + (execMs || 0);\n var bmpShare = total > 0 ? (drawMs || 0) / total : 0;\n var pxShare = total > 0 ? (pixelsMs || 0) / total : 0;\n var exShare = total > 0 ? (execMs || 0) / total : 0;\n\n if (bmpShare >= 0.45) {\n hints.push('bitmap_dominates→softCap_or_lower_capture_still_useful');\n } else {\n hints.push('bitmap_not_dominant→softCap_less_useful_now');\n }\n if (PREPROCESS_PATH === 'canvas-direct') {\n if (pxShare >= 0.35) {\n hints.push('fromPixels_still_heavy→canvas-direct_may_not_be_enough');\n } else if (bmpShare + pxShare < 0.35) {\n hints.push('preprocess_light→canvas-direct_likely_helping_or_neutral');\n } else {\n hints.push('preprocess_mixed→compare_vs_imagebitmap_A/B');\n }\n } else if (pxShare >= 0.25) {\n hints.push('imagebitmap_path→try_canvas-direct_if_fromPixels_high');\n }\n if (exShare >= 0.5) {\n hints.push('execute_dominates→frameSkip_helps_GPU_breathing_not_capture');\n }\n if (INFER_FRAME_SKIP > 0) {\n if (poseFps != null && poseFps >= MIN_TARGET_FPS) {\n hints.push('frameSkip_on_and_floor_met→keep_or_try_skip0_to_measure');\n } else {\n hints.push('frameSkip_on_but_below_floor→try_skip2_or_lower_capture');\n }\n }\n if (SOFT_CAP_PROFILE) {\n hints.push('softCap=' + SOFT_CAP_PROFILE + '_active');\n }\n return {\n dominantStage: dom.stage,\n dominantMs: dom.ms,\n bitmapShare: Math.round(bmpShare * 100) / 100,\n fromPixelsShare: Math.round(pxShare * 100) / 100,\n executeShare: Math.round(exShare * 100) / 100,\n hints: hints\n };\n }\n\n function smoothKeypoints(raw) {\n if (!smoothed) { smoothed = raw; return raw; }\n for (var i = 0; i < raw.length; i++) {\n if (raw[i].score < 0.1) continue;\n smoothed[i].xPx = SMOOTH_ALPHA * raw[i].xPx + (1 - SMOOTH_ALPHA) * smoothed[i].xPx;\n smoothed[i].yPx = SMOOTH_ALPHA * raw[i].yPx + (1 - SMOOTH_ALPHA) * smoothed[i].yPx;\n smoothed[i].score = raw[i].score;\n }\n return smoothed;\n }\n\n function activeSkeleton() {\n var def = CFG.skeletonDef;\n if (def && Array.isArray(def.keypoint_lines) && def.circles && def.lines) {\n return def;\n }\n return DEFAULT_SKELETON;\n }\n\n /**\n * Draw skeleton in display pixels (object-fit:cover mapping already applied).\n * Parity with PoseTrackerFront `lib/v2/drawOnCanvas.js` drawSkeleton.\n */\n function drawSkeletonDisplay(kps, dispW, dispH) {\n if (canvas.width !== dispW) canvas.width = dispW;\n if (canvas.height !== dispH) canvas.height = dispH;\n ctx.clearRect(0, 0, dispW, dispH);\n if (!CFG.drawSkeleton) return;\n\n var sk = activeSkeleton();\n var byName = {};\n for (var i = 0; i < kps.length; i++) {\n byName[kps[i].name] = kps[i];\n }\n\n var lineStroke = (sk.lines && sk.lines.lineStrokeColor) || '#010A73';\n var lineWidth = Number((sk.lines && sk.lines.strokeWidth) || 4);\n var lines = sk.keypoint_lines || [];\n for (var e = 0; e < lines.length; e++) {\n var pair = String(lines[e]).split('||');\n if (pair.length < 2) continue;\n var a = byName[pair[0]];\n var b = byName[pair[1]];\n if (!a || !b || a.score <= SKELETON_SCORE || b.score <= SKELETON_SCORE) continue;\n ctx.beginPath();\n ctx.moveTo(a.dx, a.dy);\n ctx.lineTo(b.dx, b.dy);\n ctx.lineWidth = lineWidth;\n ctx.strokeStyle = lineStroke;\n ctx.stroke();\n }\n\n var fill = (sk.circles && sk.circles.circleFillColor) || '#010A73';\n var stroke = (sk.circles && sk.circles.circleStrokeColor) || '#FFC300';\n var circleStrokeW = Number((sk.circles && sk.circles.strokeWidth) || 4);\n var radius = Number((sk.circles && sk.circles.radius) || 8);\n var allowed = new Set(sk.keypoints || KEYPOINT_NAMES);\n for (var j = 0; j < kps.length; j++) {\n var kp = kps[j];\n if (!allowed.has(kp.name) || kp.score <= SKELETON_SCORE) continue;\n ctx.beginPath();\n ctx.arc(kp.dx, kp.dy, radius, 0, Math.PI * 2);\n ctx.fillStyle = fill;\n ctx.fill();\n ctx.lineWidth = circleStrokeW;\n ctx.strokeStyle = stroke;\n ctx.stroke();\n }\n }\n\n window.__PT_SET_SKELETON = function (def) {\n CFG.skeletonDef = def && typeof def === 'object' ? def : null;\n };\n\n /** WASM pipeline path: decode + map + smooth inside pose-pipeline.wasm. */\n function decodeWithPipeline(data, dispW, dispH) {\n var inArr = new Float32Array(pipeline.memory.buffer, pipeline.inputPtr, 51);\n for (var i = 0; i < 51; i++) inArr[i] = data[i];\n pipeline.setFrame(\n letterbox.vw, letterbox.vh, dispW, dispH,\n letterbox.offsetX, letterbox.offsetY, letterbox.drawW, letterbox.drawH,\n INPUT_SIZE, FACING === 'user' ? 1 : 0\n );\n var meanScore = pipeline.process();\n var outArr = new Float32Array(pipeline.memory.buffer, pipeline.outputPtr, 119);\n var drawKps = [];\n var rnKps = [];\n var above = 0;\n for (var j = 0; j < 17; j++) {\n var o = j * 7;\n var rawScore = data[j * 3 + 2];\n if (rawScore >= 0.3) above += 1;\n drawKps.push({\n name: KEYPOINT_NAMES[j],\n xPx: outArr[o],\n yPx: outArr[o + 1],\n dx: outArr[o + 2],\n dy: outArr[o + 3],\n score: outArr[o + 6]\n });\n rnKps.push({\n name: KEYPOINT_NAMES[j],\n x: outArr[o + 4],\n y: outArr[o + 5],\n score: outArr[o + 6]\n });\n }\n return { drawKps: drawKps, rnKps: rnKps, meanScore: meanScore, above: above };\n }\n\n /** JS fallback path (same math as the wasm pipeline). */\n function decodeWithJs(data, dispW, dispH) {\n var drawKps = [];\n var scoreSum = 0;\n var above = 0;\n for (var i = 0; i < 17; i++) {\n var xNorm = Math.min(1, Math.max(0, data[i * 3 + 1]));\n var yNorm = Math.min(1, Math.max(0, data[i * 3]));\n var score = data[i * 3 + 2];\n var vid = modelNormToVideo(xNorm, yNorm);\n var disp = videoToCover(vid.x, vid.y, dispW, dispH);\n drawKps.push({\n name: KEYPOINT_NAMES[i],\n xPx: vid.x,\n yPx: vid.y,\n dx: disp.x,\n dy: disp.y,\n score: score\n });\n scoreSum += score;\n if (score >= 0.3) above += 1;\n }\n var raw = drawKps.map(function (k) {\n return { xPx: k.xPx, yPx: k.yPx, score: k.score };\n });\n var sm = smoothKeypoints(raw);\n for (var k = 0; k < 17; k++) {\n drawKps[k].xPx = sm[k].xPx;\n drawKps[k].yPx = sm[k].yPx;\n drawKps[k].score = sm[k].score;\n var d2 = videoToCover(sm[k].xPx, sm[k].yPx, dispW, dispH);\n drawKps[k].dx = d2.x;\n drawKps[k].dy = d2.y;\n }\n var rnKps = drawKps.map(function (k) {\n var nx = dispW > 0 ? k.dx / dispW : 0;\n var ny = dispH > 0 ? k.dy / dispH : 0;\n if (FACING === 'user') nx = 1 - nx;\n return {\n name: k.name,\n x: Math.min(1, Math.max(0, nx)),\n y: Math.min(1, Math.max(0, ny)),\n score: k.score\n };\n });\n return { drawKps: drawKps, rnKps: rnKps, meanScore: scoreSum / 17, above: above };\n }\n\n function decodeAndPublish(data, inferenceTimeMs) {\n // Pipeline (matches PoseTrackerFront):\n // MoveNet [0,1] on letterboxed 192² → video pixels → object-fit:cover display\n // Front camera: video+canvas CSS scaleX(-1). Draw in SENSOR display space;\n // flip only the RN payload so host overlays match the mirrored preview.\n var dispW = canvas.clientWidth || 1;\n var dispH = canvas.clientHeight || 1;\n var decoded = pipeline\n ? decodeWithPipeline(data, dispW, dispH)\n : decodeWithJs(data, dispW, dispH);\n var drawKps = decoded.drawKps;\n var meanScore = decoded.meanScore;\n var above = decoded.above;\n drawSkeletonDisplay(drawKps, dispW, dispH);\n\n lastInferMs.push(inferenceTimeMs);\n if (lastInferMs.length > 30) lastInferMs.shift();\n frameCount += 1;\n fpsWindow += 1;\n\n var now = performance.now();\n if (now - fpsTimer >= 1000) {\n var fps = fpsWindow;\n var skipped = skipTicksWindow;\n var inferred = inferTicksWindow;\n fpsWindow = 0;\n skipTicksWindow = 0;\n inferTicksWindow = 0;\n fpsTimer = now;\n var med = median(lastInferMs);\n var drawMs = median(stageMs.bitmap);\n var pixelsMs = median(stageMs.pixels);\n var execMs = median(stageMs.exec);\n var totalMed = median(stageMs.total);\n var estFromMed = med != null && med > 0 ? 1000 / med : null;\n var modeTag = 'main/' + activeFlags +\n (pipeline ? '/wasm-pipeline' : '') +\n '/' + PREPROCESS_PATH +\n (INFER_FRAME_SKIP > 0 ? '/skip' + INFER_FRAME_SKIP : '') +\n (SOFT_CAP_PROFILE ? '/soft:' + SOFT_CAP_PROFILE : '');\n\n var hudLine =\n 'movenet · ' + activeBackend + '/' + activeFlags + ' · ' +\n fps + ' fps · ' + (med != null ? Math.round(med) + ' ms' : '?') +\n ' · kp≥0.3=' + above + '/17';\n if (PERF_DEBUG) {\n hudLine =\n fps + 'fps med=' + (med != null ? Math.round(med) : '?') +\n 'ms (~' + (estFromMed != null ? estFromMed.toFixed(1) : '?') + 'fps) ' +\n 'bmp=' + (drawMs != null ? Math.round(drawMs) : '?') +\n ' px=' + (pixelsMs != null ? Math.round(pixelsMs) : '?') +\n ' ex=' + (execMs != null ? Math.round(execMs) : '?') +\n ' | ' + PREPROCESS_PATH +\n (INFER_FRAME_SKIP > 0 ? ' skip' + INFER_FRAME_SKIP : '') +\n (SOFT_CAP_PROFILE ? ' soft:' + SOFT_CAP_PROFILE : '') +\n ' ' + video.videoWidth + 'x' + video.videoHeight +\n ' inf/sk=' + inferred + '/' + skipped;\n }\n setHud(hudLine);\n\n var experiments = {\n platform: CFG.platform || 'unknown',\n captureMode: CAPTURE_CONSTRAINT_MODE,\n capturePriority: CAPTURE_PRIORITY,\n preprocessPath: PREPROCESS_PATH,\n inferFrameSkip: INFER_FRAME_SKIP,\n softCapProfile: SOFT_CAP_PROFILE,\n minTargetFps: MIN_TARGET_FPS,\n perfDebug: PERF_DEBUG\n };\n var leverHints = PERF_DEBUG\n ? buildLeverHints(totalMed != null ? totalMed : med, drawMs, pixelsMs, execMs, fps)\n : null;\n\n post({\n type: 'stats',\n fps: fps,\n medianInferenceMs: med,\n estimatedFpsFromMedian: estFromMed,\n frames: frameCount,\n backend: activeBackend,\n keypointsAbove03: above,\n meanScore: meanScore,\n breakdown: {\n drawMs: drawMs,\n fromPixelsMs: pixelsMs,\n executeMs: execMs,\n totalPipelineMs: totalMed\n },\n windowCounts: { inferred: inferred, skipped: skipped },\n experiments: experiments,\n leverHints: leverHints,\n videoSize: video.videoWidth + 'x' + video.videoHeight,\n mode: modeTag\n });\n }\n\n post({\n type: 'pose',\n keypoints: decoded.rnKps,\n score: meanScore,\n inferenceTimeMs: inferenceTimeMs,\n timestampMs: Date.now()\n });\n }\n\n // Loop token: a stale rAF chain (pre-suspend) must never survive next to a\n // resumed one — two chains would double the inference attempt rate.\n var loopToken = 0;\n var inferErrorStreak = 0;\n // ~45 consecutive failures ≈ a few seconds of a dead GL context / broken\n // session. Stop the loop and release the camera instead of burning battery.\n var INFER_ERROR_LIMIT = 45;\n\n async function loop(token) {\n if (token !== loopToken || !running || !model) return;\n if (!busy && video.readyState >= 2 && video.videoWidth > 0) {\n // Android frame-skip: after each inference, burn N free rAF ticks before\n // the next one. Preview keeps streaming; last skeleton stays on canvas.\n if (skipCountdown > 0) {\n skipCountdown -= 1;\n skipTicksWindow += 1;\n } else {\n busy = true;\n try {\n var result = await infer();\n inferErrorStreak = 0;\n inferTicksWindow += 1;\n decodeAndPublish(result.data, result.totalMs);\n skipCountdown = INFER_FRAME_SKIP;\n } catch (err) {\n var message = err && err.message ? err.message : String(err);\n setHud('infer error: ' + message);\n if (frameCount < 3) post({ type: 'error', message: 'inference: ' + message });\n inferErrorStreak += 1;\n if (inferErrorStreak >= INFER_ERROR_LIMIT) {\n busy = false;\n post({\n type: 'error',\n message: 'inference failed repeatedly (' + message +\n ') — pipeline stopped and camera released to protect the device'\n });\n suspendPipeline('inference-errors');\n return;\n }\n }\n busy = false;\n }\n }\n requestAnimationFrame(function () { loop(token); });\n }\n\n function startLoop() {\n running = true;\n fpsTimer = performance.now();\n loop(++loopToken);\n }\n\n // Capture constraint mode (injected by poseHtml from captureMode.ts):\n // 'device-native' — Front-like: facingMode only (often HD preview)\n // 'profile-constrained' — AdaptiveChoice ideals (previous stable default)\n // REVERT: set CAPTURE_CONSTRAINT_MODE = 'profile-constrained' in captureMode.ts\n // then npm run build:runtime-payload && npm run build\n var CAPTURE_CONSTRAINT_MODE = CFG.captureConstraintMode === 'profile-constrained'\n ? 'profile-constrained'\n : 'device-native';\n\n // Capture profiles (mirror RN AdaptiveChoice ladder). Used to pick camera\n // resolution from the zeros warm-up BEFORE getUserMedia (profile-constrained)\n // or as live setQuality downgrade targets (both modes).\n // minTargetFps is a floor (iOS 30 / Android 15), not a single setpoint.\n var MIN_TARGET_FPS = (CFG.minTargetFps != null && CFG.minTargetFps > 0)\n ? CFG.minTargetFps\n : 15;\n var CAPTURE_PROFILES = {\n prime: { id: 'prime', idealWidth: 1280, idealHeight: 720, idealFrameRate: 30 },\n pro: { id: 'pro', idealWidth: 960, idealHeight: 540, idealFrameRate: 30 },\n lite: { id: 'lite', idealWidth: 640, idealHeight: 480, idealFrameRate: 30 },\n ultralite: { id: 'ultralite', idealWidth: 480, idealHeight: 360, idealFrameRate: 24 },\n basic: { id: 'basic', idealWidth: 320, idealHeight: 240, idealFrameRate: 20 }\n };\n var CAPTURE_LADDER = ['prime', 'pro', 'lite', 'ultralite', 'basic'];\n\n function profileRank(id) {\n var i = CAPTURE_LADDER.indexOf(id);\n return i < 0 ? CAPTURE_LADDER.length - 1 : i;\n }\n\n function lowerProfile(a, b) {\n return profileRank(a) >= profileRank(b) ? a : b;\n }\n\n /** Map warm-up execute median → max safe capture profile (scales with floor). */\n function profileFromWarmupMs(medMs) {\n if (medMs == null || !(medMs > 0)) return 'basic';\n var budget = 1000 / MIN_TARGET_FPS;\n // Over floor on zeros alone → basic (camera adds cost; no ultralite gift).\n if (medMs > budget) return 'basic';\n if (medMs <= budget * 0.6) return 'prime';\n if (medMs <= budget * 0.75) return 'pro';\n if (medMs <= budget * 0.9) return 'lite';\n return 'ultralite';\n }\n\n function applyCaptureProfile(id) {\n var p = CAPTURE_PROFILES[id] || CAPTURE_PROFILES.ultralite;\n CFG.idealWidth = p.idealWidth;\n CFG.idealHeight = p.idealHeight;\n CFG.idealFrameRate = p.idealFrameRate;\n CFG.profileId = p.id;\n return p;\n }\n\n /** Build getUserMedia video constraints for the active capture mode. */\n function videoConstraints(forceProfileIdeals) {\n var video = { facingMode: { ideal: FACING } };\n if (forceProfileIdeals || CAPTURE_CONSTRAINT_MODE === 'profile-constrained') {\n video.width = { ideal: CFG.idealWidth };\n video.height = { ideal: CFG.idealHeight };\n video.frameRate = { ideal: CFG.idealFrameRate || 30 };\n return video;\n }\n // device-native + optional Android soft-cap: use the warm-up-selected\n // CFG ideals (already clamped ≤ softCap) as ideal+max so Chromium\n // cannot open unrestricted 1080p.\n if (SOFT_CAP_PROFILE) {\n video.width = { ideal: CFG.idealWidth, max: CFG.idealWidth };\n video.height = { ideal: CFG.idealHeight, max: CFG.idealHeight };\n video.frameRate = { ideal: CFG.idealFrameRate || 30 };\n }\n return video;\n }\n\n async function boot() {\n try {\n applyMirrorCss();\n if (CFG.idealFrameRate == null) CFG.idealFrameRate = 30;\n if (!CFG.profileId) CFG.profileId = 'ultralite';\n\n // 0) Proprietary pipeline module (non-fatal when unavailable).\n await initPipeline();\n\n // 1) Bench MoveNet WITHOUT the camera — estimate FPS, pick capture tier.\n post({\n type: 'initialization',\n step: 'loading_pose_model',\n message: 'loading pose model',\n ready: false\n });\n setHud('loading MoveNet (webgl)…');\n var best = await pickFastestBackend();\n\n var estFps = best.med != null && best.med > 0 ? 1000 / best.med : null;\n var rnHint = CFG.profileId;\n var fromWarmup = profileFromWarmupMs(best.med);\n // Prefer warm-up evidence for the *initial* open. Using RN hint as a hard\n // ceiling pinned poisoned crash-guard sessions on basic, then RN upgraded\n // mid-getUserMedia and aborted the stream (\"The operation was aborted\").\n // Live FPS path can still downgrade after ready.\n var selected = fromWarmup;\n if (CAPTURE_PRIORITY === 'quality') {\n // Host opted into sharp preview — do not FPS-floor to basic/ultralite.\n selected = 'prime';\n } else if (SOFT_CAP_PROFILE && CAPTURE_PROFILES[SOFT_CAP_PROFILE]) {\n selected = lowerProfile(selected, SOFT_CAP_PROFILE);\n }\n var applied = applyCaptureProfile(selected);\n\n post({\n type: 'warmup_estimate',\n medianInferenceMs: best.med,\n estimatedFps: estFps,\n minTargetFps: MIN_TARGET_FPS,\n targetFps: MIN_TARGET_FPS,\n profileId: applied.id,\n previousProfileId: rnHint,\n idealWidth: applied.idealWidth,\n idealHeight: applied.idealHeight,\n gl: glInfo()\n });\n post({\n type: 'diag',\n message: 'warmup estimatedFps=' +\n (estFps != null ? estFps.toFixed(1) : '?') +\n ' medianMs=' + (best.med != null ? best.med.toFixed(1) : '?') +\n ' minTarget=' + MIN_TARGET_FPS +\n ' capture=' + applied.id + ' (' + applied.idealWidth + 'x' + applied.idealHeight + ')' +\n (SOFT_CAP_PROFILE ? ' softCap=' + SOFT_CAP_PROFILE : '') +\n (selected !== rnHint ? ' (rnHint=' + rnHint + ')' : '')\n });\n post({\n type: 'diag',\n message: 'experiments platform=' + (CFG.platform || '?') +\n ' captureMode=' + CAPTURE_CONSTRAINT_MODE +\n ' capturePriority=' + CAPTURE_PRIORITY +\n ' preprocess=' + PREPROCESS_PATH +\n ' frameSkip=' + INFER_FRAME_SKIP +\n ' softCap=' + (SOFT_CAP_PROFILE || 'off') +\n ' minTargetFps=' + MIN_TARGET_FPS +\n ' perfDebug=' + PERF_DEBUG\n });\n\n // Free base64 payloads after final model load.\n window.__PT_MODEL_JSON = null;\n window.__PT_WEIGHTS_B64 = null;\n window.__PT_WASM_B64 = null;\n\n var flagsSnapshot = '';\n try {\n flagsSnapshot =\n ' glVersion=' + tf.env().getNumber('WEBGL_VERSION') +\n ' pack=' + tf.env().getBool('WEBGL_PACK') +\n ' f32render=' + tf.env().getBool('WEBGL_RENDER_FLOAT32_ENABLED') +\n ' fenceApi=' + tf.env().getBool('WEBGL_FENCE_API_ENABLED');\n } catch (e) {}\n post({\n type: 'diag',\n message: 'final backend=' + best.backend + ' flags=' + best.name +\n ' medianMs=' + (best.med != null ? best.med.toFixed(1) : '?') +\n ' profile=' + CFG.profileId +\n ' coldStart=' + (BOOT_CAMERA ? 'full' : 'basic') +\n flagsSnapshot\n });\n\n lastWarmBackend = best.backend;\n lastWarmMedMs = best.med;\n lastWarmTimes = best.times || [];\n lastWarmEstFps = estFps;\n\n setHud('ready · ' + best.backend + '/' + CFG.profileId + ' · warm ' +\n (best.med != null ? Math.round(best.med) + 'ms' : '?') +\n (estFps != null ? ' (~' + Math.round(estFps) + 'fps est)' : '') +\n (BOOT_CAMERA ? '' : ' · model-only'));\n\n if (BOOT_CAMERA) {\n // Full cold-start (legacy): open camera before posting ready — same\n // as the historical warmer that called getUserMedia during preload.\n await openCameraAndLoop('boot-full');\n if (suspended) {\n post({ type: 'diag', message: 'boot finished while hidden — staying suspended (camera released)' });\n stopCameraTracks();\n // Still mark model+backend ready so preload can resolve; camera\n // reopens on resume because cameraArmed is true.\n } else {\n startLoop();\n }\n postReadySignal(true, 'full');\n } else {\n // Basic cold-start: model / WebGL only — no permission prompt.\n postReadySignal(false, 'basic');\n post({\n type: 'diag',\n message: 'basic cold-start — model warm, camera deferred until openCamera/full'\n });\n if (bootCover) {\n bootCover.classList.add('hide');\n }\n applyWatermarkVisibility();\n }\n } catch (err) {\n var message = err && err.message ? err.message : String(err);\n // Abort during a concurrent setQuality must not leave the boot overlay\n // stuck forever — retry once, then surface the error.\n if (/abort/i.test(message) || (err && err.name === 'AbortError')) {\n post({ type: 'diag', message: 'boot getUserMedia aborted — retrying once' });\n try {\n if (BOOT_CAMERA) {\n await openCameraAndLoop('boot-full-retry');\n if (!suspended) startLoop();\n postReadySignal(true, 'full');\n return;\n }\n } catch (retryErr) {\n message = retryErr && retryErr.message ? retryErr.message : String(retryErr);\n }\n }\n setHud('error: ' + message);\n setBootMessage('error: ' + message, true);\n post({ type: 'error', message: message });\n }\n }\n\n window.__PT_SET_FACING = function (mode) {\n FACING = mode === 'environment' ? 'environment' : 'user';\n applyMirrorCss();\n };\n\n /** Live plan upgrade: toggle watermark without remounting the WebView. */\n window.__PT_SET_WATERMARK = function (on) {\n showWatermark = !!on;\n CFG.showWatermark = showWatermark;\n applyWatermarkVisibility();\n };\n\n /** Update boot overlay copy (WebView loading_message parity). */\n window.__PT_SET_LOADING_TEXT = function (text) {\n if (typeof text === 'string' && text.trim().length > 0) {\n loadingText = text.trim();\n } else {\n loadingText = 'AI Loading';\n }\n CFG.loadingText = loadingText;\n if (!cameraRevealed) resetBootLoadingText();\n };\n\n /**\n * Restart getUserMedia with new ideal resolution (adaptive quality ladder).\n * Inference stays at 192² letterbox — only capture / preprocess cost changes.\n */\n window.__PT_SET_QUALITY = async function (opts) {\n try {\n if (!opts) return;\n if (opts.idealWidth) CFG.idealWidth = opts.idealWidth;\n if (opts.idealHeight) CFG.idealHeight = opts.idealHeight;\n if (opts.idealFrameRate) CFG.idealFrameRate = opts.idealFrameRate;\n qualityForced = true;\n if (suspended) {\n // Camera is released while hidden; resume reopens with the new CFG.\n post({ type: 'diag', message: 'setQuality deferred (pipeline suspended)' });\n return;\n }\n // Hide during stream swap — same 0×0 / wrong-aspect flash as cold boot.\n // Boot overlay keeps branded loadingText (tech progress via diag).\n cameraRevealed = false;\n setCameraVisible(false);\n post({\n type: 'diag',\n message: 'adjusting camera (quality swap)'\n });\n var old = video.srcObject;\n if (old && old.getTracks) {\n old.getTracks().forEach(function (t) { try { t.stop(); } catch (e) {} });\n }\n // Live quality swaps always use profile ideals (downgrade ladder).\n var stream = await navigator.mediaDevices.getUserMedia({\n audio: false,\n video: videoConstraints(true)\n });\n video.srcObject = stream;\n await video.play();\n await new Promise(function (resolve) {\n if (video.videoWidth > 0) { resolve(); return; }\n video.onloadedmetadata = function () { resolve(); };\n setTimeout(resolve, 1500);\n });\n await new Promise(function (resolve) {\n if (typeof video.requestVideoFrameCallback === 'function') {\n video.requestVideoFrameCallback(function () { resolve(); });\n setTimeout(resolve, 500);\n return;\n }\n setTimeout(resolve, 80);\n });\n revealCameraWhenReady();\n post({\n type: 'diag',\n message: 'quality applied camera=' + video.videoWidth + 'x' + video.videoHeight +\n ' ideal=' + CFG.idealWidth + 'x' + CFG.idealHeight +\n '@' + (CFG.idealFrameRate || 30) +\n (opts.profileId ? ' profile=' + opts.profileId : '')\n });\n } catch (err) {\n revealCameraWhenReady();\n post({\n type: 'diag',\n message: 'setQuality failed: ' + (err && err.message ? err.message : String(err))\n });\n }\n };\n\n // ── Cold-start modes ────────────────────────────────────────────────────\n // basic (default): model + zeros warm-up only — NEVER calls getUserMedia.\n // full: open camera after warm-up (legacy warmer / explicit preload full).\n // Camera screens pass coldStart=full (or call __PT_OPEN_CAMERA later).\n var BOOT_CAMERA = CFG.coldStart === 'full' || CFG.bootCamera === true;\n // True only after a successful getUserMedia — resume must not open camera\n // for a basic (model-only) warm page.\n var cameraArmed = false;\n var lastWarmBackend = null;\n var lastWarmMedMs = null;\n var lastWarmTimes = [];\n var lastWarmEstFps = null;\n\n function postReadySignal(cameraOpened, coldStart) {\n post({\n type: 'ready',\n backend: lastWarmBackend,\n medianInferenceMs: lastWarmMedMs,\n warmUpRunsMs: lastWarmTimes,\n estimatedFps: lastWarmEstFps,\n profileId: CFG.profileId,\n cameraOpened: !!cameraOpened,\n coldStart: coldStart,\n gl: glInfo(),\n note: 'main/' + (lastWarmBackend || '?') + '/' + CFG.profileId +\n (pipeline ? '/wasm-pipeline' : '') +\n (cameraOpened ? '/camera' : '/model-only')\n });\n }\n\n async function openCameraOnce(forceProfileIdeals) {\n var stream = await navigator.mediaDevices.getUserMedia({\n audio: false,\n video: videoConstraints(!!forceProfileIdeals)\n });\n video.srcObject = stream;\n await video.play();\n await new Promise(function (resolve) {\n if (video.videoWidth > 0) { resolve(); return; }\n video.onloadedmetadata = function () { resolve(); };\n setTimeout(resolve, 2000);\n });\n await new Promise(function (resolve) {\n if (typeof video.requestVideoFrameCallback === 'function') {\n video.requestVideoFrameCallback(function () { resolve(); });\n setTimeout(resolve, 500);\n return;\n }\n setTimeout(resolve, 80);\n });\n }\n\n /** Open webcam (does not start the loop — caller decides). */\n async function openCameraAndLoop(reason) {\n if (cameraArmed && video.srcObject) {\n return;\n }\n post({\n type: 'initialization',\n step: 'accessing_webcam',\n message: 'accessing webcam',\n ready: false\n });\n setHud(\n 'getUserMedia ' +\n (CAPTURE_CONSTRAINT_MODE === 'device-native' ? 'device-native' : CFG.profileId) +\n '…'\n );\n try {\n await openCameraOnce();\n } catch (camErr) {\n var camMsg = camErr && camErr.message ? camErr.message : String(camErr);\n var isAbort = /abort/i.test(camMsg) || (camErr && camErr.name === 'AbortError');\n if (!isAbort) throw camErr;\n post({ type: 'diag', message: 'getUserMedia aborted — retrying once' });\n await new Promise(function (r) { setTimeout(r, 120); });\n await openCameraOnce();\n }\n cameraArmed = true;\n revealCameraWhenReady();\n scheduleRevealRetries(4000);\n post({\n type: 'diag',\n message: 'camera size=' + video.videoWidth + 'x' + video.videoHeight +\n ' mode=' + CAPTURE_CONSTRAINT_MODE +\n ' ideal=' + CFG.idealWidth + 'x' + CFG.idealHeight +\n ' profile=' + CFG.profileId +\n ' reason=' + reason\n });\n }\n\n window.__PT_OPEN_CAMERA = function () {\n return openCameraAndLoop('host-openCamera').then(function () {\n if (suspended) {\n stopCameraTracks();\n return;\n }\n startLoop();\n postReadySignal(true, 'full');\n });\n };\n\n // ── Battery / thermal safety: release the camera and halt inference when\n // the page is hidden (app backgrounded, view covered), reacquire on return.\n // Belt-and-braces with the RN AppState hook (__PT_SUSPEND / __PT_RESUME):\n // Android WebView and WKWebView background semantics differ, page-level\n // visibilitychange + host injection together cover both. ──────────────────\n var suspended = false;\n // True once RN pushed an explicit quality profile — resume must reopen the\n // camera with profile ideals instead of the boot-time device-native path.\n var qualityForced = false;\n\n function stopCameraTracks() {\n var s = video.srcObject;\n if (s && s.getTracks) {\n s.getTracks().forEach(function (t) { try { t.stop(); } catch (e) {} });\n }\n video.srcObject = null;\n }\n\n function suspendPipeline(reason) {\n if (suspended) return;\n suspended = true;\n running = false;\n loopToken += 1; // kill any pending rAF chain\n stopCameraTracks();\n cameraRevealed = false;\n setCameraVisible(false);\n post({\n type: 'diag',\n message: 'pipeline suspended (' + reason + ') — camera released, inference stopped'\n });\n }\n\n async function resumePipeline() {\n if (!suspended) return;\n suspended = false;\n if (!model) return; // boot still in flight — it owns the camera\n // Model-only warm page: never open the camera on foreground.\n if (!cameraArmed) {\n post({ type: 'diag', message: 'resume skipped — camera not armed (basic cold-start)' });\n return;\n }\n try {\n var stream = await navigator.mediaDevices.getUserMedia({\n audio: false,\n video: videoConstraints(qualityForced)\n });\n if (suspended) {\n // Hidden again while getUserMedia was in flight.\n stream.getTracks().forEach(function (t) { try { t.stop(); } catch (e) {} });\n return;\n }\n stopCameraTracks();\n video.srcObject = stream;\n await video.play();\n await new Promise(function (resolve) {\n if (video.videoWidth > 0) { resolve(); return; }\n video.onloadedmetadata = function () { resolve(); };\n setTimeout(resolve, 1500);\n });\n revealCameraWhenReady();\n inferErrorStreak = 0;\n startLoop();\n post({\n type: 'diag',\n message: 'pipeline resumed — camera reacquired (' +\n video.videoWidth + 'x' + video.videoHeight + ')'\n });\n } catch (err) {\n post({\n type: 'diag',\n message: 'resume failed: ' + (err && err.message ? err.message : String(err))\n });\n }\n }\n\n window.__PT_SUSPEND = function () { suspendPipeline('host'); };\n window.__PT_RESUME = function () { resumePipeline(); };\n\n document.addEventListener('visibilitychange', function () {\n if (document.visibilityState === 'hidden') {\n suspendPipeline('page-hidden');\n } else {\n resumePipeline();\n }\n });\n // WKWebView / Chromium teardown: the camera indicator must die with the page.\n window.addEventListener('pagehide', function () {\n running = false;\n loopToken += 1;\n stopCameraTracks();\n });\n\n window.addEventListener('message', function (ev) {\n try {\n var data = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data;\n if (data && data.type === 'setFacing') window.__PT_SET_FACING(data.facingMode);\n if (data && data.type === 'setQuality') window.__PT_SET_QUALITY(data);\n if (data && data.type === 'stop') {\n running = false;\n loopToken += 1;\n stopCameraTracks();\n }\n } catch (e) {}\n });\n\n boot();\n})();\n";
|
|
3
|
+
exports.POSE_RUNTIME_VERSION = "online-c9b132e5";
|
|
4
|
+
exports.POSE_RUNTIME_JS = "/**\n * PoseTracker pose-runtime (light / online) — WebView page runtime (camera,\n * capture presets, MoveNet / BlazePose inference loop, skeleton, events).\n *\n * Shipped thin inside the light npm package; TF.js + model stay remote.\n *\n * Expects globals injected by the SDK HTML assembler:\n * __PT_BUILD, __PT_CONFIG\n * __PT_MODEL_URL (preferred) — TF.js graph model URL (Front product path)\n * __PT_MODEL_ID / __PT_MODEL_KIND — e.g. blazepose (no graph URL)\n * OR __PT_MODEL_JSON + __PT_WEIGHTS_B64 — legacy in-memory artifacts\n * __PT_WASM_PATH — CDN directory for tfjs-backend-wasm (online)\n * OR __PT_WASM_B64 — embedded XNNPACK binaries (legacy)\n * __PT_PIPELINE_WASM_B64 — optional proprietary pipeline (may be null)\n *\n * BlazePose: requires CDN `window.poseDetection` (injected when model=blazepose).\n * Keypoints are mapped to COCO-17 (extra BlazePose joints dropped).\n */\n(function () {\n var CFG = window.__PT_CONFIG;\n var FACING = CFG.facingMode;\n var MIN_SCORE = CFG.minScore;\n var INPUT_SIZE = 192;\n var MODEL_ID =\n (CFG.modelId && String(CFG.modelId)) ||\n (typeof window.__PT_MODEL_ID === 'string' && window.__PT_MODEL_ID) ||\n 'movenet-singlepose-lightning';\n var MODEL_KIND =\n (CFG.modelKind && String(CFG.modelKind)) ||\n (typeof window.__PT_MODEL_KIND === 'string' && window.__PT_MODEL_KIND) ||\n (MODEL_ID === 'blazepose' ? 'blazepose' : 'movenet-graph');\n var IS_BLAZEPOSE = MODEL_KIND === 'blazepose' || MODEL_ID === 'blazepose';\n var KEYPOINT_NAMES = [\n 'nose', 'left_eye', 'right_eye', 'left_ear', 'right_ear',\n 'left_shoulder', 'right_shoulder', 'left_elbow', 'right_elbow',\n 'left_wrist', 'right_wrist', 'left_hip', 'right_hip',\n 'left_knee', 'right_knee', 'left_ankle', 'right_ankle'\n ];\n /** MediaPipe BlazePose landmark names → COCO-17 (extras dropped). */\n var BLAZE_TO_COCO = {\n nose: 'nose',\n left_eye: 'left_eye',\n right_eye: 'right_eye',\n left_ear: 'left_ear',\n right_ear: 'right_ear',\n left_shoulder: 'left_shoulder',\n right_shoulder: 'right_shoulder',\n left_elbow: 'left_elbow',\n right_elbow: 'right_elbow',\n left_wrist: 'left_wrist',\n right_wrist: 'right_wrist',\n left_hip: 'left_hip',\n right_hip: 'right_hip',\n left_knee: 'left_knee',\n right_knee: 'right_knee',\n left_ankle: 'left_ankle',\n right_ankle: 'right_ankle'\n };\n\n /**\n * Default skeleton — same document as PoseTrackerFront\n * `lib/drawing_on_canvas.js` DEFAULT_SKELETON (navy + gold, body edges).\n * Overridable via CFG.skeletonDef / __PT_SET_SKELETON (custom Strapi skeleton).\n */\n var DEFAULT_SKELETON = {\n keypoints: [\n 'nose', 'left_eye', 'right_eye', 'left_ear', 'right_ear',\n 'left_shoulder', 'right_shoulder', 'left_elbow', 'right_elbow',\n 'left_wrist', 'right_wrist', 'left_hip', 'right_hip',\n 'left_knee', 'right_knee', 'left_ankle', 'right_ankle'\n ],\n keypoint_lines: [\n 'right_shoulder||left_shoulder',\n 'right_hip||left_hip',\n 'right_hip||right_shoulder',\n 'right_hip||right_knee',\n 'right_ankle||right_knee',\n 'right_shoulder||right_elbow',\n 'right_wrist||right_elbow',\n 'left_hip||left_shoulder',\n 'left_hip||left_knee',\n 'left_ankle||left_knee',\n 'left_shoulder||left_elbow',\n 'left_wrist||left_elbow'\n ],\n keypoint_angles: [],\n angles: { fontColor: '#000000', strokeColor: '#FFC300' },\n circles: {\n circleFillColor: '#010A73',\n circleStrokeColor: '#FFC300',\n strokeWidth: '4',\n radius: '8'\n },\n lines: { lineStrokeColor: '#010A73', strokeWidth: '4' }\n };\n /** V3 drawOnCanvas threshold (not the older drawing_on_canvas 0.5). */\n var SKELETON_SCORE = 0.3;\n\n var video = document.getElementById('video');\n var canvas = document.getElementById('overlay');\n\n var still = document.getElementById('still');\n var sourceMode = (CFG.sourceType === 'video' || CFG.sourceType === 'image')\n ? CFG.sourceType\n : 'camera';\n var sourceUrl = (typeof CFG.sourceUrl === 'string' && CFG.sourceUrl) ? CFG.sourceUrl : null;\n var imageShotPending = false;\n\n function activeFrame() {\n if (sourceMode === 'image' && still) return still;\n return video;\n }\n\n function frameSize() {\n var el = activeFrame();\n if (el && el !== video) {\n return { vw: el.naturalWidth || el.width || 0, vh: el.naturalHeight || el.height || 0 };\n }\n return { vw: video.videoWidth || 0, vh: video.videoHeight || 0 };\n }\n\n function setMediaVisible(mode) {\n // mode: 'video' | 'image' | 'none'\n var showVideo = mode === 'video';\n var showImage = mode === 'image';\n var show = showVideo || showImage;\n video.style.opacity = showVideo ? '1' : '0';\n if (still) still.style.opacity = showImage ? '1' : '0';\n canvas.style.opacity = show ? '1' : '0';\n if (bootCover) {\n if (show) bootCover.classList.add('hide');\n else {\n bootCover.classList.remove('hide');\n resetBootLoadingText();\n }\n }\n applyWatermarkVisibility();\n }\n\n var hud = document.getElementById('hud');\n var bootCover = document.getElementById('boot');\n var watermarkEl = document.getElementById('wm');\n var ctx = canvas.getContext('2d');\n var cameraRevealed = false;\n var loadingText =\n (CFG.loadingText && String(CFG.loadingText).trim()) || 'AI Loading';\n var showWatermark = CFG.showWatermark !== false;\n var debugHud = !!CFG.debugHud || !!CFG.perfDebug;\n\n function applyWatermarkVisibility() {\n if (!watermarkEl) return;\n // Only on live full camera surfaces once revealed (skip 1×1 basic warmers).\n var on = showWatermark && CFG.coldStart === 'full' && cameraRevealed;\n if (on) watermarkEl.classList.add('show');\n else watermarkEl.classList.remove('show');\n }\n\n function setBootMessage(text, isError) {\n if (!bootCover) return;\n var msg = bootCover.querySelector('.boot-msg');\n if (!msg) return;\n msg.textContent = text;\n if (isError) msg.classList.add('is-error');\n else msg.classList.remove('is-error');\n }\n\n function resetBootLoadingText() {\n setBootMessage(loadingText, false);\n }\n\n /**\n * Keep video/canvas hidden until metadata + layout settle. Showing the\n * <video> at 0×0 (or before aspect is known) makes object-fit:cover flash\n * a tiny or over-zoomed frame for a few frames.\n */\n function setCameraVisible(visible) {\n if (visible) {\n setMediaVisible(sourceMode === 'image' ? 'image' : 'video');\n } else {\n setMediaVisible('none');\n }\n }\n\n function revealCameraWhenReady() {\n if (cameraRevealed) return;\n var sz = frameSize();\n if (!(sz.vw > 0 && sz.vh > 0)) return;\n // Two rAFs: let the browser apply object-fit:cover with real intrinsic size.\n requestAnimationFrame(function () {\n requestAnimationFrame(function () {\n if (cameraRevealed) return;\n var sz2 = frameSize();\n if (!(sz2.vw > 0 && sz2.vh > 0)) return;\n cameraRevealed = true;\n setCameraVisible(true);\n applyWatermarkVisibility();\n });\n });\n }\n\n /**\n * Some WebViews deliver metadata late (or after a 0×0 first frame). Keep\n * retrying reveal so the branded \"AI Loading\" overlay cannot stick forever\n * after a successful getUserMedia.\n */\n function scheduleRevealRetries(maxMs) {\n var started = Date.now();\n function tick() {\n if (cameraRevealed) return;\n revealCameraWhenReady();\n if (cameraRevealed) return;\n if (Date.now() - started >= maxMs) {\n if (video.srcObject && !(video.videoWidth > 0)) {\n post({\n type: 'diag',\n message: 'camera stream open but video still 0x0 after ' + maxMs + 'ms'\n });\n setBootMessage('camera started but preview is empty — check permissions', true);\n }\n return;\n }\n setTimeout(tick, 120);\n }\n setTimeout(tick, 60);\n }\n var model = null;\n var blazeDetector = null;\n var running = false;\n var busy = false;\n // Android experiment: skip N ready rAF ticks between inferences (iOS = 0).\n // Injected from captureMode.ANDROID_INFER_FRAME_SKIP via poseHtml.\n var INFER_FRAME_SKIP = (CFG.inferFrameSkip != null && CFG.inferFrameSkip > 0)\n ? (CFG.inferFrameSkip | 0)\n : 0;\n var skipCountdown = 0;\n var PREPROCESS_PATH = CFG.preprocessPath === 'canvas-direct'\n ? 'canvas-direct'\n : 'imagebitmap';\n var SOFT_CAP_PROFILE = CFG.softCapProfile || null;\n var CAPTURE_PRIORITY = CFG.capturePriority === 'quality' ? 'quality' : 'performance';\n var PERF_DEBUG = !!CFG.perfDebug;\n var lastInferMs = [];\n var frameCount = 0;\n var fpsWindow = 0;\n var fpsTimer = 0;\n var skipTicksWindow = 0;\n var inferTicksWindow = 0;\n var smoothed = null;\n var SMOOTH_ALPHA = 0.5;\n var stageMs = { bitmap: [], pixels: [], exec: [], total: [] };\n var activeFlags = 'default';\n var activeBackend = null;\n // Letterbox params for the last pose input (contain into 192×192).\n // Needed to map MoveNet [0,1] square coords → video pixels → object-fit:cover display.\n var letterbox = { offsetX: 0, offsetY: 0, drawW: INPUT_SIZE, drawH: INPUT_SIZE, vw: 1, vh: 1 };\n\n function post(msg) {\n try {\n if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {\n window.ReactNativeWebView.postMessage(JSON.stringify(msg));\n }\n } catch (e) {}\n }\n\n /**\n * Technical status line for the optional debug HUD only.\n * Boot overlay keeps the branded loadingText — progress is emitted via\n * `initialization` / `diag` events for the host developer.\n */\n function setHud(t) {\n if (!hud) return;\n if (debugHud) {\n hud.classList.add('debug');\n hud.textContent = t;\n }\n }\n\n function median(arr) {\n if (!arr.length) return null;\n var s = arr.slice().sort(function (a, b) { return a - b; });\n return s[Math.floor(s.length / 2)];\n }\n\n function pushStage(k, v) {\n var a = stageMs[k];\n a.push(v);\n if (a.length > 30) a.shift();\n }\n\n function applyMirrorCss() {\n var t = FACING === 'user' ? 'scaleX(-1)' : 'none';\n video.style.transform = t;\n canvas.style.transform = t;\n }\n\n function base64ToArrayBuffer(b64) {\n var bin = atob(b64);\n var bytes = new Uint8Array(bin.length);\n for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);\n return bytes.buffer;\n }\n\n function wasmBlobUrl(b64) {\n return URL.createObjectURL(\n new Blob([base64ToArrayBuffer(b64)], { type: 'application/wasm' })\n );\n }\n\n // -------------------------------------------------------------------------\n // Proprietary pose pipeline (WASM): decode + letterbox/cover mapping + EMA.\n // Falls back to the equivalent JS path below when instantiation fails.\n // -------------------------------------------------------------------------\n var pipeline = null;\n\n async function initPipeline() {\n try {\n var b64 = window.__PT_PIPELINE_WASM_B64;\n if (!b64 || typeof WebAssembly === 'undefined') {\n post({ type: 'diag', message: 'pose-pipeline: js path (no wasm payload)' });\n return;\n }\n var res = await WebAssembly.instantiate(base64ToArrayBuffer(b64), {\n env: { abort: function () {} }\n });\n var ex = res.instance.exports;\n pipeline = {\n memory: ex.memory,\n inputPtr: ex.inputPtr(),\n outputPtr: ex.outputPtr(),\n setFrame: ex.setFrame,\n process: ex.process,\n reset: ex.reset\n };\n post({ type: 'diag', message: 'pose-pipeline.wasm active' });\n } catch (e) {\n pipeline = null;\n post({\n type: 'diag',\n message: 'pose-pipeline.wasm unavailable: ' +\n (e && e.message ? e.message : String(e)) + ' — js path'\n });\n }\n window.__PT_PIPELINE_WASM_B64 = null;\n }\n\n function buildModelArtifacts() {\n var json = JSON.parse(window.__PT_MODEL_JSON);\n var shards = window.__PT_WEIGHTS_B64.map(base64ToArrayBuffer);\n var total = 0;\n shards.forEach(function (b) { total += b.byteLength; });\n var weightData = new Uint8Array(total);\n var offset = 0;\n shards.forEach(function (b) {\n weightData.set(new Uint8Array(b), offset);\n offset += b.byteLength;\n });\n var weightSpecs = [];\n json.weightsManifest.forEach(function (group) {\n weightSpecs = weightSpecs.concat(group.weights);\n });\n return {\n modelTopology: json.modelTopology,\n weightSpecs: weightSpecs,\n weightData: weightData.buffer,\n format: json.format,\n generatedBy: json.generatedBy,\n convertedBy: json.convertedBy,\n signature: json.signature,\n userDefinedMetadata: json.userDefinedMetadata\n };\n }\n\n function glInfo() {\n try {\n var c = document.createElement('canvas');\n var gl = c.getContext('webgl') || c.getContext('experimental-webgl');\n if (!gl) return null;\n var dbg = gl.getExtension('WEBGL_debug_renderer_info');\n return {\n renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null,\n vendor: dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : null,\n version: gl.getParameter(gl.VERSION)\n };\n } catch (e) { return null; }\n }\n\n function isMali(renderer) {\n return !!(renderer && /mali/i.test(renderer));\n }\n\n // Product flags (poseWorker.js) — must be set BEFORE the first setBackend('webgl').\n // Never call tf.removeBackend('webgl'): that deletes the factory from the\n // registry and setBackend then fails with \"webgl not found in registry\".\n function applyProductFlags() {\n try { tf.env().set('WEBGL_CPU_FORWARD', false); } catch (e) {}\n try { tf.env().set('WEBGL_PACK', false); } catch (e) {}\n try { tf.env().set('WEBGL_FORCE_F16_TEXTURES', false); } catch (e) {}\n try { tf.env().set('WEBGL_RENDER_FLOAT32_ENABLED', false); } catch (e) {}\n try { tf.env().set('WEBGL_FLUSH_THRESHOLD', 1.75); } catch (e) {}\n try { tf.env().set('WEBGL_DISJOINT_QUERY_TIMER_EXTENSION_VERSION', 0); } catch (e) {}\n try { tf.env().set('WEBGL_AUTO_SQUARIFY_NARROW_TEXTURE_SHAPE', true); } catch (e) {}\n }\n\n // dataSync (not await data): exact product parity — pose-detection's MoveNet\n // detector uses dataSync on WebGL. Async data() waits on a GPU fence polled\n // via setTimeout, which adds big per-frame latency in an Android WebView.\n function readOutput(out) {\n return out.dataSync();\n }\n\n async function benchZeros(n) {\n if (IS_BLAZEPOSE) {\n // No graph execute — seed a conservative median so AdaptiveChoice\n // prefers a lower capture tier (BlazePose is heavier than MoveNet).\n var seeded = [];\n for (var i = 0; i < Math.max(1, n - 1); i++) seeded.push(55);\n return seeded;\n }\n var times = [];\n for (var i = 0; i < n; i++) {\n var z = tf.zeros([1, INPUT_SIZE, INPUT_SIZE, 3], 'int32');\n var t0 = performance.now();\n var out = model.execute(z);\n readOutput(out);\n var dt = performance.now() - t0;\n z.dispose(); out.dispose();\n if (i > 0) times.push(dt);\n }\n return times;\n }\n\n async function loadModel() {\n if (IS_BLAZEPOSE) {\n if (blazeDetector && blazeDetector.dispose) {\n try { blazeDetector.dispose(); } catch (e) {}\n }\n blazeDetector = null;\n var poseDetection = window.poseDetection;\n if (!poseDetection || typeof poseDetection.createDetector !== 'function') {\n throw new Error(\n 'BlazePose requires CDN `@tensorflow-models/pose-detection` ' +\n '(window.poseDetection missing)'\n );\n }\n post({\n type: 'diag',\n message: 'createDetector BlazePose lite (tfjs)'\n });\n blazeDetector = await poseDetection.createDetector(\n poseDetection.SupportedModels.BlazePose,\n { runtime: 'tfjs', modelType: 'lite', enableSmoothing: true }\n );\n // Sentinel so loop / resume checks treat the detector as loaded.\n model = { kind: 'blazepose' };\n return;\n }\n\n if (model && model.dispose) try { model.dispose(); } catch (e) {}\n var modelUrl =\n (typeof window.__PT_MODEL_URL === 'string' && window.__PT_MODEL_URL) ||\n (CFG.modelUrl && String(CFG.modelUrl)) ||\n null;\n if (modelUrl) {\n post({\n type: 'diag',\n message: 'loadGraphModel url=' + modelUrl\n });\n model = await tf.loadGraphModel(modelUrl);\n return;\n }\n if (!window.__PT_MODEL_JSON || !window.__PT_WEIGHTS_B64) {\n throw new Error('no modelUrl and no embedded model artifacts');\n }\n model = await tf.loadGraphModel({\n load: function () { return Promise.resolve(buildModelArtifacts()); }\n });\n }\n\n async function initWasmFallback() {\n if (!tf.wasm || typeof WebAssembly === 'undefined') {\n throw new Error('wasm unavailable');\n }\n var b = window.__PT_WASM_B64;\n var wasmPath =\n (typeof window.__PT_WASM_PATH === 'string' && window.__PT_WASM_PATH) ||\n (CFG.tfjsWasmPath && String(CFG.tfjsWasmPath)) ||\n null;\n if (b && b.plain) {\n tf.wasm.setWasmPaths({\n 'tfjs-backend-wasm.wasm': wasmBlobUrl(b.plain),\n 'tfjs-backend-wasm-simd.wasm': wasmBlobUrl(b.simd),\n 'tfjs-backend-wasm-threaded-simd.wasm': wasmBlobUrl(b.threadedSimd)\n });\n } else if (wasmPath) {\n var base = wasmPath.charAt(wasmPath.length - 1) === '/' ? wasmPath : wasmPath + '/';\n tf.wasm.setWasmPaths(base);\n } else {\n throw new Error('wasm unavailable (no __PT_WASM_B64 and no __PT_WASM_PATH)');\n }\n var ok = await tf.setBackend('wasm');\n await tf.ready();\n if (ok === false || tf.getBackend() !== 'wasm') throw new Error('wasm setBackend failed');\n }\n\n /**\n * Init WebGL once (product path). WASM only if WebGL truly fails.\n */\n async function pickFastestBackend() {\n var info = glInfo();\n var mali = isMali(info && info.renderer);\n var registered = [];\n try {\n if (tf.engine && tf.engine().registryFactory) {\n registered = Object.keys(tf.engine().registryFactory);\n }\n } catch (e) {}\n post({\n type: 'diag',\n message: 'build=' + (window.__PT_BUILD || '?') +\n ' gl renderer=' + ((info && info.renderer) || '?') +\n ' mali=' + mali +\n ' backends=[' + registered.join(',') + ']'\n });\n\n try {\n post({\n type: 'initialization',\n step: 'init_webgl',\n message: 'init webgl (product flags)',\n ready: false\n });\n setHud('init webgl (product flags)…');\n applyProductFlags();\n var ok = await tf.setBackend('webgl');\n await tf.ready();\n if (ok === false || tf.getBackend() !== 'webgl') {\n throw new Error('setBackend webgl returned ' + String(tf.getBackend()));\n }\n await loadModel();\n var times = await benchZeros(5);\n var med = median(times);\n post({\n type: 'diag',\n message: 'bench webgl/stable medianMs=' +\n (med != null ? med.toFixed(1) : '?') +\n ' runs=[' + times.map(function (t) { return Math.round(t); }).join(',') + ']'\n });\n activeFlags = 'stable';\n activeBackend = 'webgl';\n return { name: 'stable', med: med, times: times, backend: 'webgl' };\n } catch (e) {\n post({\n type: 'diag',\n message: 'webgl init failed: ' + (e && e.message ? e.message : String(e)) +\n ' — falling back to wasm'\n });\n }\n\n post({\n type: 'initialization',\n step: 'init_wasm_fallback',\n message: 'webgl failed — trying wasm',\n ready: false\n });\n setHud('webgl failed — trying wasm…');\n await initWasmFallback();\n await loadModel();\n var wtimes = await benchZeros(4);\n activeFlags = 'wasm';\n activeBackend = 'wasm';\n return { name: 'wasm', med: median(wtimes), times: wtimes, backend: 'wasm' };\n }\n\n function poseCanvasCtx() {\n if (!window.__PT_FC) {\n window.__PT_FC = document.createElement('canvas');\n window.__PT_FC.width = INPUT_SIZE;\n window.__PT_FC.height = INPUT_SIZE;\n window.__PT_FCTX = window.__PT_FC.getContext('2d', { willReadFrequently: true });\n }\n return window.__PT_FCTX;\n }\n\n /**\n * Letterbox video → 192×192 canvas (contain). Stretching with\n * resizeWidth/Height=192 broke aspect ratio → wrong skeleton.\n * Returns either an ImageBitmap or the canvas itself for fromPixels.\n */\n async function preparePoseInput() {\n var t0 = performance.now();\n var frame = activeFrame();\n var sz = frameSize();\n var vw = sz.vw || 1;\n var vh = sz.vh || 1;\n var scale = Math.min(INPUT_SIZE / vw, INPUT_SIZE / vh);\n var drawW = vw * scale;\n var drawH = vh * scale;\n var offsetX = (INPUT_SIZE - drawW) / 2;\n var offsetY = (INPUT_SIZE - drawH) / 2;\n letterbox = { offsetX: offsetX, offsetY: offsetY, drawW: drawW, drawH: drawH, vw: vw, vh: vh };\n\n var c2d = poseCanvasCtx();\n c2d.fillStyle = '#000';\n c2d.fillRect(0, 0, INPUT_SIZE, INPUT_SIZE);\n c2d.drawImage(frame, 0, 0, vw, vh, offsetX, offsetY, drawW, drawH);\n\n if (PREPROCESS_PATH === 'canvas-direct') {\n pushStage('bitmap', performance.now() - t0);\n return { kind: 'canvas', source: window.__PT_FC };\n }\n\n var bmp = await createImageBitmap(window.__PT_FC);\n pushStage('bitmap', performance.now() - t0);\n return { kind: 'bitmap', source: bmp };\n }\n\n /** MoveNet norm (0–1 over the 192 square) → video pixel coords. (JS fallback) */\n function modelNormToVideo(xNorm, yNorm) {\n var xSq = xNorm * INPUT_SIZE;\n var ySq = yNorm * INPUT_SIZE;\n return {\n x: (xSq - letterbox.offsetX) * (letterbox.vw / letterbox.drawW),\n y: (ySq - letterbox.offsetY) * (letterbox.vh / letterbox.drawH)\n };\n }\n\n /** Video pixels → CSS object-fit:cover display pixels. (JS fallback) */\n function videoToCover(vx, vy, dispW, dispH) {\n var scale = Math.max(dispW / letterbox.vw, dispH / letterbox.vh);\n var ox = (dispW - letterbox.vw * scale) / 2;\n var oy = (dispH - letterbox.vh * scale) / 2;\n return { x: vx * scale + ox, y: vy * scale + oy };\n }\n\n async function infer() {\n if (IS_BLAZEPOSE) {\n if (!blazeDetector) throw new Error('BlazePose detector not loaded');\n var t0b = performance.now();\n var poses = await blazeDetector.estimatePoses(activeFrame(), {\n flipHorizontal: false,\n maxPoses: 1\n });\n var totalMsB = performance.now() - t0b;\n pushStage('exec', totalMsB);\n pushStage('total', totalMsB);\n return { kind: 'blazepose', poses: poses, totalMs: totalMsB };\n }\n\n var t0 = performance.now();\n var prepared = await preparePoseInput();\n var t1 = performance.now();\n var input = tf.tidy(function () {\n return tf.expandDims(tf.browser.fromPixels(prepared.source), 0);\n });\n if (prepared.kind === 'bitmap' && prepared.source && prepared.source.close) {\n prepared.source.close();\n }\n var t2 = performance.now();\n var out = model.execute(input);\n var data = readOutput(out);\n var t3 = performance.now();\n input.dispose();\n out.dispose();\n pushStage('pixels', t2 - t1);\n pushStage('exec', t3 - t2);\n // Full pipeline (draw + fromPixels + execute) — adaptive quality + debug\n // must see preprocess cost, especially on Android HD capture.\n var totalMs = t3 - t0;\n pushStage('total', totalMs);\n return { kind: 'movenet', data: data, totalMs: totalMs };\n }\n\n /** Rank which stage dominates the frame (for leverHints). */\n function dominantStage(drawMs, pixelsMs, execMs) {\n var entries = [\n { stage: 'bitmap', ms: drawMs || 0 },\n { stage: 'fromPixels', ms: pixelsMs || 0 },\n { stage: 'execute', ms: execMs || 0 }\n ];\n entries.sort(function (a, b) { return b.ms - a.ms; });\n return entries[0];\n }\n\n /**\n * Heuristic hints: which experiment lever is likely helping / wasted.\n * Purely diagnostic — never changes behaviour.\n */\n function buildLeverHints(medTotal, drawMs, pixelsMs, execMs, poseFps) {\n var dom = dominantStage(drawMs, pixelsMs, execMs);\n var hints = [];\n var total = medTotal > 0 ? medTotal : (drawMs || 0) + (pixelsMs || 0) + (execMs || 0);\n var bmpShare = total > 0 ? (drawMs || 0) / total : 0;\n var pxShare = total > 0 ? (pixelsMs || 0) / total : 0;\n var exShare = total > 0 ? (execMs || 0) / total : 0;\n\n if (bmpShare >= 0.45) {\n hints.push('bitmap_dominates→softCap_or_lower_capture_still_useful');\n } else {\n hints.push('bitmap_not_dominant→softCap_less_useful_now');\n }\n if (PREPROCESS_PATH === 'canvas-direct') {\n if (pxShare >= 0.35) {\n hints.push('fromPixels_still_heavy→canvas-direct_may_not_be_enough');\n } else if (bmpShare + pxShare < 0.35) {\n hints.push('preprocess_light→canvas-direct_likely_helping_or_neutral');\n } else {\n hints.push('preprocess_mixed→compare_vs_imagebitmap_A/B');\n }\n } else if (pxShare >= 0.25) {\n hints.push('imagebitmap_path→try_canvas-direct_if_fromPixels_high');\n }\n if (exShare >= 0.5) {\n hints.push('execute_dominates→frameSkip_helps_GPU_breathing_not_capture');\n }\n if (INFER_FRAME_SKIP > 0) {\n if (poseFps != null && poseFps >= MIN_TARGET_FPS) {\n hints.push('frameSkip_on_and_floor_met→keep_or_try_skip0_to_measure');\n } else {\n hints.push('frameSkip_on_but_below_floor→try_skip2_or_lower_capture');\n }\n }\n if (SOFT_CAP_PROFILE) {\n hints.push('softCap=' + SOFT_CAP_PROFILE + '_active');\n }\n return {\n dominantStage: dom.stage,\n dominantMs: dom.ms,\n bitmapShare: Math.round(bmpShare * 100) / 100,\n fromPixelsShare: Math.round(pxShare * 100) / 100,\n executeShare: Math.round(exShare * 100) / 100,\n hints: hints\n };\n }\n\n function smoothKeypoints(raw) {\n if (!smoothed) { smoothed = raw; return raw; }\n for (var i = 0; i < raw.length; i++) {\n if (raw[i].score < 0.1) continue;\n smoothed[i].xPx = SMOOTH_ALPHA * raw[i].xPx + (1 - SMOOTH_ALPHA) * smoothed[i].xPx;\n smoothed[i].yPx = SMOOTH_ALPHA * raw[i].yPx + (1 - SMOOTH_ALPHA) * smoothed[i].yPx;\n smoothed[i].score = raw[i].score;\n }\n return smoothed;\n }\n\n function activeSkeleton() {\n var def = CFG.skeletonDef;\n if (def && Array.isArray(def.keypoint_lines) && def.circles && def.lines) {\n return def;\n }\n return DEFAULT_SKELETON;\n }\n\n /**\n * Draw skeleton in display pixels (object-fit:cover mapping already applied).\n * Parity with PoseTrackerFront `lib/v2/drawOnCanvas.js` drawSkeleton.\n */\n function drawSkeletonDisplay(kps, dispW, dispH) {\n if (canvas.width !== dispW) canvas.width = dispW;\n if (canvas.height !== dispH) canvas.height = dispH;\n ctx.clearRect(0, 0, dispW, dispH);\n if (!CFG.drawSkeleton) return;\n\n var sk = activeSkeleton();\n var byName = {};\n for (var i = 0; i < kps.length; i++) {\n byName[kps[i].name] = kps[i];\n }\n\n var lineStroke = (sk.lines && sk.lines.lineStrokeColor) || '#010A73';\n var lineWidth = Number((sk.lines && sk.lines.strokeWidth) || 4);\n var lines = sk.keypoint_lines || [];\n for (var e = 0; e < lines.length; e++) {\n var pair = String(lines[e]).split('||');\n if (pair.length < 2) continue;\n var a = byName[pair[0]];\n var b = byName[pair[1]];\n if (!a || !b || a.score <= SKELETON_SCORE || b.score <= SKELETON_SCORE) continue;\n ctx.beginPath();\n ctx.moveTo(a.dx, a.dy);\n ctx.lineTo(b.dx, b.dy);\n ctx.lineWidth = lineWidth;\n ctx.strokeStyle = lineStroke;\n ctx.stroke();\n }\n\n var fill = (sk.circles && sk.circles.circleFillColor) || '#010A73';\n var stroke = (sk.circles && sk.circles.circleStrokeColor) || '#FFC300';\n var circleStrokeW = Number((sk.circles && sk.circles.strokeWidth) || 4);\n var radius = Number((sk.circles && sk.circles.radius) || 8);\n var allowed = new Set(sk.keypoints || KEYPOINT_NAMES);\n for (var j = 0; j < kps.length; j++) {\n var kp = kps[j];\n if (!allowed.has(kp.name) || kp.score <= SKELETON_SCORE) continue;\n ctx.beginPath();\n ctx.arc(kp.dx, kp.dy, radius, 0, Math.PI * 2);\n ctx.fillStyle = fill;\n ctx.fill();\n ctx.lineWidth = circleStrokeW;\n ctx.strokeStyle = stroke;\n ctx.stroke();\n }\n }\n\n window.__PT_SET_SKELETON = function (def) {\n CFG.skeletonDef = def && typeof def === 'object' ? def : null;\n };\n\n /** WASM pipeline path: decode + map + smooth inside pose-pipeline.wasm. */\n function decodeWithPipeline(data, dispW, dispH) {\n var inArr = new Float32Array(pipeline.memory.buffer, pipeline.inputPtr, 51);\n for (var i = 0; i < 51; i++) inArr[i] = data[i];\n pipeline.setFrame(\n letterbox.vw, letterbox.vh, dispW, dispH,\n letterbox.offsetX, letterbox.offsetY, letterbox.drawW, letterbox.drawH,\n INPUT_SIZE, FACING === 'user' ? 1 : 0\n );\n var meanScore = pipeline.process();\n var outArr = new Float32Array(pipeline.memory.buffer, pipeline.outputPtr, 119);\n var drawKps = [];\n var rnKps = [];\n var above = 0;\n for (var j = 0; j < 17; j++) {\n var o = j * 7;\n var rawScore = data[j * 3 + 2];\n if (rawScore >= 0.3) above += 1;\n drawKps.push({\n name: KEYPOINT_NAMES[j],\n xPx: outArr[o],\n yPx: outArr[o + 1],\n dx: outArr[o + 2],\n dy: outArr[o + 3],\n score: outArr[o + 6]\n });\n rnKps.push({\n name: KEYPOINT_NAMES[j],\n x: outArr[o + 4],\n y: outArr[o + 5],\n score: outArr[o + 6]\n });\n }\n return { drawKps: drawKps, rnKps: rnKps, meanScore: meanScore, above: above };\n }\n\n /** JS fallback path (same math as the wasm pipeline). */\n function decodeWithJs(data, dispW, dispH) {\n var drawKps = [];\n var scoreSum = 0;\n var above = 0;\n for (var i = 0; i < 17; i++) {\n var xNorm = Math.min(1, Math.max(0, data[i * 3 + 1]));\n var yNorm = Math.min(1, Math.max(0, data[i * 3]));\n var score = data[i * 3 + 2];\n var vid = modelNormToVideo(xNorm, yNorm);\n var disp = videoToCover(vid.x, vid.y, dispW, dispH);\n drawKps.push({\n name: KEYPOINT_NAMES[i],\n xPx: vid.x,\n yPx: vid.y,\n dx: disp.x,\n dy: disp.y,\n score: score\n });\n scoreSum += score;\n if (score >= 0.3) above += 1;\n }\n var raw = drawKps.map(function (k) {\n return { xPx: k.xPx, yPx: k.yPx, score: k.score };\n });\n var sm = smoothKeypoints(raw);\n for (var k = 0; k < 17; k++) {\n drawKps[k].xPx = sm[k].xPx;\n drawKps[k].yPx = sm[k].yPx;\n drawKps[k].score = sm[k].score;\n var d2 = videoToCover(sm[k].xPx, sm[k].yPx, dispW, dispH);\n drawKps[k].dx = d2.x;\n drawKps[k].dy = d2.y;\n }\n var rnKps = drawKps.map(function (k) {\n var nx = dispW > 0 ? k.dx / dispW : 0;\n var ny = dispH > 0 ? k.dy / dispH : 0;\n if (FACING === 'user') nx = 1 - nx;\n return {\n name: k.name,\n x: Math.min(1, Math.max(0, nx)),\n y: Math.min(1, Math.max(0, ny)),\n score: k.score\n };\n });\n return { drawKps: drawKps, rnKps: rnKps, meanScore: scoreSum / 17, above: above };\n }\n\n function decodeBlazePoses(poses, dispW, dispH) {\n var pose = poses && poses[0];\n var vw = video.videoWidth || 1;\n var vh = video.videoHeight || 1;\n letterbox = { offsetX: 0, offsetY: 0, drawW: vw, drawH: vh, vw: vw, vh: vh };\n var byName = {};\n if (pose && pose.keypoints) {\n for (var i = 0; i < pose.keypoints.length; i++) {\n var kp = pose.keypoints[i];\n var name = String(kp.name || '').toLowerCase();\n var coco = BLAZE_TO_COCO[name];\n if (!coco) continue;\n byName[coco] = {\n x: kp.x,\n y: kp.y,\n score: typeof kp.score === 'number' ? kp.score : 0\n };\n }\n }\n var scale = Math.max(dispW / vw, dispH / vh);\n var ox = (dispW - vw * scale) / 2;\n var oy = (dispH - vh * scale) / 2;\n var drawKps = [];\n var rnKps = [];\n var scoreSum = 0;\n var above = 0;\n for (var j = 0; j < KEYPOINT_NAMES.length; j++) {\n var kn = KEYPOINT_NAMES[j];\n var k = byName[kn] || { x: 0, y: 0, score: 0 };\n var dx = k.x * scale + ox;\n var dy = k.y * scale + oy;\n var nx = dispW > 0 ? dx / dispW : 0;\n var ny = dispH > 0 ? dy / dispH : 0;\n if (FACING === 'user') nx = 1 - nx;\n scoreSum += k.score;\n if (k.score >= 0.3) above += 1;\n drawKps.push({\n name: kn,\n xPx: k.x,\n yPx: k.y,\n dx: dx,\n dy: dy,\n score: k.score\n });\n rnKps.push({\n name: kn,\n x: Math.min(1, Math.max(0, nx)),\n y: Math.min(1, Math.max(0, ny)),\n score: k.score\n });\n }\n return {\n drawKps: drawKps,\n rnKps: rnKps,\n meanScore: scoreSum / 17,\n above: above\n };\n }\n\n function publishDecoded(decoded, inferenceTimeMs) {\n var dispW = canvas.clientWidth || 1;\n var dispH = canvas.clientHeight || 1;\n var drawKps = decoded.drawKps;\n var meanScore = decoded.meanScore;\n var above = decoded.above;\n drawSkeletonDisplay(drawKps, dispW, dispH);\n\n lastInferMs.push(inferenceTimeMs);\n if (lastInferMs.length > 30) lastInferMs.shift();\n frameCount += 1;\n fpsWindow += 1;\n\n var now = performance.now();\n if (now - fpsTimer >= 1000) {\n var fps = fpsWindow;\n var skipped = skipTicksWindow;\n var inferred = inferTicksWindow;\n fpsWindow = 0;\n skipTicksWindow = 0;\n inferTicksWindow = 0;\n fpsTimer = now;\n var med = median(lastInferMs);\n var drawMs = median(stageMs.bitmap);\n var pixelsMs = median(stageMs.pixels);\n var execMs = median(stageMs.exec);\n var totalMed = median(stageMs.total);\n var estFromMed = med != null && med > 0 ? 1000 / med : null;\n var modelTag = IS_BLAZEPOSE ? 'blazepose' : 'movenet';\n var modeTag = 'main/' + activeFlags +\n (pipeline && !IS_BLAZEPOSE ? '/wasm-pipeline' : '') +\n '/' + PREPROCESS_PATH +\n (INFER_FRAME_SKIP > 0 ? '/skip' + INFER_FRAME_SKIP : '') +\n (SOFT_CAP_PROFILE ? '/soft:' + SOFT_CAP_PROFILE : '');\n\n var hudLine =\n modelTag + ' · ' + activeBackend + '/' + activeFlags + ' · ' +\n fps + ' fps · ' + (med != null ? Math.round(med) + ' ms' : '?') +\n ' · kp≥0.3=' + above + '/17';\n if (PERF_DEBUG) {\n hudLine =\n fps + 'fps med=' + (med != null ? Math.round(med) : '?') +\n 'ms (~' + (estFromMed != null ? estFromMed.toFixed(1) : '?') + 'fps) ' +\n 'bmp=' + (drawMs != null ? Math.round(drawMs) : '?') +\n ' px=' + (pixelsMs != null ? Math.round(pixelsMs) : '?') +\n ' ex=' + (execMs != null ? Math.round(execMs) : '?') +\n ' | ' + PREPROCESS_PATH +\n (INFER_FRAME_SKIP > 0 ? ' skip' + INFER_FRAME_SKIP : '') +\n (SOFT_CAP_PROFILE ? ' soft:' + SOFT_CAP_PROFILE : '') +\n ' ' + video.videoWidth + 'x' + video.videoHeight +\n ' inf/sk=' + inferred + '/' + skipped;\n }\n setHud(hudLine);\n\n var experiments = {\n platform: CFG.platform || 'unknown',\n captureMode: CAPTURE_CONSTRAINT_MODE,\n capturePriority: CAPTURE_PRIORITY,\n preprocessPath: PREPROCESS_PATH,\n inferFrameSkip: INFER_FRAME_SKIP,\n softCapProfile: SOFT_CAP_PROFILE,\n minTargetFps: MIN_TARGET_FPS,\n perfDebug: PERF_DEBUG,\n modelId: MODEL_ID\n };\n var leverHints = PERF_DEBUG\n ? buildLeverHints(totalMed != null ? totalMed : med, drawMs, pixelsMs, execMs, fps)\n : null;\n\n post({\n type: 'stats',\n fps: fps,\n medianInferenceMs: med,\n estimatedFpsFromMedian: estFromMed,\n frames: frameCount,\n backend: activeBackend,\n keypointsAbove03: above,\n meanScore: meanScore,\n breakdown: {\n drawMs: drawMs,\n fromPixelsMs: pixelsMs,\n executeMs: execMs,\n totalPipelineMs: totalMed\n },\n windowCounts: { inferred: inferred, skipped: skipped },\n experiments: experiments,\n leverHints: leverHints,\n videoSize: video.videoWidth + 'x' + video.videoHeight,\n mode: modeTag\n });\n }\n\n post({\n type: 'pose',\n keypoints: decoded.rnKps,\n score: meanScore,\n inferenceTimeMs: inferenceTimeMs,\n timestampMs: Date.now()\n });\n }\n\n function decodeAndPublish(data, inferenceTimeMs) {\n // Pipeline (matches PoseTrackerFront):\n // MoveNet [0,1] on letterboxed 192² → video pixels → object-fit:cover display\n // Front camera: video+canvas CSS scaleX(-1). Draw in SENSOR display space;\n // flip only the RN payload so host overlays match the mirrored preview.\n var dispW = canvas.clientWidth || 1;\n var dispH = canvas.clientHeight || 1;\n var decoded = pipeline\n ? decodeWithPipeline(data, dispW, dispH)\n : decodeWithJs(data, dispW, dispH);\n publishDecoded(decoded, inferenceTimeMs);\n }\n\n function decodeAndPublishBlaze(poses, inferenceTimeMs) {\n var dispW = canvas.clientWidth || 1;\n var dispH = canvas.clientHeight || 1;\n publishDecoded(decodeBlazePoses(poses, dispW, dispH), inferenceTimeMs);\n }\n\n // Loop token: a stale rAF chain (pre-suspend) must never survive next to a\n // resumed one — two chains would double the inference attempt rate.\n var loopToken = 0;\n var inferErrorStreak = 0;\n // ~45 consecutive failures ≈ a few seconds of a dead GL context / broken\n // session. Stop the loop and release the camera instead of burning battery.\n var INFER_ERROR_LIMIT = 45;\n\n async function loop(token) {\n if (token !== loopToken || !running || !model) return;\n var szLoop = frameSize();\n var mediaReady = sourceMode === 'image'\n ? (szLoop.vw > 0 && imageShotPending)\n : (video.readyState >= 2 && video.videoWidth > 0 &&\n !(sourceMode === 'video' && (video.paused || video.ended)));\n if (!busy && mediaReady) {\n // Android frame-skip: after each inference, burn N free rAF ticks before\n // the next one. Preview keeps streaming; last skeleton stays on canvas.\n if (skipCountdown > 0) {\n skipCountdown -= 1;\n skipTicksWindow += 1;\n } else {\n busy = true;\n try {\n var result = await infer();\n inferErrorStreak = 0;\n inferTicksWindow += 1;\n if (result.kind === 'blazepose') {\n decodeAndPublishBlaze(result.poses, result.totalMs);\n } else {\n decodeAndPublish(result.data, result.totalMs);\n }\n skipCountdown = INFER_FRAME_SKIP;\n if (sourceMode === 'image') {\n imageShotPending = false;\n running = false;\n busy = false;\n return;\n }\n } catch (err) {\n var message = err && err.message ? err.message : String(err);\n setHud('infer error: ' + message);\n if (frameCount < 3) post({ type: 'error', message: 'inference: ' + message });\n inferErrorStreak += 1;\n if (inferErrorStreak >= INFER_ERROR_LIMIT) {\n busy = false;\n post({\n type: 'error',\n message: 'inference failed repeatedly (' + message +\n ') — pipeline stopped and camera released to protect the device'\n });\n suspendPipeline('inference-errors');\n return;\n }\n }\n busy = false;\n }\n }\n requestAnimationFrame(function () { loop(token); });\n }\n\n function startLoop() {\n running = true;\n fpsTimer = performance.now();\n loop(++loopToken);\n }\n\n // Capture constraint mode (injected by poseHtml from captureMode.ts):\n // 'device-native' — Front-like: facingMode only (often HD preview)\n // 'profile-constrained' — AdaptiveChoice ideals (previous stable default)\n // REVERT: set CAPTURE_CONSTRAINT_MODE = 'profile-constrained' in captureMode.ts\n // then npm run build:runtime-payload && npm run build\n var CAPTURE_CONSTRAINT_MODE = CFG.captureConstraintMode === 'profile-constrained'\n ? 'profile-constrained'\n : 'device-native';\n\n // Capture profiles (mirror RN AdaptiveChoice ladder). Used to pick camera\n // resolution from the zeros warm-up BEFORE getUserMedia (profile-constrained)\n // or as live setQuality downgrade targets (both modes).\n // minTargetFps is a floor (iOS 30 / Android 15), not a single setpoint.\n var MIN_TARGET_FPS = (CFG.minTargetFps != null && CFG.minTargetFps > 0)\n ? CFG.minTargetFps\n : 15;\n var CAPTURE_PROFILES = {\n prime: { id: 'prime', idealWidth: 1280, idealHeight: 720, idealFrameRate: 30 },\n pro: { id: 'pro', idealWidth: 960, idealHeight: 540, idealFrameRate: 30 },\n lite: { id: 'lite', idealWidth: 640, idealHeight: 480, idealFrameRate: 30 },\n ultralite: { id: 'ultralite', idealWidth: 480, idealHeight: 360, idealFrameRate: 24 },\n basic: { id: 'basic', idealWidth: 320, idealHeight: 240, idealFrameRate: 20 }\n };\n var CAPTURE_LADDER = ['prime', 'pro', 'lite', 'ultralite', 'basic'];\n\n function profileRank(id) {\n var i = CAPTURE_LADDER.indexOf(id);\n return i < 0 ? CAPTURE_LADDER.length - 1 : i;\n }\n\n function lowerProfile(a, b) {\n return profileRank(a) >= profileRank(b) ? a : b;\n }\n\n /** Map warm-up execute median → max safe capture profile (scales with floor). */\n function profileFromWarmupMs(medMs) {\n if (medMs == null || !(medMs > 0)) return 'basic';\n var budget = 1000 / MIN_TARGET_FPS;\n // Over floor on zeros alone → basic (camera adds cost; no ultralite gift).\n if (medMs > budget) return 'basic';\n if (medMs <= budget * 0.6) return 'prime';\n if (medMs <= budget * 0.75) return 'pro';\n if (medMs <= budget * 0.9) return 'lite';\n return 'ultralite';\n }\n\n function applyCaptureProfile(id) {\n var p = CAPTURE_PROFILES[id] || CAPTURE_PROFILES.ultralite;\n CFG.idealWidth = p.idealWidth;\n CFG.idealHeight = p.idealHeight;\n CFG.idealFrameRate = p.idealFrameRate;\n CFG.profileId = p.id;\n return p;\n }\n\n /** Build getUserMedia video constraints for the active capture mode. */\n function videoConstraints(forceProfileIdeals) {\n var video = { facingMode: { ideal: FACING } };\n if (forceProfileIdeals || CAPTURE_CONSTRAINT_MODE === 'profile-constrained') {\n video.width = { ideal: CFG.idealWidth };\n video.height = { ideal: CFG.idealHeight };\n video.frameRate = { ideal: CFG.idealFrameRate || 30 };\n return video;\n }\n // device-native + optional Android soft-cap: use the warm-up-selected\n // CFG ideals (already clamped ≤ softCap) as ideal+max so Chromium\n // cannot open unrestricted 1080p.\n if (SOFT_CAP_PROFILE) {\n video.width = { ideal: CFG.idealWidth, max: CFG.idealWidth };\n video.height = { ideal: CFG.idealHeight, max: CFG.idealHeight };\n video.frameRate = { ideal: CFG.idealFrameRate || 30 };\n }\n return video;\n }\n\n async function boot() {\n try {\n applyMirrorCss();\n if (CFG.idealFrameRate == null) CFG.idealFrameRate = 30;\n if (!CFG.profileId) CFG.profileId = 'ultralite';\n\n // 0) Proprietary pipeline module (MoveNet-only; skip for BlazePose).\n if (!IS_BLAZEPOSE) {\n await initPipeline();\n } else {\n post({\n type: 'diag',\n message: 'pose-pipeline: skipped (blazepose uses pose-detection)'\n });\n }\n\n // 1) Bench model WITHOUT the camera — estimate FPS, pick capture tier.\n post({\n type: 'initialization',\n step: 'loading_pose_model',\n message: IS_BLAZEPOSE ? 'loading BlazePose' : 'loading pose model',\n ready: false\n });\n setHud(IS_BLAZEPOSE ? 'loading BlazePose (webgl)…' : 'loading MoveNet (webgl)…');\n var best = await pickFastestBackend();\n\n var estFps = best.med != null && best.med > 0 ? 1000 / best.med : null;\n var rnHint = CFG.profileId;\n var fromWarmup = profileFromWarmupMs(best.med);\n // Prefer warm-up evidence for the *initial* open. Using RN hint as a hard\n // ceiling pinned poisoned crash-guard sessions on basic, then RN upgraded\n // mid-getUserMedia and aborted the stream (\"The operation was aborted\").\n // Live FPS path can still downgrade after ready.\n var selected = fromWarmup;\n if (CAPTURE_PRIORITY === 'quality') {\n // Host opted into sharp preview — do not FPS-floor to basic/ultralite.\n selected = 'prime';\n } else if (SOFT_CAP_PROFILE && CAPTURE_PROFILES[SOFT_CAP_PROFILE]) {\n selected = lowerProfile(selected, SOFT_CAP_PROFILE);\n }\n var applied = applyCaptureProfile(selected);\n\n post({\n type: 'warmup_estimate',\n medianInferenceMs: best.med,\n estimatedFps: estFps,\n minTargetFps: MIN_TARGET_FPS,\n targetFps: MIN_TARGET_FPS,\n profileId: applied.id,\n previousProfileId: rnHint,\n idealWidth: applied.idealWidth,\n idealHeight: applied.idealHeight,\n gl: glInfo()\n });\n post({\n type: 'diag',\n message: 'warmup estimatedFps=' +\n (estFps != null ? estFps.toFixed(1) : '?') +\n ' medianMs=' + (best.med != null ? best.med.toFixed(1) : '?') +\n ' minTarget=' + MIN_TARGET_FPS +\n ' capture=' + applied.id + ' (' + applied.idealWidth + 'x' + applied.idealHeight + ')' +\n (SOFT_CAP_PROFILE ? ' softCap=' + SOFT_CAP_PROFILE : '') +\n (selected !== rnHint ? ' (rnHint=' + rnHint + ')' : '')\n });\n post({\n type: 'diag',\n message: 'experiments platform=' + (CFG.platform || '?') +\n ' captureMode=' + CAPTURE_CONSTRAINT_MODE +\n ' capturePriority=' + CAPTURE_PRIORITY +\n ' preprocess=' + PREPROCESS_PATH +\n ' frameSkip=' + INFER_FRAME_SKIP +\n ' softCap=' + (SOFT_CAP_PROFILE || 'off') +\n ' minTargetFps=' + MIN_TARGET_FPS +\n ' perfDebug=' + PERF_DEBUG\n });\n\n // Free base64 payloads after final model load.\n window.__PT_MODEL_JSON = null;\n window.__PT_WEIGHTS_B64 = null;\n window.__PT_WASM_B64 = null;\n\n var flagsSnapshot = '';\n try {\n flagsSnapshot =\n ' glVersion=' + tf.env().getNumber('WEBGL_VERSION') +\n ' pack=' + tf.env().getBool('WEBGL_PACK') +\n ' f32render=' + tf.env().getBool('WEBGL_RENDER_FLOAT32_ENABLED') +\n ' fenceApi=' + tf.env().getBool('WEBGL_FENCE_API_ENABLED');\n } catch (e) {}\n post({\n type: 'diag',\n message: 'final backend=' + best.backend + ' flags=' + best.name +\n ' medianMs=' + (best.med != null ? best.med.toFixed(1) : '?') +\n ' profile=' + CFG.profileId +\n ' coldStart=' + (BOOT_CAMERA ? 'full' : 'basic') +\n flagsSnapshot\n });\n\n lastWarmBackend = best.backend;\n lastWarmMedMs = best.med;\n lastWarmTimes = best.times || [];\n lastWarmEstFps = estFps;\n\n setHud('ready · ' + best.backend + '/' + CFG.profileId + ' · warm ' +\n (best.med != null ? Math.round(best.med) + 'ms' : '?') +\n (estFps != null ? ' (~' + Math.round(estFps) + 'fps est)' : '') +\n (BOOT_CAMERA ? '' : ' · model-only'));\n\n if (BOOT_CAMERA) {\n // Full cold-start (legacy): open camera before posting ready — same\n // as the historical warmer that called getUserMedia during preload.\n if ((sourceMode === 'video' || sourceMode === 'image') && sourceUrl) {\n await window.__PT_SET_SOURCE({ type: sourceMode, url: sourceUrl });\n } else {\n await openCameraAndLoop('boot-full');\n }\n if (suspended) {\n post({ type: 'diag', message: 'boot finished while hidden — staying suspended (camera released)' });\n stopCameraTracks();\n // Still mark model+backend ready so preload can resolve; camera\n // reopens on resume because cameraArmed is true.\n } else {\n startLoop();\n }\n postReadySignal(true, 'full');\n } else {\n // Basic cold-start: model / WebGL only — no permission prompt.\n postReadySignal(false, 'basic');\n post({\n type: 'diag',\n message: 'basic cold-start — model warm, camera deferred until openCamera/full'\n });\n if (bootCover) {\n bootCover.classList.add('hide');\n }\n applyWatermarkVisibility();\n }\n } catch (err) {\n var message = err && err.message ? err.message : String(err);\n // Abort during a concurrent setQuality must not leave the boot overlay\n // stuck forever — retry once, then surface the error.\n if (/abort/i.test(message) || (err && err.name === 'AbortError')) {\n post({ type: 'diag', message: 'boot getUserMedia aborted — retrying once' });\n try {\n if (BOOT_CAMERA) {\n await openCameraAndLoop('boot-full-retry');\n if (!suspended) startLoop();\n postReadySignal(true, 'full');\n return;\n }\n } catch (retryErr) {\n message = retryErr && retryErr.message ? retryErr.message : String(retryErr);\n }\n }\n setHud('error: ' + message);\n setBootMessage('error: ' + message, true);\n post({ type: 'error', message: message });\n }\n }\n\n window.__PT_SET_FACING = function (mode) {\n FACING = mode === 'environment' ? 'environment' : 'user';\n applyMirrorCss();\n };\n\n /** Live plan upgrade: toggle watermark without remounting the WebView. */\n window.__PT_SET_WATERMARK = function (on) {\n showWatermark = !!on;\n CFG.showWatermark = showWatermark;\n applyWatermarkVisibility();\n };\n\n /** Update boot overlay copy (WebView loading_message parity). */\n window.__PT_SET_LOADING_TEXT = function (text) {\n if (typeof text === 'string' && text.trim().length > 0) {\n loadingText = text.trim();\n } else {\n loadingText = 'AI Loading';\n }\n CFG.loadingText = loadingText;\n if (!cameraRevealed) resetBootLoadingText();\n };\n\n /**\n * Restart getUserMedia with new ideal resolution (adaptive quality ladder).\n * Inference stays at 192² letterbox — only capture / preprocess cost changes.\n */\n window.__PT_SET_QUALITY = async function (opts) {\n try {\n if (!opts) return;\n if (opts.idealWidth) CFG.idealWidth = opts.idealWidth;\n if (opts.idealHeight) CFG.idealHeight = opts.idealHeight;\n if (opts.idealFrameRate) CFG.idealFrameRate = opts.idealFrameRate;\n qualityForced = true;\n if (suspended) {\n // Camera is released while hidden; resume reopens with the new CFG.\n post({ type: 'diag', message: 'setQuality deferred (pipeline suspended)' });\n return;\n }\n // Hide during stream swap — same 0×0 / wrong-aspect flash as cold boot.\n // Boot overlay keeps branded loadingText (tech progress via diag).\n cameraRevealed = false;\n setCameraVisible(false);\n post({\n type: 'diag',\n message: 'adjusting camera (quality swap)'\n });\n var old = video.srcObject;\n if (old && old.getTracks) {\n old.getTracks().forEach(function (t) { try { t.stop(); } catch (e) {} });\n }\n // Live quality swaps always use profile ideals (downgrade ladder).\n var stream = await navigator.mediaDevices.getUserMedia({\n audio: false,\n video: videoConstraints(true)\n });\n video.srcObject = stream;\n await video.play();\n await new Promise(function (resolve) {\n if (video.videoWidth > 0) { resolve(); return; }\n video.onloadedmetadata = function () { resolve(); };\n setTimeout(resolve, 1500);\n });\n await new Promise(function (resolve) {\n if (typeof video.requestVideoFrameCallback === 'function') {\n video.requestVideoFrameCallback(function () { resolve(); });\n setTimeout(resolve, 500);\n return;\n }\n setTimeout(resolve, 80);\n });\n revealCameraWhenReady();\n post({\n type: 'diag',\n message: 'quality applied camera=' + video.videoWidth + 'x' + video.videoHeight +\n ' ideal=' + CFG.idealWidth + 'x' + CFG.idealHeight +\n '@' + (CFG.idealFrameRate || 30) +\n (opts.profileId ? ' profile=' + opts.profileId : '')\n });\n } catch (err) {\n revealCameraWhenReady();\n post({\n type: 'diag',\n message: 'setQuality failed: ' + (err && err.message ? err.message : String(err))\n });\n }\n };\n\n // ── Cold-start modes ────────────────────────────────────────────────────\n // basic (default): model + zeros warm-up only — NEVER calls getUserMedia.\n // full: open camera after warm-up (legacy warmer / explicit preload full).\n // Camera screens pass coldStart=full (or call __PT_OPEN_CAMERA later).\n var BOOT_CAMERA = CFG.coldStart === 'full' || CFG.bootCamera === true;\n // True only after a successful getUserMedia — resume must not open camera\n // for a basic (model-only) warm page.\n var cameraArmed = false;\n var lastWarmBackend = null;\n var lastWarmMedMs = null;\n var lastWarmTimes = [];\n var lastWarmEstFps = null;\n\n function postReadySignal(cameraOpened, coldStart) {\n post({\n type: 'ready',\n backend: lastWarmBackend,\n medianInferenceMs: lastWarmMedMs,\n warmUpRunsMs: lastWarmTimes,\n estimatedFps: lastWarmEstFps,\n profileId: CFG.profileId,\n cameraOpened: !!cameraOpened,\n coldStart: coldStart,\n gl: glInfo(),\n note: 'main/' + (lastWarmBackend || '?') + '/' + CFG.profileId +\n (pipeline ? '/wasm-pipeline' : '') +\n (cameraOpened ? '/camera' : '/model-only')\n });\n }\n\n async function openCameraOnce(forceProfileIdeals) {\n var stream = await navigator.mediaDevices.getUserMedia({\n audio: false,\n video: videoConstraints(!!forceProfileIdeals)\n });\n video.srcObject = stream;\n await video.play();\n await new Promise(function (resolve) {\n if (video.videoWidth > 0) { resolve(); return; }\n video.onloadedmetadata = function () { resolve(); };\n setTimeout(resolve, 2000);\n });\n await new Promise(function (resolve) {\n if (typeof video.requestVideoFrameCallback === 'function') {\n video.requestVideoFrameCallback(function () { resolve(); });\n setTimeout(resolve, 500);\n return;\n }\n setTimeout(resolve, 80);\n });\n }\n\n /** Open webcam (does not start the loop — caller decides). */\n async function openCameraAndLoop(reason) {\n if (cameraArmed && video.srcObject) {\n return;\n }\n post({\n type: 'initialization',\n step: 'accessing_webcam',\n message: 'accessing webcam',\n ready: false\n });\n setHud(\n 'getUserMedia ' +\n (CAPTURE_CONSTRAINT_MODE === 'device-native' ? 'device-native' : CFG.profileId) +\n '…'\n );\n try {\n await openCameraOnce();\n } catch (camErr) {\n var camMsg = camErr && camErr.message ? camErr.message : String(camErr);\n var isAbort = /abort/i.test(camMsg) || (camErr && camErr.name === 'AbortError');\n if (!isAbort) throw camErr;\n post({ type: 'diag', message: 'getUserMedia aborted — retrying once' });\n await new Promise(function (r) { setTimeout(r, 120); });\n await openCameraOnce();\n }\n cameraArmed = true;\n revealCameraWhenReady();\n scheduleRevealRetries(4000);\n post({\n type: 'diag',\n message: 'camera size=' + video.videoWidth + 'x' + video.videoHeight +\n ' mode=' + CAPTURE_CONSTRAINT_MODE +\n ' ideal=' + CFG.idealWidth + 'x' + CFG.idealHeight +\n ' profile=' + CFG.profileId +\n ' reason=' + reason\n });\n }\n\n\n function stopNonCameraMedia() {\n try { video.pause(); } catch (e) {}\n try { video.removeAttribute('src'); video.srcObject = null; video.load(); } catch (e) {}\n if (still) {\n try { still.removeAttribute('src'); } catch (e) {}\n }\n }\n\n function loadVideoUrl(url) {\n return new Promise(function (resolve, reject) {\n video.onloadeddata = function () { resolve(); };\n video.onerror = function () { reject(new Error('Failed to load video URL')); };\n video.loop = true;\n video.muted = true;\n video.playsInline = true;\n video.src = url;\n video.load();\n video.play().catch(function () {});\n setTimeout(function () {\n if (video.videoWidth > 0) resolve();\n }, 50);\n });\n }\n\n function loadImageUrl(url) {\n return new Promise(function (resolve, reject) {\n if (!still) { reject(new Error('still image element missing')); return; }\n still.onload = function () { resolve(); };\n still.onerror = function () { reject(new Error('Failed to load image URL')); };\n still.src = url;\n if (still.complete && still.naturalWidth > 0) resolve();\n });\n }\n\n /**\n * Switch pose input: { type: 'camera'|'video'|'image', url?: string, base64?: string, mime?: string }\n * Host apps pick a file (document/image picker) and pass a URI or data URL.\n */\n window.__PT_SET_SOURCE = async function (opts) {\n opts = opts || {};\n var type = opts.type || 'camera';\n var url = opts.url || opts.uri || null;\n if (opts.base64) {\n var mime = opts.mime || (type === 'video' ? 'video/mp4' : 'image/jpeg');\n url = 'data:' + mime + ';base64,' + opts.base64;\n }\n running = false;\n loopToken += 1;\n busy = false;\n imageShotPending = false;\n stopCameraTracks();\n stopNonCameraMedia();\n cameraArmed = type === 'camera' ? cameraArmed : false;\n cameraRevealed = false;\n setCameraVisible(false);\n sourceMode = (type === 'video' || type === 'image') ? type : 'camera';\n sourceUrl = url;\n\n if (sourceMode === 'camera') {\n post({ type: 'initialization', step: 'accessing_webcam', message: 'accessing webcam (source=camera)', ready: false });\n await openCameraAndLoop('set-source-camera');\n if (!suspended) {\n startLoop();\n postReadySignal(true, 'full');\n }\n return;\n }\n\n if (!url) {\n post({ type: 'error', message: 'source=' + sourceMode + ' requires url or base64' });\n return;\n }\n\n post({\n type: 'initialization',\n step: 'loading_media',\n message: 'loading ' + sourceMode + ' (source=' + sourceMode + ')',\n ready: false\n });\n try {\n if (sourceMode === 'video') {\n cameraArmed = true; // allow resume semantics for file video? keep false for camera reopen\n cameraArmed = false;\n await loadVideoUrl(url);\n revealCameraWhenReady();\n startLoop();\n post({ type: 'ready', backend: lastWarmBackend, medianInferenceMs: lastWarmMedMs, warmUpRunsMs: lastWarmTimes, estimatedFps: lastWarmEstFps, profileId: CFG.profileId, cameraOpened: false, coldStart: 'full', gl: glInfo(), note: 'source=video' });\n } else {\n await loadImageUrl(url);\n imageShotPending = true;\n revealCameraWhenReady();\n startLoop();\n post({ type: 'ready', backend: lastWarmBackend, medianInferenceMs: lastWarmMedMs, warmUpRunsMs: lastWarmTimes, estimatedFps: lastWarmEstFps, profileId: CFG.profileId, cameraOpened: false, coldStart: 'full', gl: glInfo(), note: 'source=image' });\n }\n } catch (err) {\n post({ type: 'error', message: 'media load failed: ' + (err && err.message ? err.message : String(err)) });\n }\n };\n\n window.__PT_ANALYZE = function () {\n if (sourceMode !== 'image') return;\n imageShotPending = true;\n if (!running) startLoop();\n };\n\n window.__PT_OPEN_CAMERA = function () {\n return openCameraAndLoop('host-openCamera').then(function () {\n if (suspended) {\n stopCameraTracks();\n return;\n }\n startLoop();\n postReadySignal(true, 'full');\n });\n };\n\n // ── Battery / thermal safety: release the camera and halt inference when\n // the page is hidden (app backgrounded, view covered), reacquire on return.\n // Belt-and-braces with the RN AppState hook (__PT_SUSPEND / __PT_RESUME):\n // Android WebView and WKWebView background semantics differ, page-level\n // visibilitychange + host injection together cover both. ──────────────────\n var suspended = false;\n // True once RN pushed an explicit quality profile — resume must reopen the\n // camera with profile ideals instead of the boot-time device-native path.\n var qualityForced = false;\n\n function stopCameraTracks() {\n var s = video.srcObject;\n if (s && s.getTracks) {\n s.getTracks().forEach(function (t) { try { t.stop(); } catch (e) {} });\n }\n video.srcObject = null;\n }\n\n function suspendPipeline(reason) {\n if (suspended) return;\n suspended = true;\n running = false;\n loopToken += 1; // kill any pending rAF chain\n stopCameraTracks();\n cameraRevealed = false;\n setCameraVisible(false);\n post({\n type: 'diag',\n message: 'pipeline suspended (' + reason + ') — camera released, inference stopped'\n });\n }\n\n async function resumePipeline() {\n if (!suspended) return;\n suspended = false;\n if (!model) return; // boot still in flight — it owns the camera\n // Model-only warm page: never open the camera on foreground.\n if (!cameraArmed) {\n post({ type: 'diag', message: 'resume skipped — camera not armed (basic cold-start)' });\n return;\n }\n try {\n var stream = await navigator.mediaDevices.getUserMedia({\n audio: false,\n video: videoConstraints(qualityForced)\n });\n if (suspended) {\n // Hidden again while getUserMedia was in flight.\n stream.getTracks().forEach(function (t) { try { t.stop(); } catch (e) {} });\n return;\n }\n stopCameraTracks();\n video.srcObject = stream;\n await video.play();\n await new Promise(function (resolve) {\n if (video.videoWidth > 0) { resolve(); return; }\n video.onloadedmetadata = function () { resolve(); };\n setTimeout(resolve, 1500);\n });\n revealCameraWhenReady();\n inferErrorStreak = 0;\n startLoop();\n post({\n type: 'diag',\n message: 'pipeline resumed — camera reacquired (' +\n video.videoWidth + 'x' + video.videoHeight + ')'\n });\n } catch (err) {\n post({\n type: 'diag',\n message: 'resume failed: ' + (err && err.message ? err.message : String(err))\n });\n }\n }\n\n window.__PT_SUSPEND = function () { suspendPipeline('host'); };\n window.__PT_RESUME = function () { resumePipeline(); };\n\n document.addEventListener('visibilitychange', function () {\n if (document.visibilityState === 'hidden') {\n suspendPipeline('page-hidden');\n } else {\n resumePipeline();\n }\n });\n // WKWebView / Chromium teardown: the camera indicator must die with the page.\n window.addEventListener('pagehide', function () {\n running = false;\n loopToken += 1;\n stopCameraTracks();\n });\n\n window.addEventListener('message', function (ev) {\n try {\n var data = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data;\n if (data && data.type === 'setFacing') window.__PT_SET_FACING(data.facingMode);\n if (data && data.type === 'setQuality') window.__PT_SET_QUALITY(data);\n if (data && data.type === 'stop') {\n running = false;\n loopToken += 1;\n stopCameraTracks();\n }\n } catch (e) {}\n });\n\n boot();\n})();\n";
|
|
@@ -72,6 +72,18 @@ export interface WebViewPoseViewProps {
|
|
|
72
72
|
* Fetched via `GET /api/sdk/skeleton`. Ignored if `skeletonDef` is set.
|
|
73
73
|
*/
|
|
74
74
|
skeletonUuid?: string | null;
|
|
75
|
+
/**
|
|
76
|
+
* Input source. Default `camera` (getUserMedia).
|
|
77
|
+
* For `video` / `image`, the host app picks a file and passes `sourceUri`
|
|
78
|
+
* (file://, content://, https, data URL) or `sourceBase64`.
|
|
79
|
+
*/
|
|
80
|
+
source?: 'camera' | 'video' | 'image';
|
|
81
|
+
/** URI injected into the WebView for video/image modes. */
|
|
82
|
+
sourceUri?: string;
|
|
83
|
+
/** Optional base64 payload (without data: prefix); paired with sourceMime. */
|
|
84
|
+
sourceBase64?: string;
|
|
85
|
+
/** MIME for sourceBase64 (default image/jpeg or video/mp4). */
|
|
86
|
+
sourceMime?: string;
|
|
75
87
|
onPose?: (pose: Pose, stats: {
|
|
76
88
|
inferenceTimeMs: number;
|
|
77
89
|
timestampMs: number;
|