@pose-tracker/react-native-pose-estimation-light 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/LICENSE +45 -0
  2. package/PoseTrackerVision.podspec +28 -0
  3. package/README.md +161 -0
  4. package/THIRD_PARTY_NOTICES.md +19 -0
  5. package/ios/PoseTrackerVision/PoseTrackerBodyPosePlugin.m +18 -0
  6. package/ios/PoseTrackerVision/PoseTrackerBodyPosePlugin.swift +108 -0
  7. package/lib/PoseTrackerProvider.d.ts +102 -0
  8. package/lib/PoseTrackerProvider.js +204 -0
  9. package/lib/api/configure.d.ts +29 -0
  10. package/lib/api/configure.js +64 -0
  11. package/lib/api/skeleton.d.ts +17 -0
  12. package/lib/api/skeleton.js +53 -0
  13. package/lib/api/track.d.ts +61 -0
  14. package/lib/api/track.js +146 -0
  15. package/lib/backends/PoseBackend.d.ts +50 -0
  16. package/lib/backends/PoseBackend.js +11 -0
  17. package/lib/backends/vision/VisionPoseBackend.d.ts +72 -0
  18. package/lib/backends/vision/VisionPoseBackend.js +177 -0
  19. package/lib/backends/vision/mapVisionJoints.d.ts +35 -0
  20. package/lib/backends/vision/mapVisionJoints.js +162 -0
  21. package/lib/backends/vision/optionalVision.d.ts +32 -0
  22. package/lib/backends/vision/optionalVision.js +76 -0
  23. package/lib/backends/webview/WebViewPoseBackend.d.ts +223 -0
  24. package/lib/backends/webview/WebViewPoseBackend.js +382 -0
  25. package/lib/backends/webview/brandAssets.d.ts +5 -0
  26. package/lib/backends/webview/brandAssets.js +8 -0
  27. package/lib/backends/webview/onlineRuntime.d.ts +45 -0
  28. package/lib/backends/webview/onlineRuntime.js +57 -0
  29. package/lib/backends/webview/poseHtml.d.ts +71 -0
  30. package/lib/backends/webview/poseHtml.js +188 -0
  31. package/lib/backends/webview/poseRuntimeSource.d.ts +3 -0
  32. package/lib/backends/webview/poseRuntimeSource.js +4 -0
  33. package/lib/cache/obfuscate.d.ts +18 -0
  34. package/lib/cache/obfuscate.js +90 -0
  35. package/lib/camera/PoseCameraView.d.ts +51 -0
  36. package/lib/camera/PoseCameraView.js +99 -0
  37. package/lib/camera/WebViewPoseView.d.ts +81 -0
  38. package/lib/camera/WebViewPoseView.js +312 -0
  39. package/lib/client.d.ts +361 -0
  40. package/lib/client.js +1046 -0
  41. package/lib/diagnostics/logReport.d.ts +23 -0
  42. package/lib/diagnostics/logReport.js +102 -0
  43. package/lib/engine/EngineLoader.d.ts +95 -0
  44. package/lib/engine/EngineLoader.js +347 -0
  45. package/lib/engine/types.d.ts +83 -0
  46. package/lib/engine/types.js +11 -0
  47. package/lib/events/classicMessage.d.ts +31 -0
  48. package/lib/events/classicMessage.js +259 -0
  49. package/lib/exercises/aliases.d.ts +18 -0
  50. package/lib/exercises/aliases.js +48 -0
  51. package/lib/index.d.ts +72 -0
  52. package/lib/index.js +170 -0
  53. package/lib/models/poseModels.d.ts +39 -0
  54. package/lib/models/poseModels.js +63 -0
  55. package/lib/quality/AdaptiveQualityController.d.ts +120 -0
  56. package/lib/quality/AdaptiveQualityController.js +423 -0
  57. package/lib/quality/RuntimeGuard.d.ts +34 -0
  58. package/lib/quality/RuntimeGuard.js +105 -0
  59. package/lib/quality/captureMode.d.ts +82 -0
  60. package/lib/quality/captureMode.js +75 -0
  61. package/lib/quality/deviceCapability.d.ts +37 -0
  62. package/lib/quality/deviceCapability.js +177 -0
  63. package/lib/quality/profiles.d.ts +125 -0
  64. package/lib/quality/profiles.js +202 -0
  65. package/lib/runtime/RuntimeCache.d.ts +75 -0
  66. package/lib/runtime/RuntimeCache.js +230 -0
  67. package/lib/sdkVersion.d.ts +2 -0
  68. package/lib/sdkVersion.js +5 -0
  69. package/lib/support/optionalModules.d.ts +46 -0
  70. package/lib/support/optionalModules.js +82 -0
  71. package/lib/types/acceleration.d.ts +86 -0
  72. package/lib/types/acceleration.js +15 -0
  73. package/lib/types/events.d.ts +345 -0
  74. package/lib/types/events.js +10 -0
  75. package/lib/types/features.d.ts +90 -0
  76. package/lib/types/features.js +122 -0
  77. package/lib/types/manifest.d.ts +197 -0
  78. package/lib/types/manifest.js +10 -0
  79. package/lib/types/pose.d.ts +36 -0
  80. package/lib/types/pose.js +41 -0
  81. package/lib/types/preload.d.ts +17 -0
  82. package/lib/types/preload.js +10 -0
  83. package/lib/types/skeleton.d.ts +29 -0
  84. package/lib/types/skeleton.js +52 -0
  85. package/package.json +92 -0
  86. package/react-native.config.js +12 -0
  87. package/src/PoseTrackerProvider.tsx +332 -0
  88. package/src/api/configure.ts +82 -0
  89. package/src/api/skeleton.ts +60 -0
  90. package/src/api/track.ts +182 -0
  91. package/src/backends/PoseBackend.ts +59 -0
  92. package/src/backends/vision/VisionPoseBackend.ts +238 -0
  93. package/src/backends/vision/mapVisionJoints.ts +185 -0
  94. package/src/backends/vision/optionalVision.ts +94 -0
  95. package/src/backends/webview/WebViewPoseBackend.ts +582 -0
  96. package/src/backends/webview/brandAssets.ts +5 -0
  97. package/src/backends/webview/onlineRuntime.ts +94 -0
  98. package/src/backends/webview/poseHtml.ts +259 -0
  99. package/src/backends/webview/poseRuntimeSource.d.ts +3 -0
  100. package/src/cache/obfuscate.ts +94 -0
  101. package/src/camera/PoseCameraView.tsx +223 -0
  102. package/src/camera/WebViewPoseView.tsx +466 -0
  103. package/src/client.ts +1295 -0
  104. package/src/diagnostics/logReport.ts +131 -0
  105. package/src/engine/EngineLoader.ts +428 -0
  106. package/src/engine/types.ts +93 -0
  107. package/src/events/classicMessage.ts +271 -0
  108. package/src/exercises/aliases.ts +50 -0
  109. package/src/index.ts +243 -0
  110. package/src/models/poseModels.ts +93 -0
  111. package/src/quality/AdaptiveQualityController.ts +579 -0
  112. package/src/quality/RuntimeGuard.ts +119 -0
  113. package/src/quality/captureMode.ts +97 -0
  114. package/src/quality/deviceCapability.ts +187 -0
  115. package/src/quality/profiles.ts +253 -0
  116. package/src/runtime/RuntimeCache.ts +300 -0
  117. package/src/sdkVersion.ts +2 -0
  118. package/src/support/optionalModules.ts +114 -0
  119. package/src/types/acceleration.ts +94 -0
  120. package/src/types/events.ts +437 -0
  121. package/src/types/features.ts +176 -0
  122. package/src/types/manifest.ts +230 -0
  123. package/src/types/pose.ts +85 -0
  124. package/src/types/preload.ts +19 -0
  125. package/src/types/skeleton.ts +77 -0
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Online (light) pose-runtime loader.
3
+ *
4
+ * The npm package ships only the thin page runtime (`pose-runtime.js`).
5
+ * TF.js + WASM backends load from a CDN; MoveNet (or a custom graph model)
6
+ * loads from a URL on every WebView boot — same idea as the historical
7
+ * PoseTracker Front tracking page.
8
+ */
9
+ import { type PoseModelAlias } from '../../models/poseModels';
10
+ /** TF.js version pinned for CDN scripts (matches offline SDK build). */
11
+ export declare const ONLINE_TFJS_VERSION = "4.22.0";
12
+ export interface OnlineRuntimeUrls {
13
+ /** Ordered <script src> list (core → converter → webgl → wasm). */
14
+ tfjsScriptUrls: string[];
15
+ /** Directory passed to `tf.wasm.setWasmPaths`. */
16
+ tfjsWasmPath: string;
17
+ }
18
+ export interface OnlineRuntimeParts {
19
+ version: string;
20
+ delivery: 'online';
21
+ modelId: string;
22
+ modelUrl: string;
23
+ tfjsScriptUrls: string[];
24
+ tfjsWasmPath: string;
25
+ /** Thin page runtime shipped in the package (~50 KB). */
26
+ runtimeJs: string;
27
+ }
28
+ export interface GetOnlineRuntimeOptions {
29
+ model?: PoseModelAlias;
30
+ modelUrl?: string;
31
+ /**
32
+ * Override jsDelivr package root, e.g.
33
+ * `https://cdn.jsdelivr.net/npm` (default) or a mirrored host.
34
+ */
35
+ tfjsCdnBase?: string;
36
+ /** Override TF.js version segment in CDN URLs. */
37
+ tfjsVersion?: string;
38
+ }
39
+ export declare function defaultOnlineRuntimeUrls(cdnBase?: string, tfjsVersion?: string): OnlineRuntimeUrls;
40
+ /**
41
+ * Build the online runtime descriptor used by {@link buildPoseHtml}.
42
+ * Throws when the selected model cannot be loaded (e.g. blazepose without URL).
43
+ */
44
+ export declare function getOnlineRuntimeParts(options?: GetOnlineRuntimeOptions): OnlineRuntimeParts;
45
+ export declare function getOnlineRuntimeVersion(): string;
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ /**
3
+ * Online (light) pose-runtime loader.
4
+ *
5
+ * The npm package ships only the thin page runtime (`pose-runtime.js`).
6
+ * TF.js + WASM backends load from a CDN; MoveNet (or a custom graph model)
7
+ * loads from a URL on every WebView boot — same idea as the historical
8
+ * PoseTracker Front tracking page.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.ONLINE_TFJS_VERSION = void 0;
12
+ exports.defaultOnlineRuntimeUrls = defaultOnlineRuntimeUrls;
13
+ exports.getOnlineRuntimeParts = getOnlineRuntimeParts;
14
+ exports.getOnlineRuntimeVersion = getOnlineRuntimeVersion;
15
+ const poseRuntimeSource_1 = require("./poseRuntimeSource");
16
+ const poseModels_1 = require("../../models/poseModels");
17
+ /** TF.js version pinned for CDN scripts (matches offline SDK build). */
18
+ exports.ONLINE_TFJS_VERSION = '4.22.0';
19
+ const JSDELIVR = 'https://cdn.jsdelivr.net/npm';
20
+ function defaultOnlineRuntimeUrls(cdnBase = JSDELIVR, tfjsVersion = exports.ONLINE_TFJS_VERSION) {
21
+ const base = cdnBase.replace(/\/$/, '');
22
+ return {
23
+ tfjsScriptUrls: [
24
+ `${base}/@tensorflow/tfjs-core@${tfjsVersion}/dist/tf-core.min.js`,
25
+ `${base}/@tensorflow/tfjs-converter@${tfjsVersion}/dist/tf-converter.min.js`,
26
+ `${base}/@tensorflow/tfjs-backend-webgl@${tfjsVersion}/dist/tf-backend-webgl.min.js`,
27
+ `${base}/@tensorflow/tfjs-backend-wasm@${tfjsVersion}/dist/tf-backend-wasm.min.js`,
28
+ ],
29
+ tfjsWasmPath: `${base}/@tensorflow/tfjs-backend-wasm@${tfjsVersion}/dist/`,
30
+ };
31
+ }
32
+ /**
33
+ * Build the online runtime descriptor used by {@link buildPoseHtml}.
34
+ * Throws when the selected model cannot be loaded (e.g. blazepose without URL).
35
+ */
36
+ function getOnlineRuntimeParts(options = {}) {
37
+ const resolved = (0, poseModels_1.resolvePoseModel)({
38
+ model: options.model,
39
+ modelUrl: options.modelUrl,
40
+ });
41
+ if (!resolved.modelUrl) {
42
+ throw new Error(resolved.unsupportedReason ?? `Model "${resolved.modelId}" is unavailable.`);
43
+ }
44
+ const urls = defaultOnlineRuntimeUrls(options.tfjsCdnBase, options.tfjsVersion);
45
+ return {
46
+ version: poseRuntimeSource_1.POSE_RUNTIME_VERSION,
47
+ delivery: 'online',
48
+ modelId: resolved.modelId,
49
+ modelUrl: resolved.modelUrl,
50
+ tfjsScriptUrls: urls.tfjsScriptUrls,
51
+ tfjsWasmPath: urls.tfjsWasmPath,
52
+ runtimeJs: poseRuntimeSource_1.POSE_RUNTIME_JS,
53
+ };
54
+ }
55
+ function getOnlineRuntimeVersion() {
56
+ return poseRuntimeSource_1.POSE_RUNTIME_VERSION;
57
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * WebView HTML assembler (light / online).
3
+ *
4
+ * Loads TF.js from CDN script tags and MoveNet (or a custom graph model)
5
+ * from {@link OnlineRuntimeParts.modelUrl}. The thin page runtime is still
6
+ * inlined from the package. Same camera / adaptive quality / postMessage
7
+ * events as the offline SDK.
8
+ */
9
+ import type { CapturePriority } from '../../quality/profiles';
10
+ import type { SkeletonDefinition } from '../../types/skeleton';
11
+ import type { OnlineRuntimeParts } from './onlineRuntime';
12
+ /** Bumped on every assembler-path change — appears in WebView diag logs. */
13
+ export declare const POSE_HTML_BUILD = "20260811-onlineLight-localhostBase";
14
+ /** Default boot overlay copy (WebView `loading_message` parity). */
15
+ export declare const DEFAULT_LOADING_TEXT = "AI Loading";
16
+ export interface PoseHtmlOptions {
17
+ facingMode?: 'user' | 'environment';
18
+ /** Min keypoint score for the in-page skeleton. Default 0.25. */
19
+ minScore?: number;
20
+ /**
21
+ * getUserMedia resolution hint. Defaults follow the adaptive quality
22
+ * profile (often UltraLite on Android, Pro/Prime on iOS). Inference always
23
+ * letterboxes down to 192×192 regardless.
24
+ */
25
+ idealWidth?: number;
26
+ idealHeight?: number;
27
+ idealFrameRate?: number;
28
+ /** Active adaptive quality profile id (ceiling for warm-up downgrades). */
29
+ profileId?: string;
30
+ /**
31
+ * Platform minimum target FPS (floor). iOS ≈ 30, Android ≈ 10 (experiment).
32
+ * Passed from RN so the HTML page shares the same AdaptiveChoice policy.
33
+ */
34
+ minTargetFps?: number;
35
+ /** Draw the skeleton overlay inside the page. Default true. */
36
+ drawSkeleton?: boolean;
37
+ /**
38
+ * `performance` (default) applies Android soft-cap / warm-up floor mapping.
39
+ * `quality` disables soft-cap and opens at prime / device-native HD.
40
+ */
41
+ capturePriority?: CapturePriority;
42
+ /**
43
+ * `basic` (default for warmers): model + WebGL zeros only — no getUserMedia.
44
+ * `full`: open the camera after warm-up (camera screens / explicit full preload).
45
+ */
46
+ coldStart?: 'basic' | 'full';
47
+ /**
48
+ * Custom skeleton overlay (Strapi skeleton document). When omitted, the
49
+ * page uses PoseTrackerFront `DEFAULT_SKELETON` (navy + gold).
50
+ */
51
+ skeletonDef?: SkeletonDefinition | null;
52
+ /**
53
+ * Boot overlay label (WebView `loading_message` query param parity).
54
+ * Default {@link DEFAULT_LOADING_TEXT}. Technical progress stays on
55
+ * `initialization` / `diag` events — never on this UI.
56
+ */
57
+ loadingText?: string;
58
+ /**
59
+ * Bottom-right “powered by” + join-logo watermark on the live camera
60
+ * surface. Hosts typically derive this from plan (`!isPaidPlan`).
61
+ * Ignored / forced off for `coldStart: 'basic'` warmers.
62
+ */
63
+ showWatermark?: boolean;
64
+ /** Show the technical `#hud` overlay (FPS / backend). Default false. */
65
+ debugHud?: boolean;
66
+ }
67
+ /**
68
+ * Assemble the pose page for online delivery: CDN TF.js + remote model URL
69
+ * + inlined thin page runtime.
70
+ */
71
+ export declare function buildPoseHtml(parts: OnlineRuntimeParts, options?: PoseHtmlOptions): string;
@@ -0,0 +1,188 @@
1
+ "use strict";
2
+ /**
3
+ * WebView HTML assembler (light / online).
4
+ *
5
+ * Loads TF.js from CDN script tags and MoveNet (or a custom graph model)
6
+ * from {@link OnlineRuntimeParts.modelUrl}. The thin page runtime is still
7
+ * inlined from the package. Same camera / adaptive quality / postMessage
8
+ * events as the offline SDK.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.DEFAULT_LOADING_TEXT = exports.POSE_HTML_BUILD = void 0;
12
+ exports.buildPoseHtml = buildPoseHtml;
13
+ const react_native_1 = require("react-native");
14
+ const captureMode_1 = require("../../quality/captureMode");
15
+ const brandAssets_1 = require("./brandAssets");
16
+ /** Bumped on every assembler-path change — appears in WebView diag logs. */
17
+ exports.POSE_HTML_BUILD = '20260811-onlineLight-localhostBase';
18
+ /** Default boot overlay copy (WebView `loading_message` parity). */
19
+ exports.DEFAULT_LOADING_TEXT = 'AI Loading';
20
+ const PAGE_CSS = `
21
+ html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }
22
+ #wrap { position:relative; width:100%; height:100%; background:#000; }
23
+ video, canvas {
24
+ position:absolute; inset:0; width:100%; height:100%;
25
+ object-fit:cover; opacity:0; transition:opacity 180ms ease-out;
26
+ }
27
+ video { z-index:1; background:#000; }
28
+ canvas { z-index:2; pointer-events:none; }
29
+ #boot {
30
+ position:absolute; inset:0; z-index:4;
31
+ display:flex; flex-direction:column; align-items:center; justify-content:center;
32
+ background:#0a0a0a; color:#fff;
33
+ font:15px/1.4 -apple-system,system-ui,sans-serif;
34
+ transition:opacity 180ms ease-out;
35
+ gap:0;
36
+ padding:24px;
37
+ box-sizing:border-box;
38
+ }
39
+ #boot.hide { opacity:0; pointer-events:none; }
40
+ #boot .boot-powered {
41
+ color:rgba(255,255,255,0.55);
42
+ font-size:11px;
43
+ font-weight:600;
44
+ letter-spacing:0.14em;
45
+ text-transform:uppercase;
46
+ margin-bottom:10px;
47
+ }
48
+ #boot .boot-logo {
49
+ width:min(220px, 62vw);
50
+ height:auto;
51
+ margin-bottom:22px;
52
+ display:block;
53
+ box-sizing:border-box;
54
+ background:#3a3a3a;
55
+ padding:14px 16px;
56
+ border-radius:10px;
57
+ }
58
+ #boot .boot-spinner {
59
+ width:28px; height:28px; margin-bottom:16px;
60
+ border:2px solid #333; border-top-color:#ffc300; border-radius:50%;
61
+ animation:ptspin 0.8s linear infinite;
62
+ }
63
+ #boot .boot-msg {
64
+ color:#e8e8e8; font-size:14px; font-weight:500;
65
+ text-align:center; padding:0 12px; min-height:1.2em;
66
+ }
67
+ #boot .boot-msg.is-error { color:#FE8370; font-size:13px; }
68
+ @keyframes ptspin { to { transform:rotate(360deg); } }
69
+ #wm {
70
+ position:absolute; right:10px; bottom:10px; z-index:3;
71
+ display:none; flex-direction:column; align-items:flex-end;
72
+ pointer-events:none;
73
+ text-shadow:0 1px 2px rgba(0,0,0,0.55);
74
+ }
75
+ #wm.show { display:flex; }
76
+ #wm .wm-powered {
77
+ color:rgba(255,255,255,0.7);
78
+ font:600 11px/1.2 -apple-system,system-ui,sans-serif;
79
+ letter-spacing:0.12em;
80
+ text-transform:uppercase;
81
+ margin-bottom:4px;
82
+ }
83
+ #wm .wm-logo {
84
+ width:130px; height:auto; display:block;
85
+ opacity:0.92;
86
+ filter:drop-shadow(0 1px 2px rgba(0,0,0,0.45));
87
+ }
88
+ #hud {
89
+ position:absolute; left:8px; right:8px; bottom:8px; z-index:3;
90
+ color:#ffc300; font:12px/1.3 -apple-system,system-ui,sans-serif;
91
+ text-shadow:0 1px 2px #000; pointer-events:none;
92
+ display:none;
93
+ }
94
+ #hud.debug { display:block; }
95
+ `;
96
+ /** Escape text for safe HTML text-node content. */
97
+ function escapeHtml(text) {
98
+ return text
99
+ .replace(/&/g, '&amp;')
100
+ .replace(/</g, '&lt;')
101
+ .replace(/>/g, '&gt;')
102
+ .replace(/"/g, '&quot;')
103
+ .replace(/'/g, '&#39;');
104
+ }
105
+ /** `</script` inside injected JS text would terminate the inline script tag. */
106
+ function escapeScript(js) {
107
+ return js.replace(/<\/script/gi, '<\\/script');
108
+ }
109
+ function escapeAttr(value) {
110
+ return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
111
+ }
112
+ /**
113
+ * Assemble the pose page for online delivery: CDN TF.js + remote model URL
114
+ * + inlined thin page runtime.
115
+ */
116
+ function buildPoseHtml(parts, options) {
117
+ const isAndroid = react_native_1.Platform.OS === 'android';
118
+ const capturePriority = options?.capturePriority === 'quality' ? 'quality' : 'performance';
119
+ const preferQuality = capturePriority === 'quality';
120
+ const coldStart = options?.coldStart === 'full' ? 'full' : 'basic';
121
+ const loadingText = typeof options?.loadingText === 'string' && options.loadingText.trim().length > 0
122
+ ? options.loadingText.trim()
123
+ : exports.DEFAULT_LOADING_TEXT;
124
+ const watermarkOn = coldStart === 'full' && (options?.showWatermark ?? true);
125
+ const debugHud = !!options?.debugHud;
126
+ const config = JSON.stringify({
127
+ facingMode: options?.facingMode ?? 'user',
128
+ minScore: options?.minScore ?? 0.25,
129
+ idealWidth: options?.idealWidth ?? 480,
130
+ idealHeight: options?.idealHeight ?? 360,
131
+ idealFrameRate: options?.idealFrameRate ?? 24,
132
+ profileId: options?.profileId ?? 'ultralite',
133
+ minTargetFps: options?.minTargetFps ?? 15,
134
+ drawSkeleton: options?.drawSkeleton ?? true,
135
+ coldStart,
136
+ skeletonDef: options?.skeletonDef ?? null,
137
+ loadingText,
138
+ showWatermark: watermarkOn,
139
+ debugHud,
140
+ captureConstraintMode: captureMode_1.CAPTURE_CONSTRAINT_MODE,
141
+ capturePriority,
142
+ platform: react_native_1.Platform.OS,
143
+ modelUrl: parts.modelUrl,
144
+ modelId: parts.modelId,
145
+ tfjsWasmPath: parts.tfjsWasmPath,
146
+ inferFrameSkip: isAndroid && captureMode_1.ANDROID_INFER_FRAME_SKIP > 0 ? captureMode_1.ANDROID_INFER_FRAME_SKIP : 0,
147
+ preprocessPath: isAndroid ? captureMode_1.ANDROID_PREPROCESS_PATH : 'imagebitmap',
148
+ softCapProfile: isAndroid && !preferQuality ? captureMode_1.ANDROID_SOFT_CAP_PROFILE : null,
149
+ perfDebug: isAndroid && captureMode_1.ANDROID_PERF_DEBUG,
150
+ });
151
+ const cdnScripts = parts.tfjsScriptUrls
152
+ .map((url) => `<script src="${escapeAttr(url)}"></script>`)
153
+ .join('');
154
+ return [
155
+ '<!DOCTYPE html><html><head><meta charset="utf-8" />',
156
+ '<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />',
157
+ '<style>', PAGE_CSS, '</style></head><body>',
158
+ '<div id="wrap">',
159
+ '<video id="video" playsinline muted autoplay></video>',
160
+ '<canvas id="overlay"></canvas>',
161
+ '<div id="wm">',
162
+ '<div class="wm-powered">powered by</div>',
163
+ '<img class="wm-logo" alt="PoseTracker" src="', brandAssets_1.POSETRACKER_LOGO_DATA_URL, '" />',
164
+ '</div>',
165
+ '<div id="boot">',
166
+ '<div class="boot-powered">powered by</div>',
167
+ '<img class="boot-logo" alt="PoseTracker" src="', brandAssets_1.POSETRACKER_LOGO_DATA_URL, '" />',
168
+ '<div class="boot-spinner"></div>',
169
+ '<div class="boot-msg">', escapeHtml(loadingText), '</div>',
170
+ '</div>',
171
+ '<div id="hud"', debugHud || (isAndroid && captureMode_1.ANDROID_PERF_DEBUG) ? ' class="debug"' : '', '>',
172
+ 'booting…',
173
+ '</div>',
174
+ '</div>',
175
+ cdnScripts,
176
+ '<script>window.__PT_BUILD=', JSON.stringify(`${exports.POSE_HTML_BUILD}/${parts.version}`),
177
+ ';window.__PT_CONFIG=', config,
178
+ ';window.__PT_MODEL_URL=', JSON.stringify(parts.modelUrl),
179
+ ';window.__PT_WASM_PATH=', JSON.stringify(parts.tfjsWasmPath),
180
+ ';window.__PT_MODEL_JSON=null',
181
+ ';window.__PT_WEIGHTS_B64=null',
182
+ ';window.__PT_WASM_B64=null',
183
+ ';window.__PT_PIPELINE_WASM_B64=null',
184
+ ';</script>',
185
+ '<script>', escapeScript(parts.runtimeJs), '</script>',
186
+ '</body></html>',
187
+ ].join('');
188
+ }
@@ -0,0 +1,3 @@
1
+ /** AUTO-GENERATED by scripts/embed-pose-runtime.mjs — do not edit. */
2
+ export declare const POSE_RUNTIME_VERSION: string;
3
+ export declare const POSE_RUNTIME_JS: string;
@@ -0,0 +1,4 @@
1
+ /** AUTO-GENERATED by scripts/embed-pose-runtime.mjs — do not edit. */
2
+ 'use strict';
3
+ exports.POSE_RUNTIME_VERSION = "online-7c1c31f7";
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";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Obfuscation of the local session cache (manifest + engine bundle).
3
+ *
4
+ * Purpose: the cached business logic and exercise configs must not be
5
+ * readable without the API key that earned them (commercial boundary). The
6
+ * scheme is an SHA-256-based XOR keystream keyed by the API token — this is
7
+ * deliberate *obfuscation*, not cryptographic security against an attacker
8
+ * who owns both the device and a valid token (such an attacker can already
9
+ * read the downloaded engine at runtime). What it guarantees:
10
+ * - a device with no API token cannot exploit a cache left by another app
11
+ * or a previous integration;
12
+ * - the cache written for token A is unreadable after switching to token B.
13
+ */
14
+ /** Derives the cache secret from the API token (never stored on disk). */
15
+ export declare function deriveCacheSecret(apiToken: string): string;
16
+ export declare function sealString(plain: string, secret: string): string;
17
+ /** Returns null when the payload is not a sealed blob or cannot be decoded. */
18
+ export declare function openString(sealed: string, secret: string): string | null;
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ /**
3
+ * Obfuscation of the local session cache (manifest + engine bundle).
4
+ *
5
+ * Purpose: the cached business logic and exercise configs must not be
6
+ * readable without the API key that earned them (commercial boundary). The
7
+ * scheme is an SHA-256-based XOR keystream keyed by the API token — this is
8
+ * deliberate *obfuscation*, not cryptographic security against an attacker
9
+ * who owns both the device and a valid token (such an attacker can already
10
+ * read the downloaded engine at runtime). What it guarantees:
11
+ * - a device with no API token cannot exploit a cache left by another app
12
+ * or a previous integration;
13
+ * - the cache written for token A is unreadable after switching to token B.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.deriveCacheSecret = deriveCacheSecret;
17
+ exports.sealString = sealString;
18
+ exports.openString = openString;
19
+ const js_sha256_1 = require("js-sha256");
20
+ const MAGIC = 'ptc1:';
21
+ /** Derives the cache secret from the API token (never stored on disk). */
22
+ function deriveCacheSecret(apiToken) {
23
+ return (0, js_sha256_1.sha256)(`posetracker-sdk-cache:${apiToken}`);
24
+ }
25
+ function utf8Encode(text) {
26
+ // encodeURIComponent-based UTF-8, avoids relying on TextEncoder (Hermes).
27
+ const escaped = unescape(encodeURIComponent(text));
28
+ const bytes = new Uint8Array(escaped.length);
29
+ for (let i = 0; i < escaped.length; i++) {
30
+ bytes[i] = escaped.charCodeAt(i);
31
+ }
32
+ return bytes;
33
+ }
34
+ function utf8Decode(bytes) {
35
+ let escaped = '';
36
+ for (let i = 0; i < bytes.length; i++) {
37
+ escaped += String.fromCharCode(bytes[i]);
38
+ }
39
+ return decodeURIComponent(escape(escaped));
40
+ }
41
+ function toHex(bytes) {
42
+ let hex = '';
43
+ for (let i = 0; i < bytes.length; i++) {
44
+ hex += bytes[i].toString(16).padStart(2, '0');
45
+ }
46
+ return hex;
47
+ }
48
+ function fromHex(hex) {
49
+ if (hex.length % 2 !== 0 || /[^0-9a-f]/.test(hex)) {
50
+ return null;
51
+ }
52
+ const bytes = new Uint8Array(hex.length / 2);
53
+ for (let i = 0; i < bytes.length; i++) {
54
+ bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
55
+ }
56
+ return bytes;
57
+ }
58
+ /** SHA-256 counter-mode keystream. */
59
+ function xorWithKeystream(bytes, secret) {
60
+ const out = new Uint8Array(bytes.length);
61
+ let counter = 0;
62
+ let offset = 0;
63
+ while (offset < bytes.length) {
64
+ const block = js_sha256_1.sha256.array(`${secret}:${counter++}`);
65
+ for (let i = 0; i < block.length && offset < bytes.length; i++, offset++) {
66
+ out[offset] = bytes[offset] ^ block[i];
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+ function sealString(plain, secret) {
72
+ return MAGIC + toHex(xorWithKeystream(utf8Encode(plain), secret));
73
+ }
74
+ /** Returns null when the payload is not a sealed blob or cannot be decoded. */
75
+ function openString(sealed, secret) {
76
+ if (!sealed.startsWith(MAGIC)) {
77
+ return null;
78
+ }
79
+ const bytes = fromHex(sealed.slice(MAGIC.length));
80
+ if (!bytes) {
81
+ return null;
82
+ }
83
+ try {
84
+ return utf8Decode(xorWithKeystream(bytes, secret));
85
+ }
86
+ catch {
87
+ // Wrong secret produces invalid UTF-8 → decodeURIComponent throws.
88
+ return null;
89
+ }
90
+ }