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