@pose-tracker/react-native-pose-estimation-light 0.3.5 → 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.
@@ -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-cb62233e";
4
- exports.POSE_RUNTIME_JS = "/**\n * PoseTracker pose-runtime (light / online) — WebView page runtime (camera,\n * capture presets, MoveNet / BlazePose inference loop, skeleton, events).\n *\n * Shipped thin inside the light npm package; TF.js + model stay remote.\n *\n * Expects globals injected by the SDK HTML assembler:\n * __PT_BUILD, __PT_CONFIG\n * __PT_MODEL_URL (preferred) — TF.js graph model URL (Front product path)\n * __PT_MODEL_ID / __PT_MODEL_KIND — e.g. blazepose (no graph URL)\n * OR __PT_MODEL_JSON + __PT_WEIGHTS_B64 — legacy in-memory artifacts\n * __PT_WASM_PATH — CDN directory for tfjs-backend-wasm (online)\n * OR __PT_WASM_B64 — embedded XNNPACK binaries (legacy)\n * __PT_PIPELINE_WASM_B64 — optional proprietary pipeline (may be null)\n *\n * BlazePose: requires CDN `window.poseDetection` (injected when model=blazepose).\n * Keypoints are mapped to COCO-17 (extra BlazePose joints dropped).\n */\n(function () {\n var CFG = window.__PT_CONFIG;\n var FACING = CFG.facingMode;\n var MIN_SCORE = CFG.minScore;\n var INPUT_SIZE = 192;\n /** BlazePose TF.js detector input (contain letterbox). Do not reuse MoveNet 192. */\n var BLAZE_INPUT_SIZE = 256;\n var MODEL_ID =\n (CFG.modelId && String(CFG.modelId)) ||\n (typeof window.__PT_MODEL_ID === 'string' && window.__PT_MODEL_ID) ||\n 'movenet-singlepose-lightning';\n var MODEL_KIND =\n (CFG.modelKind && String(CFG.modelKind)) ||\n (typeof window.__PT_MODEL_KIND === 'string' && window.__PT_MODEL_KIND) ||\n (MODEL_ID === 'blazepose' ? 'blazepose' : 'movenet-graph');\n var IS_BLAZEPOSE = MODEL_KIND === 'blazepose' || MODEL_ID === 'blazepose';\n var KEYPOINT_NAMES = [\n 'nose', 'left_eye', 'right_eye', 'left_ear', 'right_ear',\n 'left_shoulder', 'right_shoulder', 'left_elbow', 'right_elbow',\n 'left_wrist', 'right_wrist', 'left_hip', 'right_hip',\n 'left_knee', 'right_knee', 'left_ankle', 'right_ankle'\n ];\n /** MediaPipe BlazePose landmark names → COCO-17 (extras dropped). */\n var BLAZE_TO_COCO = {\n nose: 'nose',\n left_eye: 'left_eye',\n right_eye: 'right_eye',\n left_ear: 'left_ear',\n right_ear: 'right_ear',\n left_shoulder: 'left_shoulder',\n right_shoulder: 'right_shoulder',\n left_elbow: 'left_elbow',\n right_elbow: 'right_elbow',\n left_wrist: 'left_wrist',\n right_wrist: 'right_wrist',\n left_hip: 'left_hip',\n right_hip: 'right_hip',\n left_knee: 'left_knee',\n right_knee: 'right_knee',\n left_ankle: 'left_ankle',\n right_ankle: 'right_ankle'\n };\n\n /**\n * Default skeleton — same document as PoseTrackerFront\n * `lib/drawing_on_canvas.js` DEFAULT_SKELETON (navy + gold, body edges).\n * Overridable via CFG.skeletonDef / __PT_SET_SKELETON (custom Strapi skeleton).\n */\n var DEFAULT_SKELETON = {\n keypoints: [\n 'nose', 'left_eye', 'right_eye', 'left_ear', 'right_ear',\n 'left_shoulder', 'right_shoulder', 'left_elbow', 'right_elbow',\n 'left_wrist', 'right_wrist', 'left_hip', 'right_hip',\n 'left_knee', 'right_knee', 'left_ankle', 'right_ankle'\n ],\n keypoint_lines: [\n 'right_shoulder||left_shoulder',\n 'right_hip||left_hip',\n 'right_hip||right_shoulder',\n 'right_hip||right_knee',\n 'right_ankle||right_knee',\n 'right_shoulder||right_elbow',\n 'right_wrist||right_elbow',\n 'left_hip||left_shoulder',\n 'left_hip||left_knee',\n 'left_ankle||left_knee',\n 'left_shoulder||left_elbow',\n 'left_wrist||left_elbow'\n ],\n keypoint_angles: [],\n angles: { fontColor: '#000000', strokeColor: '#FFC300' },\n circles: {\n circleFillColor: '#010A73',\n circleStrokeColor: '#FFC300',\n strokeWidth: '4',\n radius: '8'\n },\n lines: { lineStrokeColor: '#010A73', strokeWidth: '4' }\n };\n /** V3 drawOnCanvas threshold (not the older drawing_on_canvas 0.5). */\n var SKELETON_SCORE = 0.3;\n\n var video = document.getElementById('video');\n var canvas = document.getElementById('overlay');\n\n var still = document.getElementById('still');\n var sourceMode = (CFG.sourceType === 'video' || CFG.sourceType === 'image')\n ? CFG.sourceType\n : 'camera';\n var sourceUrl = (typeof CFG.sourceUrl === 'string' && CFG.sourceUrl) ? CFG.sourceUrl : null;\n var imageShotPending = false;\n\n function activeFrame() {\n if (sourceMode === 'image' && still) return still;\n return video;\n }\n\n function frameSize() {\n var el = activeFrame();\n if (el && el !== video) {\n return { vw: el.naturalWidth || el.width || 0, vh: el.naturalHeight || el.height || 0 };\n }\n return { vw: video.videoWidth || 0, vh: video.videoHeight || 0 };\n }\n\n function setMediaVisible(mode) {\n // mode: 'video' | 'image' | 'none'\n var showVideo = mode === 'video';\n var showImage = mode === 'image';\n var show = showVideo || showImage;\n video.style.opacity = showVideo ? '1' : '0';\n if (still) still.style.opacity = showImage ? '1' : '0';\n canvas.style.opacity = show ? '1' : '0';\n if (bootCover) {\n if (show) bootCover.classList.add('hide');\n else {\n bootCover.classList.remove('hide');\n resetBootLoadingText();\n }\n }\n applyWatermarkVisibility();\n }\n\n var hud = document.getElementById('hud');\n var bootCover = document.getElementById('boot');\n var watermarkEl = document.getElementById('wm');\n var ctx = canvas.getContext('2d');\n var cameraRevealed = false;\n var loadingText =\n (CFG.loadingText && String(CFG.loadingText).trim()) || 'AI Loading';\n var showWatermark = CFG.showWatermark !== false;\n var debugHud = !!CFG.debugHud || !!CFG.perfDebug;\n\n function applyWatermarkVisibility() {\n if (!watermarkEl) return;\n // Only on live full camera surfaces once revealed (skip 1×1 basic warmers).\n var on = showWatermark && CFG.coldStart === 'full' && cameraRevealed;\n if (on) watermarkEl.classList.add('show');\n else watermarkEl.classList.remove('show');\n }\n\n function setBootMessage(text, isError) {\n if (!bootCover) return;\n var msg = bootCover.querySelector('.boot-msg');\n if (!msg) return;\n msg.textContent = text;\n if (isError) msg.classList.add('is-error');\n else msg.classList.remove('is-error');\n }\n\n function resetBootLoadingText() {\n setBootMessage(loadingText, false);\n }\n\n /**\n * Keep video/canvas hidden until metadata + layout settle. Showing the\n * <video> at 0×0 (or before aspect is known) makes object-fit:cover flash\n * a tiny or over-zoomed frame for a few frames.\n */\n function setCameraVisible(visible) {\n if (visible) {\n setMediaVisible(sourceMode === 'image' ? 'image' : 'video');\n } else {\n setMediaVisible('none');\n }\n }\n\n function revealCameraWhenReady() {\n if (cameraRevealed) return;\n var sz = frameSize();\n if (!(sz.vw > 0 && sz.vh > 0)) return;\n // Two rAFs: let the browser apply object-fit:cover with real intrinsic size.\n requestAnimationFrame(function () {\n requestAnimationFrame(function () {\n if (cameraRevealed) return;\n var sz2 = frameSize();\n if (!(sz2.vw > 0 && sz2.vh > 0)) return;\n cameraRevealed = true;\n setCameraVisible(true);\n applyWatermarkVisibility();\n });\n });\n }\n\n /**\n * Some WebViews deliver metadata late (or after a 0×0 first frame). Keep\n * retrying reveal so the branded \"AI Loading\" overlay cannot stick forever\n * after a successful getUserMedia.\n */\n function scheduleRevealRetries(maxMs) {\n var started = Date.now();\n function tick() {\n if (cameraRevealed) return;\n revealCameraWhenReady();\n if (cameraRevealed) return;\n if (Date.now() - started >= maxMs) {\n if (video.srcObject && !(video.videoWidth > 0)) {\n post({\n type: 'diag',\n message: 'camera stream open but video still 0x0 after ' + maxMs + 'ms'\n });\n setBootMessage('camera started but preview is empty — check permissions', true);\n }\n return;\n }\n setTimeout(tick, 120);\n }\n setTimeout(tick, 60);\n }\n var model = null;\n var blazeDetector = null;\n var running = false;\n var busy = false;\n // Android experiment: skip N ready rAF ticks between inferences (iOS = 0).\n // Injected from captureMode.ANDROID_INFER_FRAME_SKIP via poseHtml.\n var INFER_FRAME_SKIP = (CFG.inferFrameSkip != null && CFG.inferFrameSkip > 0)\n ? (CFG.inferFrameSkip | 0)\n : 0;\n var skipCountdown = 0;\n var PREPROCESS_PATH = CFG.preprocessPath === 'canvas-direct'\n ? 'canvas-direct'\n : 'imagebitmap';\n var SOFT_CAP_PROFILE = CFG.softCapProfile || null;\n var CAPTURE_PRIORITY = CFG.capturePriority === 'quality' ? 'quality' : 'performance';\n var PERF_DEBUG = !!CFG.perfDebug;\n var lastInferMs = [];\n var frameCount = 0;\n var fpsWindow = 0;\n var fpsTimer = 0;\n var skipTicksWindow = 0;\n var inferTicksWindow = 0;\n var smoothed = null;\n var SMOOTH_ALPHA = 0.5;\n var stageMs = { bitmap: [], pixels: [], exec: [], total: [] };\n var activeFlags = 'default';\n var activeBackend = null;\n // Letterbox params for the last pose input (contain into 192×192).\n // Needed to map MoveNet [0,1] square coords → video pixels → object-fit:cover display.\n var letterbox = {\n offsetX: 0,\n offsetY: 0,\n drawW: INPUT_SIZE,\n drawH: INPUT_SIZE,\n vw: 1,\n vh: 1,\n poseSize: INPUT_SIZE\n };\n\n function post(msg) {\n try {\n if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {\n window.ReactNativeWebView.postMessage(JSON.stringify(msg));\n }\n } catch (e) {}\n }\n\n /**\n * Technical status line for the optional debug HUD only.\n * Boot overlay keeps the branded loadingText — progress is emitted via\n * `initialization` / `diag` events for the host developer.\n */\n function setHud(t) {\n if (!hud) return;\n if (debugHud) {\n hud.classList.add('debug');\n hud.textContent = t;\n }\n }\n\n function median(arr) {\n if (!arr.length) return null;\n var s = arr.slice().sort(function (a, b) { return a - b; });\n return s[Math.floor(s.length / 2)];\n }\n\n function pushStage(k, v) {\n var a = stageMs[k];\n a.push(v);\n if (a.length > 30) a.shift();\n }\n\n function applyMirrorCss() {\n var t = FACING === 'user' ? 'scaleX(-1)' : 'none';\n video.style.transform = t;\n canvas.style.transform = t;\n }\n\n function base64ToArrayBuffer(b64) {\n var bin = atob(b64);\n var bytes = new Uint8Array(bin.length);\n for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);\n return bytes.buffer;\n }\n\n function wasmBlobUrl(b64) {\n return URL.createObjectURL(\n new Blob([base64ToArrayBuffer(b64)], { type: 'application/wasm' })\n );\n }\n\n // -------------------------------------------------------------------------\n // Proprietary pose pipeline (WASM): decode + letterbox/cover mapping + EMA.\n // Falls back to the equivalent JS path below when instantiation fails.\n // -------------------------------------------------------------------------\n var pipeline = null;\n\n async function initPipeline() {\n try {\n var b64 = window.__PT_PIPELINE_WASM_B64;\n if (!b64 || typeof WebAssembly === 'undefined') {\n post({ type: 'diag', message: 'pose-pipeline: js path (no wasm payload)' });\n return;\n }\n var res = await WebAssembly.instantiate(base64ToArrayBuffer(b64), {\n env: { abort: function () {} }\n });\n var ex = res.instance.exports;\n pipeline = {\n memory: ex.memory,\n inputPtr: ex.inputPtr(),\n outputPtr: ex.outputPtr(),\n setFrame: ex.setFrame,\n process: ex.process,\n reset: ex.reset\n };\n post({ type: 'diag', message: 'pose-pipeline.wasm active' });\n } catch (e) {\n pipeline = null;\n post({\n type: 'diag',\n message: 'pose-pipeline.wasm unavailable: ' +\n (e && e.message ? e.message : String(e)) + ' — js path'\n });\n }\n window.__PT_PIPELINE_WASM_B64 = null;\n }\n\n function buildModelArtifacts() {\n var json = JSON.parse(window.__PT_MODEL_JSON);\n var shards = window.__PT_WEIGHTS_B64.map(base64ToArrayBuffer);\n var total = 0;\n shards.forEach(function (b) { total += b.byteLength; });\n var weightData = new Uint8Array(total);\n var offset = 0;\n shards.forEach(function (b) {\n weightData.set(new Uint8Array(b), offset);\n offset += b.byteLength;\n });\n var weightSpecs = [];\n json.weightsManifest.forEach(function (group) {\n weightSpecs = weightSpecs.concat(group.weights);\n });\n return {\n modelTopology: json.modelTopology,\n weightSpecs: weightSpecs,\n weightData: weightData.buffer,\n format: json.format,\n generatedBy: json.generatedBy,\n convertedBy: json.convertedBy,\n signature: json.signature,\n userDefinedMetadata: json.userDefinedMetadata\n };\n }\n\n function glInfo() {\n try {\n var c = document.createElement('canvas');\n var gl = c.getContext('webgl') || c.getContext('experimental-webgl');\n if (!gl) return null;\n var dbg = gl.getExtension('WEBGL_debug_renderer_info');\n return {\n renderer: dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null,\n vendor: dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : null,\n version: gl.getParameter(gl.VERSION)\n };\n } catch (e) { return null; }\n }\n\n function isMali(renderer) {\n return !!(renderer && /mali/i.test(renderer));\n }\n\n // Product flags (poseWorker.js) — must be set BEFORE the first setBackend('webgl').\n // Never call tf.removeBackend('webgl'): that deletes the factory from the\n // registry and setBackend then fails with \"webgl not found in registry\".\n function applyProductFlags() {\n try { tf.env().set('WEBGL_CPU_FORWARD', false); } catch (e) {}\n try { tf.env().set('WEBGL_PACK', false); } catch (e) {}\n try { tf.env().set('WEBGL_FORCE_F16_TEXTURES', false); } catch (e) {}\n try { tf.env().set('WEBGL_RENDER_FLOAT32_ENABLED', false); } catch (e) {}\n try { tf.env().set('WEBGL_FLUSH_THRESHOLD', 1.75); } catch (e) {}\n try { tf.env().set('WEBGL_DISJOINT_QUERY_TIMER_EXTENSION_VERSION', 0); } catch (e) {}\n try { tf.env().set('WEBGL_AUTO_SQUARIFY_NARROW_TEXTURE_SHAPE', true); } catch (e) {}\n }\n\n // dataSync (not await data): exact product parity — pose-detection's MoveNet\n // detector uses dataSync on WebGL. Async data() waits on a GPU fence polled\n // via setTimeout, which adds big per-frame latency in an Android WebView.\n function readOutput(out) {\n return out.dataSync();\n }\n\n async function benchZeros(n) {\n if (IS_BLAZEPOSE) {\n // No graph execute — seed a conservative median so AdaptiveChoice\n // prefers a lower capture tier (BlazePose is heavier than MoveNet).\n var seeded = [];\n for (var i = 0; i < Math.max(1, n - 1); i++) seeded.push(55);\n return seeded;\n }\n var times = [];\n for (var i = 0; i < n; i++) {\n var z = tf.zeros([1, INPUT_SIZE, INPUT_SIZE, 3], 'int32');\n var t0 = performance.now();\n var out = model.execute(z);\n readOutput(out);\n var dt = performance.now() - t0;\n z.dispose(); out.dispose();\n if (i > 0) times.push(dt);\n }\n return times;\n }\n\n async function loadModel() {\n if (IS_BLAZEPOSE) {\n if (blazeDetector && blazeDetector.dispose) {\n try { blazeDetector.dispose(); } catch (e) {}\n }\n blazeDetector = null;\n var poseDetection = window.poseDetection;\n if (!poseDetection || typeof poseDetection.createDetector !== 'function') {\n throw new Error(\n 'BlazePose requires CDN `@tensorflow-models/pose-detection` ' +\n '(window.poseDetection missing)'\n );\n }\n post({\n type: 'diag',\n message: 'createDetector BlazePose lite (tfjs)'\n });\n blazeDetector = await poseDetection.createDetector(\n poseDetection.SupportedModels.BlazePose,\n { runtime: 'tfjs', modelType: 'lite', enableSmoothing: false }\n );\n // Sentinel so loop / resume checks treat the detector as loaded.\n model = { kind: 'blazepose' };\n return;\n }\n\n if (model && model.dispose) try { model.dispose(); } catch (e) {}\n var modelUrl =\n (typeof window.__PT_MODEL_URL === 'string' && window.__PT_MODEL_URL) ||\n (CFG.modelUrl && String(CFG.modelUrl)) ||\n null;\n if (modelUrl) {\n post({\n type: 'diag',\n message: 'loadGraphModel url=' + modelUrl\n });\n model = await tf.loadGraphModel(modelUrl);\n return;\n }\n if (!window.__PT_MODEL_JSON || !window.__PT_WEIGHTS_B64) {\n throw new Error('no modelUrl and no embedded model artifacts');\n }\n model = await tf.loadGraphModel({\n load: function () { return Promise.resolve(buildModelArtifacts()); }\n });\n }\n\n async function initWasmFallback() {\n if (!tf.wasm || typeof WebAssembly === 'undefined') {\n throw new Error('wasm unavailable');\n }\n var b = window.__PT_WASM_B64;\n var wasmPath =\n (typeof window.__PT_WASM_PATH === 'string' && window.__PT_WASM_PATH) ||\n (CFG.tfjsWasmPath && String(CFG.tfjsWasmPath)) ||\n null;\n if (b && b.plain) {\n tf.wasm.setWasmPaths({\n 'tfjs-backend-wasm.wasm': wasmBlobUrl(b.plain),\n 'tfjs-backend-wasm-simd.wasm': wasmBlobUrl(b.simd),\n 'tfjs-backend-wasm-threaded-simd.wasm': wasmBlobUrl(b.threadedSimd)\n });\n } else if (wasmPath) {\n var base = wasmPath.charAt(wasmPath.length - 1) === '/' ? wasmPath : wasmPath + '/';\n tf.wasm.setWasmPaths(base);\n } else {\n throw new Error('wasm unavailable (no __PT_WASM_B64 and no __PT_WASM_PATH)');\n }\n var ok = await tf.setBackend('wasm');\n await tf.ready();\n if (ok === false || tf.getBackend() !== 'wasm') throw new Error('wasm setBackend failed');\n }\n\n /**\n * Init WebGL once (product path). WASM only if WebGL truly fails.\n */\n async function pickFastestBackend() {\n var info = glInfo();\n var mali = isMali(info && info.renderer);\n var registered = [];\n try {\n if (tf.engine && tf.engine().registryFactory) {\n registered = Object.keys(tf.engine().registryFactory);\n }\n } catch (e) {}\n post({\n type: 'diag',\n message: 'build=' + (window.__PT_BUILD || '?') +\n ' gl renderer=' + ((info && info.renderer) || '?') +\n ' mali=' + mali +\n ' backends=[' + registered.join(',') + ']'\n });\n\n try {\n post({\n type: 'initialization',\n step: 'init_webgl',\n message: 'init webgl (product flags)',\n ready: false\n });\n setHud('init webgl (product flags)…');\n applyProductFlags();\n var ok = await tf.setBackend('webgl');\n await tf.ready();\n if (ok === false || tf.getBackend() !== 'webgl') {\n throw new Error('setBackend webgl returned ' + String(tf.getBackend()));\n }\n await loadModel();\n var times = await benchZeros(5);\n var med = median(times);\n post({\n type: 'diag',\n message: 'bench webgl/stable medianMs=' +\n (med != null ? med.toFixed(1) : '?') +\n ' runs=[' + times.map(function (t) { return Math.round(t); }).join(',') + ']'\n });\n activeFlags = 'stable';\n activeBackend = 'webgl';\n return { name: 'stable', med: med, times: times, backend: 'webgl' };\n } catch (e) {\n post({\n type: 'diag',\n message: 'webgl init failed: ' + (e && e.message ? e.message : String(e)) +\n ' — falling back to wasm'\n });\n }\n\n post({\n type: 'initialization',\n step: 'init_wasm_fallback',\n message: 'webgl failed — trying wasm',\n ready: false\n });\n setHud('webgl failed — trying wasm…');\n await initWasmFallback();\n await loadModel();\n var wtimes = await benchZeros(4);\n activeFlags = 'wasm';\n activeBackend = 'wasm';\n return { name: 'wasm', med: median(wtimes), times: wtimes, backend: 'wasm' };\n }\n\n function poseCanvasCtx(poseSize) {\n var size = poseSize || INPUT_SIZE;\n if (\n !window.__PT_FC ||\n window.__PT_FC.width !== size ||\n window.__PT_FC.height !== size\n ) {\n window.__PT_FC = document.createElement('canvas');\n window.__PT_FC.width = size;\n window.__PT_FC.height = size;\n window.__PT_FCTX = window.__PT_FC.getContext('2d', { willReadFrequently: true });\n }\n return window.__PT_FCTX;\n }\n\n /**\n * Letterbox video → poseSize² canvas (contain). MoveNet uses 192; BlazePose 256.\n * Stretching with resizeWidth/Height broke aspect ratio → wrong skeleton.\n * Returns either an ImageBitmap or the canvas itself for fromPixels / estimatePoses.\n */\n async function preparePoseInput(poseSize) {\n var size = poseSize || INPUT_SIZE;\n var t0 = performance.now();\n var frame = activeFrame();\n var sz = frameSize();\n var vw = sz.vw || 1;\n var vh = sz.vh || 1;\n var scale = Math.min(size / vw, size / vh);\n var drawW = vw * scale;\n var drawH = vh * scale;\n var offsetX = (size - drawW) / 2;\n var offsetY = (size - drawH) / 2;\n letterbox = {\n offsetX: offsetX,\n offsetY: offsetY,\n drawW: drawW,\n drawH: drawH,\n vw: vw,\n vh: vh,\n poseSize: size\n };\n\n var c2d = poseCanvasCtx(size);\n c2d.fillStyle = '#000';\n c2d.fillRect(0, 0, size, size);\n c2d.drawImage(frame, 0, 0, vw, vh, offsetX, offsetY, drawW, drawH);\n\n if (PREPROCESS_PATH === 'canvas-direct') {\n pushStage('bitmap', performance.now() - t0);\n return { kind: 'canvas', source: window.__PT_FC };\n }\n\n var bmp = await createImageBitmap(window.__PT_FC);\n pushStage('bitmap', performance.now() - t0);\n return { kind: 'bitmap', source: bmp };\n }\n\n /** MoveNet norm (0–1 over the letterboxed square) → video pixel coords. (JS fallback) */\n function modelNormToVideo(xNorm, yNorm) {\n var poseSize = letterbox.poseSize || INPUT_SIZE;\n var xSq = xNorm * poseSize;\n var ySq = yNorm * poseSize;\n return {\n x: (xSq - letterbox.offsetX) * (letterbox.vw / letterbox.drawW),\n y: (ySq - letterbox.offsetY) * (letterbox.vh / letterbox.drawH)\n };\n }\n\n /** Video pixels → CSS object-fit:cover display pixels. (JS fallback) */\n function videoToCover(vx, vy, dispW, dispH) {\n var scale = Math.max(dispW / letterbox.vw, dispH / letterbox.vh);\n var ox = (dispW - letterbox.vw * scale) / 2;\n var oy = (dispH - letterbox.vh * scale) / 2;\n return { x: vx * scale + ox, y: vy * scale + oy };\n }\n\n async function infer() {\n if (IS_BLAZEPOSE) {\n if (!blazeDetector) throw new Error('BlazePose detector not loaded');\n var t0b = performance.now();\n var preparedB = await preparePoseInput(BLAZE_INPUT_SIZE);\n var poses = await blazeDetector.estimatePoses(preparedB.source, {\n flipHorizontal: false,\n maxPoses: 1\n });\n if (preparedB.kind === 'bitmap' && preparedB.source && preparedB.source.close) {\n preparedB.source.close();\n }\n var totalMsB = performance.now() - t0b;\n pushStage('exec', totalMsB);\n pushStage('total', totalMsB);\n return { kind: 'blazepose', poses: poses, totalMs: totalMsB };\n }\n\n var t0 = performance.now();\n var prepared = await preparePoseInput();\n var t1 = performance.now();\n var input = tf.tidy(function () {\n return tf.expandDims(tf.browser.fromPixels(prepared.source), 0);\n });\n if (prepared.kind === 'bitmap' && prepared.source && prepared.source.close) {\n prepared.source.close();\n }\n var t2 = performance.now();\n var out = model.execute(input);\n var data = readOutput(out);\n var t3 = performance.now();\n input.dispose();\n out.dispose();\n pushStage('pixels', t2 - t1);\n pushStage('exec', t3 - t2);\n // Full pipeline (draw + fromPixels + execute) — adaptive quality + debug\n // must see preprocess cost, especially on Android HD capture.\n var totalMs = t3 - t0;\n pushStage('total', totalMs);\n return { kind: 'movenet', data: data, totalMs: totalMs };\n }\n\n /** Rank which stage dominates the frame (for leverHints). */\n function dominantStage(drawMs, pixelsMs, execMs) {\n var entries = [\n { stage: 'bitmap', ms: drawMs || 0 },\n { stage: 'fromPixels', ms: pixelsMs || 0 },\n { stage: 'execute', ms: execMs || 0 }\n ];\n entries.sort(function (a, b) { return b.ms - a.ms; });\n return entries[0];\n }\n\n /**\n * Heuristic hints: which experiment lever is likely helping / wasted.\n * Purely diagnostic — never changes behaviour.\n */\n function buildLeverHints(medTotal, drawMs, pixelsMs, execMs, poseFps) {\n var dom = dominantStage(drawMs, pixelsMs, execMs);\n var hints = [];\n var total = medTotal > 0 ? medTotal : (drawMs || 0) + (pixelsMs || 0) + (execMs || 0);\n var bmpShare = total > 0 ? (drawMs || 0) / total : 0;\n var pxShare = total > 0 ? (pixelsMs || 0) / total : 0;\n var exShare = total > 0 ? (execMs || 0) / total : 0;\n\n if (bmpShare >= 0.45) {\n hints.push('bitmap_dominates→softCap_or_lower_capture_still_useful');\n } else {\n hints.push('bitmap_not_dominant→softCap_less_useful_now');\n }\n if (PREPROCESS_PATH === 'canvas-direct') {\n if (pxShare >= 0.35) {\n hints.push('fromPixels_still_heavy→canvas-direct_may_not_be_enough');\n } else if (bmpShare + pxShare < 0.35) {\n hints.push('preprocess_light→canvas-direct_likely_helping_or_neutral');\n } else {\n hints.push('preprocess_mixed→compare_vs_imagebitmap_A/B');\n }\n } else if (pxShare >= 0.25) {\n hints.push('imagebitmap_path→try_canvas-direct_if_fromPixels_high');\n }\n if (exShare >= 0.5) {\n hints.push('execute_dominates→frameSkip_helps_GPU_breathing_not_capture');\n }\n if (INFER_FRAME_SKIP > 0) {\n if (poseFps != null && poseFps >= MIN_TARGET_FPS) {\n hints.push('frameSkip_on_and_floor_met→keep_or_try_skip0_to_measure');\n } else {\n hints.push('frameSkip_on_but_below_floor→try_skip2_or_lower_capture');\n }\n }\n if (SOFT_CAP_PROFILE) {\n hints.push('softCap=' + SOFT_CAP_PROFILE + '_active');\n }\n return {\n dominantStage: dom.stage,\n dominantMs: dom.ms,\n bitmapShare: Math.round(bmpShare * 100) / 100,\n fromPixelsShare: Math.round(pxShare * 100) / 100,\n executeShare: Math.round(exShare * 100) / 100,\n hints: hints\n };\n }\n\n function smoothKeypoints(raw) {\n if (!smoothed) { smoothed = raw; return raw; }\n for (var i = 0; i < raw.length; i++) {\n if (raw[i].score < 0.1) continue;\n smoothed[i].xPx = SMOOTH_ALPHA * raw[i].xPx + (1 - SMOOTH_ALPHA) * smoothed[i].xPx;\n smoothed[i].yPx = SMOOTH_ALPHA * raw[i].yPx + (1 - SMOOTH_ALPHA) * smoothed[i].yPx;\n smoothed[i].score = raw[i].score;\n }\n return smoothed;\n }\n\n function activeSkeleton() {\n var def = CFG.skeletonDef;\n if (def && Array.isArray(def.keypoint_lines) && def.circles && def.lines) {\n return def;\n }\n return DEFAULT_SKELETON;\n }\n\n /**\n * Draw skeleton in display pixels (object-fit:cover mapping already applied).\n * Parity with PoseTrackerFront `lib/v2/drawOnCanvas.js` drawSkeleton.\n */\n function drawSkeletonDisplay(kps, dispW, dispH) {\n if (canvas.width !== dispW) canvas.width = dispW;\n if (canvas.height !== dispH) canvas.height = dispH;\n ctx.clearRect(0, 0, dispW, dispH);\n if (!CFG.drawSkeleton) return;\n\n var sk = activeSkeleton();\n var byName = {};\n for (var i = 0; i < kps.length; i++) {\n byName[kps[i].name] = kps[i];\n }\n\n var lineStroke = (sk.lines && sk.lines.lineStrokeColor) || '#010A73';\n var lineWidth = Number((sk.lines && sk.lines.strokeWidth) || 4);\n var lines = sk.keypoint_lines || [];\n for (var e = 0; e < lines.length; e++) {\n var pair = String(lines[e]).split('||');\n if (pair.length < 2) continue;\n var a = byName[pair[0]];\n var b = byName[pair[1]];\n if (!a || !b || a.score <= SKELETON_SCORE || b.score <= SKELETON_SCORE) continue;\n ctx.beginPath();\n ctx.moveTo(a.dx, a.dy);\n ctx.lineTo(b.dx, b.dy);\n ctx.lineWidth = lineWidth;\n ctx.strokeStyle = lineStroke;\n ctx.stroke();\n }\n\n var fill = (sk.circles && sk.circles.circleFillColor) || '#010A73';\n var stroke = (sk.circles && sk.circles.circleStrokeColor) || '#FFC300';\n var circleStrokeW = Number((sk.circles && sk.circles.strokeWidth) || 4);\n var radius = Number((sk.circles && sk.circles.radius) || 8);\n var allowed = new Set(sk.keypoints || KEYPOINT_NAMES);\n for (var j = 0; j < kps.length; j++) {\n var kp = kps[j];\n if (!allowed.has(kp.name) || kp.score <= SKELETON_SCORE) continue;\n ctx.beginPath();\n ctx.arc(kp.dx, kp.dy, radius, 0, Math.PI * 2);\n ctx.fillStyle = fill;\n ctx.fill();\n ctx.lineWidth = circleStrokeW;\n ctx.strokeStyle = stroke;\n ctx.stroke();\n }\n }\n\n window.__PT_SET_SKELETON = function (def) {\n CFG.skeletonDef = def && typeof def === 'object' ? def : null;\n };\n\n /** WASM pipeline path: decode + map + smooth inside pose-pipeline.wasm. */\n function decodeWithPipeline(data, dispW, dispH) {\n var inArr = new Float32Array(pipeline.memory.buffer, pipeline.inputPtr, 51);\n for (var i = 0; i < 51; i++) inArr[i] = data[i];\n pipeline.setFrame(\n letterbox.vw, letterbox.vh, dispW, dispH,\n letterbox.offsetX, letterbox.offsetY, letterbox.drawW, letterbox.drawH,\n INPUT_SIZE, FACING === 'user' ? 1 : 0\n );\n var meanScore = pipeline.process();\n var outArr = new Float32Array(pipeline.memory.buffer, pipeline.outputPtr, 119);\n var drawKps = [];\n var rnKps = [];\n var above = 0;\n for (var j = 0; j < 17; j++) {\n var o = j * 7;\n var rawScore = data[j * 3 + 2];\n if (rawScore >= 0.3) above += 1;\n drawKps.push({\n name: KEYPOINT_NAMES[j],\n xPx: outArr[o],\n yPx: outArr[o + 1],\n dx: outArr[o + 2],\n dy: outArr[o + 3],\n score: outArr[o + 6]\n });\n rnKps.push({\n name: KEYPOINT_NAMES[j],\n x: outArr[o + 4],\n y: outArr[o + 5],\n score: outArr[o + 6]\n });\n }\n return { drawKps: drawKps, rnKps: rnKps, meanScore: meanScore, above: above };\n }\n\n /** JS fallback path (same math as the wasm pipeline). */\n function decodeWithJs(data, dispW, dispH) {\n var drawKps = [];\n var scoreSum = 0;\n var above = 0;\n for (var i = 0; i < 17; i++) {\n var xNorm = Math.min(1, Math.max(0, data[i * 3 + 1]));\n var yNorm = Math.min(1, Math.max(0, data[i * 3]));\n var score = data[i * 3 + 2];\n var vid = modelNormToVideo(xNorm, yNorm);\n var disp = videoToCover(vid.x, vid.y, dispW, dispH);\n drawKps.push({\n name: KEYPOINT_NAMES[i],\n xPx: vid.x,\n yPx: vid.y,\n dx: disp.x,\n dy: disp.y,\n score: score\n });\n scoreSum += score;\n if (score >= 0.3) above += 1;\n }\n var raw = drawKps.map(function (k) {\n return { xPx: k.xPx, yPx: k.yPx, score: k.score };\n });\n var sm = smoothKeypoints(raw);\n for (var k = 0; k < 17; k++) {\n drawKps[k].xPx = sm[k].xPx;\n drawKps[k].yPx = sm[k].yPx;\n drawKps[k].score = sm[k].score;\n var d2 = videoToCover(sm[k].xPx, sm[k].yPx, dispW, dispH);\n drawKps[k].dx = d2.x;\n drawKps[k].dy = d2.y;\n }\n var rnKps = drawKps.map(function (k) {\n var nx = dispW > 0 ? k.dx / dispW : 0;\n var ny = dispH > 0 ? k.dy / dispH : 0;\n if (FACING === 'user') nx = 1 - nx;\n return {\n name: k.name,\n x: Math.min(1, Math.max(0, nx)),\n y: Math.min(1, Math.max(0, ny)),\n score: k.score\n };\n });\n return { drawKps: drawKps, rnKps: rnKps, meanScore: scoreSum / 17, above: above };\n }\n\n function canvasToVideoPx(x, y, maxCoord) {\n var poseSize = letterbox.poseSize || BLAZE_INPUT_SIZE;\n var inNorm = maxCoord <= 1.5 && poseSize > 2;\n var xSq = inNorm ? x * poseSize : x;\n var ySq = inNorm ? y * poseSize : y;\n var drawW = letterbox.drawW || poseSize;\n var drawH = letterbox.drawH || poseSize;\n return {\n x: (xSq - letterbox.offsetX) * (letterbox.vw / drawW),\n y: (ySq - letterbox.offsetY) * (letterbox.vh / drawH)\n };\n }\n\n function decodeBlazePoses(poses, dispW, dispH) {\n var pose = poses && poses[0];\n var vw = letterbox.vw || video.videoWidth || 1;\n var vh = letterbox.vh || video.videoHeight || 1;\n var byName = {};\n var maxCoord = 0;\n if (pose && pose.keypoints) {\n for (var m = 0; m < pose.keypoints.length; m++) {\n maxCoord = Math.max(\n maxCoord,\n Math.abs(pose.keypoints[m].x || 0),\n Math.abs(pose.keypoints[m].y || 0)\n );\n }\n for (var i = 0; i < pose.keypoints.length; i++) {\n var kp = pose.keypoints[i];\n var name = String(kp.name || '').toLowerCase();\n var coco = BLAZE_TO_COCO[name];\n if (!coco) continue;\n var src = canvasToVideoPx(kp.x, kp.y, maxCoord);\n byName[coco] = {\n x: src.x,\n y: src.y,\n score: typeof kp.score === 'number' ? kp.score : 0\n };\n }\n }\n var scale = Math.max(dispW / vw, dispH / vh);\n var ox = (dispW - vw * scale) / 2;\n var oy = (dispH - vh * scale) / 2;\n var drawKps = [];\n var rnKps = [];\n var scoreSum = 0;\n var above = 0;\n for (var j = 0; j < KEYPOINT_NAMES.length; j++) {\n var kn = KEYPOINT_NAMES[j];\n var k = byName[kn] || { x: 0, y: 0, score: 0 };\n var dx = k.x * scale + ox;\n var dy = k.y * scale + oy;\n var nx = dispW > 0 ? dx / dispW : 0;\n var ny = dispH > 0 ? dy / dispH : 0;\n if (FACING === 'user') nx = 1 - nx;\n scoreSum += k.score;\n if (k.score >= 0.3) above += 1;\n drawKps.push({\n name: kn,\n xPx: k.x,\n yPx: k.y,\n dx: dx,\n dy: dy,\n score: k.score\n });\n rnKps.push({\n name: kn,\n x: Math.min(1, Math.max(0, nx)),\n y: Math.min(1, Math.max(0, ny)),\n score: k.score\n });\n }\n return {\n drawKps: drawKps,\n rnKps: rnKps,\n meanScore: scoreSum / 17,\n above: above\n };\n }\n\n function publishDecoded(decoded, inferenceTimeMs) {\n var dispW = canvas.clientWidth || 1;\n var dispH = canvas.clientHeight || 1;\n var drawKps = decoded.drawKps;\n var meanScore = decoded.meanScore;\n var above = decoded.above;\n drawSkeletonDisplay(drawKps, dispW, dispH);\n\n lastInferMs.push(inferenceTimeMs);\n if (lastInferMs.length > 30) lastInferMs.shift();\n frameCount += 1;\n fpsWindow += 1;\n\n var now = performance.now();\n if (now - fpsTimer >= 1000) {\n var fps = fpsWindow;\n var skipped = skipTicksWindow;\n var inferred = inferTicksWindow;\n fpsWindow = 0;\n skipTicksWindow = 0;\n inferTicksWindow = 0;\n fpsTimer = now;\n var med = median(lastInferMs);\n var drawMs = median(stageMs.bitmap);\n var pixelsMs = median(stageMs.pixels);\n var execMs = median(stageMs.exec);\n var totalMed = median(stageMs.total);\n var estFromMed = med != null && med > 0 ? 1000 / med : null;\n var modelTag = IS_BLAZEPOSE ? 'blazepose' : 'movenet';\n var modeTag = 'main/' + activeFlags +\n (pipeline && !IS_BLAZEPOSE ? '/wasm-pipeline' : '') +\n '/' + PREPROCESS_PATH +\n (INFER_FRAME_SKIP > 0 ? '/skip' + INFER_FRAME_SKIP : '') +\n (SOFT_CAP_PROFILE ? '/soft:' + SOFT_CAP_PROFILE : '');\n\n var hudLine =\n modelTag + ' · ' + activeBackend + '/' + activeFlags + ' · ' +\n fps + ' fps · ' + (med != null ? Math.round(med) + ' ms' : '?') +\n ' · kp≥0.3=' + above + '/17';\n if (PERF_DEBUG) {\n hudLine =\n fps + 'fps med=' + (med != null ? Math.round(med) : '?') +\n 'ms (~' + (estFromMed != null ? estFromMed.toFixed(1) : '?') + 'fps) ' +\n 'bmp=' + (drawMs != null ? Math.round(drawMs) : '?') +\n ' px=' + (pixelsMs != null ? Math.round(pixelsMs) : '?') +\n ' ex=' + (execMs != null ? Math.round(execMs) : '?') +\n ' | ' + PREPROCESS_PATH +\n (INFER_FRAME_SKIP > 0 ? ' skip' + INFER_FRAME_SKIP : '') +\n (SOFT_CAP_PROFILE ? ' soft:' + SOFT_CAP_PROFILE : '') +\n ' ' + video.videoWidth + 'x' + video.videoHeight +\n ' inf/sk=' + inferred + '/' + skipped;\n }\n setHud(hudLine);\n\n var experiments = {\n platform: CFG.platform || 'unknown',\n captureMode: CAPTURE_CONSTRAINT_MODE,\n capturePriority: CAPTURE_PRIORITY,\n preprocessPath: PREPROCESS_PATH,\n inferFrameSkip: INFER_FRAME_SKIP,\n softCapProfile: SOFT_CAP_PROFILE,\n minTargetFps: MIN_TARGET_FPS,\n perfDebug: PERF_DEBUG,\n modelId: MODEL_ID\n };\n var leverHints = PERF_DEBUG\n ? buildLeverHints(totalMed != null ? totalMed : med, drawMs, pixelsMs, execMs, fps)\n : null;\n\n post({\n type: 'stats',\n fps: fps,\n medianInferenceMs: med,\n estimatedFpsFromMedian: estFromMed,\n frames: frameCount,\n backend: activeBackend,\n keypointsAbove03: above,\n meanScore: meanScore,\n breakdown: {\n drawMs: drawMs,\n fromPixelsMs: pixelsMs,\n executeMs: execMs,\n totalPipelineMs: totalMed\n },\n windowCounts: { inferred: inferred, skipped: skipped },\n experiments: experiments,\n leverHints: leverHints,\n videoSize: video.videoWidth + 'x' + video.videoHeight,\n mode: modeTag\n });\n }\n\n post({\n type: 'pose',\n keypoints: decoded.rnKps,\n score: meanScore,\n inferenceTimeMs: inferenceTimeMs,\n timestampMs: Date.now()\n });\n }\n\n function decodeAndPublish(data, inferenceTimeMs) {\n // Pipeline (matches PoseTrackerFront):\n // MoveNet [0,1] on letterboxed 192² → video pixels → object-fit:cover display\n // Front camera: video+canvas CSS scaleX(-1). Draw in SENSOR display space;\n // flip only the RN payload so host overlays match the mirrored preview.\n var dispW = canvas.clientWidth || 1;\n var dispH = canvas.clientHeight || 1;\n var decoded = pipeline\n ? decodeWithPipeline(data, dispW, dispH)\n : decodeWithJs(data, dispW, dispH);\n publishDecoded(decoded, inferenceTimeMs);\n }\n\n function decodeAndPublishBlaze(poses, inferenceTimeMs) {\n var dispW = canvas.clientWidth || 1;\n var dispH = canvas.clientHeight || 1;\n publishDecoded(decodeBlazePoses(poses, dispW, dispH), inferenceTimeMs);\n }\n\n // Loop token: a stale rAF chain (pre-suspend) must never survive next to a\n // resumed one — two chains would double the inference attempt rate.\n var loopToken = 0;\n var inferErrorStreak = 0;\n // ~45 consecutive failures ≈ a few seconds of a dead GL context / broken\n // session. Stop the loop and release the camera instead of burning battery.\n var INFER_ERROR_LIMIT = 45;\n\n async function loop(token) {\n if (token !== loopToken || !running || !model) return;\n var szLoop = frameSize();\n var mediaReady = sourceMode === 'image'\n ? (szLoop.vw > 0 && imageShotPending)\n : (video.readyState >= 2 && video.videoWidth > 0 &&\n !(sourceMode === 'video' && (video.paused || video.ended)));\n if (!busy && mediaReady) {\n // Android frame-skip: after each inference, burn N free rAF ticks before\n // the next one. Preview keeps streaming; last skeleton stays on canvas.\n if (skipCountdown > 0) {\n skipCountdown -= 1;\n skipTicksWindow += 1;\n } else {\n busy = true;\n try {\n var result = await infer();\n inferErrorStreak = 0;\n inferTicksWindow += 1;\n if (result.kind === 'blazepose') {\n decodeAndPublishBlaze(result.poses, result.totalMs);\n } else {\n decodeAndPublish(result.data, result.totalMs);\n }\n skipCountdown = INFER_FRAME_SKIP;\n if (sourceMode === 'image') {\n imageShotPending = false;\n running = false;\n busy = false;\n return;\n }\n } catch (err) {\n var message = err && err.message ? err.message : String(err);\n setHud('infer error: ' + message);\n if (frameCount < 3) post({ type: 'error', message: 'inference: ' + message });\n inferErrorStreak += 1;\n if (inferErrorStreak >= INFER_ERROR_LIMIT) {\n busy = false;\n post({\n type: 'error',\n message: 'inference failed repeatedly (' + message +\n ') — pipeline stopped and camera released to protect the device'\n });\n suspendPipeline('inference-errors');\n return;\n }\n }\n busy = false;\n }\n }\n requestAnimationFrame(function () { loop(token); });\n }\n\n function startLoop() {\n running = true;\n fpsTimer = performance.now();\n loop(++loopToken);\n }\n\n // Capture constraint mode (injected by poseHtml from captureMode.ts):\n // 'device-native' — Front-like: facingMode only (often HD preview)\n // 'profile-constrained' — AdaptiveChoice ideals (previous stable default)\n // REVERT: set CAPTURE_CONSTRAINT_MODE = 'profile-constrained' in captureMode.ts\n // then npm run build:runtime-payload && npm run build\n var CAPTURE_CONSTRAINT_MODE = CFG.captureConstraintMode === 'profile-constrained'\n ? 'profile-constrained'\n : 'device-native';\n\n // Capture profiles (mirror RN AdaptiveChoice ladder). Used to pick camera\n // resolution from the zeros warm-up BEFORE getUserMedia (profile-constrained)\n // or as live setQuality downgrade targets (both modes).\n // minTargetFps is a floor (iOS 30 / Android 15), not a single setpoint.\n var MIN_TARGET_FPS = (CFG.minTargetFps != null && CFG.minTargetFps > 0)\n ? CFG.minTargetFps\n : 15;\n var CAPTURE_PROFILES = {\n prime: { id: 'prime', idealWidth: 1280, idealHeight: 720, idealFrameRate: 30 },\n pro: { id: 'pro', idealWidth: 960, idealHeight: 540, idealFrameRate: 30 },\n lite: { id: 'lite', idealWidth: 640, idealHeight: 480, idealFrameRate: 30 },\n ultralite: { id: 'ultralite', idealWidth: 480, idealHeight: 360, idealFrameRate: 24 },\n basic: { id: 'basic', idealWidth: 320, idealHeight: 240, idealFrameRate: 20 }\n };\n var CAPTURE_LADDER = ['prime', 'pro', 'lite', 'ultralite', 'basic'];\n\n function profileRank(id) {\n var i = CAPTURE_LADDER.indexOf(id);\n return i < 0 ? CAPTURE_LADDER.length - 1 : i;\n }\n\n function lowerProfile(a, b) {\n return profileRank(a) >= profileRank(b) ? a : b;\n }\n\n /** Map warm-up execute median → max safe capture profile (scales with floor). */\n function profileFromWarmupMs(medMs) {\n if (medMs == null || !(medMs > 0)) return 'basic';\n var budget = 1000 / MIN_TARGET_FPS;\n // Over floor on zeros alone → basic (camera adds cost; no ultralite gift).\n if (medMs > budget) return 'basic';\n if (medMs <= budget * 0.6) return 'prime';\n if (medMs <= budget * 0.75) return 'pro';\n if (medMs <= budget * 0.9) return 'lite';\n return 'ultralite';\n }\n\n function applyCaptureProfile(id) {\n var p = CAPTURE_PROFILES[id] || CAPTURE_PROFILES.ultralite;\n CFG.idealWidth = p.idealWidth;\n CFG.idealHeight = p.idealHeight;\n CFG.idealFrameRate = p.idealFrameRate;\n CFG.profileId = p.id;\n return p;\n }\n\n /** Build getUserMedia video constraints for the active capture mode. */\n function videoConstraints(forceProfileIdeals) {\n var video = { facingMode: { ideal: FACING } };\n if (forceProfileIdeals || CAPTURE_CONSTRAINT_MODE === 'profile-constrained') {\n video.width = { ideal: CFG.idealWidth };\n video.height = { ideal: CFG.idealHeight };\n video.frameRate = { ideal: CFG.idealFrameRate || 30 };\n return video;\n }\n // device-native + optional Android soft-cap: use the warm-up-selected\n // CFG ideals (already clamped ≤ softCap) as ideal+max so Chromium\n // cannot open unrestricted 1080p.\n if (SOFT_CAP_PROFILE) {\n video.width = { ideal: CFG.idealWidth, max: CFG.idealWidth };\n video.height = { ideal: CFG.idealHeight, max: CFG.idealHeight };\n video.frameRate = { ideal: CFG.idealFrameRate || 30 };\n }\n return video;\n }\n\n async function boot() {\n try {\n applyMirrorCss();\n if (CFG.idealFrameRate == null) CFG.idealFrameRate = 30;\n if (!CFG.profileId) CFG.profileId = 'ultralite';\n\n // 0) Proprietary pipeline module (MoveNet-only; skip for BlazePose).\n if (!IS_BLAZEPOSE) {\n await initPipeline();\n } else {\n post({\n type: 'diag',\n message: 'pose-pipeline: skipped (blazepose uses pose-detection)'\n });\n }\n\n // 1) Bench model WITHOUT the camera — estimate FPS, pick capture tier.\n post({\n type: 'initialization',\n step: 'loading_pose_model',\n message: IS_BLAZEPOSE ? 'loading BlazePose' : 'loading pose model',\n ready: false\n });\n setHud(IS_BLAZEPOSE ? 'loading BlazePose (webgl)…' : 'loading MoveNet (webgl)…');\n var best = await pickFastestBackend();\n\n var estFps = best.med != null && best.med > 0 ? 1000 / best.med : null;\n var rnHint = CFG.profileId;\n var fromWarmup = profileFromWarmupMs(best.med);\n // Prefer warm-up evidence for the *initial* open. Using RN hint as a hard\n // ceiling pinned poisoned crash-guard sessions on basic, then RN upgraded\n // mid-getUserMedia and aborted the stream (\"The operation was aborted\").\n // Live FPS path can still downgrade after ready.\n var selected = fromWarmup;\n if (CAPTURE_PRIORITY === 'quality') {\n // Host opted into sharp preview — do not FPS-floor to basic/ultralite.\n selected = 'prime';\n } else if (SOFT_CAP_PROFILE && CAPTURE_PROFILES[SOFT_CAP_PROFILE]) {\n selected = lowerProfile(selected, SOFT_CAP_PROFILE);\n }\n var applied = applyCaptureProfile(selected);\n\n post({\n type: 'warmup_estimate',\n medianInferenceMs: best.med,\n estimatedFps: estFps,\n minTargetFps: MIN_TARGET_FPS,\n targetFps: MIN_TARGET_FPS,\n profileId: applied.id,\n previousProfileId: rnHint,\n idealWidth: applied.idealWidth,\n idealHeight: applied.idealHeight,\n gl: glInfo()\n });\n post({\n type: 'diag',\n message: 'warmup estimatedFps=' +\n (estFps != null ? estFps.toFixed(1) : '?') +\n ' medianMs=' + (best.med != null ? best.med.toFixed(1) : '?') +\n ' minTarget=' + MIN_TARGET_FPS +\n ' capture=' + applied.id + ' (' + applied.idealWidth + 'x' + applied.idealHeight + ')' +\n (SOFT_CAP_PROFILE ? ' softCap=' + SOFT_CAP_PROFILE : '') +\n (selected !== rnHint ? ' (rnHint=' + rnHint + ')' : '')\n });\n post({\n type: 'diag',\n message: 'experiments platform=' + (CFG.platform || '?') +\n ' captureMode=' + CAPTURE_CONSTRAINT_MODE +\n ' capturePriority=' + CAPTURE_PRIORITY +\n ' preprocess=' + PREPROCESS_PATH +\n ' frameSkip=' + INFER_FRAME_SKIP +\n ' softCap=' + (SOFT_CAP_PROFILE || 'off') +\n ' minTargetFps=' + MIN_TARGET_FPS +\n ' perfDebug=' + PERF_DEBUG\n });\n\n // Free base64 payloads after final model load.\n window.__PT_MODEL_JSON = null;\n window.__PT_WEIGHTS_B64 = null;\n window.__PT_WASM_B64 = null;\n\n var flagsSnapshot = '';\n try {\n flagsSnapshot =\n ' glVersion=' + tf.env().getNumber('WEBGL_VERSION') +\n ' pack=' + tf.env().getBool('WEBGL_PACK') +\n ' f32render=' + tf.env().getBool('WEBGL_RENDER_FLOAT32_ENABLED') +\n ' fenceApi=' + tf.env().getBool('WEBGL_FENCE_API_ENABLED');\n } catch (e) {}\n post({\n type: 'diag',\n message: 'final backend=' + best.backend + ' flags=' + best.name +\n ' medianMs=' + (best.med != null ? best.med.toFixed(1) : '?') +\n ' profile=' + CFG.profileId +\n ' coldStart=' + (BOOT_CAMERA ? 'full' : 'basic') +\n flagsSnapshot\n });\n\n lastWarmBackend = best.backend;\n lastWarmMedMs = best.med;\n lastWarmTimes = best.times || [];\n lastWarmEstFps = estFps;\n\n setHud('ready · ' + best.backend + '/' + CFG.profileId + ' · warm ' +\n (best.med != null ? Math.round(best.med) + 'ms' : '?') +\n (estFps != null ? ' (~' + Math.round(estFps) + 'fps est)' : '') +\n (BOOT_CAMERA ? '' : ' · model-only'));\n\n if (BOOT_CAMERA) {\n // Full cold-start (legacy): open camera before posting ready — same\n // as the historical warmer that called getUserMedia during preload.\n if ((sourceMode === 'video' || sourceMode === 'image') && sourceUrl) {\n await window.__PT_SET_SOURCE({ type: sourceMode, url: sourceUrl });\n } else {\n await openCameraAndLoop('boot-full');\n }\n if (suspended) {\n post({ type: 'diag', message: 'boot finished while hidden — staying suspended (camera released)' });\n stopCameraTracks();\n // Still mark model+backend ready so preload can resolve; camera\n // reopens on resume because cameraArmed is true.\n } else {\n startLoop();\n }\n postReadySignal(true, 'full');\n } else {\n // Basic cold-start: model / WebGL only — no permission prompt.\n postReadySignal(false, 'basic');\n post({\n type: 'diag',\n message: 'basic cold-start — model warm, camera deferred until openCamera/full'\n });\n if (bootCover) {\n bootCover.classList.add('hide');\n }\n applyWatermarkVisibility();\n }\n } catch (err) {\n var message = err && err.message ? err.message : String(err);\n // Abort during a concurrent setQuality must not leave the boot overlay\n // stuck forever — retry once, then surface the error.\n if (/abort/i.test(message) || (err && err.name === 'AbortError')) {\n post({ type: 'diag', message: 'boot getUserMedia aborted — retrying once' });\n try {\n if (BOOT_CAMERA) {\n await openCameraAndLoop('boot-full-retry');\n if (!suspended) startLoop();\n postReadySignal(true, 'full');\n return;\n }\n } catch (retryErr) {\n message = retryErr && retryErr.message ? retryErr.message : String(retryErr);\n }\n }\n setHud('error: ' + message);\n setBootMessage('error: ' + message, true);\n post({ type: 'error', message: message });\n }\n }\n\n window.__PT_SET_FACING = function (mode) {\n FACING = mode === 'environment' ? 'environment' : 'user';\n applyMirrorCss();\n };\n\n /** Live plan upgrade: toggle watermark without remounting the WebView. */\n window.__PT_SET_WATERMARK = function (on) {\n showWatermark = !!on;\n CFG.showWatermark = showWatermark;\n applyWatermarkVisibility();\n };\n\n /** Update boot overlay copy (WebView loading_message parity). */\n window.__PT_SET_LOADING_TEXT = function (text) {\n if (typeof text === 'string' && text.trim().length > 0) {\n loadingText = text.trim();\n } else {\n loadingText = 'AI Loading';\n }\n CFG.loadingText = loadingText;\n if (!cameraRevealed) resetBootLoadingText();\n };\n\n /**\n * Restart getUserMedia with new ideal resolution (adaptive quality ladder).\n * Inference stays at 192² letterbox — only capture / preprocess cost changes.\n */\n window.__PT_SET_QUALITY = async function (opts) {\n try {\n if (!opts) return;\n if (opts.idealWidth) CFG.idealWidth = opts.idealWidth;\n if (opts.idealHeight) CFG.idealHeight = opts.idealHeight;\n if (opts.idealFrameRate) CFG.idealFrameRate = opts.idealFrameRate;\n qualityForced = true;\n if (suspended) {\n // Camera is released while hidden; resume reopens with the new CFG.\n post({ type: 'diag', message: 'setQuality deferred (pipeline suspended)' });\n return;\n }\n // Hide during stream swap — same 0×0 / wrong-aspect flash as cold boot.\n // Boot overlay keeps branded loadingText (tech progress via diag).\n cameraRevealed = false;\n setCameraVisible(false);\n post({\n type: 'diag',\n message: 'adjusting camera (quality swap)'\n });\n var old = video.srcObject;\n if (old && old.getTracks) {\n old.getTracks().forEach(function (t) { try { t.stop(); } catch (e) {} });\n }\n // Live quality swaps always use profile ideals (downgrade ladder).\n var stream = await navigator.mediaDevices.getUserMedia({\n audio: false,\n video: videoConstraints(true)\n });\n video.srcObject = stream;\n await video.play();\n await new Promise(function (resolve) {\n if (video.videoWidth > 0) { resolve(); return; }\n video.onloadedmetadata = function () { resolve(); };\n setTimeout(resolve, 1500);\n });\n await new Promise(function (resolve) {\n if (typeof video.requestVideoFrameCallback === 'function') {\n video.requestVideoFrameCallback(function () { resolve(); });\n setTimeout(resolve, 500);\n return;\n }\n setTimeout(resolve, 80);\n });\n revealCameraWhenReady();\n post({\n type: 'diag',\n message: 'quality applied camera=' + video.videoWidth + 'x' + video.videoHeight +\n ' ideal=' + CFG.idealWidth + 'x' + CFG.idealHeight +\n '@' + (CFG.idealFrameRate || 30) +\n (opts.profileId ? ' profile=' + opts.profileId : '')\n });\n } catch (err) {\n revealCameraWhenReady();\n post({\n type: 'diag',\n message: 'setQuality failed: ' + (err && err.message ? err.message : String(err))\n });\n }\n };\n\n // ── Cold-start modes ────────────────────────────────────────────────────\n // basic (default): model + zeros warm-up only — NEVER calls getUserMedia.\n // full: open camera after warm-up (legacy warmer / explicit preload full).\n // Camera screens pass coldStart=full (or call __PT_OPEN_CAMERA later).\n var BOOT_CAMERA = CFG.coldStart === 'full' || CFG.bootCamera === true;\n // True only after a successful getUserMedia — resume must not open camera\n // for a basic (model-only) warm page.\n var cameraArmed = false;\n var lastWarmBackend = null;\n var lastWarmMedMs = null;\n var lastWarmTimes = [];\n var lastWarmEstFps = null;\n\n function postReadySignal(cameraOpened, coldStart) {\n post({\n type: 'ready',\n backend: lastWarmBackend,\n medianInferenceMs: lastWarmMedMs,\n warmUpRunsMs: lastWarmTimes,\n estimatedFps: lastWarmEstFps,\n profileId: CFG.profileId,\n cameraOpened: !!cameraOpened,\n coldStart: coldStart,\n gl: glInfo(),\n note: 'main/' + (lastWarmBackend || '?') + '/' + CFG.profileId +\n (pipeline ? '/wasm-pipeline' : '') +\n (cameraOpened ? '/camera' : '/model-only')\n });\n }\n\n async function openCameraOnce(forceProfileIdeals) {\n var stream = await navigator.mediaDevices.getUserMedia({\n audio: false,\n video: videoConstraints(!!forceProfileIdeals)\n });\n video.srcObject = stream;\n await video.play();\n await new Promise(function (resolve) {\n if (video.videoWidth > 0) { resolve(); return; }\n video.onloadedmetadata = function () { resolve(); };\n setTimeout(resolve, 2000);\n });\n await new Promise(function (resolve) {\n if (typeof video.requestVideoFrameCallback === 'function') {\n video.requestVideoFrameCallback(function () { resolve(); });\n setTimeout(resolve, 500);\n return;\n }\n setTimeout(resolve, 80);\n });\n }\n\n /** Open webcam (does not start the loop — caller decides). */\n async function openCameraAndLoop(reason) {\n if (cameraArmed && video.srcObject) {\n return;\n }\n post({\n type: 'initialization',\n step: 'accessing_webcam',\n message: 'accessing webcam',\n ready: false\n });\n setHud(\n 'getUserMedia ' +\n (CAPTURE_CONSTRAINT_MODE === 'device-native' ? 'device-native' : CFG.profileId) +\n '…'\n );\n try {\n await openCameraOnce();\n } catch (camErr) {\n var camMsg = camErr && camErr.message ? camErr.message : String(camErr);\n var isAbort = /abort/i.test(camMsg) || (camErr && camErr.name === 'AbortError');\n if (!isAbort) throw camErr;\n post({ type: 'diag', message: 'getUserMedia aborted — retrying once' });\n await new Promise(function (r) { setTimeout(r, 120); });\n await openCameraOnce();\n }\n cameraArmed = true;\n revealCameraWhenReady();\n scheduleRevealRetries(4000);\n post({\n type: 'diag',\n message: 'camera size=' + video.videoWidth + 'x' + video.videoHeight +\n ' mode=' + CAPTURE_CONSTRAINT_MODE +\n ' ideal=' + CFG.idealWidth + 'x' + CFG.idealHeight +\n ' profile=' + CFG.profileId +\n ' reason=' + reason\n });\n }\n\n\n function stopNonCameraMedia() {\n try { video.pause(); } catch (e) {}\n try { video.removeAttribute('src'); video.srcObject = null; video.load(); } catch (e) {}\n if (still) {\n try { still.removeAttribute('src'); } catch (e) {}\n }\n }\n\n function loadVideoUrl(url) {\n return new Promise(function (resolve, reject) {\n video.onloadeddata = function () { resolve(); };\n video.onerror = function () { reject(new Error('Failed to load video URL')); };\n video.loop = true;\n video.muted = true;\n video.playsInline = true;\n video.src = url;\n video.load();\n video.play().catch(function () {});\n setTimeout(function () {\n if (video.videoWidth > 0) resolve();\n }, 50);\n });\n }\n\n function loadImageUrl(url) {\n return new Promise(function (resolve, reject) {\n if (!still) { reject(new Error('still image element missing')); return; }\n still.onload = function () { resolve(); };\n still.onerror = function () { reject(new Error('Failed to load image URL')); };\n still.src = url;\n if (still.complete && still.naturalWidth > 0) resolve();\n });\n }\n\n /**\n * Switch pose input: { type: 'camera'|'video'|'image', url?: string, base64?: string, mime?: string }\n * Host apps pick a file (document/image picker) and pass a URI or data URL.\n */\n window.__PT_SET_SOURCE = async function (opts) {\n opts = opts || {};\n var type = opts.type || 'camera';\n var url = opts.url || opts.uri || null;\n if (opts.base64) {\n var mime = opts.mime || (type === 'video' ? 'video/mp4' : 'image/jpeg');\n url = 'data:' + mime + ';base64,' + opts.base64;\n }\n running = false;\n loopToken += 1;\n busy = false;\n imageShotPending = false;\n stopCameraTracks();\n stopNonCameraMedia();\n cameraArmed = type === 'camera' ? cameraArmed : false;\n cameraRevealed = false;\n setCameraVisible(false);\n sourceMode = (type === 'video' || type === 'image') ? type : 'camera';\n sourceUrl = url;\n\n if (sourceMode === 'camera') {\n post({ type: 'initialization', step: 'accessing_webcam', message: 'accessing webcam (source=camera)', ready: false });\n await openCameraAndLoop('set-source-camera');\n if (!suspended) {\n startLoop();\n postReadySignal(true, 'full');\n }\n return;\n }\n\n if (!url) {\n post({ type: 'error', message: 'source=' + sourceMode + ' requires url or base64' });\n return;\n }\n\n post({\n type: 'initialization',\n step: 'loading_media',\n message: 'loading ' + sourceMode + ' (source=' + sourceMode + ')',\n ready: false\n });\n try {\n if (sourceMode === 'video') {\n cameraArmed = true; // allow resume semantics for file video? keep false for camera reopen\n cameraArmed = false;\n await loadVideoUrl(url);\n revealCameraWhenReady();\n startLoop();\n post({ type: 'ready', backend: lastWarmBackend, medianInferenceMs: lastWarmMedMs, warmUpRunsMs: lastWarmTimes, estimatedFps: lastWarmEstFps, profileId: CFG.profileId, cameraOpened: false, coldStart: 'full', gl: glInfo(), note: 'source=video' });\n } else {\n await loadImageUrl(url);\n imageShotPending = true;\n revealCameraWhenReady();\n startLoop();\n post({ type: 'ready', backend: lastWarmBackend, medianInferenceMs: lastWarmMedMs, warmUpRunsMs: lastWarmTimes, estimatedFps: lastWarmEstFps, profileId: CFG.profileId, cameraOpened: false, coldStart: 'full', gl: glInfo(), note: 'source=image' });\n }\n } catch (err) {\n post({ type: 'error', message: 'media load failed: ' + (err && err.message ? err.message : String(err)) });\n }\n };\n\n window.__PT_ANALYZE = function () {\n if (sourceMode !== 'image') return;\n imageShotPending = true;\n if (!running) startLoop();\n };\n\n window.__PT_OPEN_CAMERA = function () {\n return openCameraAndLoop('host-openCamera').then(function () {\n if (suspended) {\n stopCameraTracks();\n return;\n }\n startLoop();\n postReadySignal(true, 'full');\n });\n };\n\n // ── Battery / thermal safety: release the camera and halt inference when\n // the page is hidden (app backgrounded, view covered), reacquire on return.\n // Belt-and-braces with the RN AppState hook (__PT_SUSPEND / __PT_RESUME):\n // Android WebView and WKWebView background semantics differ, page-level\n // visibilitychange + host injection together cover both. ──────────────────\n var suspended = false;\n // True once RN pushed an explicit quality profile — resume must reopen the\n // camera with profile ideals instead of the boot-time device-native path.\n var qualityForced = false;\n\n function stopCameraTracks() {\n var s = video.srcObject;\n if (s && s.getTracks) {\n s.getTracks().forEach(function (t) { try { t.stop(); } catch (e) {} });\n }\n video.srcObject = null;\n }\n\n function suspendPipeline(reason) {\n if (suspended) return;\n suspended = true;\n running = false;\n loopToken += 1; // kill any pending rAF chain\n stopCameraTracks();\n cameraRevealed = false;\n setCameraVisible(false);\n post({\n type: 'diag',\n message: 'pipeline suspended (' + reason + ') — camera released, inference stopped'\n });\n }\n\n async function resumePipeline() {\n if (!suspended) return;\n suspended = false;\n if (!model) return; // boot still in flight — it owns the camera\n // Model-only warm page: never open the camera on foreground.\n if (!cameraArmed) {\n post({ type: 'diag', message: 'resume skipped — camera not armed (basic cold-start)' });\n return;\n }\n try {\n var stream = await navigator.mediaDevices.getUserMedia({\n audio: false,\n video: videoConstraints(qualityForced)\n });\n if (suspended) {\n // Hidden again while getUserMedia was in flight.\n stream.getTracks().forEach(function (t) { try { t.stop(); } catch (e) {} });\n return;\n }\n stopCameraTracks();\n video.srcObject = stream;\n await video.play();\n await new Promise(function (resolve) {\n if (video.videoWidth > 0) { resolve(); return; }\n video.onloadedmetadata = function () { resolve(); };\n setTimeout(resolve, 1500);\n });\n revealCameraWhenReady();\n inferErrorStreak = 0;\n startLoop();\n post({\n type: 'diag',\n message: 'pipeline resumed — camera reacquired (' +\n video.videoWidth + 'x' + video.videoHeight + ')'\n });\n } catch (err) {\n post({\n type: 'diag',\n message: 'resume failed: ' + (err && err.message ? err.message : String(err))\n });\n }\n }\n\n window.__PT_SUSPEND = function () { suspendPipeline('host'); };\n window.__PT_RESUME = function () { resumePipeline(); };\n\n document.addEventListener('visibilitychange', function () {\n if (document.visibilityState === 'hidden') {\n suspendPipeline('page-hidden');\n } else {\n resumePipeline();\n }\n });\n // WKWebView / Chromium teardown: the camera indicator must die with the page.\n window.addEventListener('pagehide', function () {\n running = false;\n loopToken += 1;\n stopCameraTracks();\n });\n\n window.addEventListener('message', function (ev) {\n try {\n var data = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data;\n if (data && data.type === 'setFacing') window.__PT_SET_FACING(data.facingMode);\n if (data && data.type === 'setQuality') window.__PT_SET_QUALITY(data);\n if (data && data.type === 'stop') {\n running = false;\n loopToken += 1;\n stopCameraTracks();\n }\n } catch (e) {}\n });\n\n boot();\n})();\n";
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";
@@ -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
  };
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
- * Full pipeline for one camera frame: pose estimation + `keypoints` event
359
- * (both modes), then engine processing when a session is active
360
- * (full-engine mode). Mode upgrades take effect transparently here.
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
- * Full pipeline for one camera frame: pose estimation + `keypoints` event
986
- * (both modes), then engine processing when a session is active
987
- * (full-engine mode). Mode upgrades take effect transparently here.
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) => {
package/lib/index.d.ts CHANGED
@@ -33,7 +33,7 @@ export { DEFAULT_SKELETON_DEFINITION } from './types/skeleton';
33
33
  export { fetchSkeletonDefinition, SkeletonFetchError } from './api/skeleton';
34
34
  export type { FetchSkeletonOptions } from './api/skeleton';
35
35
  export { PoseTrackerProvider, usePoseTracker } from './PoseTrackerProvider';
36
- export type { PoseTrackerContextValue, PoseTrackerProviderProps } from './PoseTrackerProvider';
36
+ export type { PoseTrackerContextValue, PoseTrackerProviderProps, ProcessFrameFn, } from './PoseTrackerProvider';
37
37
  export type { PoseBackend, PoseBackendInitOptions, PoseInputFrame } from './backends/PoseBackend';
38
38
  export { WebViewPoseBackend, isWebViewPoseBackend } from './backends/webview/WebViewPoseBackend';
39
39
  export type { WebViewPoseBackendOptions, WebViewPoseMessage } from './backends/webview/WebViewPoseBackend';
@@ -72,3 +72,4 @@ export type { PoseTrackerFeatures, ResolvedFeatures } from './types/features';
72
72
  export * from './types/events';
73
73
  export * from './types/manifest';
74
74
  export * from './types/pose';
75
+ export * from './types/externalFrame';
package/lib/index.js CHANGED
@@ -181,3 +181,4 @@ Object.defineProperty(exports, "resolveFeatures", { enumerable: true, get: funct
181
181
  __exportStar(require("./types/events"), exports);
182
182
  __exportStar(require("./types/manifest"), exports);
183
183
  __exportStar(require("./types/pose"), exports);
184
+ __exportStar(require("./types/externalFrame"), exports);
@@ -1,2 +1,2 @@
1
1
  /** AUTO-GENERATED by scripts/sync-sdk-version.mjs — do not edit. */
2
- export declare const SDK_VERSION = "0.3.5";
2
+ export declare const SDK_VERSION = "0.3.6";
package/lib/sdkVersion.js CHANGED
@@ -2,4 +2,4 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SDK_VERSION = void 0;
4
4
  /** AUTO-GENERATED by scripts/sync-sdk-version.mjs — do not edit. */
5
- exports.SDK_VERSION = '0.3.5';
5
+ exports.SDK_VERSION = '0.3.6';