@pose-tracker/react-native-pose-estimation-light 0.3.4 → 0.3.6
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 +56 -9
- package/lib/PoseTrackerProvider.d.ts +17 -1
- package/lib/PoseTrackerProvider.js +36 -2
- package/lib/backends/webview/WebViewPoseBackend.d.ts +30 -3
- package/lib/backends/webview/WebViewPoseBackend.js +79 -3
- package/lib/backends/webview/poseRuntimeSource.js +2 -2
- package/lib/camera/WebViewPoseView.d.ts +2 -1
- package/lib/camera/WebViewPoseView.js +21 -12
- package/lib/client.d.ts +29 -3
- package/lib/client.js +76 -3
- package/lib/index.d.ts +2 -1
- package/lib/index.js +1 -0
- package/lib/sdkVersion.d.ts +1 -1
- package/lib/sdkVersion.js +1 -1
- package/lib/types/externalFrame.d.ts +41 -0
- package/lib/types/externalFrame.js +16 -0
- package/package.json +2 -1
- package/src/PoseTrackerProvider.tsx +74 -3
- package/src/backends/webview/WebViewPoseBackend.ts +100 -3
- package/src/camera/WebViewPoseView.tsx +38 -17
- package/src/client.ts +98 -4
- package/src/index.ts +6 -1
- package/src/sdkVersion.ts +1 -1
- package/src/types/externalFrame.ts +53 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** AUTO-GENERATED by scripts/embed-pose-runtime.mjs — do not edit. */
|
|
2
2
|
'use strict';
|
|
3
|
-
exports.POSE_RUNTIME_VERSION = "online-
|
|
4
|
-
exports.POSE_RUNTIME_JS = "/**\n * PoseTracker pose-runtime (light / online) — WebView page runtime (camera,\n * capture presets, MoveNet / 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";
|
|
3
|
+
exports.POSE_RUNTIME_VERSION = "online-98a421a2";
|
|
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 * Set only while __PT_PUSH_FRAME infers a host-provided frame:\n * { id, width, height, mirrored, timestampMs }. Keypoints are normalized to\n * that frame (not to the 1×1 warmer view) and nothing is drawn.\n */\n var externalFrame = null;\n\n function activeFrame() {\n if ((sourceMode === 'image' || externalFrame) && 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, mirrorOutput() ? 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 (mirrorOutput()) 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 (mirrorOutput()) 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 = outputWidth();\n var dispH = outputHeight();\n var drawKps = decoded.drawKps;\n var meanScore = decoded.meanScore;\n var above = decoded.above;\n if (externalFrame) {\n post({\n type: 'pose',\n frameId: externalFrame.id,\n keypoints: decoded.rnKps,\n score: meanScore,\n inferenceTimeMs: inferenceTimeMs,\n timestampMs: externalFrame.timestampMs\n });\n return;\n }\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 outputWidth() {\n return externalFrame ? externalFrame.width : (canvas.clientWidth || 1);\n }\n\n function outputHeight() {\n return externalFrame ? externalFrame.height : (canvas.clientHeight || 1);\n }\n\n function mirrorOutput() {\n return externalFrame ? externalFrame.mirrored : FACING === 'user';\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 = outputWidth();\n var dispH = outputHeight();\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 = outputWidth();\n var dispH = outputHeight();\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 function decodeStill(url) {\n return new Promise(function (resolve, reject) {\n if (!still) { reject(new Error('still image element missing')); return; }\n still.onload = null;\n still.onerror = null;\n still.src = url;\n if (typeof still.decode === 'function') {\n still.decode().then(resolve, function () {\n reject(new Error('could not decode frame'));\n });\n return;\n }\n if (still.complete && still.naturalWidth > 0) { resolve(); return; }\n still.onload = function () { resolve(); };\n still.onerror = function () { reject(new Error('could not decode frame')); };\n });\n }\n\n /**\n * Host-owned camera: infer one frame on the warm session without\n * getUserMedia, without touching the loop and without drawing. Posts\n * `pose` (with frameId) then `frame_result`. One frame in flight: a frame\n * pushed while busy is dropped without decoding.\n */\n window.__PT_PUSH_FRAME = async function (opts) {\n opts = opts || {};\n var id = opts.id;\n if (!model) {\n post({ type: 'frame_result', id: id, dropped: false, error: 'pose model is not ready' });\n return;\n }\n if (running || cameraArmed) {\n post({ type: 'frame_result', id: id, dropped: false, error: 'a camera or media session is running on this page' });\n return;\n }\n if (busy) {\n post({ type: 'frame_result', id: id, dropped: true });\n return;\n }\n busy = true;\n try {\n var url = opts.uri || opts.url || null;\n if (opts.base64) {\n url = 'data:' + (opts.mime || 'image/jpeg') + ';base64,' + opts.base64;\n }\n if (!url) throw new Error('frame requires base64 or uri');\n await decodeStill(url);\n var w = still.naturalWidth || opts.width || 0;\n var h = still.naturalHeight || opts.height || 0;\n if (!(w > 0 && h > 0)) throw new Error('frame has no pixels');\n externalFrame = {\n id: id,\n width: w,\n height: h,\n mirrored: opts.mirrored !== false,\n timestampMs: typeof opts.timestampMs === 'number' ? opts.timestampMs : Date.now()\n };\n var result = await infer();\n if (result.kind === 'blazepose') {\n decodeAndPublishBlaze(result.poses, result.totalMs);\n } else {\n decodeAndPublish(result.data, result.totalMs);\n }\n post({ type: 'frame_result', id: id, dropped: false });\n } catch (err) {\n post({\n type: 'frame_result',\n id: id,\n dropped: false,\n error: err && err.message ? err.message : String(err)\n });\n } finally {\n externalFrame = null;\n busy = false;\n }\n };\n\n window.__PT_OPEN_CAMERA = function () {\n return openCameraAndLoop('host-openCamera').then(function () {\n if (suspended) {\n stopCameraTracks();\n return;\n }\n startLoop();\n postReadySignal(true, 'full');\n });\n };\n\n // ── Battery / thermal safety: release the camera and halt inference when\n // the page is hidden (app backgrounded, view covered), reacquire on return.\n // Belt-and-braces with the RN AppState hook (__PT_SUSPEND / __PT_RESUME):\n // Android WebView and WKWebView background semantics differ, page-level\n // visibilitychange + host injection together cover both. ──────────────────\n var suspended = false;\n // True once RN pushed an explicit quality profile — resume must reopen the\n // camera with profile ideals instead of the boot-time device-native path.\n var qualityForced = false;\n\n function stopCameraTracks() {\n var s = video.srcObject;\n if (s && s.getTracks) {\n s.getTracks().forEach(function (t) { try { t.stop(); } catch (e) {} });\n }\n video.srcObject = null;\n }\n\n function suspendPipeline(reason) {\n if (suspended) return;\n suspended = true;\n running = false;\n loopToken += 1; // kill any pending rAF chain\n stopCameraTracks();\n cameraRevealed = false;\n setCameraVisible(false);\n post({\n type: 'diag',\n message: 'pipeline suspended (' + reason + ') — camera released, inference stopped'\n });\n }\n\n async function resumePipeline() {\n if (!suspended) return;\n suspended = false;\n if (!model) return; // boot still in flight — it owns the camera\n // Model-only warm page: never open the camera on foreground.\n if (!cameraArmed) {\n post({ type: 'diag', message: 'resume skipped — camera not armed (basic cold-start)' });\n return;\n }\n try {\n var stream = await navigator.mediaDevices.getUserMedia({\n audio: false,\n video: videoConstraints(qualityForced)\n });\n if (suspended) {\n // Hidden again while getUserMedia was in flight.\n stream.getTracks().forEach(function (t) { try { t.stop(); } catch (e) {} });\n return;\n }\n stopCameraTracks();\n video.srcObject = stream;\n await video.play();\n await new Promise(function (resolve) {\n if (video.videoWidth > 0) { resolve(); return; }\n video.onloadedmetadata = function () { resolve(); };\n setTimeout(resolve, 1500);\n });\n revealCameraWhenReady();\n inferErrorStreak = 0;\n startLoop();\n post({\n type: 'diag',\n message: 'pipeline resumed — camera reacquired (' +\n video.videoWidth + 'x' + video.videoHeight + ')'\n });\n } catch (err) {\n post({\n type: 'diag',\n message: 'resume failed: ' + (err && err.message ? err.message : String(err))\n });\n }\n }\n\n window.__PT_SUSPEND = function () { suspendPipeline('host'); };\n window.__PT_RESUME = function () { resumePipeline(); };\n\n document.addEventListener('visibilitychange', function () {\n if (document.visibilityState === 'hidden') {\n suspendPipeline('page-hidden');\n } else {\n resumePipeline();\n }\n });\n // WKWebView / Chromium teardown: the camera indicator must die with the page.\n window.addEventListener('pagehide', function () {\n running = false;\n loopToken += 1;\n stopCameraTracks();\n });\n\n window.addEventListener('message', function (ev) {\n try {\n var data = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data;\n if (data && data.type === 'setFacing') window.__PT_SET_FACING(data.facingMode);\n if (data && data.type === 'setQuality') window.__PT_SET_QUALITY(data);\n if (data && data.type === 'stop') {\n running = false;\n loopToken += 1;\n stopCameraTracks();\n }\n } catch (e) {}\n });\n\n boot();\n})();\n";
|
|
@@ -28,8 +28,9 @@ export interface WebViewPoseViewProps {
|
|
|
28
28
|
*/
|
|
29
29
|
drawSkeleton?: boolean;
|
|
30
30
|
/**
|
|
31
|
-
* Show
|
|
31
|
+
* Show the placement zone (iframe `placementOverlay` parity) while the
|
|
32
32
|
* active exercise reports `posture.ready === false`. Default `true`.
|
|
33
|
+
* Gray masks sit outside the box; the frame is the PoseTracker stroke.
|
|
33
34
|
* Drawn as an RN sibling overlay (reliable on iOS; Android WebView may
|
|
34
35
|
* composite above it — hosts can also render from `onPosture`).
|
|
35
36
|
*/
|
|
@@ -251,6 +251,10 @@ function WebViewPoseView(props) {
|
|
|
251
251
|
backend.setOpenCameraHandler(() => {
|
|
252
252
|
webRef.current?.injectJavaScript?.('window.__PT_OPEN_CAMERA && window.__PT_OPEN_CAMERA(); true;');
|
|
253
253
|
});
|
|
254
|
+
backend.setPushFrameHandler((payload) => {
|
|
255
|
+
const safe = payload.replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
256
|
+
webRef.current?.injectJavaScript?.(`window.__PT_PUSH_FRAME && window.__PT_PUSH_FRAME(${safe}); true;`);
|
|
257
|
+
});
|
|
254
258
|
// Live downgrades restart getUserMedia without remounting the WebView.
|
|
255
259
|
client.setQualityApplyHandler((next) => {
|
|
256
260
|
injectQuality(webRef, next);
|
|
@@ -258,6 +262,7 @@ function WebViewPoseView(props) {
|
|
|
258
262
|
return () => {
|
|
259
263
|
backend.setOnPose(undefined);
|
|
260
264
|
backend.setOpenCameraHandler(undefined);
|
|
265
|
+
backend.setPushFrameHandler(undefined);
|
|
261
266
|
backend.setAttached(false);
|
|
262
267
|
client.setQualityApplyHandler(undefined);
|
|
263
268
|
};
|
|
@@ -316,26 +321,30 @@ function WebViewPoseView(props) {
|
|
|
316
321
|
type: 'error',
|
|
317
322
|
message: e.nativeEvent.description ?? 'WebView error',
|
|
318
323
|
}));
|
|
319
|
-
} }), showPlacementBox ? ((0, jsx_runtime_1.jsx)(
|
|
324
|
+
} }), showPlacementBox ? ((0, jsx_runtime_1.jsx)(PlacementZone, { paddingPercent: placementPaddingPercent })) : null, props.children] }));
|
|
325
|
+
}
|
|
326
|
+
/** Iframe V4 placement: outside dim `#000` at 0.55, demo frame `#4DD21D`. */
|
|
327
|
+
const PLACEMENT_DIM = 'rgba(0,0,0,0.55)';
|
|
328
|
+
const PLACEMENT_STROKE = '#4DD21D';
|
|
329
|
+
function PlacementZone({ paddingPercent }) {
|
|
330
|
+
const pad = `${paddingPercent}%`;
|
|
331
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { pointerEvents: "none", style: react_native_1.StyleSheet.absoluteFill, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.placementDim, { top: 0, left: 0, right: 0, height: pad }] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.placementDim, { bottom: 0, left: 0, right: 0, height: pad }] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.placementDim, { top: pad, bottom: pad, left: 0, width: pad }] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.placementDim, { top: pad, bottom: pad, right: 0, width: pad }] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
|
|
320
332
|
styles.placementBox,
|
|
321
|
-
{
|
|
322
|
-
|
|
323
|
-
bottom: `${placementPaddingPercent}%`,
|
|
324
|
-
left: `${placementPaddingPercent}%`,
|
|
325
|
-
right: `${placementPaddingPercent}%`,
|
|
326
|
-
},
|
|
327
|
-
] })) : null, props.children] }));
|
|
333
|
+
{ top: pad, bottom: pad, left: pad, right: pad },
|
|
334
|
+
] })] }));
|
|
328
335
|
}
|
|
329
336
|
const styles = react_native_1.StyleSheet.create({
|
|
330
337
|
fill: { flex: 1, backgroundColor: '#000' },
|
|
331
338
|
center: { alignItems: 'center', justifyContent: 'center', padding: 16 },
|
|
332
339
|
error: { color: '#FE8370', textAlign: 'center', fontSize: 13 },
|
|
333
|
-
|
|
340
|
+
placementDim: {
|
|
341
|
+
position: 'absolute',
|
|
342
|
+
backgroundColor: PLACEMENT_DIM,
|
|
343
|
+
},
|
|
334
344
|
placementBox: {
|
|
335
345
|
position: 'absolute',
|
|
336
|
-
borderWidth:
|
|
337
|
-
borderColor:
|
|
338
|
-
borderRadius: 8,
|
|
346
|
+
borderWidth: 2,
|
|
347
|
+
borderColor: PLACEMENT_STROKE,
|
|
339
348
|
backgroundColor: 'transparent',
|
|
340
349
|
},
|
|
341
350
|
});
|
package/lib/client.d.ts
CHANGED
|
@@ -34,6 +34,7 @@ import type { ColdStartMode, PreloadOptions } from './types/preload';
|
|
|
34
34
|
import type { SkeletonDefinition } from './types/skeleton';
|
|
35
35
|
import type { ErrorEvent, PoseTrackerEventListener, PoseTrackerMode, PoseTrackerStatus } from './types/events';
|
|
36
36
|
import type { Pose } from './types/pose';
|
|
37
|
+
import { type ExternalFrame, type ExternalFrameResult } from './types/externalFrame';
|
|
37
38
|
import type { AccelerationDiagnostics, AccelerationState } from './types/acceleration';
|
|
38
39
|
import { type QualityState } from './quality/AdaptiveQualityController';
|
|
39
40
|
import { type CapturePriority, type QualityChoice, type QualityProfile } from './quality/profiles';
|
|
@@ -165,6 +166,15 @@ export declare class PoseTrackerClient {
|
|
|
165
166
|
private lastCameraStartInfo;
|
|
166
167
|
/** Consecutive engine processPose failures (see SESSION_ERROR_STREAK_LIMIT). */
|
|
167
168
|
private sessionErrorStreak;
|
|
169
|
+
/**
|
|
170
|
+
* External frames (opt-in): the provider mounts a hidden basic warmer only
|
|
171
|
+
* when this is true. Never set on the default camera path.
|
|
172
|
+
*/
|
|
173
|
+
private externalWarmerRequested;
|
|
174
|
+
private externalReady;
|
|
175
|
+
private externalInFlight;
|
|
176
|
+
/** Events emitted while one external frame is in flight. */
|
|
177
|
+
private externalCollector;
|
|
168
178
|
/** Requested tracking features with WebView-parity defaults applied. */
|
|
169
179
|
private readonly features;
|
|
170
180
|
/** WebView-only keys passed by untyped hosts (blazepose, poseEngine, …). */
|
|
@@ -354,12 +364,28 @@ export declare class PoseTrackerClient {
|
|
|
354
364
|
getBackend(): PoseBackend;
|
|
355
365
|
/** Raw pose estimation, no engine involvement. */
|
|
356
366
|
estimatePose(frame: PoseInputFrame): Promise<Pose | null>;
|
|
367
|
+
/** True once {@link warmupExternal} was called (provider mounts the warmer). */
|
|
368
|
+
isExternalWarmerRequested(): boolean;
|
|
357
369
|
/**
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
370
|
+
* Load the pose model for {@link processFrame} with external frames. Never
|
|
371
|
+
* opens the camera. Idempotent. With `PoseTrackerProvider`, a hidden 1×1
|
|
372
|
+
* basic WebView is mounted for this, unless a `WebViewPoseView` is already
|
|
373
|
+
* attached, in which case that view is reused.
|
|
374
|
+
*/
|
|
375
|
+
warmupExternal(): Promise<void>;
|
|
376
|
+
/**
|
|
377
|
+
* External frame (`{ base64 | uri, width, height, timestampMs }`): infer it
|
|
378
|
+
* on the warm session and run the active exercise engine. Resolves with
|
|
379
|
+
* the pose and the events this frame produced; those events also reach
|
|
380
|
+
* the regular listeners. Nothing is drawn. One frame in flight: a call
|
|
381
|
+
* made while one is running resolves `{ dropped: true }` at once.
|
|
382
|
+
*
|
|
383
|
+
* Any other argument keeps the original behaviour: pose estimation +
|
|
384
|
+
* engine for a backend-specific frame.
|
|
361
385
|
*/
|
|
386
|
+
processFrame(frame: ExternalFrame): Promise<ExternalFrameResult>;
|
|
362
387
|
processFrame(frame: PoseInputFrame): Promise<Pose | null>;
|
|
388
|
+
private processExternalFrame;
|
|
363
389
|
/**
|
|
364
390
|
* Feed an externally-estimated pose into the pipeline: `keypoints` event
|
|
365
391
|
* (both modes) + engine processing when a session is active. This is how
|
package/lib/client.js
CHANGED
|
@@ -36,6 +36,7 @@ const WebViewPoseBackend_1 = require("./backends/webview/WebViewPoseBackend");
|
|
|
36
36
|
const aliases_1 = require("./exercises/aliases");
|
|
37
37
|
const engineChannel_1 = require("./engineChannel");
|
|
38
38
|
const skeleton_1 = require("./api/skeleton");
|
|
39
|
+
const externalFrame_1 = require("./types/externalFrame");
|
|
39
40
|
const logReport_1 = require("./diagnostics/logReport");
|
|
40
41
|
const AdaptiveQualityController_1 = require("./quality/AdaptiveQualityController");
|
|
41
42
|
const profiles_1 = require("./quality/profiles");
|
|
@@ -86,6 +87,15 @@ class PoseTrackerClient {
|
|
|
86
87
|
this.lastCameraStartInfo = null;
|
|
87
88
|
/** Consecutive engine processPose failures (see SESSION_ERROR_STREAK_LIMIT). */
|
|
88
89
|
this.sessionErrorStreak = 0;
|
|
90
|
+
/**
|
|
91
|
+
* External frames (opt-in): the provider mounts a hidden basic warmer only
|
|
92
|
+
* when this is true. Never set on the default camera path.
|
|
93
|
+
*/
|
|
94
|
+
this.externalWarmerRequested = false;
|
|
95
|
+
this.externalReady = false;
|
|
96
|
+
this.externalInFlight = false;
|
|
97
|
+
/** Events emitted while one external frame is in flight. */
|
|
98
|
+
this.externalCollector = null;
|
|
89
99
|
/** One-shot flags so plan-gating errors are not re-emitted on every retry. */
|
|
90
100
|
this.featureGateReported = { unsupported: false, freeBlock: false, missingToken: false };
|
|
91
101
|
this.keypointsSuppressionLogged = false;
|
|
@@ -981,12 +991,41 @@ class PoseTrackerClient {
|
|
|
981
991
|
estimatePose(frame) {
|
|
982
992
|
return this.backend.estimatePose(frame);
|
|
983
993
|
}
|
|
994
|
+
// -------------------------------------------------------------------------
|
|
995
|
+
// External frames (opt-in: the host app owns the camera)
|
|
996
|
+
// -------------------------------------------------------------------------
|
|
997
|
+
/** True once {@link warmupExternal} was called (provider mounts the warmer). */
|
|
998
|
+
isExternalWarmerRequested() {
|
|
999
|
+
return this.externalWarmerRequested;
|
|
1000
|
+
}
|
|
984
1001
|
/**
|
|
985
|
-
*
|
|
986
|
-
*
|
|
987
|
-
*
|
|
1002
|
+
* Load the pose model for {@link processFrame} with external frames. Never
|
|
1003
|
+
* opens the camera. Idempotent. With `PoseTrackerProvider`, a hidden 1×1
|
|
1004
|
+
* basic WebView is mounted for this, unless a `WebViewPoseView` is already
|
|
1005
|
+
* attached, in which case that view is reused.
|
|
988
1006
|
*/
|
|
1007
|
+
async warmupExternal() {
|
|
1008
|
+
const backend = this.backend;
|
|
1009
|
+
if (!(backend instanceof WebViewPoseBackend_1.WebViewPoseBackend)) {
|
|
1010
|
+
throw new Error('warmupExternal() requires the WebView backend (preferredBackend auto or webview).');
|
|
1011
|
+
}
|
|
1012
|
+
if (!this.externalWarmerRequested) {
|
|
1013
|
+
this.externalWarmerRequested = true;
|
|
1014
|
+
this.notifyState();
|
|
1015
|
+
}
|
|
1016
|
+
if (this.preloadPromise) {
|
|
1017
|
+
await this.preloadPromise;
|
|
1018
|
+
}
|
|
1019
|
+
else {
|
|
1020
|
+
await this.preload({ coldStart: 'basic' });
|
|
1021
|
+
}
|
|
1022
|
+
await backend.warmup();
|
|
1023
|
+
this.externalReady = true;
|
|
1024
|
+
}
|
|
989
1025
|
async processFrame(frame) {
|
|
1026
|
+
if ((0, externalFrame_1.isExternalFrame)(frame)) {
|
|
1027
|
+
return this.processExternalFrame(frame);
|
|
1028
|
+
}
|
|
990
1029
|
const pose = await this.estimatePose(frame);
|
|
991
1030
|
if (!pose) {
|
|
992
1031
|
return null;
|
|
@@ -994,6 +1033,35 @@ class PoseTrackerClient {
|
|
|
994
1033
|
this.ingestPose(pose);
|
|
995
1034
|
return pose;
|
|
996
1035
|
}
|
|
1036
|
+
async processExternalFrame(frame) {
|
|
1037
|
+
const backend = this.backend;
|
|
1038
|
+
if (!(backend instanceof WebViewPoseBackend_1.WebViewPoseBackend)) {
|
|
1039
|
+
throw new Error('processFrame: external frames require the WebView backend.');
|
|
1040
|
+
}
|
|
1041
|
+
if (!this.externalReady || !backend.isWarm()) {
|
|
1042
|
+
throw new Error('processFrame: call warmupExternal() and wait for it to resolve first.');
|
|
1043
|
+
}
|
|
1044
|
+
if (backend.isCameraOpened()) {
|
|
1045
|
+
throw new Error('processFrame: the SDK camera is open on this client. External frames and the SDK camera cannot run together.');
|
|
1046
|
+
}
|
|
1047
|
+
if (this.externalInFlight) {
|
|
1048
|
+
return { dropped: true, pose: backend.getLastPose(), events: [] };
|
|
1049
|
+
}
|
|
1050
|
+
this.externalInFlight = true;
|
|
1051
|
+
const events = [];
|
|
1052
|
+
this.externalCollector = events;
|
|
1053
|
+
try {
|
|
1054
|
+
const result = await backend.pushFrame(frame);
|
|
1055
|
+
if (result.dropped) {
|
|
1056
|
+
return { dropped: true, pose: backend.getLastPose(), events: [] };
|
|
1057
|
+
}
|
|
1058
|
+
return { dropped: false, pose: result.pose, events };
|
|
1059
|
+
}
|
|
1060
|
+
finally {
|
|
1061
|
+
this.externalCollector = null;
|
|
1062
|
+
this.externalInFlight = false;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
997
1065
|
/**
|
|
998
1066
|
* Feed an externally-estimated pose into the pipeline: `keypoints` event
|
|
999
1067
|
* (both modes) + engine processing when a session is active. This is how
|
|
@@ -1063,6 +1131,10 @@ class PoseTrackerClient {
|
|
|
1063
1131
|
this.lastCameraStartInfo = null;
|
|
1064
1132
|
this.featureGateReported = { unsupported: false, freeBlock: false, missingToken: false };
|
|
1065
1133
|
this.keypointsSuppressionLogged = false;
|
|
1134
|
+
this.externalWarmerRequested = false;
|
|
1135
|
+
this.externalReady = false;
|
|
1136
|
+
this.externalInFlight = false;
|
|
1137
|
+
this.externalCollector = null;
|
|
1066
1138
|
this.engine = null;
|
|
1067
1139
|
this.engineSource = null;
|
|
1068
1140
|
this.manifest = null;
|
|
@@ -1102,6 +1174,7 @@ class PoseTrackerClient {
|
|
|
1102
1174
|
this.stateListeners.forEach((l) => l());
|
|
1103
1175
|
}
|
|
1104
1176
|
emit(event) {
|
|
1177
|
+
this.externalCollector?.push(event);
|
|
1105
1178
|
// A throwing host listener must never break other listeners or the frame
|
|
1106
1179
|
// pipeline (emit is called from the per-frame ingest path).
|
|
1107
1180
|
this.listeners.forEach((l) => {
|