@pose-tracker/pose-estimation-web 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -3,7 +3,7 @@
3
3
  var jsSha256 = require('js-sha256');
4
4
 
5
5
  // src/version.ts
6
- var SDK_VERSION = "0.1.0";
6
+ var SDK_VERSION = "0.3.0";
7
7
  var SDK_NAME = "posetracker-web";
8
8
 
9
9
  // src/api/configure.ts
@@ -24,7 +24,8 @@ async function configure(apiToken, options = {}) {
24
24
  targetPlatform: "web",
25
25
  poseModelProfile: options.poseModelProfile ?? "AdaptiveChoice",
26
26
  locale: options.locale,
27
- localVersions: options.localVersions
27
+ localVersions: options.localVersions,
28
+ engineChannel: options.engine === "v4" ? "v4" : "v3"
28
29
  };
29
30
  let response;
30
31
  try {
@@ -461,6 +462,135 @@ function stopMediaStream(stream) {
461
462
  }
462
463
  }
463
464
 
465
+ // src/media/resolveMedia.ts
466
+ function isVideoElement(v) {
467
+ return typeof HTMLVideoElement !== "undefined" && v instanceof HTMLVideoElement;
468
+ }
469
+ function isImageElement(v) {
470
+ return typeof HTMLImageElement !== "undefined" && v instanceof HTMLImageElement;
471
+ }
472
+ function isImageBitmap(v) {
473
+ return typeof ImageBitmap !== "undefined" && v instanceof ImageBitmap;
474
+ }
475
+ function mediaSize(el) {
476
+ if (isImageBitmap(el)) {
477
+ return { width: el.width || 0, height: el.height || 0 };
478
+ }
479
+ if (isVideoElement(el)) {
480
+ return { width: el.videoWidth || 0, height: el.videoHeight || 0 };
481
+ }
482
+ return {
483
+ width: el.naturalWidth || el.width || 0,
484
+ height: el.naturalHeight || el.height || 0
485
+ };
486
+ }
487
+ async function resolveObjectUrl(src) {
488
+ if (typeof src === "string") {
489
+ return { url: src, revoke: () => void 0 };
490
+ }
491
+ const url = URL.createObjectURL(src);
492
+ return {
493
+ url,
494
+ revoke: () => {
495
+ try {
496
+ URL.revokeObjectURL(url);
497
+ } catch {
498
+ }
499
+ }
500
+ };
501
+ }
502
+ function waitForVideoReady(video, timeoutMs = 15e3) {
503
+ return new Promise((resolve, reject) => {
504
+ if (video.readyState >= 2 && video.videoWidth > 0) {
505
+ resolve();
506
+ return;
507
+ }
508
+ const onReady = () => {
509
+ cleanup();
510
+ resolve();
511
+ };
512
+ const onError = () => {
513
+ cleanup();
514
+ reject(new Error("Failed to load video source"));
515
+ };
516
+ const timer = setTimeout(() => {
517
+ cleanup();
518
+ reject(new Error("Timed out waiting for video metadata"));
519
+ }, timeoutMs);
520
+ const cleanup = () => {
521
+ clearTimeout(timer);
522
+ video.removeEventListener("loadeddata", onReady);
523
+ video.removeEventListener("error", onError);
524
+ };
525
+ video.addEventListener("loadeddata", onReady);
526
+ video.addEventListener("error", onError);
527
+ });
528
+ }
529
+ function waitForImageReady(img, timeoutMs = 15e3) {
530
+ return new Promise((resolve, reject) => {
531
+ if (img.complete && img.naturalWidth > 0) {
532
+ resolve();
533
+ return;
534
+ }
535
+ if (img.complete) {
536
+ reject(new Error("Image failed to decode (naturalWidth=0)"));
537
+ return;
538
+ }
539
+ const onReady = () => {
540
+ cleanup();
541
+ resolve();
542
+ };
543
+ const onError = () => {
544
+ cleanup();
545
+ reject(new Error("Failed to load image source"));
546
+ };
547
+ const timer = setTimeout(() => {
548
+ cleanup();
549
+ reject(new Error("Timed out waiting for image"));
550
+ }, timeoutMs);
551
+ const cleanup = () => {
552
+ clearTimeout(timer);
553
+ img.removeEventListener("load", onReady);
554
+ img.removeEventListener("error", onError);
555
+ };
556
+ img.addEventListener("load", onReady);
557
+ img.addEventListener("error", onError);
558
+ });
559
+ }
560
+ async function imageBitmapToObjectUrl(bitmap) {
561
+ const canvas = document.createElement("canvas");
562
+ canvas.width = bitmap.width;
563
+ canvas.height = bitmap.height;
564
+ const ctx = canvas.getContext("2d");
565
+ if (!ctx) throw new Error("2d context unavailable");
566
+ ctx.drawImage(bitmap, 0, 0);
567
+ const blob = await new Promise((resolve, reject) => {
568
+ canvas.toBlob((b) => b ? resolve(b) : reject(new Error("toBlob failed")), "image/png");
569
+ });
570
+ return resolveObjectUrl(blob);
571
+ }
572
+
573
+ // src/types/source.ts
574
+ function normalizePoseSource(source, extras) {
575
+ if (source && typeof source === "object" && "type" in source) {
576
+ return source;
577
+ }
578
+ const kind = source === "video" || source === "image" || source === "camera" ? source : "camera";
579
+ if (kind === "camera") {
580
+ return { type: "camera", facingMode: extras?.facingMode };
581
+ }
582
+ const src = extras?.sourceFile ?? extras?.sourceUrl;
583
+ if (!src) {
584
+ throw new Error(
585
+ `PoseSource type "${kind}" requires sourceUrl or sourceFile (or pass a full PoseSource object)`
586
+ );
587
+ }
588
+ if (kind === "video") {
589
+ return { type: "video", src };
590
+ }
591
+ return { type: "image", src };
592
+ }
593
+
464
594
  // src/exercises/aliases.ts
465
595
  var EXERCISE_ALIASES = {
466
596
  face_squat: "squat",
@@ -489,6 +619,19 @@ function findExerciseByIdOrAlias(exerciseId, exercises) {
489
619
  return exercises.find((e) => resolveExerciseName(e.id) === canonical);
490
620
  }
491
621
 
622
+ // src/engineChannel.ts
623
+ var V4_ONLY_EXERCISE_IDS = [
624
+ "shoulder_roll",
625
+ "shoulder_deep_breath",
626
+ "chair_forward_fold"
627
+ ];
628
+ function normalizeEngineChannel(raw) {
629
+ return raw === "v4" ? "v4" : "v3";
630
+ }
631
+ function requiresEngineV4(exerciseId) {
632
+ return V4_ONLY_EXERCISE_IDS.includes(exerciseId);
633
+ }
634
+
492
635
  // src/events/classicMessage.ts
493
636
  var ANGLE_RELATIONS = {
494
637
  left_knee: "from_hip_to_ankle",
@@ -913,6 +1056,18 @@ async function resolvePoseDetection(options) {
913
1056
  }
914
1057
 
915
1058
  // src/models/adapters/blazepose.ts
1059
+ function blazeInputSize(input) {
1060
+ if (typeof HTMLVideoElement !== "undefined" && input instanceof HTMLVideoElement) {
1061
+ return { vw: input.videoWidth || 0, vh: input.videoHeight || 0 };
1062
+ }
1063
+ if (typeof HTMLImageElement !== "undefined" && input instanceof HTMLImageElement) {
1064
+ return { vw: input.naturalWidth || input.width || 0, vh: input.naturalHeight || input.height || 0 };
1065
+ }
1066
+ if (typeof HTMLCanvasElement !== "undefined" && input instanceof HTMLCanvasElement) {
1067
+ return { vw: input.width || 0, vh: input.height || 0 };
1068
+ }
1069
+ return { vw: input.width || 0, vh: input.height || 0 };
1070
+ }
916
1071
  var BLAZE_TO_COCO = {
917
1072
  nose: "nose",
918
1073
  left_eye: "left_eye",
@@ -932,6 +1087,23 @@ var BLAZE_TO_COCO = {
932
1087
  left_ankle: "left_ankle",
933
1088
  right_ankle: "right_ankle"
934
1089
  };
1090
+ var FACE_EXTRA_NAMES = /* @__PURE__ */ new Set([
1091
+ "left_eye_inner",
1092
+ "left_eye_outer",
1093
+ "right_eye_inner",
1094
+ "right_eye_outer",
1095
+ "mouth_left",
1096
+ "mouth_right"
1097
+ ]);
1098
+ var BLAZE_MAX_SIDE = 1280;
1099
+ function toSourcePixels(x, y, scratchW, scratchH, srcW, srcH, maxCoord) {
1100
+ if (maxCoord <= 1.5 && scratchW > 2) {
1101
+ return { x: x * srcW, y: y * srcH };
1102
+ }
1103
+ const sx = scratchW / srcW;
1104
+ const sy = scratchH / srcH;
1105
+ return { x: sx > 0 ? x / sx : x, y: sy > 0 ? y / sy : y };
1106
+ }
935
1107
  var BlazePoseAdapter = class {
936
1108
  constructor(_ctx) {
937
1109
  this.modelId = "blazepose";
@@ -952,15 +1124,34 @@ var BlazePoseAdapter = class {
952
1124
  {
953
1125
  runtime: "tfjs",
954
1126
  modelType: this.modelType,
955
- enableSmoothing: true
1127
+ enableSmoothing: false
956
1128
  }
957
1129
  );
958
1130
  }
959
- async estimate(video, options) {
1131
+ async estimate(input, options) {
960
1132
  if (!this.detector) throw new Error("BlazePose adapter not loaded");
961
- if (!(video.videoWidth > 0 && video.videoHeight > 0)) return null;
1133
+ const size = blazeInputSize(input);
1134
+ if (!(size.vw > 0 && size.vh > 0)) return null;
1135
+ if (options.temporalSmooth === false) this.resetTemporal();
1136
+ let estimateTarget = input;
1137
+ let scratch = null;
1138
+ let scratchW = size.vw;
1139
+ let scratchH = size.vh;
1140
+ if (typeof HTMLImageElement !== "undefined" && input instanceof HTMLImageElement || typeof HTMLVideoElement !== "undefined" && input instanceof HTMLVideoElement || typeof HTMLCanvasElement !== "undefined" && input instanceof HTMLCanvasElement) {
1141
+ const fit = Math.min(1, BLAZE_MAX_SIDE / Math.max(size.vw, size.vh));
1142
+ scratchW = Math.max(1, Math.round(size.vw * fit));
1143
+ scratchH = Math.max(1, Math.round(size.vh * fit));
1144
+ scratch = document.createElement("canvas");
1145
+ scratch.width = scratchW;
1146
+ scratch.height = scratchH;
1147
+ const ctx = scratch.getContext("2d");
1148
+ if (ctx) {
1149
+ ctx.drawImage(input, 0, 0, scratchW, scratchH);
1150
+ estimateTarget = scratch;
1151
+ }
1152
+ }
962
1153
  const t0 = performance.now();
963
- const poses = await this.detector.estimatePoses(video, {
1154
+ const poses = await this.detector.estimatePoses(estimateTarget, {
964
1155
  flipHorizontal: false,
965
1156
  maxPoses: 1
966
1157
  });
@@ -980,27 +1171,33 @@ var BlazePoseAdapter = class {
980
1171
  letterbox: {
981
1172
  offsetX: 0,
982
1173
  offsetY: 0,
983
- drawW: video.videoWidth,
984
- drawH: video.videoHeight,
985
- vw: video.videoWidth,
986
- vh: video.videoHeight
1174
+ drawW: size.vw,
1175
+ drawH: size.vh,
1176
+ vw: size.vw,
1177
+ vh: size.vh
987
1178
  }
988
1179
  };
989
1180
  }
990
- const vw = video.videoWidth || 1;
991
- const vh = video.videoHeight || 1;
1181
+ const vw = size.vw || 1;
1182
+ const vh = size.vh || 1;
992
1183
  const dispW = options.displayWidth || 1;
993
1184
  const dispH = options.displayHeight || 1;
1185
+ let maxCoord = 0;
1186
+ for (const kp of pose.keypoints) {
1187
+ maxCoord = Math.max(maxCoord, Math.abs(kp.x || 0), Math.abs(kp.y || 0));
1188
+ }
994
1189
  const byName = /* @__PURE__ */ new Map();
1190
+ const extrasRaw = [];
995
1191
  for (const kp of pose.keypoints) {
996
1192
  const name = String(kp.name || "").toLowerCase();
1193
+ const score = typeof kp.score === "number" ? kp.score : 0;
1194
+ const src = toSourcePixels(kp.x, kp.y, scratchW, scratchH, vw, vh, maxCoord);
997
1195
  const coco = BLAZE_TO_COCO[name];
998
- if (!coco) continue;
999
- byName.set(coco, {
1000
- x: kp.x,
1001
- y: kp.y,
1002
- score: typeof kp.score === "number" ? kp.score : 0
1003
- });
1196
+ if (coco) {
1197
+ byName.set(coco, { x: src.x, y: src.y, score });
1198
+ } else if (FACE_EXTRA_NAMES.has(name)) {
1199
+ extrasRaw.push({ name, x: src.x, y: src.y, score });
1200
+ }
1004
1201
  }
1005
1202
  const videoKeypoints = COCO_KEYPOINT_NAMES.map((name) => {
1006
1203
  const k = byName.get(name);
@@ -1029,6 +1226,19 @@ var BlazePoseAdapter = class {
1029
1226
  score: k.score
1030
1227
  };
1031
1228
  });
1229
+ for (const extra of extrasRaw) {
1230
+ const dx = extra.x * scale + ox;
1231
+ const dy = extra.y * scale + oy;
1232
+ let nx = dispW > 0 ? dx / dispW : 0;
1233
+ const ny = dispH > 0 ? dy / dispH : 0;
1234
+ if (options.facingMode === "user") nx = 1 - nx;
1235
+ keypoints.push({
1236
+ name: extra.name,
1237
+ x: Math.min(1, Math.max(0, nx)),
1238
+ y: Math.min(1, Math.max(0, ny)),
1239
+ score: extra.score
1240
+ });
1241
+ }
1032
1242
  return {
1033
1243
  keypoints,
1034
1244
  score: scoreSum / 17,
@@ -1044,6 +1254,13 @@ var BlazePoseAdapter = class {
1044
1254
  }
1045
1255
  };
1046
1256
  }
1257
+ /** Clear cached ROI / landmark filters (call when the still changes). */
1258
+ resetTemporal() {
1259
+ try {
1260
+ this.detector?.reset?.();
1261
+ } catch {
1262
+ }
1263
+ }
1047
1264
  dispose() {
1048
1265
  try {
1049
1266
  this.detector?.dispose?.();
@@ -1054,14 +1271,27 @@ var BlazePoseAdapter = class {
1054
1271
  };
1055
1272
 
1056
1273
  // src/models/adapters/movenet.ts
1274
+ function inputSize(input) {
1275
+ if (typeof HTMLVideoElement !== "undefined" && input instanceof HTMLVideoElement) {
1276
+ return { vw: input.videoWidth || 0, vh: input.videoHeight || 0 };
1277
+ }
1278
+ if (typeof HTMLImageElement !== "undefined" && input instanceof HTMLImageElement) {
1279
+ return { vw: input.naturalWidth || input.width || 0, vh: input.naturalHeight || input.height || 0 };
1280
+ }
1281
+ if (typeof HTMLCanvasElement !== "undefined" && input instanceof HTMLCanvasElement) {
1282
+ return { vw: input.width || 0, vh: input.height || 0 };
1283
+ }
1284
+ return { vw: input.width || 0, vh: input.height || 0 };
1285
+ }
1057
1286
  var INPUT_SIZE = 192;
1058
- var SMOOTH_ALPHA = 0.5;
1287
+ var SMOOTH_ALPHA = 0.45;
1288
+ var STILL_TARGET_FILL = 0.52;
1059
1289
  function modelNormToVideo(xNorm, yNorm, lb) {
1060
1290
  const xSq = xNorm * INPUT_SIZE;
1061
1291
  const ySq = yNorm * INPUT_SIZE;
1062
1292
  return {
1063
- x: (xSq - lb.offsetX) * (lb.vw / lb.drawW),
1064
- y: (ySq - lb.offsetY) * (lb.vh / lb.drawH)
1293
+ x: (xSq - lb.offsetX) * (lb.vw / lb.drawW) + (lb.originX ?? 0),
1294
+ y: (ySq - lb.offsetY) * (lb.vh / lb.drawH) + (lb.originY ?? 0)
1065
1295
  };
1066
1296
  }
1067
1297
  function videoToCover(vx, vy, dispW, dispH, lb) {
@@ -1078,6 +1308,18 @@ var MoveNetGraphAdapter = class {
1078
1308
  this.offscreen = null;
1079
1309
  this.offCtx = null;
1080
1310
  this.smoothed = null;
1311
+ this.lastVideoKps = null;
1312
+ this.stillPass = 0;
1313
+ this.inferLb = {
1314
+ offsetX: 0,
1315
+ offsetY: 0,
1316
+ drawW: INPUT_SIZE,
1317
+ drawH: INPUT_SIZE,
1318
+ vw: 1,
1319
+ vh: 1,
1320
+ originX: 0,
1321
+ originY: 0
1322
+ };
1081
1323
  this.letterbox = {
1082
1324
  offsetX: 0,
1083
1325
  offsetY: 0,
@@ -1117,19 +1359,81 @@ var MoveNetGraphAdapter = class {
1117
1359
  if (!this.offCtx) throw new Error("2d context unavailable");
1118
1360
  return this.offCtx;
1119
1361
  }
1120
- prepareInput(video) {
1121
- const vw = video.videoWidth || 1;
1122
- const vh = video.videoHeight || 1;
1123
- const scale = Math.min(INPUT_SIZE / vw, INPUT_SIZE / vh);
1124
- const drawW = vw * scale;
1125
- const drawH = vh * scale;
1362
+ prepareInput(input, trackRoi) {
1363
+ const { vw: rawW, vh: rawH } = inputSize(input);
1364
+ const vw = rawW || 1;
1365
+ const vh = rawH || 1;
1366
+ let winX = 0;
1367
+ let winY = 0;
1368
+ let winW = vw;
1369
+ let winH = vh;
1370
+ if (trackRoi && this.lastVideoKps && this.stillPass > 0) {
1371
+ const pts = this.lastVideoKps.filter((k) => k.score >= 0.15);
1372
+ if (pts.length >= 4) {
1373
+ let minx = Infinity;
1374
+ let miny = Infinity;
1375
+ let maxx = -Infinity;
1376
+ let maxy = -Infinity;
1377
+ for (const p of pts) {
1378
+ minx = Math.min(minx, p.xPx);
1379
+ miny = Math.min(miny, p.yPx);
1380
+ maxx = Math.max(maxx, p.xPx);
1381
+ maxy = Math.max(maxy, p.yPx);
1382
+ }
1383
+ const bw = Math.max(8, maxx - minx);
1384
+ const bh = Math.max(8, maxy - miny);
1385
+ const cx = (minx + maxx) / 2;
1386
+ const cy = (miny + maxy) / 2;
1387
+ winW = Math.max(bw / STILL_TARGET_FILL, vw * 0.35);
1388
+ winH = Math.max(bh / STILL_TARGET_FILL, vh * 0.35);
1389
+ const imgAspect = vw / vh;
1390
+ if (winW / winH < imgAspect) winW = winH * imgAspect;
1391
+ else winH = winW / imgAspect;
1392
+ winX = cx - winW / 2;
1393
+ winY = cy - winH / 2;
1394
+ }
1395
+ }
1396
+ const scale = Math.min(INPUT_SIZE / winW, INPUT_SIZE / winH);
1397
+ const drawW = winW * scale;
1398
+ const drawH = winH * scale;
1126
1399
  const offsetX = (INPUT_SIZE - drawW) / 2;
1127
1400
  const offsetY = (INPUT_SIZE - drawH) / 2;
1128
- this.letterbox = { offsetX, offsetY, drawW, drawH, vw, vh };
1401
+ this.inferLb = {
1402
+ offsetX,
1403
+ offsetY,
1404
+ drawW,
1405
+ drawH,
1406
+ vw: winW,
1407
+ vh: winH,
1408
+ originX: winX,
1409
+ originY: winY
1410
+ };
1411
+ this.letterbox = { offsetX: 0, offsetY: 0, drawW: vw, drawH: vh, vw, vh };
1129
1412
  const c2d = this.poseCanvasCtx();
1130
1413
  c2d.fillStyle = "#000";
1131
1414
  c2d.fillRect(0, 0, INPUT_SIZE, INPUT_SIZE);
1132
- c2d.drawImage(video, 0, 0, vw, vh, offsetX, offsetY, drawW, drawH);
1415
+ const srcX = Math.max(0, winX);
1416
+ const srcY = Math.max(0, winY);
1417
+ const srcR = Math.min(vw, winX + winW);
1418
+ const srcB = Math.min(vh, winY + winH);
1419
+ if (srcR > srcX && srcB > srcY) {
1420
+ const dx = offsetX + (srcX - winX) * (drawW / winW);
1421
+ const dy = offsetY + (srcY - winY) * (drawH / winH);
1422
+ const dw = (srcR - srcX) * (drawW / winW);
1423
+ const dh = (srcB - srcY) * (drawH / winH);
1424
+ c2d.drawImage(
1425
+ input,
1426
+ srcX,
1427
+ srcY,
1428
+ srcR - srcX,
1429
+ srcB - srcY,
1430
+ dx,
1431
+ dy,
1432
+ dw,
1433
+ dh
1434
+ );
1435
+ }
1436
+ this.stillPass += 1;
1133
1437
  return this.offscreen;
1134
1438
  }
1135
1439
  smooth(raw) {
@@ -1145,20 +1449,26 @@ var MoveNetGraphAdapter = class {
1145
1449
  }
1146
1450
  return this.smoothed;
1147
1451
  }
1148
- async estimate(video, options) {
1452
+ async estimate(input, options) {
1149
1453
  if (!this.tf || !this.model) throw new Error("MoveNet adapter not loaded");
1150
- if (!(video.videoWidth > 0 && video.videoHeight > 0)) return null;
1454
+ const size = inputSize(input);
1455
+ if (!(size.vw > 0 && size.vh > 0)) return null;
1151
1456
  const t0 = performance.now();
1152
- const canvas = this.prepareInput(video);
1153
- const input = this.tf.tidy(() => this.tf.expandDims(this.tf.browser.fromPixels(canvas), 0));
1154
- const out = this.model.execute(input);
1457
+ const isStill = typeof HTMLImageElement !== "undefined" && input instanceof HTMLImageElement;
1458
+ const trackRoi = options.temporalSmooth !== false && isStill;
1459
+ const canvas = this.prepareInput(input, trackRoi);
1460
+ const tensorIn = this.tf.tidy(
1461
+ () => this.tf.expandDims(this.tf.browser.fromPixels(canvas), 0)
1462
+ );
1463
+ const out = this.model.execute(tensorIn);
1155
1464
  const tensor = Array.isArray(out) ? out[0] : out;
1156
1465
  const data = tensor.dataSync();
1157
- input.dispose();
1466
+ tensorIn.dispose();
1158
1467
  if (Array.isArray(out)) out.forEach((t) => t.dispose());
1159
1468
  else out.dispose();
1160
1469
  const inferenceMs = performance.now() - t0;
1161
- const lb = this.letterbox;
1470
+ const lb = this.inferLb;
1471
+ const coverLb = this.letterbox;
1162
1472
  const dispW = options.displayWidth || 1;
1163
1473
  const dispH = options.displayHeight || 1;
1164
1474
  const drawRaw = [];
@@ -1176,7 +1486,8 @@ var MoveNetGraphAdapter = class {
1176
1486
  });
1177
1487
  scoreSum += score;
1178
1488
  }
1179
- const sm = this.smooth(drawRaw);
1489
+ const sm = options.temporalSmooth === false ? drawRaw : this.smooth(drawRaw);
1490
+ this.lastVideoKps = sm.map((k) => ({ xPx: k.xPx, yPx: k.yPx, score: k.score }));
1180
1491
  const videoKeypoints = sm.map((k, i) => ({
1181
1492
  name: COCO_KEYPOINT_NAMES[i],
1182
1493
  xPx: k.xPx,
@@ -1184,7 +1495,7 @@ var MoveNetGraphAdapter = class {
1184
1495
  score: k.score
1185
1496
  }));
1186
1497
  const keypoints = videoKeypoints.map((k) => {
1187
- const d = videoToCover(k.xPx, k.yPx, dispW, dispH, lb);
1498
+ const d = videoToCover(k.xPx, k.yPx, dispW, dispH, coverLb);
1188
1499
  let nx = dispW > 0 ? d.x / dispW : 0;
1189
1500
  const ny = dispH > 0 ? d.y / dispH : 0;
1190
1501
  if (options.facingMode === "user") nx = 1 - nx;
@@ -1201,9 +1512,15 @@ var MoveNetGraphAdapter = class {
1201
1512
  score: scoreSum / 17,
1202
1513
  inferenceMs,
1203
1514
  videoKeypoints,
1204
- letterbox: lb
1515
+ letterbox: coverLb
1205
1516
  };
1206
1517
  }
1518
+ /** Clear EMA state (call when the still / clip changes). */
1519
+ resetTemporal() {
1520
+ this.smoothed = null;
1521
+ this.lastVideoKps = null;
1522
+ this.stillPass = 0;
1523
+ }
1207
1524
  dispose() {
1208
1525
  try {
1209
1526
  this.model?.dispose();
@@ -1211,6 +1528,8 @@ var MoveNetGraphAdapter = class {
1211
1528
  }
1212
1529
  this.model = null;
1213
1530
  this.smoothed = null;
1531
+ this.lastVideoKps = null;
1532
+ this.stillPass = 0;
1214
1533
  this.offscreen = null;
1215
1534
  this.offCtx = null;
1216
1535
  }
@@ -1283,11 +1602,11 @@ var SHELL_CSS = `
1283
1602
  position: relative; width: 100%; height: 100%; min-height: 240px;
1284
1603
  background: #000; overflow: hidden; isolation: isolate;
1285
1604
  }
1286
- .pt-root video, .pt-root canvas.pt-overlay {
1605
+ .pt-root video, .pt-root img.pt-still, .pt-root canvas.pt-overlay {
1287
1606
  position: absolute; inset: 0; width: 100%; height: 100%;
1288
1607
  object-fit: cover; opacity: 0; transition: opacity 180ms ease-out;
1289
1608
  }
1290
- .pt-root video { z-index: 1; background: #000; }
1609
+ .pt-root video, .pt-root img.pt-still { z-index: 1; background: #000; }
1291
1610
  .pt-root canvas.pt-overlay { z-index: 2; pointer-events: none; }
1292
1611
  .pt-boot {
1293
1612
  position: absolute; inset: 0; z-index: 4;
@@ -1361,6 +1680,7 @@ function mountDomShell(host, options = {}) {
1361
1680
  root.className = "pt-root";
1362
1681
  root.innerHTML = `
1363
1682
  <video playsinline muted autoplay></video>
1683
+ <img class="pt-still" alt="" draggable="false" />
1364
1684
  <canvas class="pt-overlay"></canvas>
1365
1685
  <div class="pt-boot">
1366
1686
  <div class="pt-boot-powered">Powered by</div>
@@ -1377,6 +1697,7 @@ function mountDomShell(host, options = {}) {
1377
1697
  `;
1378
1698
  host.appendChild(root);
1379
1699
  const video = root.querySelector("video");
1700
+ const image = root.querySelector("img.pt-still");
1380
1701
  const canvas = root.querySelector("canvas.pt-overlay");
1381
1702
  const boot = root.querySelector(".pt-boot");
1382
1703
  const bootMsg = root.querySelector(".pt-boot-msg");
@@ -1386,6 +1707,7 @@ function mountDomShell(host, options = {}) {
1386
1707
  return {
1387
1708
  root,
1388
1709
  video,
1710
+ image,
1389
1711
  canvas,
1390
1712
  boot,
1391
1713
  bootMsg,
@@ -1397,10 +1719,21 @@ function mountDomShell(host, options = {}) {
1397
1719
  bootMsg.classList.toggle("is-error", isError);
1398
1720
  },
1399
1721
  setCameraVisible(visible) {
1400
- video.style.opacity = visible ? "1" : "0";
1401
- canvas.style.opacity = visible ? "1" : "0";
1402
- boot.classList.toggle("hide", visible);
1403
- if (!visible) {
1722
+ if (visible) {
1723
+ this.setMediaMode(image.src ? "image" : "video");
1724
+ } else {
1725
+ this.setMediaMode("none");
1726
+ }
1727
+ },
1728
+ setMediaMode(mode) {
1729
+ const showVideo = mode === "video";
1730
+ const showImage = mode === "image";
1731
+ const showMedia = showVideo || showImage;
1732
+ video.style.opacity = showVideo ? "1" : "0";
1733
+ image.style.opacity = showImage ? "1" : "0";
1734
+ canvas.style.opacity = showMedia ? "1" : "0";
1735
+ boot.classList.toggle("hide", showMedia);
1736
+ if (!showMedia) {
1404
1737
  bootMsg.textContent = loadingText;
1405
1738
  bootMsg.classList.remove("is-error");
1406
1739
  }
@@ -1425,6 +1758,7 @@ function mountDomShell(host, options = {}) {
1425
1758
  applyMirror(facingMode) {
1426
1759
  const t = facingMode === "user" ? "scaleX(-1)" : "none";
1427
1760
  video.style.transform = t;
1761
+ image.style.transform = "none";
1428
1762
  canvas.style.transform = t;
1429
1763
  },
1430
1764
  destroy() {
@@ -1628,6 +1962,10 @@ var PoseTrackerClient = class {
1628
1962
  this.keypointsSuppressionLogged = false;
1629
1963
  this.shell = null;
1630
1964
  this.stream = null;
1965
+ this.poseSource = { type: "camera" };
1966
+ this.objectUrlRevoke = null;
1967
+ this.estimateInput = null;
1968
+ this.imageShotPending = false;
1631
1969
  this.adapter = null;
1632
1970
  this.running = false;
1633
1971
  this.busy = false;
@@ -1654,6 +1992,15 @@ var PoseTrackerClient = class {
1654
1992
  this.resolved = resolvePoseModel({ model: options.model, modelUrl: options.modelUrl });
1655
1993
  this.skeletonDef = options.skeletonDef ?? null;
1656
1994
  this.coldStartMode = options.coldStart === "full" ? "full" : "basic";
1995
+ try {
1996
+ this.poseSource = normalizePoseSource(options.source, {
1997
+ sourceUrl: options.sourceUrl,
1998
+ sourceFile: options.sourceFile,
1999
+ facingMode: facingFromOptions(options)
2000
+ });
2001
+ } catch {
2002
+ this.poseSource = { type: "camera", facingMode: facingFromOptions(options) };
2003
+ }
1657
2004
  this.files = options.fileStore !== void 0 ? options.fileStore : createWebFileStore();
1658
2005
  this.engineLoader = options.engineLoader ?? new EngineLoader({
1659
2006
  fileStore: this.files,
@@ -1700,6 +2047,10 @@ var PoseTrackerClient = class {
1700
2047
  getColdStartMode() {
1701
2048
  return this.coldStartMode;
1702
2049
  }
2050
+ /** Active pose input source (`camera` | `video` | `image`). */
2051
+ getSource() {
2052
+ return this.poseSource;
2053
+ }
1703
2054
  addEventListener(listener) {
1704
2055
  this.listeners.add(listener);
1705
2056
  return () => this.listeners.delete(listener);
@@ -1763,31 +2114,102 @@ var PoseTrackerClient = class {
1763
2114
  warmup(options) {
1764
2115
  return this.preload(options);
1765
2116
  }
1766
- /** Mount + load model + open camera + start loop (camera-screen convenience). */
2117
+ /**
2118
+ * Mount + load model + open source + start inference.
2119
+ * Camera / video stream continuously; image runs a single shot (re-run with {@link analyze}).
2120
+ */
1767
2121
  async start() {
1768
2122
  if (this.disposed) throw new Error("PoseTracker disposed");
1769
2123
  if (!this.shell) throw new Error("Call mount() before start()");
1770
- await this.preload({ coldStart: this.opts.coldStart === "basic" ? "basic" : "full" });
1771
- if (this.coldStartMode === "basic") {
1772
- await this.ensureColdStartMode("full");
2124
+ const needsMedia = this.poseSource.type === "camera" || this.poseSource.type === "video" || this.poseSource.type === "image";
2125
+ await this.preload({
2126
+ coldStart: this.opts.coldStart === "basic" && this.poseSource.type === "camera" ? "basic" : needsMedia ? "full" : "basic"
2127
+ });
2128
+ if (this.coldStartMode === "basic" || !this.estimateInput) {
2129
+ await this.ensureSource();
2130
+ }
2131
+ if (this.poseSource.type === "image") {
2132
+ this.imageShotPending = true;
1773
2133
  }
1774
2134
  this.beginLoop();
1775
2135
  }
2136
+ /**
2137
+ * Switch input source. Stops the current media, attaches the new source, and
2138
+ * restarts the loop when previously running.
2139
+ */
2140
+ async setSource(source, extras) {
2141
+ if (this.disposed) throw new Error("PoseTracker disposed");
2142
+ const wasRunning = this.running;
2143
+ this.stopMediaOnly();
2144
+ this.poseSource = normalizePoseSource(source, {
2145
+ sourceUrl: extras?.sourceUrl ?? this.opts.sourceUrl,
2146
+ sourceFile: extras?.sourceFile ?? this.opts.sourceFile,
2147
+ facingMode: extras?.facingMode ?? facingFromOptions(this.opts)
2148
+ });
2149
+ this.opts.source = this.poseSource;
2150
+ if (this.poseSource.type === "camera" && this.poseSource.facingMode) {
2151
+ this.opts.facingMode = this.poseSource.facingMode;
2152
+ }
2153
+ if (this.adapter?.resetTemporal) {
2154
+ this.adapter.resetTemporal();
2155
+ }
2156
+ if (!this.shell) return;
2157
+ await this.ensureAdapter();
2158
+ await this.ensureSource();
2159
+ if (wasRunning || this.poseSource.type === "image") {
2160
+ if (this.poseSource.type === "image") this.imageShotPending = true;
2161
+ this.beginLoop();
2162
+ }
2163
+ }
2164
+ /**
2165
+ * Re-run pose estimation on the current still image (no-op for camera/video
2166
+ * while the loop is already running — for those, call {@link start}).
2167
+ */
2168
+ async analyze() {
2169
+ if (this.disposed) throw new Error("PoseTracker disposed");
2170
+ if (!this.shell) throw new Error("Call mount() before analyze()");
2171
+ await this.ensureAdapter();
2172
+ if (!this.estimateInput) await this.ensureSource();
2173
+ if (this.poseSource.type === "image") {
2174
+ this.imageShotPending = true;
2175
+ if (!this.running) this.beginLoop();
2176
+ return null;
2177
+ }
2178
+ return this.runOneFrame();
2179
+ }
1776
2180
  stop() {
1777
2181
  this.running = false;
1778
2182
  this.loopToken += 1;
1779
2183
  this.busy = false;
1780
- stopMediaStream(this.stream);
1781
- this.stream = null;
2184
+ this.imageShotPending = false;
2185
+ this.stopMediaOnly();
1782
2186
  if (this.shell) {
1783
- this.shell.video.srcObject = null;
1784
- this.shell.setCameraVisible(false);
2187
+ this.shell.setMediaMode("none");
1785
2188
  this.shell.setPlacementVisible(false);
1786
2189
  this.cameraRevealed = false;
1787
2190
  this.applyWatermark();
1788
2191
  }
1789
2192
  if (this.status !== "error") this.setStatus("idle");
1790
2193
  }
2194
+ stopMediaOnly() {
2195
+ stopMediaStream(this.stream);
2196
+ this.stream = null;
2197
+ this.estimateInput = null;
2198
+ if (this.objectUrlRevoke) {
2199
+ this.objectUrlRevoke();
2200
+ this.objectUrlRevoke = null;
2201
+ }
2202
+ if (this.shell) {
2203
+ try {
2204
+ this.shell.video.pause();
2205
+ } catch {
2206
+ }
2207
+ this.shell.video.removeAttribute("src");
2208
+ this.shell.video.srcObject = null;
2209
+ this.shell.video.load();
2210
+ this.shell.image.removeAttribute("src");
2211
+ }
2212
+ }
1791
2213
  async dispose() {
1792
2214
  this.stopExercise();
1793
2215
  this.stop();
@@ -1844,7 +2266,30 @@ var PoseTrackerClient = class {
1844
2266
  return this.configurePromise;
1845
2267
  }
1846
2268
  getAvailableExercises() {
1847
- return this.mode === "full-engine" ? this.manifest?.exercises ?? [] : [];
2269
+ if (this.mode !== "full-engine") return [];
2270
+ if (normalizeEngineChannel(this.opts.engine) === "v4") {
2271
+ const listed = this.engine?.listExercises?.() ?? [];
2272
+ if (listed.length) return listed.map((e) => this.v4ExerciseConfig(e));
2273
+ }
2274
+ return this.manifest?.exercises ?? [];
2275
+ }
2276
+ getEngineChannel() {
2277
+ return normalizeEngineChannel(this.opts.engine);
2278
+ }
2279
+ v4ExerciseConfig(entry) {
2280
+ const type = entry.type === "static" ? "static" : "dynamic";
2281
+ return {
2282
+ id: entry.id,
2283
+ name: entry.displayName || entry.id,
2284
+ type,
2285
+ movement: {
2286
+ name: entry.id,
2287
+ type,
2288
+ scale_acceptance: {},
2289
+ movement_steps: [],
2290
+ movement_initial_posture: null
2291
+ }
2292
+ };
1848
2293
  }
1849
2294
  getAvailableCustomExercises() {
1850
2295
  if (this.mode !== "full-engine" || !this.engine?.listCustomExercises) return [];
@@ -1880,6 +2325,28 @@ var PoseTrackerClient = class {
1880
2325
  throw new Error(FREE_PLAN_FEATURES_MESSAGE);
1881
2326
  }
1882
2327
  }
2328
+ if (requiresEngineV4(exerciseId) && normalizeEngineChannel(this.opts.engine) !== "v4") {
2329
+ const message = `Exercise '${exerciseId}' requires engine: 'v4'`;
2330
+ this.reportError({ type: "error", code: "invalid_exercise", message });
2331
+ throw new Error(message);
2332
+ }
2333
+ if (normalizeEngineChannel(this.opts.engine) === "v4") {
2334
+ const listed = this.engine.listExercises?.() ?? [];
2335
+ const v4Hit = listed.find((e) => e.id === exerciseId);
2336
+ if (v4Hit) {
2337
+ this.beginEngineSession(this.v4ExerciseConfig(v4Hit), options);
2338
+ return;
2339
+ }
2340
+ const customs = this.getAvailableCustomExercises();
2341
+ const custom = customs.find((e) => e.id === exerciseId) ?? findExerciseByIdOrAlias(exerciseId, customs);
2342
+ if (custom) {
2343
+ this.startCustomExercise(custom, options);
2344
+ return;
2345
+ }
2346
+ const message = `Exercise '${exerciseId}' is not available in V4 engine`;
2347
+ this.reportError({ type: "error", code: "invalid_exercise", message });
2348
+ throw new Error(message);
2349
+ }
1883
2350
  const available = this.getAvailableExercises();
1884
2351
  const exercise = findExerciseByIdOrAlias(exerciseId, available);
1885
2352
  if (!exercise) {
@@ -1893,8 +2360,12 @@ var PoseTrackerClient = class {
1893
2360
  this.reportError({ type: "error", code: "invalid_exercise", message });
1894
2361
  throw new Error(message);
1895
2362
  }
2363
+ this.beginEngineSession(exercise, options);
2364
+ }
2365
+ beginEngineSession(exercise, options) {
2366
+ if (!this.engine) return;
1896
2367
  this.stopExercise();
1897
- this.currentExerciseId = exerciseId;
2368
+ this.currentExerciseId = exercise.id;
1898
2369
  this.keypointsSuppressionLogged = false;
1899
2370
  this.session = this.engine.createSession(
1900
2371
  {
@@ -2056,7 +2527,7 @@ var PoseTrackerClient = class {
2056
2527
  async ensureColdStartMode(mode) {
2057
2528
  if (mode !== "full") return;
2058
2529
  if (!this.shell) return;
2059
- await this.ensureCamera();
2530
+ await this.ensureSource();
2060
2531
  }
2061
2532
  async ensureAdapter() {
2062
2533
  if (this.adapter) return;
@@ -2073,48 +2544,102 @@ var PoseTrackerClient = class {
2073
2544
  this.adapter = createPoseAdapter({ resolved: this.resolved });
2074
2545
  await this.adapter.load();
2075
2546
  }
2076
- async ensureCamera() {
2077
- if (!this.shell) throw new Error("Call mount() before opening the camera");
2078
- if (this.stream) return;
2547
+ async ensureSource() {
2548
+ if (!this.shell) throw new Error("Call mount() before opening a pose source");
2549
+ const src = this.poseSource;
2550
+ if (src.type === "camera") {
2551
+ if (this.stream && this.estimateInput) return;
2552
+ this.emit({
2553
+ type: "initialization",
2554
+ step: "accessing_webcam",
2555
+ message: "accessing webcam (source=camera)",
2556
+ ready: false
2557
+ });
2558
+ try {
2559
+ this.stream = await openCameraStream({
2560
+ facingMode: src.facingMode ?? facingFromOptions(this.opts),
2561
+ idealWidth: this.opts.idealWidth,
2562
+ idealHeight: this.opts.idealHeight
2563
+ });
2564
+ } catch (err) {
2565
+ const msg = err instanceof Error ? err.message : String(err);
2566
+ const denied = /NotAllowed|Permission|denied/i.test(msg);
2567
+ this.reportError({
2568
+ type: "error",
2569
+ code: denied ? "camera_denied" : "camera_unavailable",
2570
+ message: msg
2571
+ });
2572
+ this.shell.setBootMessage(
2573
+ denied ? "Camera permission denied \u2014 allow access in the browser" : "Camera unavailable",
2574
+ true
2575
+ );
2576
+ throw err;
2577
+ }
2578
+ this.shell.video.srcObject = this.stream;
2579
+ this.shell.applyMirror(src.facingMode ?? facingFromOptions(this.opts));
2580
+ this.shell.setMediaMode("video");
2581
+ try {
2582
+ await this.shell.video.play();
2583
+ } catch {
2584
+ }
2585
+ this.estimateInput = this.shell.video;
2586
+ this.scheduleReveal("camera");
2587
+ void this.handleCameraStart({
2588
+ backend: this.acceleration.backend ?? "webgl",
2589
+ profileId: null
2590
+ });
2591
+ return;
2592
+ }
2593
+ if (src.type === "video") {
2594
+ this.emit({
2595
+ type: "initialization",
2596
+ step: "loading_media",
2597
+ message: "loading video (source=video)",
2598
+ ready: false
2599
+ });
2600
+ try {
2601
+ await this.attachVideoSource(src.src, {
2602
+ autoplay: src.autoplay !== false,
2603
+ loop: src.loop !== false,
2604
+ muted: src.muted !== false
2605
+ });
2606
+ } catch (err) {
2607
+ const msg = err instanceof Error ? err.message : String(err);
2608
+ this.reportError({ type: "error", code: "media_load_failed", message: msg });
2609
+ this.shell.setBootMessage("Could not load video source", true);
2610
+ throw err;
2611
+ }
2612
+ this.shell.applyMirror("none");
2613
+ this.shell.setMediaMode("video");
2614
+ this.scheduleReveal("video");
2615
+ return;
2616
+ }
2079
2617
  this.emit({
2080
2618
  type: "initialization",
2081
- step: "accessing_webcam",
2082
- message: "accessing webcam",
2619
+ step: "loading_media",
2620
+ message: "loading image (source=image)",
2083
2621
  ready: false
2084
2622
  });
2085
2623
  try {
2086
- this.stream = await openCameraStream({
2087
- facingMode: facingFromOptions(this.opts),
2088
- idealWidth: this.opts.idealWidth,
2089
- idealHeight: this.opts.idealHeight
2090
- });
2624
+ await this.attachImageSource(src.src);
2091
2625
  } catch (err) {
2092
2626
  const msg = err instanceof Error ? err.message : String(err);
2093
- const denied = /NotAllowed|Permission|denied/i.test(msg);
2094
- this.reportError({
2095
- type: "error",
2096
- code: denied ? "camera_denied" : "camera_unavailable",
2097
- message: msg
2098
- });
2099
- this.shell.setBootMessage(
2100
- denied ? "Camera permission denied \u2014 allow access in the browser" : "Camera unavailable",
2101
- true
2102
- );
2627
+ this.reportError({ type: "error", code: "media_load_failed", message: msg });
2628
+ this.shell.setBootMessage("Could not load image source", true);
2103
2629
  throw err;
2104
2630
  }
2105
- this.shell.video.srcObject = this.stream;
2106
- this.shell.applyMirror(facingFromOptions(this.opts));
2107
- try {
2108
- await this.shell.video.play();
2109
- } catch {
2110
- }
2631
+ this.shell.applyMirror("none");
2632
+ this.shell.setMediaMode("image");
2633
+ this.scheduleReveal("image");
2634
+ }
2635
+ scheduleReveal(kind) {
2111
2636
  const started = Date.now();
2112
2637
  const tick = () => {
2113
2638
  this.revealCamera();
2114
2639
  if (this.cameraRevealed) return;
2115
2640
  if (Date.now() - started > 8e3) {
2116
2641
  this.shell?.setBootMessage(
2117
- "camera started but preview is empty \u2014 check permissions",
2642
+ `${kind} loaded but preview is empty \u2014 check the file or CORS`,
2118
2643
  true
2119
2644
  );
2120
2645
  return;
@@ -2122,88 +2647,200 @@ var PoseTrackerClient = class {
2122
2647
  setTimeout(tick, 120);
2123
2648
  };
2124
2649
  setTimeout(tick, 60);
2125
- void this.handleCameraStart({
2126
- backend: this.acceleration.backend ?? "webgl",
2127
- profileId: null
2128
- });
2650
+ }
2651
+ async attachVideoSource(src, opts) {
2652
+ if (!this.shell) throw new Error("no shell");
2653
+ const video = this.shell.video;
2654
+ if (isVideoElement(src)) {
2655
+ if (src.parentElement && src !== video) {
2656
+ this.estimateInput = src;
2657
+ if (opts.autoplay) {
2658
+ try {
2659
+ await src.play();
2660
+ } catch {
2661
+ }
2662
+ }
2663
+ await waitForVideoReady(src);
2664
+ return;
2665
+ }
2666
+ if (src.srcObject) {
2667
+ video.srcObject = src.srcObject;
2668
+ } else if (src.currentSrc || src.src) {
2669
+ video.src = src.currentSrc || src.src;
2670
+ }
2671
+ } else {
2672
+ const resolved = await resolveObjectUrl(src);
2673
+ this.objectUrlRevoke = resolved.revoke;
2674
+ video.src = resolved.url;
2675
+ }
2676
+ video.loop = opts.loop;
2677
+ video.muted = opts.muted;
2678
+ video.playsInline = true;
2679
+ await waitForVideoReady(video);
2680
+ if (opts.autoplay) {
2681
+ try {
2682
+ await video.play();
2683
+ } catch {
2684
+ }
2685
+ }
2686
+ this.estimateInput = video;
2687
+ }
2688
+ async attachImageSource(src) {
2689
+ if (!this.shell) throw new Error("no shell");
2690
+ if (isImageBitmap(src)) {
2691
+ const resolved2 = await imageBitmapToObjectUrl(src);
2692
+ this.objectUrlRevoke = resolved2.revoke;
2693
+ this.shell.image.src = resolved2.url;
2694
+ await waitForImageReady(this.shell.image);
2695
+ this.estimateInput = src;
2696
+ return;
2697
+ }
2698
+ if (isImageElement(src)) {
2699
+ if (src.complete && src.naturalWidth > 0) {
2700
+ this.shell.image.src = src.currentSrc || src.src;
2701
+ this.estimateInput = src;
2702
+ return;
2703
+ }
2704
+ await waitForImageReady(src);
2705
+ this.shell.image.src = src.currentSrc || src.src;
2706
+ this.estimateInput = src;
2707
+ return;
2708
+ }
2709
+ const resolved = await resolveObjectUrl(src);
2710
+ this.objectUrlRevoke = resolved.revoke;
2711
+ this.shell.image.src = resolved.url;
2712
+ await waitForImageReady(this.shell.image);
2713
+ this.estimateInput = this.shell.image;
2129
2714
  }
2130
2715
  beginLoop() {
2131
2716
  this.running = true;
2132
2717
  this.fpsTimer = performance.now();
2133
2718
  void this.loop(++this.loopToken);
2134
2719
  }
2720
+ frameReady(input) {
2721
+ const size = mediaSize(
2722
+ input
2723
+ );
2724
+ if (!(size.width > 0 && size.height > 0)) return false;
2725
+ if (isVideoElement(input)) {
2726
+ if (input.readyState < 2) return false;
2727
+ if (this.poseSource.type === "video" && (input.paused || input.ended)) return false;
2728
+ }
2729
+ return true;
2730
+ }
2731
+ async runOneFrame() {
2732
+ if (!this.adapter || !this.shell || !this.estimateInput) return null;
2733
+ if (!this.frameReady(this.estimateInput)) return null;
2734
+ const input = this.estimateInput;
2735
+ const clientW = this.shell.canvas.clientWidth || this.shell.root.clientWidth || 1;
2736
+ const clientH = this.shell.canvas.clientHeight || this.shell.root.clientHeight || 1;
2737
+ const media = mediaSize(input);
2738
+ const useMediaSpace = this.poseSource.type === "image" && media.width > 0 && media.height > 0;
2739
+ const dispW = useMediaSpace ? media.width : clientW;
2740
+ const dispH = useMediaSpace ? media.height : clientH;
2741
+ const facing = this.poseSource.type === "camera" ? facingFromOptions(this.opts) : "environment";
2742
+ const stillTracked = this.poseSource.type === "image" && this.opts.stillMode === "tracked";
2743
+ const result = await this.adapter.estimate(input, {
2744
+ facingMode: facing,
2745
+ displayWidth: dispW,
2746
+ displayHeight: dispH,
2747
+ temporalSmooth: this.poseSource.type === "camera" || this.poseSource.type === "video" || stillTracked
2748
+ });
2749
+ if (!result) return null;
2750
+ const ctx = this.shell.canvas.getContext("2d");
2751
+ if (ctx && this.opts.drawSkeleton !== false) {
2752
+ const mapped = mapVideoKeypointsToDisplay(
2753
+ result.videoKeypoints,
2754
+ result.letterbox,
2755
+ clientW,
2756
+ clientH
2757
+ );
2758
+ drawSkeleton(ctx, mapped, clientW, clientH, true, this.skeletonDef);
2759
+ } else if (ctx && this.opts.drawSkeleton === false) {
2760
+ ctx.clearRect(0, 0, clientW, clientH);
2761
+ }
2762
+ const pose = {
2763
+ keypoints: result.keypoints,
2764
+ score: result.score,
2765
+ timestampMs: Date.now()
2766
+ };
2767
+ this.ingestPose(pose);
2768
+ this.inferMs.push(result.inferenceMs);
2769
+ if (this.inferMs.length > 30) this.inferMs.shift();
2770
+ this.fpsWindow += 1;
2771
+ const now = performance.now();
2772
+ if (now - this.fpsTimer >= 1e3) {
2773
+ const fps = this.fpsWindow;
2774
+ this.fpsWindow = 0;
2775
+ this.fpsTimer = now;
2776
+ const med = median(this.inferMs);
2777
+ this.acceleration.medianInferenceMs = med;
2778
+ const above = result.keypoints.filter((k) => k.score >= 0.3).length;
2779
+ const backend = "getBackend" in this.adapter && typeof this.adapter.getBackend === "function" ? this.adapter.getBackend() : null;
2780
+ this.acceleration.backend = backend;
2781
+ const size = mediaSize(input);
2782
+ if (this.opts.debugHud) {
2783
+ this.shell.setHud(
2784
+ `${this.resolved.modelId} \xB7 ${this.poseSource.type} \xB7 ${backend ?? "?"} \xB7 ${fps} fps \xB7 ${med != null ? Math.round(med) + " ms" : "?"} \xB7 kp\u22650.3=${above}/17`,
2785
+ true
2786
+ );
2787
+ }
2788
+ this.emit({
2789
+ type: "stats",
2790
+ fps,
2791
+ medianInferenceMs: med,
2792
+ backend,
2793
+ keypointsAbove03: above,
2794
+ meanScore: result.score,
2795
+ videoSize: `${size.width}x${size.height}`,
2796
+ timestampMs: Date.now()
2797
+ });
2798
+ }
2799
+ this.revealCamera();
2800
+ return pose;
2801
+ }
2135
2802
  async loop(token) {
2136
- if (token !== this.loopToken || !this.running || !this.adapter || !this.shell || this.disposed) {
2803
+ if (token !== this.loopToken || !this.running || !this.shell || this.disposed) {
2137
2804
  return;
2138
2805
  }
2139
- const video = this.shell.video;
2140
- if (!this.busy && video.readyState >= 2 && video.videoWidth > 0) {
2806
+ if (!this.adapter) {
2807
+ requestAnimationFrame(() => {
2808
+ void this.loop(token);
2809
+ });
2810
+ return;
2811
+ }
2812
+ const input = this.estimateInput;
2813
+ const shouldInfer = !!input && !this.busy && this.frameReady(input) && (this.poseSource.type !== "image" || this.imageShotPending);
2814
+ if (shouldInfer) {
2141
2815
  this.busy = true;
2142
2816
  try {
2143
- const dispW = this.shell.canvas.clientWidth || this.shell.root.clientWidth || 1;
2144
- const dispH = this.shell.canvas.clientHeight || this.shell.root.clientHeight || 1;
2145
- const result = await this.adapter.estimate(video, {
2146
- facingMode: facingFromOptions(this.opts),
2147
- displayWidth: dispW,
2148
- displayHeight: dispH
2149
- });
2150
- if (result) {
2151
- const ctx = this.shell.canvas.getContext("2d");
2152
- if (ctx && this.opts.drawSkeleton !== false) {
2153
- const mapped = mapVideoKeypointsToDisplay(
2154
- result.videoKeypoints,
2155
- result.letterbox,
2156
- dispW,
2157
- dispH
2158
- );
2159
- drawSkeleton(ctx, mapped, dispW, dispH, true, this.skeletonDef);
2160
- } else if (ctx && this.opts.drawSkeleton === false) {
2161
- ctx.clearRect(0, 0, dispW, dispH);
2162
- }
2163
- const pose = {
2164
- keypoints: result.keypoints,
2165
- score: result.score,
2166
- timestampMs: Date.now()
2167
- };
2168
- this.ingestPose(pose);
2169
- this.inferMs.push(result.inferenceMs);
2170
- if (this.inferMs.length > 30) this.inferMs.shift();
2171
- this.fpsWindow += 1;
2172
- const now = performance.now();
2173
- if (now - this.fpsTimer >= 1e3) {
2174
- const fps = this.fpsWindow;
2175
- this.fpsWindow = 0;
2176
- this.fpsTimer = now;
2177
- const med = median(this.inferMs);
2178
- this.acceleration.medianInferenceMs = med;
2179
- const above = result.keypoints.filter((k) => k.score >= 0.3).length;
2180
- const backend = "getBackend" in this.adapter && typeof this.adapter.getBackend === "function" ? this.adapter.getBackend() : null;
2181
- this.acceleration.backend = backend;
2182
- if (this.opts.debugHud) {
2183
- this.shell.setHud(
2184
- `${this.resolved.modelId} \xB7 ${backend ?? "?"} \xB7 ${fps} fps \xB7 ${med != null ? Math.round(med) + " ms" : "?"} \xB7 kp\u22650.3=${above}/17`,
2185
- true
2186
- );
2187
- }
2188
- this.emit({
2189
- type: "stats",
2190
- fps,
2191
- medianInferenceMs: med,
2192
- backend,
2193
- keypointsAbove03: above,
2194
- meanScore: result.score,
2195
- videoSize: `${video.videoWidth}x${video.videoHeight}`,
2196
- timestampMs: Date.now()
2197
- });
2198
- }
2817
+ const pose = await this.runOneFrame();
2818
+ if (this.poseSource.type === "image" && pose) {
2819
+ this.imageShotPending = false;
2820
+ this.running = false;
2821
+ this.busy = false;
2822
+ return;
2199
2823
  }
2200
- this.revealCamera();
2201
2824
  } catch (err) {
2202
2825
  const message = err instanceof Error ? err.message : String(err);
2203
2826
  this.emit({ type: "error", code: "internal", message: `inference: ${message}` });
2827
+ if (this.poseSource.type === "image") {
2828
+ this.imageShotPending = false;
2829
+ this.running = false;
2830
+ this.busy = false;
2831
+ return;
2832
+ }
2204
2833
  }
2205
2834
  this.busy = false;
2206
2835
  }
2836
+ if (token !== this.loopToken || !this.running) return;
2837
+ const videoEl = input && isVideoElement(input) ? input : this.shell.video;
2838
+ if (this.poseSource.type !== "image" && typeof videoEl.requestVideoFrameCallback === "function" && (this.poseSource.type === "video" || this.poseSource.type === "camera")) {
2839
+ videoEl.requestVideoFrameCallback(() => {
2840
+ void this.loop(token);
2841
+ });
2842
+ return;
2843
+ }
2207
2844
  requestAnimationFrame(() => {
2208
2845
  void this.loop(token);
2209
2846
  });
@@ -2460,14 +3097,19 @@ var PoseTrackerClient = class {
2460
3097
  }
2461
3098
  revealCamera() {
2462
3099
  if (!this.shell || this.cameraRevealed) return;
2463
- const v = this.shell.video;
2464
- if (!(v.videoWidth > 0 && v.videoHeight > 0)) return;
3100
+ const input = this.estimateInput;
3101
+ if (!input) return;
3102
+ const size = mediaSize(input);
3103
+ if (!(size.width > 0 && size.height > 0)) return;
2465
3104
  requestAnimationFrame(() => {
2466
3105
  requestAnimationFrame(() => {
2467
3106
  if (this.cameraRevealed || !this.shell) return;
2468
- if (!(this.shell.video.videoWidth > 0)) return;
2469
3107
  this.cameraRevealed = true;
2470
- this.shell.setCameraVisible(true);
3108
+ if (this.poseSource.type === "image") {
3109
+ this.shell.setMediaMode("image");
3110
+ } else {
3111
+ this.shell.setMediaMode("video");
3112
+ }
2471
3113
  this.applyWatermark();
2472
3114
  });
2473
3115
  });
@@ -2576,6 +3218,7 @@ exports.SDK_VERSION = SDK_VERSION;
2576
3218
  exports.SkeletonFetchError = SkeletonFetchError;
2577
3219
  exports.TrackError = TrackError;
2578
3220
  exports.UsageTracker = UsageTracker;
3221
+ exports.V4_ONLY_EXERCISE_IDS = V4_ONLY_EXERCISE_IDS;
2579
3222
  exports.configure = configure;
2580
3223
  exports.createLocalFileStore = createLocalFileStore;
2581
3224
  exports.createLocalKeyValueStore = createLocalKeyValueStore;
@@ -2594,7 +3237,10 @@ exports.initWebGlBackend = initWebGlBackend;
2594
3237
  exports.isPaidPlan = isPaidPlan;
2595
3238
  exports.mapVideoKeypointsToDisplay = mapVideoKeypointsToDisplay;
2596
3239
  exports.mountDomShell = mountDomShell;
3240
+ exports.normalizeEngineChannel = normalizeEngineChannel;
3241
+ exports.normalizePoseSource = normalizePoseSource;
2597
3242
  exports.openCameraStream = openCameraStream;
3243
+ exports.requiresEngineV4 = requiresEngineV4;
2598
3244
  exports.resolveExerciseName = resolveExerciseName;
2599
3245
  exports.resolveFeatures = resolveFeatures;
2600
3246
  exports.resolvePoseDetection = resolvePoseDetection;