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