@pose-tracker/react-native-pose-estimation-light 0.3.1 → 0.3.2

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 CHANGED
@@ -162,7 +162,9 @@ Yes (`react-native-webview` peer).
162
162
  Pass `model: 'blazepose'` on `PoseTrackerProvider` options. Loads
163
163
  `@tensorflow-models/pose-detection` from jsDelivr in the WebView (lite /
164
164
  TF.js). Keypoints stay COCO-17. Heavier than MoveNet — expect lower FPS on
165
- mid-range Android. Offline SDK does **not** ship BlazePose.
165
+ mid-range Android. The **offline** package can run the same CDN BlazePose
166
+ path but still ships unused bundled MoveNet; use light unless you need
167
+ offline MoveNet.
166
168
 
167
169
  **Who sees the watermark?**
168
170
  Keyless and free plans. Hidden for paid plans.
@@ -11,7 +11,7 @@ import type { CapturePriority } from '../../quality/profiles';
11
11
  import type { SkeletonDefinition } from '../../types/skeleton';
12
12
  import type { OnlineRuntimeParts } from './onlineRuntime';
13
13
  /** Bumped on every assembler-path change — appears in WebView diag logs. */
14
- export declare const POSE_HTML_BUILD = "20260812-onlineLight-mediaSources";
14
+ export declare const POSE_HTML_BUILD = "20260921-blazeposeTfOpts";
15
15
  /** Default boot overlay copy (WebView `loading_message` parity). */
16
16
  export declare const DEFAULT_LOADING_TEXT = "AI Loading";
17
17
  export interface PoseHtmlOptions {
@@ -15,7 +15,7 @@ const react_native_1 = require("react-native");
15
15
  const captureMode_1 = require("../../quality/captureMode");
16
16
  const brandAssets_1 = require("./brandAssets");
17
17
  /** Bumped on every assembler-path change — appears in WebView diag logs. */
18
- exports.POSE_HTML_BUILD = '20260812-onlineLight-mediaSources';
18
+ exports.POSE_HTML_BUILD = '20260921-blazeposeTfOpts';
19
19
  /** Default boot overlay copy (WebView `loading_message` parity). */
20
20
  exports.DEFAULT_LOADING_TEXT = 'AI Loading';
21
21
  const PAGE_CSS = `
@@ -149,7 +149,11 @@ function buildPoseHtml(parts, options) {
149
149
  modelId: parts.modelId,
150
150
  modelKind: parts.modelKind,
151
151
  tfjsWasmPath: parts.tfjsWasmPath,
152
- inferFrameSkip: isAndroid && captureMode_1.ANDROID_INFER_FRAME_SKIP > 0 ? captureMode_1.ANDROID_INFER_FRAME_SKIP : 0,
152
+ inferFrameSkip: isAndroid
153
+ ? (parts.modelKind === 'blazepose' || parts.modelId === 'blazepose'
154
+ ? captureMode_1.ANDROID_INFER_FRAME_SKIP_BLAZEPOSE
155
+ : captureMode_1.ANDROID_INFER_FRAME_SKIP)
156
+ : 0,
153
157
  preprocessPath: isAndroid ? captureMode_1.ANDROID_PREPROCESS_PATH : 'imagebitmap',
154
158
  softCapProfile: isAndroid && !preferQuality ? captureMode_1.ANDROID_SOFT_CAP_PROFILE : null,
155
159
  perfDebug: isAndroid && captureMode_1.ANDROID_PERF_DEBUG,
@@ -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-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";
3
+ exports.POSE_RUNTIME_VERSION = "online-cb62233e";
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 /** BlazePose TF.js detector input (contain letterbox). Do not reuse MoveNet 192. */\n var BLAZE_INPUT_SIZE = 256;\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 = {\n offsetX: 0,\n offsetY: 0,\n drawW: INPUT_SIZE,\n drawH: INPUT_SIZE,\n vw: 1,\n vh: 1,\n poseSize: INPUT_SIZE\n };\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: false }\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(poseSize) {\n var size = poseSize || INPUT_SIZE;\n if (\n !window.__PT_FC ||\n window.__PT_FC.width !== size ||\n window.__PT_FC.height !== size\n ) {\n window.__PT_FC = document.createElement('canvas');\n window.__PT_FC.width = size;\n window.__PT_FC.height = size;\n window.__PT_FCTX = window.__PT_FC.getContext('2d', { willReadFrequently: true });\n }\n return window.__PT_FCTX;\n }\n\n /**\n * Letterbox video → poseSize² canvas (contain). MoveNet uses 192; BlazePose 256.\n * Stretching with resizeWidth/Height broke aspect ratio → wrong skeleton.\n * Returns either an ImageBitmap or the canvas itself for fromPixels / estimatePoses.\n */\n async function preparePoseInput(poseSize) {\n var size = poseSize || INPUT_SIZE;\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(size / vw, size / vh);\n var drawW = vw * scale;\n var drawH = vh * scale;\n var offsetX = (size - drawW) / 2;\n var offsetY = (size - drawH) / 2;\n letterbox = {\n offsetX: offsetX,\n offsetY: offsetY,\n drawW: drawW,\n drawH: drawH,\n vw: vw,\n vh: vh,\n poseSize: size\n };\n\n var c2d = poseCanvasCtx(size);\n c2d.fillStyle = '#000';\n c2d.fillRect(0, 0, size, 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 letterboxed square) → video pixel coords. (JS fallback) */\n function modelNormToVideo(xNorm, yNorm) {\n var poseSize = letterbox.poseSize || INPUT_SIZE;\n var xSq = xNorm * poseSize;\n var ySq = yNorm * poseSize;\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 preparedB = await preparePoseInput(BLAZE_INPUT_SIZE);\n var poses = await blazeDetector.estimatePoses(preparedB.source, {\n flipHorizontal: false,\n maxPoses: 1\n });\n if (preparedB.kind === 'bitmap' && preparedB.source && preparedB.source.close) {\n preparedB.source.close();\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 canvasToVideoPx(x, y, maxCoord) {\n var poseSize = letterbox.poseSize || BLAZE_INPUT_SIZE;\n var inNorm = maxCoord <= 1.5 && poseSize > 2;\n var xSq = inNorm ? x * poseSize : x;\n var ySq = inNorm ? y * poseSize : y;\n var drawW = letterbox.drawW || poseSize;\n var drawH = letterbox.drawH || poseSize;\n return {\n x: (xSq - letterbox.offsetX) * (letterbox.vw / drawW),\n y: (ySq - letterbox.offsetY) * (letterbox.vh / drawH)\n };\n }\n\n function decodeBlazePoses(poses, dispW, dispH) {\n var pose = poses && poses[0];\n var vw = letterbox.vw || video.videoWidth || 1;\n var vh = letterbox.vh || video.videoHeight || 1;\n var byName = {};\n var maxCoord = 0;\n if (pose && pose.keypoints) {\n for (var m = 0; m < pose.keypoints.length; m++) {\n maxCoord = Math.max(\n maxCoord,\n Math.abs(pose.keypoints[m].x || 0),\n Math.abs(pose.keypoints[m].y || 0)\n );\n }\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 var src = canvasToVideoPx(kp.x, kp.y, maxCoord);\n byName[coco] = {\n x: src.x,\n y: src.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";
package/lib/client.d.ts CHANGED
@@ -313,8 +313,9 @@ export declare class PoseTrackerClient {
313
313
  private resolveManifest;
314
314
  /**
315
315
  * WebView parity — the load-time gating of `TrackingAppV3`:
316
- * - `blazepose` / `poseEngine` / other WebView-only keys → clear error
317
- * (this SDK ships MoveNet Lightning only);
316
+ * - `blazepose` / `poseEngine` / other WebView-only keys **as features
317
+ * flags** → clear error (`options.model = 'blazepose'` is the supported
318
+ * BlazePose path);
318
319
  * - developer features requested WITHOUT an API key → the front's exact
319
320
  * "Invalid params… token=YOUR API_KEY…" message;
320
321
  * - plan `free` + angles/recommendations/progression → the front's exact
package/lib/client.js CHANGED
@@ -698,8 +698,9 @@ class PoseTrackerClient {
698
698
  }
699
699
  /**
700
700
  * WebView parity — the load-time gating of `TrackingAppV3`:
701
- * - `blazepose` / `poseEngine` / other WebView-only keys → clear error
702
- * (this SDK ships MoveNet Lightning only);
701
+ * - `blazepose` / `poseEngine` / other WebView-only keys **as features
702
+ * flags** → clear error (`options.model = 'blazepose'` is the supported
703
+ * BlazePose path);
703
704
  * - developer features requested WITHOUT an API key → the front's exact
704
705
  * "Invalid params… token=YOUR API_KEY…" message;
705
706
  * - plan `free` + angles/recommendations/progression → the front's exact
package/lib/index.d.ts CHANGED
@@ -60,7 +60,7 @@ export type { ClassicMessageListener, ClassicNativeMessage, } from './events/cla
60
60
  export { AdaptiveQualityController, } from './quality/AdaptiveQualityController';
61
61
  export type { AdaptiveQualityControllerOptions, QualityState, QualityStatsSample, } from './quality/AdaptiveQualityController';
62
62
  export { CRITICAL_FPS_THRESHOLD, IDEAL_FPS_RANGE_ANDROID, IDEAL_FPS_RANGE_IOS, LOW_FPS_STREAK_BEFORE_DOWNGRADE, QUALITY_SETTLE_MS, MIN_TARGET_FPS_ANDROID, MIN_TARGET_FPS_DEFAULT, MIN_TARGET_FPS_IOS, QUALITY_LADDER, QUALITY_PROFILES, TARGET_FPS, TARGET_MEDIAN_MS, currentQualityPlatform, estimatedFpsFromMedianMs, getCriticalFpsThreshold, getIdealFpsRange, getMinTargetFps, getQualityProfile, isQualityProfileId, lowerQualityProfile, minMedianMsForMinTarget, nextLowerQualityProfile, profileFromWarmupMedianMs, } from './quality/profiles';
63
- export { ANDROID_INFER_FRAME_SKIP, ANDROID_MIN_TARGET_FPS, ANDROID_PERF_DEBUG, ANDROID_PREPROCESS_PATH, ANDROID_SOFT_CAP_PROFILE, CAPTURE_CONSTRAINT_MODE, ENABLE_MALI_HARD_CAP, } from './quality/captureMode';
63
+ export { ANDROID_INFER_FRAME_SKIP, ANDROID_INFER_FRAME_SKIP_BLAZEPOSE, ANDROID_MIN_TARGET_FPS, ANDROID_PERF_DEBUG, ANDROID_PREPROCESS_PATH, ANDROID_SOFT_CAP_PROFILE, CAPTURE_CONSTRAINT_MODE, ENABLE_MALI_HARD_CAP, } from './quality/captureMode';
64
64
  export type { AndroidPreprocessPath, AndroidSoftCapProfile, CaptureConstraintMode, } from './quality/captureMode';
65
65
  export type { FpsRange, CapturePriority, QualityChoice, QualityPlatform, QualityProfile, QualityProfileId, } from './quality/profiles';
66
66
  export { isMaliRenderer, profileFromScore, scoreDeviceCapability, } from './quality/deviceCapability';
package/lib/index.js CHANGED
@@ -29,8 +29,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
29
29
  };
30
30
  Object.defineProperty(exports, "__esModule", { value: true });
31
31
  exports.isExpoGo = exports.PoseCameraView = exports.VISION_JOINT_TO_SDK = exports.poseFromVisionPluginResult = exports.VISION_BODY_POSE_PLUGIN_NAME = exports.isVisionPoseCameraAvailable = exports.isVisionBackendAvailable = exports.VisionUnavailableError = exports.VisionPoseBackend = exports.POSETRACKER_LOGO_DATA_URL = exports.POSE_HTML_BUILD = exports.DEFAULT_LOADING_TEXT = exports.buildPoseHtml = exports.WebViewPoseView = exports.isWebViewPoseBackend = exports.WebViewPoseBackend = exports.usePoseTracker = exports.PoseTrackerProvider = exports.SkeletonFetchError = exports.fetchSkeletonDefinition = exports.DEFAULT_SKELETON_DEFINITION = exports.resolveMovementEngine = exports.requiresEngineV4 = exports.normalizeEngineChannel = exports.isProductionSquatId = exports.isJumpExercise = exports.V4_ONLY_EXERCISE_IDS = exports.PRODUCTION_SQUAT_IDS = exports.JUMP_EXERCISE_IDS = exports.PoseTrackerClient = exports.resolvePoseModel = exports.originFromModelUrl = exports.DEFAULT_MOVENET_LIGHTNING_URL = exports.ONLINE_POSE_DETECTION_VERSION = exports.ONLINE_TFJS_VERSION = exports.defaultPoseDetectionCdnUrl = exports.defaultOnlineRuntimeUrls = exports.getOnlineRuntimeVersion = exports.getOnlineRuntimeParts = exports.RuntimeCache = exports.TrackError = exports.UsageTracker = exports.resolveExerciseName = exports.findExerciseByIdOrAlias = exports.EXERCISE_ALIASES = exports.SDK_VERSION = exports.SDK_NAME = exports.DEFAULT_BASE_URL = exports.ConfigureError = exports.configure = void 0;
32
- exports.COMBINED_REFERENCE_EXERCISE_MESSAGE = exports.INVALID_TOKEN_MESSAGE = exports.FREE_PLAN_FEATURES_MESSAGE = exports.DEFAULT_FEATURES = exports.RuntimeGuard = exports.scoreDeviceCapability = exports.profileFromScore = exports.isMaliRenderer = exports.ENABLE_MALI_HARD_CAP = exports.CAPTURE_CONSTRAINT_MODE = exports.ANDROID_SOFT_CAP_PROFILE = exports.ANDROID_PREPROCESS_PATH = exports.ANDROID_PERF_DEBUG = exports.ANDROID_MIN_TARGET_FPS = exports.ANDROID_INFER_FRAME_SKIP = exports.profileFromWarmupMedianMs = exports.nextLowerQualityProfile = exports.minMedianMsForMinTarget = exports.lowerQualityProfile = exports.isQualityProfileId = exports.getQualityProfile = exports.getMinTargetFps = exports.getIdealFpsRange = exports.getCriticalFpsThreshold = exports.estimatedFpsFromMedianMs = exports.currentQualityPlatform = exports.TARGET_MEDIAN_MS = exports.TARGET_FPS = exports.QUALITY_PROFILES = exports.QUALITY_LADDER = exports.MIN_TARGET_FPS_IOS = exports.MIN_TARGET_FPS_DEFAULT = exports.MIN_TARGET_FPS_ANDROID = exports.QUALITY_SETTLE_MS = exports.LOW_FPS_STREAK_BEFORE_DOWNGRADE = exports.IDEAL_FPS_RANGE_IOS = exports.IDEAL_FPS_RANGE_ANDROID = exports.CRITICAL_FPS_THRESHOLD = exports.AdaptiveQualityController = exports.toClassicNativeMessage = exports.logPlatformBanner = exports.logFrameStats = exports.logAccelerationReport = exports.defaultDiagnosticLogger = exports.createNativeFileStore = exports.createMemoryFileStore = exports.createBlobUtilFileStore = exports.createRnfsFileStore = exports.createExpoFileStore = exports.EngineLoader = void 0;
33
- exports.resolveFeatures = exports.shouldShowWatermark = exports.isPaidPlan = exports.freeBlockedFeatures = exports.featureNotSupportedMessage = void 0;
32
+ exports.INVALID_TOKEN_MESSAGE = exports.FREE_PLAN_FEATURES_MESSAGE = exports.DEFAULT_FEATURES = exports.RuntimeGuard = exports.scoreDeviceCapability = exports.profileFromScore = exports.isMaliRenderer = exports.ENABLE_MALI_HARD_CAP = exports.CAPTURE_CONSTRAINT_MODE = exports.ANDROID_SOFT_CAP_PROFILE = exports.ANDROID_PREPROCESS_PATH = exports.ANDROID_PERF_DEBUG = exports.ANDROID_MIN_TARGET_FPS = exports.ANDROID_INFER_FRAME_SKIP_BLAZEPOSE = exports.ANDROID_INFER_FRAME_SKIP = exports.profileFromWarmupMedianMs = exports.nextLowerQualityProfile = exports.minMedianMsForMinTarget = exports.lowerQualityProfile = exports.isQualityProfileId = exports.getQualityProfile = exports.getMinTargetFps = exports.getIdealFpsRange = exports.getCriticalFpsThreshold = exports.estimatedFpsFromMedianMs = exports.currentQualityPlatform = exports.TARGET_MEDIAN_MS = exports.TARGET_FPS = exports.QUALITY_PROFILES = exports.QUALITY_LADDER = exports.MIN_TARGET_FPS_IOS = exports.MIN_TARGET_FPS_DEFAULT = exports.MIN_TARGET_FPS_ANDROID = exports.QUALITY_SETTLE_MS = exports.LOW_FPS_STREAK_BEFORE_DOWNGRADE = exports.IDEAL_FPS_RANGE_IOS = exports.IDEAL_FPS_RANGE_ANDROID = exports.CRITICAL_FPS_THRESHOLD = exports.AdaptiveQualityController = exports.toClassicNativeMessage = exports.logPlatformBanner = exports.logFrameStats = exports.logAccelerationReport = exports.defaultDiagnosticLogger = exports.createNativeFileStore = exports.createMemoryFileStore = exports.createBlobUtilFileStore = exports.createRnfsFileStore = exports.createExpoFileStore = exports.EngineLoader = void 0;
33
+ exports.resolveFeatures = exports.shouldShowWatermark = exports.isPaidPlan = exports.freeBlockedFeatures = exports.featureNotSupportedMessage = exports.COMBINED_REFERENCE_EXERCISE_MESSAGE = void 0;
34
34
  // Handshake + usage tracking
35
35
  var configure_1 = require("./api/configure");
36
36
  Object.defineProperty(exports, "configure", { enumerable: true, get: function () { return configure_1.configure; } });
@@ -153,6 +153,7 @@ Object.defineProperty(exports, "nextLowerQualityProfile", { enumerable: true, ge
153
153
  Object.defineProperty(exports, "profileFromWarmupMedianMs", { enumerable: true, get: function () { return profiles_1.profileFromWarmupMedianMs; } });
154
154
  var captureMode_1 = require("./quality/captureMode");
155
155
  Object.defineProperty(exports, "ANDROID_INFER_FRAME_SKIP", { enumerable: true, get: function () { return captureMode_1.ANDROID_INFER_FRAME_SKIP; } });
156
+ Object.defineProperty(exports, "ANDROID_INFER_FRAME_SKIP_BLAZEPOSE", { enumerable: true, get: function () { return captureMode_1.ANDROID_INFER_FRAME_SKIP_BLAZEPOSE; } });
156
157
  Object.defineProperty(exports, "ANDROID_MIN_TARGET_FPS", { enumerable: true, get: function () { return captureMode_1.ANDROID_MIN_TARGET_FPS; } });
157
158
  Object.defineProperty(exports, "ANDROID_PERF_DEBUG", { enumerable: true, get: function () { return captureMode_1.ANDROID_PERF_DEBUG; } });
158
159
  Object.defineProperty(exports, "ANDROID_PREPROCESS_PATH", { enumerable: true, get: function () { return captureMode_1.ANDROID_PREPROCESS_PATH; } });
@@ -54,6 +54,13 @@ export declare const ENABLE_MALI_HARD_CAP = false;
54
54
  * - `2` — every 3rd, etc.
55
55
  */
56
56
  export declare const ANDROID_INFER_FRAME_SKIP = 1;
57
+ /**
58
+ * Android-only extra rAF ticks to skip between **BlazePose** inferences.
59
+ * MoveNet keeps {@link ANDROID_INFER_FRAME_SKIP}. Preview is unchanged.
60
+ *
61
+ * - `2` — infer on every 3rd ready tick (heavier TF.js BlazePose lite)
62
+ */
63
+ export declare const ANDROID_INFER_FRAME_SKIP_BLAZEPOSE = 2;
57
64
  /**
58
65
  * Android-only preprocess path. iOS always uses `imagebitmap` (unchanged).
59
66
  */
@@ -26,7 +26,7 @@
26
26
  * You also have a zip archive of the previous SDK as a full rollback.
27
27
  */
28
28
  Object.defineProperty(exports, "__esModule", { value: true });
29
- exports.ANDROID_PERF_DEBUG = exports.ANDROID_MIN_TARGET_FPS = exports.ANDROID_SOFT_CAP_PROFILE = exports.ANDROID_PREPROCESS_PATH = exports.ANDROID_INFER_FRAME_SKIP = exports.ENABLE_MALI_HARD_CAP = exports.CAPTURE_CONSTRAINT_MODE = void 0;
29
+ exports.ANDROID_PERF_DEBUG = exports.ANDROID_MIN_TARGET_FPS = exports.ANDROID_SOFT_CAP_PROFILE = exports.ANDROID_PREPROCESS_PATH = exports.ANDROID_INFER_FRAME_SKIP_BLAZEPOSE = exports.ANDROID_INFER_FRAME_SKIP = exports.ENABLE_MALI_HARD_CAP = exports.CAPTURE_CONSTRAINT_MODE = void 0;
30
30
  /**
31
31
  * - `device-native` — facingMode only (like PoseTrackerFront). Preview = device default (often HD).
32
32
  * - `profile-constrained` — ideal width/height/frameRate from the AdaptiveChoice ladder.
@@ -47,6 +47,13 @@ exports.ENABLE_MALI_HARD_CAP = false;
47
47
  * - `2` — every 3rd, etc.
48
48
  */
49
49
  exports.ANDROID_INFER_FRAME_SKIP = 1;
50
+ /**
51
+ * Android-only extra rAF ticks to skip between **BlazePose** inferences.
52
+ * MoveNet keeps {@link ANDROID_INFER_FRAME_SKIP}. Preview is unchanged.
53
+ *
54
+ * - `2` — infer on every 3rd ready tick (heavier TF.js BlazePose lite)
55
+ */
56
+ exports.ANDROID_INFER_FRAME_SKIP_BLAZEPOSE = 2;
50
57
  /**
51
58
  * Android-only preprocess path. iOS always uses `imagebitmap` (unchanged).
52
59
  */
@@ -1,2 +1,2 @@
1
1
  /** AUTO-GENERATED by scripts/sync-sdk-version.mjs — do not edit. */
2
- export declare const SDK_VERSION = "0.3.1";
2
+ export declare const SDK_VERSION = "0.3.2";
package/lib/sdkVersion.js CHANGED
@@ -2,4 +2,4 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SDK_VERSION = void 0;
4
4
  /** AUTO-GENERATED by scripts/sync-sdk-version.mjs — do not edit. */
5
- exports.SDK_VERSION = '0.3.1';
5
+ exports.SDK_VERSION = '0.3.2';
@@ -65,8 +65,9 @@ export interface ErrorEvent {
65
65
  */
66
66
  | 'free_plan_feature_blocked'
67
67
  /**
68
- * A WebView-only option was requested (blazepose, poseEngine,
69
- * mediapipeModel, …): this SDK ships MoveNet Lightning only.
68
+ * A WebView-only option was requested as a **features flag**
69
+ * (`features.blazepose`, poseEngine, mediapipeModel, …).
70
+ * Select BlazePose with `options.model = 'blazepose'` instead.
70
71
  */
71
72
  | 'feature_not_supported'
72
73
  /** Unknown exercise id (WebView parity: `invalid_exercise`). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pose-tracker/react-native-pose-estimation-light",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Human pose estimation SDK for React Native (light / online) — MoveNet Lightning via CDN + model URL each session, no bundled weights. iOS & Android (Expo Go). Optional API-key exercise engine.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -11,6 +11,7 @@
11
11
  import { Platform } from 'react-native';
12
12
  import {
13
13
  ANDROID_INFER_FRAME_SKIP,
14
+ ANDROID_INFER_FRAME_SKIP_BLAZEPOSE,
14
15
  ANDROID_PERF_DEBUG,
15
16
  ANDROID_PREPROCESS_PATH,
16
17
  ANDROID_SOFT_CAP_PROFILE,
@@ -22,7 +23,7 @@ import { POSETRACKER_LOGO_DATA_URL } from './brandAssets';
22
23
  import type { OnlineRuntimeParts } from './onlineRuntime';
23
24
 
24
25
  /** Bumped on every assembler-path change — appears in WebView diag logs. */
25
- export const POSE_HTML_BUILD = '20260812-onlineLight-mediaSources';
26
+ export const POSE_HTML_BUILD = '20260921-blazeposeTfOpts';
26
27
 
27
28
  /** Default boot overlay copy (WebView `loading_message` parity). */
28
29
  export const DEFAULT_LOADING_TEXT = 'AI Loading';
@@ -224,8 +225,11 @@ export function buildPoseHtml(parts: OnlineRuntimeParts, options?: PoseHtmlOptio
224
225
  modelId: parts.modelId,
225
226
  modelKind: parts.modelKind,
226
227
  tfjsWasmPath: parts.tfjsWasmPath,
227
- inferFrameSkip:
228
- isAndroid && ANDROID_INFER_FRAME_SKIP > 0 ? ANDROID_INFER_FRAME_SKIP : 0,
228
+ inferFrameSkip: isAndroid
229
+ ? (parts.modelKind === 'blazepose' || parts.modelId === 'blazepose'
230
+ ? ANDROID_INFER_FRAME_SKIP_BLAZEPOSE
231
+ : ANDROID_INFER_FRAME_SKIP)
232
+ : 0,
229
233
  preprocessPath: isAndroid ? ANDROID_PREPROCESS_PATH : 'imagebitmap',
230
234
  softCapProfile:
231
235
  isAndroid && !preferQuality ? ANDROID_SOFT_CAP_PROFILE : null,
package/src/client.ts CHANGED
@@ -921,8 +921,9 @@ export class PoseTrackerClient {
921
921
 
922
922
  /**
923
923
  * WebView parity — the load-time gating of `TrackingAppV3`:
924
- * - `blazepose` / `poseEngine` / other WebView-only keys → clear error
925
- * (this SDK ships MoveNet Lightning only);
924
+ * - `blazepose` / `poseEngine` / other WebView-only keys **as features
925
+ * flags** → clear error (`options.model = 'blazepose'` is the supported
926
+ * BlazePose path);
926
927
  * - developer features requested WITHOUT an API key → the front's exact
927
928
  * "Invalid params… token=YOUR API_KEY…" message;
928
929
  * - plan `free` + angles/recommendations/progression → the front's exact
package/src/index.ts CHANGED
@@ -208,6 +208,7 @@ export {
208
208
  } from './quality/profiles';
209
209
  export {
210
210
  ANDROID_INFER_FRAME_SKIP,
211
+ ANDROID_INFER_FRAME_SKIP_BLAZEPOSE,
211
212
  ANDROID_MIN_TARGET_FPS,
212
213
  ANDROID_PERF_DEBUG,
213
214
  ANDROID_PREPROCESS_PATH,
@@ -66,6 +66,14 @@ export const ENABLE_MALI_HARD_CAP = false;
66
66
  */
67
67
  export const ANDROID_INFER_FRAME_SKIP = 1;
68
68
 
69
+ /**
70
+ * Android-only extra rAF ticks to skip between **BlazePose** inferences.
71
+ * MoveNet keeps {@link ANDROID_INFER_FRAME_SKIP}. Preview is unchanged.
72
+ *
73
+ * - `2` — infer on every 3rd ready tick (heavier TF.js BlazePose lite)
74
+ */
75
+ export const ANDROID_INFER_FRAME_SKIP_BLAZEPOSE = 2;
76
+
69
77
  /**
70
78
  * Android-only preprocess path. iOS always uses `imagebitmap` (unchanged).
71
79
  */
package/src/sdkVersion.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** AUTO-GENERATED by scripts/sync-sdk-version.mjs — do not edit. */
2
- export const SDK_VERSION = '0.3.1';
2
+ export const SDK_VERSION = '0.3.2';
@@ -87,8 +87,9 @@ export interface ErrorEvent {
87
87
  */
88
88
  | 'free_plan_feature_blocked'
89
89
  /**
90
- * A WebView-only option was requested (blazepose, poseEngine,
91
- * mediapipeModel, …): this SDK ships MoveNet Lightning only.
90
+ * A WebView-only option was requested as a **features flag**
91
+ * (`features.blazepose`, poseEngine, mediapipeModel, …).
92
+ * Select BlazePose with `options.model = 'blazepose'` instead.
92
93
  */
93
94
  | 'feature_not_supported'
94
95
  /** Unknown exercise id (WebView parity: `invalid_exercise`). */