@pose-tracker/pose-estimation-web 0.1.0 → 0.2.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.2.0";
7
7
  var SDK_NAME = "posetracker-web";
8
8
 
9
9
  // src/api/configure.ts
@@ -461,6 +461,131 @@ function stopMediaStream(stream) {
461
461
  }
462
462
  }
463
463
 
464
+ // src/media/resolveMedia.ts
465
+ function isVideoElement(v) {
466
+ return typeof HTMLVideoElement !== "undefined" && v instanceof HTMLVideoElement;
467
+ }
468
+ function isImageElement(v) {
469
+ return typeof HTMLImageElement !== "undefined" && v instanceof HTMLImageElement;
470
+ }
471
+ function isImageBitmap(v) {
472
+ return typeof ImageBitmap !== "undefined" && v instanceof ImageBitmap;
473
+ }
474
+ function mediaSize(el) {
475
+ if (isImageBitmap(el)) {
476
+ return { width: el.width || 0, height: el.height || 0 };
477
+ }
478
+ if (isVideoElement(el)) {
479
+ return { width: el.videoWidth || 0, height: el.videoHeight || 0 };
480
+ }
481
+ return {
482
+ width: el.naturalWidth || el.width || 0,
483
+ height: el.naturalHeight || el.height || 0
484
+ };
485
+ }
486
+ async function resolveObjectUrl(src) {
487
+ if (typeof src === "string") {
488
+ return { url: src, revoke: () => void 0 };
489
+ }
490
+ const url = URL.createObjectURL(src);
491
+ return {
492
+ url,
493
+ revoke: () => {
494
+ try {
495
+ URL.revokeObjectURL(url);
496
+ } catch {
497
+ }
498
+ }
499
+ };
500
+ }
501
+ function waitForVideoReady(video, timeoutMs = 15e3) {
502
+ return new Promise((resolve, reject) => {
503
+ if (video.readyState >= 2 && video.videoWidth > 0) {
504
+ resolve();
505
+ return;
506
+ }
507
+ const onReady = () => {
508
+ cleanup();
509
+ resolve();
510
+ };
511
+ const onError = () => {
512
+ cleanup();
513
+ reject(new Error("Failed to load video source"));
514
+ };
515
+ const timer = setTimeout(() => {
516
+ cleanup();
517
+ reject(new Error("Timed out waiting for video metadata"));
518
+ }, timeoutMs);
519
+ const cleanup = () => {
520
+ clearTimeout(timer);
521
+ video.removeEventListener("loadeddata", onReady);
522
+ video.removeEventListener("error", onError);
523
+ };
524
+ video.addEventListener("loadeddata", onReady);
525
+ video.addEventListener("error", onError);
526
+ });
527
+ }
528
+ function waitForImageReady(img, timeoutMs = 15e3) {
529
+ return new Promise((resolve, reject) => {
530
+ if (img.complete && img.naturalWidth > 0) {
531
+ resolve();
532
+ return;
533
+ }
534
+ const onReady = () => {
535
+ cleanup();
536
+ resolve();
537
+ };
538
+ const onError = () => {
539
+ cleanup();
540
+ reject(new Error("Failed to load image source"));
541
+ };
542
+ const timer = setTimeout(() => {
543
+ cleanup();
544
+ reject(new Error("Timed out waiting for image"));
545
+ }, timeoutMs);
546
+ const cleanup = () => {
547
+ clearTimeout(timer);
548
+ img.removeEventListener("load", onReady);
549
+ img.removeEventListener("error", onError);
550
+ };
551
+ img.addEventListener("load", onReady);
552
+ img.addEventListener("error", onError);
553
+ });
554
+ }
555
+ async function imageBitmapToObjectUrl(bitmap) {
556
+ const canvas = document.createElement("canvas");
557
+ canvas.width = bitmap.width;
558
+ canvas.height = bitmap.height;
559
+ const ctx = canvas.getContext("2d");
560
+ if (!ctx) throw new Error("2d context unavailable");
561
+ ctx.drawImage(bitmap, 0, 0);
562
+ const blob = await new Promise((resolve, reject) => {
563
+ canvas.toBlob((b) => b ? resolve(b) : reject(new Error("toBlob failed")), "image/png");
564
+ });
565
+ return resolveObjectUrl(blob);
566
+ }
567
+
568
+ // src/types/source.ts
569
+ function normalizePoseSource(source, extras) {
570
+ if (source && typeof source === "object" && "type" in source) {
571
+ return source;
572
+ }
573
+ const kind = source === "video" || source === "image" || source === "camera" ? source : "camera";
574
+ if (kind === "camera") {
575
+ return { type: "camera", facingMode: extras?.facingMode };
576
+ }
577
+ const src = extras?.sourceFile ?? extras?.sourceUrl;
578
+ if (!src) {
579
+ throw new Error(
580
+ `PoseSource type "${kind}" requires sourceUrl or sourceFile (or pass a full PoseSource object)`
581
+ );
582
+ }
583
+ if (kind === "video") {
584
+ return { type: "video", src };
585
+ }
586
+ return { type: "image", src };
587
+ }
588
+
464
589
  // src/exercises/aliases.ts
465
590
  var EXERCISE_ALIASES = {
466
591
  face_squat: "squat",
@@ -913,6 +1038,18 @@ async function resolvePoseDetection(options) {
913
1038
  }
914
1039
 
915
1040
  // src/models/adapters/blazepose.ts
1041
+ function blazeInputSize(input) {
1042
+ if (typeof HTMLVideoElement !== "undefined" && input instanceof HTMLVideoElement) {
1043
+ return { vw: input.videoWidth || 0, vh: input.videoHeight || 0 };
1044
+ }
1045
+ if (typeof HTMLImageElement !== "undefined" && input instanceof HTMLImageElement) {
1046
+ return { vw: input.naturalWidth || input.width || 0, vh: input.naturalHeight || input.height || 0 };
1047
+ }
1048
+ if (typeof HTMLCanvasElement !== "undefined" && input instanceof HTMLCanvasElement) {
1049
+ return { vw: input.width || 0, vh: input.height || 0 };
1050
+ }
1051
+ return { vw: input.width || 0, vh: input.height || 0 };
1052
+ }
916
1053
  var BLAZE_TO_COCO = {
917
1054
  nose: "nose",
918
1055
  left_eye: "left_eye",
@@ -956,11 +1093,12 @@ var BlazePoseAdapter = class {
956
1093
  }
957
1094
  );
958
1095
  }
959
- async estimate(video, options) {
1096
+ async estimate(input, options) {
960
1097
  if (!this.detector) throw new Error("BlazePose adapter not loaded");
961
- if (!(video.videoWidth > 0 && video.videoHeight > 0)) return null;
1098
+ const size = blazeInputSize(input);
1099
+ if (!(size.vw > 0 && size.vh > 0)) return null;
962
1100
  const t0 = performance.now();
963
- const poses = await this.detector.estimatePoses(video, {
1101
+ const poses = await this.detector.estimatePoses(input, {
964
1102
  flipHorizontal: false,
965
1103
  maxPoses: 1
966
1104
  });
@@ -980,15 +1118,15 @@ var BlazePoseAdapter = class {
980
1118
  letterbox: {
981
1119
  offsetX: 0,
982
1120
  offsetY: 0,
983
- drawW: video.videoWidth,
984
- drawH: video.videoHeight,
985
- vw: video.videoWidth,
986
- vh: video.videoHeight
1121
+ drawW: size.vw,
1122
+ drawH: size.vh,
1123
+ vw: size.vw,
1124
+ vh: size.vh
987
1125
  }
988
1126
  };
989
1127
  }
990
- const vw = video.videoWidth || 1;
991
- const vh = video.videoHeight || 1;
1128
+ const vw = size.vw || 1;
1129
+ const vh = size.vh || 1;
992
1130
  const dispW = options.displayWidth || 1;
993
1131
  const dispH = options.displayHeight || 1;
994
1132
  const byName = /* @__PURE__ */ new Map();
@@ -1054,6 +1192,18 @@ var BlazePoseAdapter = class {
1054
1192
  };
1055
1193
 
1056
1194
  // src/models/adapters/movenet.ts
1195
+ function inputSize(input) {
1196
+ if (typeof HTMLVideoElement !== "undefined" && input instanceof HTMLVideoElement) {
1197
+ return { vw: input.videoWidth || 0, vh: input.videoHeight || 0 };
1198
+ }
1199
+ if (typeof HTMLImageElement !== "undefined" && input instanceof HTMLImageElement) {
1200
+ return { vw: input.naturalWidth || input.width || 0, vh: input.naturalHeight || input.height || 0 };
1201
+ }
1202
+ if (typeof HTMLCanvasElement !== "undefined" && input instanceof HTMLCanvasElement) {
1203
+ return { vw: input.width || 0, vh: input.height || 0 };
1204
+ }
1205
+ return { vw: input.width || 0, vh: input.height || 0 };
1206
+ }
1057
1207
  var INPUT_SIZE = 192;
1058
1208
  var SMOOTH_ALPHA = 0.5;
1059
1209
  function modelNormToVideo(xNorm, yNorm, lb) {
@@ -1117,9 +1267,10 @@ var MoveNetGraphAdapter = class {
1117
1267
  if (!this.offCtx) throw new Error("2d context unavailable");
1118
1268
  return this.offCtx;
1119
1269
  }
1120
- prepareInput(video) {
1121
- const vw = video.videoWidth || 1;
1122
- const vh = video.videoHeight || 1;
1270
+ prepareInput(input) {
1271
+ const { vw: rawW, vh: rawH } = inputSize(input);
1272
+ const vw = rawW || 1;
1273
+ const vh = rawH || 1;
1123
1274
  const scale = Math.min(INPUT_SIZE / vw, INPUT_SIZE / vh);
1124
1275
  const drawW = vw * scale;
1125
1276
  const drawH = vh * scale;
@@ -1129,7 +1280,7 @@ var MoveNetGraphAdapter = class {
1129
1280
  const c2d = this.poseCanvasCtx();
1130
1281
  c2d.fillStyle = "#000";
1131
1282
  c2d.fillRect(0, 0, INPUT_SIZE, INPUT_SIZE);
1132
- c2d.drawImage(video, 0, 0, vw, vh, offsetX, offsetY, drawW, drawH);
1283
+ c2d.drawImage(input, 0, 0, vw, vh, offsetX, offsetY, drawW, drawH);
1133
1284
  return this.offscreen;
1134
1285
  }
1135
1286
  smooth(raw) {
@@ -1145,16 +1296,19 @@ var MoveNetGraphAdapter = class {
1145
1296
  }
1146
1297
  return this.smoothed;
1147
1298
  }
1148
- async estimate(video, options) {
1299
+ async estimate(input, options) {
1149
1300
  if (!this.tf || !this.model) throw new Error("MoveNet adapter not loaded");
1150
- if (!(video.videoWidth > 0 && video.videoHeight > 0)) return null;
1301
+ const size = inputSize(input);
1302
+ if (!(size.vw > 0 && size.vh > 0)) return null;
1151
1303
  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);
1304
+ const canvas = this.prepareInput(input);
1305
+ const tensorIn = this.tf.tidy(
1306
+ () => this.tf.expandDims(this.tf.browser.fromPixels(canvas), 0)
1307
+ );
1308
+ const out = this.model.execute(tensorIn);
1155
1309
  const tensor = Array.isArray(out) ? out[0] : out;
1156
1310
  const data = tensor.dataSync();
1157
- input.dispose();
1311
+ tensorIn.dispose();
1158
1312
  if (Array.isArray(out)) out.forEach((t) => t.dispose());
1159
1313
  else out.dispose();
1160
1314
  const inferenceMs = performance.now() - t0;
@@ -1283,11 +1437,11 @@ var SHELL_CSS = `
1283
1437
  position: relative; width: 100%; height: 100%; min-height: 240px;
1284
1438
  background: #000; overflow: hidden; isolation: isolate;
1285
1439
  }
1286
- .pt-root video, .pt-root canvas.pt-overlay {
1440
+ .pt-root video, .pt-root img.pt-still, .pt-root canvas.pt-overlay {
1287
1441
  position: absolute; inset: 0; width: 100%; height: 100%;
1288
1442
  object-fit: cover; opacity: 0; transition: opacity 180ms ease-out;
1289
1443
  }
1290
- .pt-root video { z-index: 1; background: #000; }
1444
+ .pt-root video, .pt-root img.pt-still { z-index: 1; background: #000; }
1291
1445
  .pt-root canvas.pt-overlay { z-index: 2; pointer-events: none; }
1292
1446
  .pt-boot {
1293
1447
  position: absolute; inset: 0; z-index: 4;
@@ -1361,6 +1515,7 @@ function mountDomShell(host, options = {}) {
1361
1515
  root.className = "pt-root";
1362
1516
  root.innerHTML = `
1363
1517
  <video playsinline muted autoplay></video>
1518
+ <img class="pt-still" alt="" draggable="false" />
1364
1519
  <canvas class="pt-overlay"></canvas>
1365
1520
  <div class="pt-boot">
1366
1521
  <div class="pt-boot-powered">Powered by</div>
@@ -1377,6 +1532,7 @@ function mountDomShell(host, options = {}) {
1377
1532
  `;
1378
1533
  host.appendChild(root);
1379
1534
  const video = root.querySelector("video");
1535
+ const image = root.querySelector("img.pt-still");
1380
1536
  const canvas = root.querySelector("canvas.pt-overlay");
1381
1537
  const boot = root.querySelector(".pt-boot");
1382
1538
  const bootMsg = root.querySelector(".pt-boot-msg");
@@ -1386,6 +1542,7 @@ function mountDomShell(host, options = {}) {
1386
1542
  return {
1387
1543
  root,
1388
1544
  video,
1545
+ image,
1389
1546
  canvas,
1390
1547
  boot,
1391
1548
  bootMsg,
@@ -1397,10 +1554,21 @@ function mountDomShell(host, options = {}) {
1397
1554
  bootMsg.classList.toggle("is-error", isError);
1398
1555
  },
1399
1556
  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) {
1557
+ if (visible) {
1558
+ this.setMediaMode(image.src ? "image" : "video");
1559
+ } else {
1560
+ this.setMediaMode("none");
1561
+ }
1562
+ },
1563
+ setMediaMode(mode) {
1564
+ const showVideo = mode === "video";
1565
+ const showImage = mode === "image";
1566
+ const showMedia = showVideo || showImage;
1567
+ video.style.opacity = showVideo ? "1" : "0";
1568
+ image.style.opacity = showImage ? "1" : "0";
1569
+ canvas.style.opacity = showMedia ? "1" : "0";
1570
+ boot.classList.toggle("hide", showMedia);
1571
+ if (!showMedia) {
1404
1572
  bootMsg.textContent = loadingText;
1405
1573
  bootMsg.classList.remove("is-error");
1406
1574
  }
@@ -1425,6 +1593,7 @@ function mountDomShell(host, options = {}) {
1425
1593
  applyMirror(facingMode) {
1426
1594
  const t = facingMode === "user" ? "scaleX(-1)" : "none";
1427
1595
  video.style.transform = t;
1596
+ image.style.transform = "none";
1428
1597
  canvas.style.transform = t;
1429
1598
  },
1430
1599
  destroy() {
@@ -1628,6 +1797,10 @@ var PoseTrackerClient = class {
1628
1797
  this.keypointsSuppressionLogged = false;
1629
1798
  this.shell = null;
1630
1799
  this.stream = null;
1800
+ this.poseSource = { type: "camera" };
1801
+ this.objectUrlRevoke = null;
1802
+ this.estimateInput = null;
1803
+ this.imageShotPending = false;
1631
1804
  this.adapter = null;
1632
1805
  this.running = false;
1633
1806
  this.busy = false;
@@ -1654,6 +1827,15 @@ var PoseTrackerClient = class {
1654
1827
  this.resolved = resolvePoseModel({ model: options.model, modelUrl: options.modelUrl });
1655
1828
  this.skeletonDef = options.skeletonDef ?? null;
1656
1829
  this.coldStartMode = options.coldStart === "full" ? "full" : "basic";
1830
+ try {
1831
+ this.poseSource = normalizePoseSource(options.source, {
1832
+ sourceUrl: options.sourceUrl,
1833
+ sourceFile: options.sourceFile,
1834
+ facingMode: facingFromOptions(options)
1835
+ });
1836
+ } catch {
1837
+ this.poseSource = { type: "camera", facingMode: facingFromOptions(options) };
1838
+ }
1657
1839
  this.files = options.fileStore !== void 0 ? options.fileStore : createWebFileStore();
1658
1840
  this.engineLoader = options.engineLoader ?? new EngineLoader({
1659
1841
  fileStore: this.files,
@@ -1700,6 +1882,10 @@ var PoseTrackerClient = class {
1700
1882
  getColdStartMode() {
1701
1883
  return this.coldStartMode;
1702
1884
  }
1885
+ /** Active pose input source (`camera` | `video` | `image`). */
1886
+ getSource() {
1887
+ return this.poseSource;
1888
+ }
1703
1889
  addEventListener(listener) {
1704
1890
  this.listeners.add(listener);
1705
1891
  return () => this.listeners.delete(listener);
@@ -1763,31 +1949,98 @@ var PoseTrackerClient = class {
1763
1949
  warmup(options) {
1764
1950
  return this.preload(options);
1765
1951
  }
1766
- /** Mount + load model + open camera + start loop (camera-screen convenience). */
1952
+ /**
1953
+ * Mount + load model + open source + start inference.
1954
+ * Camera / video stream continuously; image runs a single shot (re-run with {@link analyze}).
1955
+ */
1767
1956
  async start() {
1768
1957
  if (this.disposed) throw new Error("PoseTracker disposed");
1769
1958
  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");
1959
+ const needsMedia = this.poseSource.type === "camera" || this.poseSource.type === "video" || this.poseSource.type === "image";
1960
+ await this.preload({
1961
+ coldStart: this.opts.coldStart === "basic" && this.poseSource.type === "camera" ? "basic" : needsMedia ? "full" : "basic"
1962
+ });
1963
+ if (this.coldStartMode === "basic" || !this.estimateInput) {
1964
+ await this.ensureSource();
1965
+ }
1966
+ if (this.poseSource.type === "image") {
1967
+ this.imageShotPending = true;
1773
1968
  }
1774
1969
  this.beginLoop();
1775
1970
  }
1971
+ /**
1972
+ * Switch input source. Stops the current media, attaches the new source, and
1973
+ * restarts the loop when previously running.
1974
+ */
1975
+ async setSource(source, extras) {
1976
+ if (this.disposed) throw new Error("PoseTracker disposed");
1977
+ const wasRunning = this.running;
1978
+ this.stopMediaOnly();
1979
+ this.poseSource = normalizePoseSource(source, {
1980
+ sourceUrl: extras?.sourceUrl ?? this.opts.sourceUrl,
1981
+ sourceFile: extras?.sourceFile ?? this.opts.sourceFile,
1982
+ facingMode: extras?.facingMode ?? facingFromOptions(this.opts)
1983
+ });
1984
+ this.opts.source = this.poseSource;
1985
+ if (this.poseSource.type === "camera" && this.poseSource.facingMode) {
1986
+ this.opts.facingMode = this.poseSource.facingMode;
1987
+ }
1988
+ if (!this.shell) return;
1989
+ await this.ensureSource();
1990
+ if (wasRunning || this.poseSource.type === "image") {
1991
+ if (this.poseSource.type === "image") this.imageShotPending = true;
1992
+ this.beginLoop();
1993
+ }
1994
+ }
1995
+ /**
1996
+ * Re-run pose estimation on the current still image (no-op for camera/video
1997
+ * while the loop is already running — for those, call {@link start}).
1998
+ */
1999
+ async analyze() {
2000
+ if (this.disposed) throw new Error("PoseTracker disposed");
2001
+ if (!this.shell) throw new Error("Call mount() before analyze()");
2002
+ await this.ensureAdapter();
2003
+ if (!this.estimateInput) await this.ensureSource();
2004
+ if (this.poseSource.type === "image") {
2005
+ this.imageShotPending = true;
2006
+ if (!this.running) this.beginLoop();
2007
+ return null;
2008
+ }
2009
+ return this.runOneFrame();
2010
+ }
1776
2011
  stop() {
1777
2012
  this.running = false;
1778
2013
  this.loopToken += 1;
1779
2014
  this.busy = false;
1780
- stopMediaStream(this.stream);
1781
- this.stream = null;
2015
+ this.imageShotPending = false;
2016
+ this.stopMediaOnly();
1782
2017
  if (this.shell) {
1783
- this.shell.video.srcObject = null;
1784
- this.shell.setCameraVisible(false);
2018
+ this.shell.setMediaMode("none");
1785
2019
  this.shell.setPlacementVisible(false);
1786
2020
  this.cameraRevealed = false;
1787
2021
  this.applyWatermark();
1788
2022
  }
1789
2023
  if (this.status !== "error") this.setStatus("idle");
1790
2024
  }
2025
+ stopMediaOnly() {
2026
+ stopMediaStream(this.stream);
2027
+ this.stream = null;
2028
+ this.estimateInput = null;
2029
+ if (this.objectUrlRevoke) {
2030
+ this.objectUrlRevoke();
2031
+ this.objectUrlRevoke = null;
2032
+ }
2033
+ if (this.shell) {
2034
+ try {
2035
+ this.shell.video.pause();
2036
+ } catch {
2037
+ }
2038
+ this.shell.video.removeAttribute("src");
2039
+ this.shell.video.srcObject = null;
2040
+ this.shell.video.load();
2041
+ this.shell.image.removeAttribute("src");
2042
+ }
2043
+ }
1791
2044
  async dispose() {
1792
2045
  this.stopExercise();
1793
2046
  this.stop();
@@ -2056,7 +2309,7 @@ var PoseTrackerClient = class {
2056
2309
  async ensureColdStartMode(mode) {
2057
2310
  if (mode !== "full") return;
2058
2311
  if (!this.shell) return;
2059
- await this.ensureCamera();
2312
+ await this.ensureSource();
2060
2313
  }
2061
2314
  async ensureAdapter() {
2062
2315
  if (this.adapter) return;
@@ -2073,48 +2326,102 @@ var PoseTrackerClient = class {
2073
2326
  this.adapter = createPoseAdapter({ resolved: this.resolved });
2074
2327
  await this.adapter.load();
2075
2328
  }
2076
- async ensureCamera() {
2077
- if (!this.shell) throw new Error("Call mount() before opening the camera");
2078
- if (this.stream) return;
2329
+ async ensureSource() {
2330
+ if (!this.shell) throw new Error("Call mount() before opening a pose source");
2331
+ const src = this.poseSource;
2332
+ if (src.type === "camera") {
2333
+ if (this.stream && this.estimateInput) return;
2334
+ this.emit({
2335
+ type: "initialization",
2336
+ step: "accessing_webcam",
2337
+ message: "accessing webcam (source=camera)",
2338
+ ready: false
2339
+ });
2340
+ try {
2341
+ this.stream = await openCameraStream({
2342
+ facingMode: src.facingMode ?? facingFromOptions(this.opts),
2343
+ idealWidth: this.opts.idealWidth,
2344
+ idealHeight: this.opts.idealHeight
2345
+ });
2346
+ } catch (err) {
2347
+ const msg = err instanceof Error ? err.message : String(err);
2348
+ const denied = /NotAllowed|Permission|denied/i.test(msg);
2349
+ this.reportError({
2350
+ type: "error",
2351
+ code: denied ? "camera_denied" : "camera_unavailable",
2352
+ message: msg
2353
+ });
2354
+ this.shell.setBootMessage(
2355
+ denied ? "Camera permission denied \u2014 allow access in the browser" : "Camera unavailable",
2356
+ true
2357
+ );
2358
+ throw err;
2359
+ }
2360
+ this.shell.video.srcObject = this.stream;
2361
+ this.shell.applyMirror(src.facingMode ?? facingFromOptions(this.opts));
2362
+ this.shell.setMediaMode("video");
2363
+ try {
2364
+ await this.shell.video.play();
2365
+ } catch {
2366
+ }
2367
+ this.estimateInput = this.shell.video;
2368
+ this.scheduleReveal("camera");
2369
+ void this.handleCameraStart({
2370
+ backend: this.acceleration.backend ?? "webgl",
2371
+ profileId: null
2372
+ });
2373
+ return;
2374
+ }
2375
+ if (src.type === "video") {
2376
+ this.emit({
2377
+ type: "initialization",
2378
+ step: "loading_media",
2379
+ message: "loading video (source=video)",
2380
+ ready: false
2381
+ });
2382
+ try {
2383
+ await this.attachVideoSource(src.src, {
2384
+ autoplay: src.autoplay !== false,
2385
+ loop: src.loop !== false,
2386
+ muted: src.muted !== false
2387
+ });
2388
+ } catch (err) {
2389
+ const msg = err instanceof Error ? err.message : String(err);
2390
+ this.reportError({ type: "error", code: "media_load_failed", message: msg });
2391
+ this.shell.setBootMessage("Could not load video source", true);
2392
+ throw err;
2393
+ }
2394
+ this.shell.applyMirror("none");
2395
+ this.shell.setMediaMode("video");
2396
+ this.scheduleReveal("video");
2397
+ return;
2398
+ }
2079
2399
  this.emit({
2080
2400
  type: "initialization",
2081
- step: "accessing_webcam",
2082
- message: "accessing webcam",
2401
+ step: "loading_media",
2402
+ message: "loading image (source=image)",
2083
2403
  ready: false
2084
2404
  });
2085
2405
  try {
2086
- this.stream = await openCameraStream({
2087
- facingMode: facingFromOptions(this.opts),
2088
- idealWidth: this.opts.idealWidth,
2089
- idealHeight: this.opts.idealHeight
2090
- });
2406
+ await this.attachImageSource(src.src);
2091
2407
  } catch (err) {
2092
2408
  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
- );
2409
+ this.reportError({ type: "error", code: "media_load_failed", message: msg });
2410
+ this.shell.setBootMessage("Could not load image source", true);
2103
2411
  throw err;
2104
2412
  }
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
- }
2413
+ this.shell.applyMirror("none");
2414
+ this.shell.setMediaMode("image");
2415
+ this.scheduleReveal("image");
2416
+ }
2417
+ scheduleReveal(kind) {
2111
2418
  const started = Date.now();
2112
2419
  const tick = () => {
2113
2420
  this.revealCamera();
2114
2421
  if (this.cameraRevealed) return;
2115
2422
  if (Date.now() - started > 8e3) {
2116
2423
  this.shell?.setBootMessage(
2117
- "camera started but preview is empty \u2014 check permissions",
2424
+ `${kind} loaded but preview is empty \u2014 check the file or CORS`,
2118
2425
  true
2119
2426
  );
2120
2427
  return;
@@ -2122,88 +2429,182 @@ var PoseTrackerClient = class {
2122
2429
  setTimeout(tick, 120);
2123
2430
  };
2124
2431
  setTimeout(tick, 60);
2125
- void this.handleCameraStart({
2126
- backend: this.acceleration.backend ?? "webgl",
2127
- profileId: null
2128
- });
2432
+ }
2433
+ async attachVideoSource(src, opts) {
2434
+ if (!this.shell) throw new Error("no shell");
2435
+ const video = this.shell.video;
2436
+ if (isVideoElement(src)) {
2437
+ if (src.parentElement && src !== video) {
2438
+ this.estimateInput = src;
2439
+ if (opts.autoplay) {
2440
+ try {
2441
+ await src.play();
2442
+ } catch {
2443
+ }
2444
+ }
2445
+ await waitForVideoReady(src);
2446
+ return;
2447
+ }
2448
+ if (src.srcObject) {
2449
+ video.srcObject = src.srcObject;
2450
+ } else if (src.currentSrc || src.src) {
2451
+ video.src = src.currentSrc || src.src;
2452
+ }
2453
+ } else {
2454
+ const resolved = await resolveObjectUrl(src);
2455
+ this.objectUrlRevoke = resolved.revoke;
2456
+ video.src = resolved.url;
2457
+ }
2458
+ video.loop = opts.loop;
2459
+ video.muted = opts.muted;
2460
+ video.playsInline = true;
2461
+ await waitForVideoReady(video);
2462
+ if (opts.autoplay) {
2463
+ try {
2464
+ await video.play();
2465
+ } catch {
2466
+ }
2467
+ }
2468
+ this.estimateInput = video;
2469
+ }
2470
+ async attachImageSource(src) {
2471
+ if (!this.shell) throw new Error("no shell");
2472
+ if (isImageBitmap(src)) {
2473
+ const resolved2 = await imageBitmapToObjectUrl(src);
2474
+ this.objectUrlRevoke = resolved2.revoke;
2475
+ this.shell.image.src = resolved2.url;
2476
+ await waitForImageReady(this.shell.image);
2477
+ this.estimateInput = src;
2478
+ return;
2479
+ }
2480
+ if (isImageElement(src)) {
2481
+ if (src.complete && src.naturalWidth > 0) {
2482
+ this.shell.image.src = src.currentSrc || src.src;
2483
+ this.estimateInput = src;
2484
+ return;
2485
+ }
2486
+ await waitForImageReady(src);
2487
+ this.shell.image.src = src.currentSrc || src.src;
2488
+ this.estimateInput = src;
2489
+ return;
2490
+ }
2491
+ const resolved = await resolveObjectUrl(src);
2492
+ this.objectUrlRevoke = resolved.revoke;
2493
+ this.shell.image.src = resolved.url;
2494
+ await waitForImageReady(this.shell.image);
2495
+ this.estimateInput = this.shell.image;
2129
2496
  }
2130
2497
  beginLoop() {
2131
2498
  this.running = true;
2132
2499
  this.fpsTimer = performance.now();
2133
2500
  void this.loop(++this.loopToken);
2134
2501
  }
2502
+ frameReady(input) {
2503
+ const size = mediaSize(
2504
+ input
2505
+ );
2506
+ if (!(size.width > 0 && size.height > 0)) return false;
2507
+ if (isVideoElement(input)) {
2508
+ if (input.readyState < 2) return false;
2509
+ if (this.poseSource.type === "video" && (input.paused || input.ended)) return false;
2510
+ }
2511
+ return true;
2512
+ }
2513
+ async runOneFrame() {
2514
+ if (!this.adapter || !this.shell || !this.estimateInput) return null;
2515
+ if (!this.frameReady(this.estimateInput)) return null;
2516
+ const input = this.estimateInput;
2517
+ const dispW = this.shell.canvas.clientWidth || this.shell.root.clientWidth || 1;
2518
+ const dispH = this.shell.canvas.clientHeight || this.shell.root.clientHeight || 1;
2519
+ const facing = this.poseSource.type === "camera" ? facingFromOptions(this.opts) : "environment";
2520
+ const result = await this.adapter.estimate(input, {
2521
+ facingMode: facing,
2522
+ displayWidth: dispW,
2523
+ displayHeight: dispH
2524
+ });
2525
+ if (!result) return null;
2526
+ const ctx = this.shell.canvas.getContext("2d");
2527
+ if (ctx && this.opts.drawSkeleton !== false) {
2528
+ const mapped = mapVideoKeypointsToDisplay(
2529
+ result.videoKeypoints,
2530
+ result.letterbox,
2531
+ dispW,
2532
+ dispH
2533
+ );
2534
+ drawSkeleton(ctx, mapped, dispW, dispH, true, this.skeletonDef);
2535
+ } else if (ctx && this.opts.drawSkeleton === false) {
2536
+ ctx.clearRect(0, 0, dispW, dispH);
2537
+ }
2538
+ const pose = {
2539
+ keypoints: result.keypoints,
2540
+ score: result.score,
2541
+ timestampMs: Date.now()
2542
+ };
2543
+ this.ingestPose(pose);
2544
+ this.inferMs.push(result.inferenceMs);
2545
+ if (this.inferMs.length > 30) this.inferMs.shift();
2546
+ this.fpsWindow += 1;
2547
+ const now = performance.now();
2548
+ if (now - this.fpsTimer >= 1e3) {
2549
+ const fps = this.fpsWindow;
2550
+ this.fpsWindow = 0;
2551
+ this.fpsTimer = now;
2552
+ const med = median(this.inferMs);
2553
+ this.acceleration.medianInferenceMs = med;
2554
+ const above = result.keypoints.filter((k) => k.score >= 0.3).length;
2555
+ const backend = "getBackend" in this.adapter && typeof this.adapter.getBackend === "function" ? this.adapter.getBackend() : null;
2556
+ this.acceleration.backend = backend;
2557
+ const size = mediaSize(input);
2558
+ if (this.opts.debugHud) {
2559
+ this.shell.setHud(
2560
+ `${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`,
2561
+ true
2562
+ );
2563
+ }
2564
+ this.emit({
2565
+ type: "stats",
2566
+ fps,
2567
+ medianInferenceMs: med,
2568
+ backend,
2569
+ keypointsAbove03: above,
2570
+ meanScore: result.score,
2571
+ videoSize: `${size.width}x${size.height}`,
2572
+ timestampMs: Date.now()
2573
+ });
2574
+ }
2575
+ this.revealCamera();
2576
+ return pose;
2577
+ }
2135
2578
  async loop(token) {
2136
2579
  if (token !== this.loopToken || !this.running || !this.adapter || !this.shell || this.disposed) {
2137
2580
  return;
2138
2581
  }
2139
- const video = this.shell.video;
2140
- if (!this.busy && video.readyState >= 2 && video.videoWidth > 0) {
2582
+ const input = this.estimateInput;
2583
+ const shouldInfer = !!input && !this.busy && this.frameReady(input) && (this.poseSource.type !== "image" || this.imageShotPending);
2584
+ if (shouldInfer) {
2141
2585
  this.busy = true;
2142
2586
  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
- }
2587
+ await this.runOneFrame();
2588
+ if (this.poseSource.type === "image") {
2589
+ this.imageShotPending = false;
2590
+ this.running = false;
2591
+ this.busy = false;
2592
+ return;
2199
2593
  }
2200
- this.revealCamera();
2201
2594
  } catch (err) {
2202
2595
  const message = err instanceof Error ? err.message : String(err);
2203
2596
  this.emit({ type: "error", code: "internal", message: `inference: ${message}` });
2204
2597
  }
2205
2598
  this.busy = false;
2206
2599
  }
2600
+ if (token !== this.loopToken || !this.running) return;
2601
+ const videoEl = input && isVideoElement(input) ? input : this.shell.video;
2602
+ if (this.poseSource.type !== "image" && typeof videoEl.requestVideoFrameCallback === "function" && (this.poseSource.type === "video" || this.poseSource.type === "camera")) {
2603
+ videoEl.requestVideoFrameCallback(() => {
2604
+ void this.loop(token);
2605
+ });
2606
+ return;
2607
+ }
2207
2608
  requestAnimationFrame(() => {
2208
2609
  void this.loop(token);
2209
2610
  });
@@ -2460,14 +2861,19 @@ var PoseTrackerClient = class {
2460
2861
  }
2461
2862
  revealCamera() {
2462
2863
  if (!this.shell || this.cameraRevealed) return;
2463
- const v = this.shell.video;
2464
- if (!(v.videoWidth > 0 && v.videoHeight > 0)) return;
2864
+ const input = this.estimateInput;
2865
+ if (!input) return;
2866
+ const size = mediaSize(input);
2867
+ if (!(size.width > 0 && size.height > 0)) return;
2465
2868
  requestAnimationFrame(() => {
2466
2869
  requestAnimationFrame(() => {
2467
2870
  if (this.cameraRevealed || !this.shell) return;
2468
- if (!(this.shell.video.videoWidth > 0)) return;
2469
2871
  this.cameraRevealed = true;
2470
- this.shell.setCameraVisible(true);
2872
+ if (this.poseSource.type === "image") {
2873
+ this.shell.setMediaMode("image");
2874
+ } else {
2875
+ this.shell.setMediaMode("video");
2876
+ }
2471
2877
  this.applyWatermark();
2472
2878
  });
2473
2879
  });
@@ -2594,6 +3000,7 @@ exports.initWebGlBackend = initWebGlBackend;
2594
3000
  exports.isPaidPlan = isPaidPlan;
2595
3001
  exports.mapVideoKeypointsToDisplay = mapVideoKeypointsToDisplay;
2596
3002
  exports.mountDomShell = mountDomShell;
3003
+ exports.normalizePoseSource = normalizePoseSource;
2597
3004
  exports.openCameraStream = openCameraStream;
2598
3005
  exports.resolveExerciseName = resolveExerciseName;
2599
3006
  exports.resolveFeatures = resolveFeatures;