@pose-tracker/pose-estimation-web 0.1.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/LICENSE +41 -0
- package/README.md +91 -0
- package/THIRD_PARTY_NOTICES.md +17 -0
- package/dist/index.cjs +2607 -0
- package/dist/index.d.cts +1325 -0
- package/dist/index.d.ts +1325 -0
- package/dist/index.js +2552 -0
- package/dist/pose-tracker.global.js +94 -0
- package/package.json +80 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2607 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var jsSha256 = require('js-sha256');
|
|
4
|
+
|
|
5
|
+
// src/version.ts
|
|
6
|
+
var SDK_VERSION = "0.1.0";
|
|
7
|
+
var SDK_NAME = "posetracker-web";
|
|
8
|
+
|
|
9
|
+
// src/api/configure.ts
|
|
10
|
+
var DEFAULT_BASE_URL = "https://movelytics-strapi-c78a339b7070.herokuapp.com";
|
|
11
|
+
var ConfigureError = class extends Error {
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.name = "ConfigureError";
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
async function configure(apiToken, options = {}) {
|
|
19
|
+
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
20
|
+
const body = {
|
|
21
|
+
...apiToken ? { apiToken } : {},
|
|
22
|
+
sdkName: SDK_NAME,
|
|
23
|
+
sdkVersion: SDK_VERSION,
|
|
24
|
+
targetPlatform: "web",
|
|
25
|
+
poseModelProfile: options.poseModelProfile ?? "AdaptiveChoice",
|
|
26
|
+
locale: options.locale,
|
|
27
|
+
localVersions: options.localVersions
|
|
28
|
+
};
|
|
29
|
+
let response;
|
|
30
|
+
try {
|
|
31
|
+
response = await fetch(`${baseUrl}/api/sdk/configure`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "Content-Type": "application/json" },
|
|
34
|
+
body: JSON.stringify(body)
|
|
35
|
+
});
|
|
36
|
+
} catch (err) {
|
|
37
|
+
throw new ConfigureError("network", `Handshake request failed: ${String(err)}`);
|
|
38
|
+
}
|
|
39
|
+
if (response.status === 401 || response.status === 403) {
|
|
40
|
+
throw new ConfigureError("invalid_token", "Invalid, revoked or unauthorized API token.");
|
|
41
|
+
}
|
|
42
|
+
if (response.status === 429) {
|
|
43
|
+
throw new ConfigureError("quota_exceeded", "API call quota exceeded for the current plan.");
|
|
44
|
+
}
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
throw new ConfigureError("internal", `Handshake failed with HTTP ${response.status}.`);
|
|
47
|
+
}
|
|
48
|
+
return await response.json();
|
|
49
|
+
}
|
|
50
|
+
var MAGIC = "ptc1:";
|
|
51
|
+
function deriveCacheSecret(apiToken) {
|
|
52
|
+
return jsSha256.sha256(`posetracker-sdk-cache:${apiToken}`);
|
|
53
|
+
}
|
|
54
|
+
function utf8Encode(text) {
|
|
55
|
+
const escaped = unescape(encodeURIComponent(text));
|
|
56
|
+
const bytes = new Uint8Array(escaped.length);
|
|
57
|
+
for (let i = 0; i < escaped.length; i++) {
|
|
58
|
+
bytes[i] = escaped.charCodeAt(i);
|
|
59
|
+
}
|
|
60
|
+
return bytes;
|
|
61
|
+
}
|
|
62
|
+
function utf8Decode(bytes) {
|
|
63
|
+
let escaped = "";
|
|
64
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
65
|
+
escaped += String.fromCharCode(bytes[i]);
|
|
66
|
+
}
|
|
67
|
+
return decodeURIComponent(escape(escaped));
|
|
68
|
+
}
|
|
69
|
+
function toHex(bytes) {
|
|
70
|
+
let hex = "";
|
|
71
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
72
|
+
hex += bytes[i].toString(16).padStart(2, "0");
|
|
73
|
+
}
|
|
74
|
+
return hex;
|
|
75
|
+
}
|
|
76
|
+
function fromHex(hex) {
|
|
77
|
+
if (hex.length % 2 !== 0 || /[^0-9a-f]/.test(hex)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
81
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
82
|
+
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
83
|
+
}
|
|
84
|
+
return bytes;
|
|
85
|
+
}
|
|
86
|
+
function xorWithKeystream(bytes, secret) {
|
|
87
|
+
const out = new Uint8Array(bytes.length);
|
|
88
|
+
let counter = 0;
|
|
89
|
+
let offset = 0;
|
|
90
|
+
while (offset < bytes.length) {
|
|
91
|
+
const block = jsSha256.sha256.array(`${secret}:${counter++}`);
|
|
92
|
+
for (let i = 0; i < block.length && offset < bytes.length; i++, offset++) {
|
|
93
|
+
out[offset] = bytes[offset] ^ block[i];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
function sealString(plain, secret) {
|
|
99
|
+
return MAGIC + toHex(xorWithKeystream(utf8Encode(plain), secret));
|
|
100
|
+
}
|
|
101
|
+
function openString(sealed, secret) {
|
|
102
|
+
if (!sealed.startsWith(MAGIC)) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const bytes = fromHex(sealed.slice(MAGIC.length));
|
|
106
|
+
if (!bytes) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
return utf8Decode(xorWithKeystream(bytes, secret));
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/engine/EngineLoader.ts
|
|
117
|
+
var LS_PREFIX = "posetracker.web.";
|
|
118
|
+
function createLocalKeyValueStore() {
|
|
119
|
+
if (typeof localStorage === "undefined") return null;
|
|
120
|
+
return {
|
|
121
|
+
async getItem(key) {
|
|
122
|
+
try {
|
|
123
|
+
return localStorage.getItem(LS_PREFIX + key);
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
async setItem(key, value) {
|
|
129
|
+
try {
|
|
130
|
+
localStorage.setItem(LS_PREFIX + key, value);
|
|
131
|
+
} catch {
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
async removeItem(key) {
|
|
135
|
+
try {
|
|
136
|
+
localStorage.removeItem(LS_PREFIX + key);
|
|
137
|
+
} catch {
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function createLocalFileStore() {
|
|
143
|
+
const kv = createLocalKeyValueStore();
|
|
144
|
+
if (!kv) return null;
|
|
145
|
+
return {
|
|
146
|
+
read: (key) => kv.getItem(`file.${key}`),
|
|
147
|
+
write: (key, contents) => kv.setItem(`file.${key}`, contents),
|
|
148
|
+
remove: (key) => kv.removeItem(`file.${key}`)
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function createMemoryFileStore() {
|
|
152
|
+
const entries = /* @__PURE__ */ new Map();
|
|
153
|
+
return {
|
|
154
|
+
async read(key) {
|
|
155
|
+
return entries.has(key) ? entries.get(key) : null;
|
|
156
|
+
},
|
|
157
|
+
async write(key, contents) {
|
|
158
|
+
entries.set(key, contents);
|
|
159
|
+
},
|
|
160
|
+
async remove(key) {
|
|
161
|
+
entries.delete(key);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function createWebFileStore() {
|
|
166
|
+
return createLocalFileStore() ?? createMemoryFileStore();
|
|
167
|
+
}
|
|
168
|
+
var GUARD_KEY_PREFIX = "posetracker.engine.guard.";
|
|
169
|
+
var EngineLoader = class {
|
|
170
|
+
constructor(options = {}) {
|
|
171
|
+
this.lastError = null;
|
|
172
|
+
this.files = options.fileStore !== void 0 ? options.fileStore : createWebFileStore();
|
|
173
|
+
this.kv = options.keyValueStore !== void 0 ? options.keyValueStore : createLocalKeyValueStore();
|
|
174
|
+
this.fetchFn = options.fetchFn ?? fetch;
|
|
175
|
+
this.onDiagnostic = options.onDiagnostic;
|
|
176
|
+
this.onDiagnostic?.(
|
|
177
|
+
`[posetracker-web] EngineLoader fileStore=${this.files ? "yes" : "none"} kv=${this.kv ? "localStorage" : "none"}`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
async clearGuard(descriptor) {
|
|
181
|
+
if (!descriptor || !this.kv) return;
|
|
182
|
+
const guardKey = `${GUARD_KEY_PREFIX}${descriptor.version}.${descriptor.sha256}`;
|
|
183
|
+
await this.kv.removeItem(guardKey).catch(() => {
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
async load(descriptor, cacheSecret, options) {
|
|
187
|
+
this.lastError = null;
|
|
188
|
+
if (!descriptor) {
|
|
189
|
+
this.lastError = "manifest has no engine descriptor";
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const guardKey = `${GUARD_KEY_PREFIX}${descriptor.version}.${descriptor.sha256}`;
|
|
193
|
+
if (options?.forceRetry) {
|
|
194
|
+
await this.kv?.removeItem(guardKey).catch(() => {
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const guardState = await this.kv?.getItem(guardKey);
|
|
198
|
+
if (guardState === "probing" || guardState === "failed") {
|
|
199
|
+
await this.kv?.setItem(guardKey, "failed");
|
|
200
|
+
this.lastError = `engine crash-guard blocked version=${descriptor.version} (state=${guardState}) \u2014 call configure() again to force retry`;
|
|
201
|
+
this.onDiagnostic?.(`[posetracker-web] ${this.lastError}`);
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const cacheKey = `engine-${descriptor.version}.sealed`;
|
|
205
|
+
const sealed = await this.files?.read(cacheKey);
|
|
206
|
+
if (sealed !== null && sealed !== void 0) {
|
|
207
|
+
const code = openString(sealed, cacheSecret);
|
|
208
|
+
if (code !== null && jsSha256.sha256(code) === descriptor.sha256) {
|
|
209
|
+
const engine = await this.evaluate(code, guardKey);
|
|
210
|
+
if (engine) {
|
|
211
|
+
this.onDiagnostic?.(
|
|
212
|
+
`[posetracker-web] engine loaded from cache version=${descriptor.version}`
|
|
213
|
+
);
|
|
214
|
+
return { engine, source: "remote-cache" };
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
this.onDiagnostic?.(
|
|
218
|
+
`[posetracker-web] engine cache purged (unseal/integrity mismatch) key=${cacheKey}`
|
|
219
|
+
);
|
|
220
|
+
await this.files?.remove(cacheKey);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
this.onDiagnostic?.(
|
|
225
|
+
`[posetracker-web] downloading engine version=${descriptor.version}\u2026`
|
|
226
|
+
);
|
|
227
|
+
const response = await this.fetchFn(descriptor.signedUrl);
|
|
228
|
+
if (!response.ok) {
|
|
229
|
+
throw new Error(`HTTP ${response.status}`);
|
|
230
|
+
}
|
|
231
|
+
const code = await response.text();
|
|
232
|
+
const digest = jsSha256.sha256(code);
|
|
233
|
+
if (digest !== descriptor.sha256) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`integrity check failed (got ${digest.slice(0, 12)}\u2026 expected ${descriptor.sha256.slice(0, 12)}\u2026, bytes=${code.length})`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
const engine = await this.evaluate(code, guardKey);
|
|
239
|
+
if (engine) {
|
|
240
|
+
try {
|
|
241
|
+
await this.files?.write(cacheKey, sealString(code, cacheSecret));
|
|
242
|
+
} catch (err) {
|
|
243
|
+
this.onDiagnostic?.(
|
|
244
|
+
`[posetracker-web] engine cache write failed (non-fatal): ` + (err instanceof Error ? err.message : String(err))
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
this.onDiagnostic?.(
|
|
248
|
+
`[posetracker-web] engine loaded from download version=${descriptor.version} bytes=${code.length}`
|
|
249
|
+
);
|
|
250
|
+
return { engine, source: "remote-download" };
|
|
251
|
+
}
|
|
252
|
+
} catch (err) {
|
|
253
|
+
this.lastError = err instanceof Error ? err.message : String(err);
|
|
254
|
+
this.onDiagnostic?.(`[posetracker-web] engine download/load failed: ${this.lastError}`);
|
|
255
|
+
}
|
|
256
|
+
if (!this.lastError) {
|
|
257
|
+
this.lastError = "engine evaluate returned null";
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
async evaluate(code, guardKey) {
|
|
262
|
+
await this.kv?.setItem(guardKey, "probing");
|
|
263
|
+
try {
|
|
264
|
+
const moduleRef = { exports: {} };
|
|
265
|
+
const run = new Function("module", "exports", code);
|
|
266
|
+
run(moduleRef, moduleRef.exports);
|
|
267
|
+
const factory = moduleRef.exports.createEngine;
|
|
268
|
+
if (typeof factory !== "function") {
|
|
269
|
+
throw new Error(
|
|
270
|
+
`engine bundle does not export createEngine() (got ${typeof factory}, keys=${Object.keys(moduleRef.exports).join(",")})`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
const engine = factory();
|
|
274
|
+
await this.kv?.setItem(guardKey, "passed");
|
|
275
|
+
return engine;
|
|
276
|
+
} catch (err) {
|
|
277
|
+
this.lastError = "engine evaluate failed: " + (err instanceof Error ? err.message : String(err));
|
|
278
|
+
this.onDiagnostic?.(`[posetracker-web] ${this.lastError}`);
|
|
279
|
+
await this.kv?.setItem(guardKey, "failed");
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// src/api/track.ts
|
|
286
|
+
var QUEUE_KEY = "posetracker.track.queue";
|
|
287
|
+
var QUEUE_LIMIT = 100;
|
|
288
|
+
var TrackError = class extends Error {
|
|
289
|
+
constructor(code, message) {
|
|
290
|
+
super(message);
|
|
291
|
+
this.code = code;
|
|
292
|
+
this.name = "TrackError";
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
var UsageTracker = class {
|
|
296
|
+
constructor(options = {}) {
|
|
297
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
298
|
+
this.kv = options.keyValueStore !== void 0 ? options.keyValueStore : createLocalKeyValueStore();
|
|
299
|
+
this.fetchFn = options.fetchFn ?? fetch;
|
|
300
|
+
}
|
|
301
|
+
async trackMetered(request) {
|
|
302
|
+
return this.post({
|
|
303
|
+
event: request.event ?? "camera_start",
|
|
304
|
+
apiToken: request.apiToken,
|
|
305
|
+
platform: "web",
|
|
306
|
+
sdkVersion: SDK_VERSION,
|
|
307
|
+
params: request.params
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
async trackAnonymous(request = {}) {
|
|
311
|
+
const payload = {
|
|
312
|
+
event: request.event ?? "camera_start",
|
|
313
|
+
platform: "web",
|
|
314
|
+
sdkVersion: SDK_VERSION,
|
|
315
|
+
params: request.params,
|
|
316
|
+
queuedAtMs: Date.now()
|
|
317
|
+
};
|
|
318
|
+
try {
|
|
319
|
+
await this.post({ ...payload });
|
|
320
|
+
void this.flushQueue();
|
|
321
|
+
} catch {
|
|
322
|
+
await this.enqueue(payload);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
async flushQueue() {
|
|
326
|
+
const queue = await this.readQueue();
|
|
327
|
+
if (queue.length === 0) return;
|
|
328
|
+
const remaining = [];
|
|
329
|
+
for (const item of queue) {
|
|
330
|
+
try {
|
|
331
|
+
await this.post({
|
|
332
|
+
...item,
|
|
333
|
+
params: { ...item.params ?? {}, queuedAtMs: item.queuedAtMs }
|
|
334
|
+
});
|
|
335
|
+
} catch {
|
|
336
|
+
remaining.push(item);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
await this.writeQueue(remaining);
|
|
340
|
+
}
|
|
341
|
+
async post(body) {
|
|
342
|
+
let response;
|
|
343
|
+
try {
|
|
344
|
+
response = await this.fetchFn(`${this.baseUrl}/api/sdk/track`, {
|
|
345
|
+
method: "POST",
|
|
346
|
+
headers: { "Content-Type": "application/json" },
|
|
347
|
+
body: JSON.stringify(body)
|
|
348
|
+
});
|
|
349
|
+
} catch (err) {
|
|
350
|
+
throw new TrackError("network", `Usage tracking request failed: ${String(err)}`);
|
|
351
|
+
}
|
|
352
|
+
if (response.status === 401 || response.status === 403) {
|
|
353
|
+
throw new TrackError("invalid_token", "Invalid, revoked or unauthorized API token.");
|
|
354
|
+
}
|
|
355
|
+
if (response.status === 429) {
|
|
356
|
+
throw new TrackError("quota_exceeded", "API call quota exceeded for the current plan.");
|
|
357
|
+
}
|
|
358
|
+
if (!response.ok) {
|
|
359
|
+
throw new TrackError("internal", `Usage tracking failed with HTTP ${response.status}.`);
|
|
360
|
+
}
|
|
361
|
+
return await response.json();
|
|
362
|
+
}
|
|
363
|
+
async readQueue() {
|
|
364
|
+
try {
|
|
365
|
+
const raw = await this.kv?.getItem(QUEUE_KEY);
|
|
366
|
+
if (!raw) return [];
|
|
367
|
+
const parsed = JSON.parse(raw);
|
|
368
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
369
|
+
} catch {
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async writeQueue(queue) {
|
|
374
|
+
try {
|
|
375
|
+
if (queue.length === 0) {
|
|
376
|
+
await this.kv?.removeItem(QUEUE_KEY);
|
|
377
|
+
} else {
|
|
378
|
+
await this.kv?.setItem(QUEUE_KEY, JSON.stringify(queue.slice(-QUEUE_LIMIT)));
|
|
379
|
+
}
|
|
380
|
+
} catch {
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
async enqueue(item) {
|
|
384
|
+
const queue = await this.readQueue();
|
|
385
|
+
queue.push(item);
|
|
386
|
+
await this.writeQueue(queue);
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// src/api/skeleton.ts
|
|
391
|
+
var SkeletonFetchError = class extends Error {
|
|
392
|
+
constructor(code, message) {
|
|
393
|
+
super(message);
|
|
394
|
+
this.code = code;
|
|
395
|
+
this.name = "SkeletonFetchError";
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
async function fetchSkeletonDefinition(uuid, options = {}) {
|
|
399
|
+
const id = uuid.trim();
|
|
400
|
+
if (!id || id === "true" || id === "false") {
|
|
401
|
+
throw new SkeletonFetchError("invalid", "skeleton uuid is required");
|
|
402
|
+
}
|
|
403
|
+
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
404
|
+
let response;
|
|
405
|
+
try {
|
|
406
|
+
response = await fetch(`${baseUrl}/api/sdk/skeleton?uuid=${encodeURIComponent(id)}`);
|
|
407
|
+
} catch (err) {
|
|
408
|
+
throw new SkeletonFetchError("network", `Skeleton fetch failed: ${String(err)}`);
|
|
409
|
+
}
|
|
410
|
+
if (response.status === 404) {
|
|
411
|
+
throw new SkeletonFetchError("not_found", `Skeleton '${id}' not found`);
|
|
412
|
+
}
|
|
413
|
+
if (!response.ok) {
|
|
414
|
+
throw new SkeletonFetchError("network", `Skeleton fetch HTTP ${response.status}`);
|
|
415
|
+
}
|
|
416
|
+
const body = await response.json();
|
|
417
|
+
if (!body?.keypoints || !body?.keypoint_lines || !body?.circles || !body?.lines) {
|
|
418
|
+
throw new SkeletonFetchError("invalid", "Skeleton payload incomplete");
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
keypoints: body.keypoints,
|
|
422
|
+
keypoint_lines: body.keypoint_lines,
|
|
423
|
+
keypoint_angles: body.keypoint_angles ?? [],
|
|
424
|
+
circles: body.circles,
|
|
425
|
+
lines: body.lines,
|
|
426
|
+
angles: body.angles
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// src/camera/getUserMedia.ts
|
|
431
|
+
async function openCameraStream(options = {}) {
|
|
432
|
+
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {
|
|
433
|
+
throw new Error("getUserMedia is not available in this environment");
|
|
434
|
+
}
|
|
435
|
+
const facingMode = options.facingMode ?? "user";
|
|
436
|
+
const constraints = {
|
|
437
|
+
audio: false,
|
|
438
|
+
video: {
|
|
439
|
+
facingMode,
|
|
440
|
+
width: options.idealWidth ? { ideal: options.idealWidth } : void 0,
|
|
441
|
+
height: options.idealHeight ? { ideal: options.idealHeight } : void 0,
|
|
442
|
+
frameRate: options.idealFrameRate ? { ideal: options.idealFrameRate } : void 0
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
try {
|
|
446
|
+
return await navigator.mediaDevices.getUserMedia(constraints);
|
|
447
|
+
} catch {
|
|
448
|
+
return navigator.mediaDevices.getUserMedia({
|
|
449
|
+
audio: false,
|
|
450
|
+
video: { facingMode }
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
function stopMediaStream(stream) {
|
|
455
|
+
if (!stream) return;
|
|
456
|
+
for (const track of stream.getTracks()) {
|
|
457
|
+
try {
|
|
458
|
+
track.stop();
|
|
459
|
+
} catch {
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// src/exercises/aliases.ts
|
|
465
|
+
var EXERCISE_ALIASES = {
|
|
466
|
+
face_squat: "squat",
|
|
467
|
+
face_pushup: "push_up",
|
|
468
|
+
face_plank: "plank",
|
|
469
|
+
face_lunge: "lunge",
|
|
470
|
+
face_jumping_jack: "jumping_jack",
|
|
471
|
+
face_low_impact_jack: "low_impact_jack",
|
|
472
|
+
face_balance_leg: "balance_leg",
|
|
473
|
+
face_balance_leg_left: "balance_leg_left",
|
|
474
|
+
face_balance_leg_right: "balance_leg_right",
|
|
475
|
+
jump: "air_time_jump"
|
|
476
|
+
};
|
|
477
|
+
function resolveExerciseName(name) {
|
|
478
|
+
return EXERCISE_ALIASES[name] ?? name;
|
|
479
|
+
}
|
|
480
|
+
function findExerciseByIdOrAlias(exerciseId, exercises) {
|
|
481
|
+
const exact = exercises.find((e) => e.id === exerciseId);
|
|
482
|
+
if (exact) return exact;
|
|
483
|
+
const canonical = resolveExerciseName(exerciseId);
|
|
484
|
+
const legacyIds = Object.entries(EXERCISE_ALIASES).filter(([, v3]) => v3 === canonical).map(([legacy]) => legacy);
|
|
485
|
+
for (const legacy of legacyIds) {
|
|
486
|
+
const hit = exercises.find((e) => e.id === legacy);
|
|
487
|
+
if (hit) return hit;
|
|
488
|
+
}
|
|
489
|
+
return exercises.find((e) => resolveExerciseName(e.id) === canonical);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// src/events/classicMessage.ts
|
|
493
|
+
var ANGLE_RELATIONS = {
|
|
494
|
+
left_knee: "from_hip_to_ankle",
|
|
495
|
+
right_knee: "from_hip_to_ankle",
|
|
496
|
+
left_hip: "from_knee_to_shoulder",
|
|
497
|
+
right_hip: "from_knee_to_shoulder",
|
|
498
|
+
left_shoulder: "from_hip_to_elbow",
|
|
499
|
+
right_shoulder: "from_hip_to_elbow",
|
|
500
|
+
left_elbow: "from_shoulder_to_wrist",
|
|
501
|
+
right_elbow: "from_shoulder_to_wrist"
|
|
502
|
+
};
|
|
503
|
+
function anglesToClassicTree(angles) {
|
|
504
|
+
const data = {
|
|
505
|
+
left_side: {},
|
|
506
|
+
right_side: {}
|
|
507
|
+
};
|
|
508
|
+
for (const a of angles) {
|
|
509
|
+
if (a.side !== "left" && a.side !== "right") continue;
|
|
510
|
+
const side = a.side === "left" ? data.left_side : data.right_side;
|
|
511
|
+
const joint = a.id.replace(/^(left_|right_)/, "");
|
|
512
|
+
const angleType = `${joint}_angle`;
|
|
513
|
+
const relation = ANGLE_RELATIONS[a.id] ?? "value";
|
|
514
|
+
if (!side[angleType]) side[angleType] = {};
|
|
515
|
+
side[angleType][relation] = a.degrees;
|
|
516
|
+
side[angleType][`score_${relation}`] = a.score;
|
|
517
|
+
}
|
|
518
|
+
return data;
|
|
519
|
+
}
|
|
520
|
+
function toClassicNativeMessage(event) {
|
|
521
|
+
switch (event.type) {
|
|
522
|
+
case "initialization":
|
|
523
|
+
return {
|
|
524
|
+
type: "initialization",
|
|
525
|
+
// WebView-literal parity: TrackingAppV3 emits exactly
|
|
526
|
+
// "checking you plan and access" (sic) while the plan loads — hosts
|
|
527
|
+
// migrating from the WebView may string-match it.
|
|
528
|
+
message: event.step === "configuring" ? "checking you plan and access" : event.message ?? humanInitStep(event.step),
|
|
529
|
+
ready: event.ready,
|
|
530
|
+
step: event.step,
|
|
531
|
+
...event.mode != null ? { mode: event.mode } : {},
|
|
532
|
+
...event.acceleration != null ? { acceleration: event.acceleration } : {}
|
|
533
|
+
};
|
|
534
|
+
case "error":
|
|
535
|
+
return {
|
|
536
|
+
type: "error",
|
|
537
|
+
error: event.code,
|
|
538
|
+
message: event.message
|
|
539
|
+
};
|
|
540
|
+
case "warning":
|
|
541
|
+
return {
|
|
542
|
+
type: "warning",
|
|
543
|
+
error: event.code,
|
|
544
|
+
message: event.message
|
|
545
|
+
};
|
|
546
|
+
case "keypoints":
|
|
547
|
+
return {
|
|
548
|
+
type: "keypoints",
|
|
549
|
+
data: event.keypoints.map((k) => ({
|
|
550
|
+
name: k.name,
|
|
551
|
+
x: k.x,
|
|
552
|
+
y: k.y,
|
|
553
|
+
...k.z != null ? { z: k.z } : {},
|
|
554
|
+
score: k.score
|
|
555
|
+
})),
|
|
556
|
+
score: event.score,
|
|
557
|
+
timestampMs: event.timestampMs
|
|
558
|
+
};
|
|
559
|
+
case "stats":
|
|
560
|
+
return {
|
|
561
|
+
type: "stats",
|
|
562
|
+
fps: event.fps,
|
|
563
|
+
medianInferenceMs: event.medianInferenceMs,
|
|
564
|
+
backend: event.backend,
|
|
565
|
+
keypointsAbove03: event.keypointsAbove03,
|
|
566
|
+
meanScore: event.meanScore,
|
|
567
|
+
videoSize: event.videoSize,
|
|
568
|
+
timestampMs: event.timestampMs
|
|
569
|
+
};
|
|
570
|
+
case "angles":
|
|
571
|
+
return {
|
|
572
|
+
type: "angles",
|
|
573
|
+
// WebView V3 shape: nested left_side / right_side tree (not the typed flat array).
|
|
574
|
+
data: anglesToClassicTree(event.angles),
|
|
575
|
+
timestampMs: event.timestampMs
|
|
576
|
+
};
|
|
577
|
+
case "counter":
|
|
578
|
+
return {
|
|
579
|
+
type: "counter",
|
|
580
|
+
current_count: event.count,
|
|
581
|
+
// GitBook: authoritative grade for a counted rep lives on counter.form_score.
|
|
582
|
+
...event.formScore != null ? {
|
|
583
|
+
form_score: {
|
|
584
|
+
score: event.formScore.score,
|
|
585
|
+
avg_score: event.formScore.average,
|
|
586
|
+
grade: event.formScore.grade
|
|
587
|
+
}
|
|
588
|
+
} : {},
|
|
589
|
+
...event.referenceScore != null ? { reference_score: { overallScore: event.referenceScore } } : {},
|
|
590
|
+
timestampMs: event.timestampMs
|
|
591
|
+
};
|
|
592
|
+
case "posture":
|
|
593
|
+
return {
|
|
594
|
+
type: "posture",
|
|
595
|
+
message: event.hint,
|
|
596
|
+
ready: event.ready,
|
|
597
|
+
direction: event.direction ?? "",
|
|
598
|
+
requirements: { missingKeypoints: event.missingKeypoints },
|
|
599
|
+
timestampMs: event.timestampMs
|
|
600
|
+
};
|
|
601
|
+
case "progression":
|
|
602
|
+
return {
|
|
603
|
+
type: "progression",
|
|
604
|
+
value: event.value,
|
|
605
|
+
timestampMs: event.timestampMs
|
|
606
|
+
};
|
|
607
|
+
case "recommendations":
|
|
608
|
+
return {
|
|
609
|
+
type: "recommendations",
|
|
610
|
+
data: event.recommendations,
|
|
611
|
+
timestampMs: event.timestampMs
|
|
612
|
+
};
|
|
613
|
+
case "form_score":
|
|
614
|
+
return {
|
|
615
|
+
type: "form_score",
|
|
616
|
+
score: event.score,
|
|
617
|
+
avg_score: event.average,
|
|
618
|
+
grade: event.grade,
|
|
619
|
+
timestampMs: event.timestampMs
|
|
620
|
+
};
|
|
621
|
+
case "exercise_summary":
|
|
622
|
+
return {
|
|
623
|
+
type: "exercise_summary",
|
|
624
|
+
exercise: event.exercise,
|
|
625
|
+
counter: event.counter,
|
|
626
|
+
avg_rep_score: event.averageFormScore,
|
|
627
|
+
avg_similarity: event.averageSimilarity,
|
|
628
|
+
grade: event.grade,
|
|
629
|
+
history: event.history,
|
|
630
|
+
durationMs: event.durationMs,
|
|
631
|
+
timestampMs: event.timestampMs
|
|
632
|
+
};
|
|
633
|
+
// Custom jump exercises: the classic contract already uses these exact
|
|
634
|
+
// type strings and camelCase field names — pass through minus timestampMs
|
|
635
|
+
// restructuring (front payloads have no timestampMs, but extra fields are
|
|
636
|
+
// harmless for string-matching hosts).
|
|
637
|
+
case "jump_calibration":
|
|
638
|
+
return {
|
|
639
|
+
type: "jump_calibration",
|
|
640
|
+
calibrated: event.calibrated,
|
|
641
|
+
cmPerPixel: event.cmPerPixel,
|
|
642
|
+
baselineY: event.baselineY,
|
|
643
|
+
visibleHips: event.visibleHips,
|
|
644
|
+
message: event.message,
|
|
645
|
+
timestampMs: event.timestampMs
|
|
646
|
+
};
|
|
647
|
+
case "jump_started":
|
|
648
|
+
return {
|
|
649
|
+
type: "jump_started",
|
|
650
|
+
message: event.message,
|
|
651
|
+
timestampMs: event.timestampMs
|
|
652
|
+
};
|
|
653
|
+
case "jump_height": {
|
|
654
|
+
const { type, timestampMs, ...rest } = event;
|
|
655
|
+
return { type, ...rest, timestampMs };
|
|
656
|
+
}
|
|
657
|
+
case "jump_discarded":
|
|
658
|
+
return {
|
|
659
|
+
type: "jump_discarded",
|
|
660
|
+
reason: event.reason,
|
|
661
|
+
userMessage: event.userMessage,
|
|
662
|
+
timestampMs: event.timestampMs
|
|
663
|
+
};
|
|
664
|
+
case "jump_result": {
|
|
665
|
+
const { type, timestampMs, ...rest } = event;
|
|
666
|
+
return { type, ...rest, timestampMs };
|
|
667
|
+
}
|
|
668
|
+
case "jump_summary": {
|
|
669
|
+
const { type, timestampMs, ...rest } = event;
|
|
670
|
+
return { type, ...rest, timestampMs };
|
|
671
|
+
}
|
|
672
|
+
case "performance_warning":
|
|
673
|
+
return {
|
|
674
|
+
type: "error",
|
|
675
|
+
error: event.code,
|
|
676
|
+
message: event.message,
|
|
677
|
+
meanFps: event.meanFps,
|
|
678
|
+
thresholdFps: event.thresholdFps,
|
|
679
|
+
activeProfile: event.activeProfile,
|
|
680
|
+
medianInferenceMs: event.medianInferenceMs,
|
|
681
|
+
videoSize: event.videoSize,
|
|
682
|
+
timestampMs: event.timestampMs
|
|
683
|
+
};
|
|
684
|
+
case "quality_changed":
|
|
685
|
+
return {
|
|
686
|
+
type: "warning",
|
|
687
|
+
error: "quality_downgraded",
|
|
688
|
+
message: event.detail,
|
|
689
|
+
previousProfile: event.previousProfile,
|
|
690
|
+
activeProfile: event.activeProfile,
|
|
691
|
+
reason: event.reason,
|
|
692
|
+
timestampMs: event.timestampMs
|
|
693
|
+
};
|
|
694
|
+
case "runtime_download_progress":
|
|
695
|
+
return {
|
|
696
|
+
type: "initialization",
|
|
697
|
+
message: `downloading pose runtime ${event.completedParts + 1}/${event.totalParts}`,
|
|
698
|
+
ready: false,
|
|
699
|
+
step: "downloading",
|
|
700
|
+
part: event.part,
|
|
701
|
+
completedParts: event.completedParts,
|
|
702
|
+
totalParts: event.totalParts,
|
|
703
|
+
timestampMs: event.timestampMs
|
|
704
|
+
};
|
|
705
|
+
default: {
|
|
706
|
+
const _exhaustive = event;
|
|
707
|
+
return { type: "unknown", event: _exhaustive };
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
function humanInitStep(step) {
|
|
712
|
+
switch (step) {
|
|
713
|
+
case "configuring":
|
|
714
|
+
return "checking you plan and access";
|
|
715
|
+
case "downloading":
|
|
716
|
+
return "downloading engine";
|
|
717
|
+
case "warming":
|
|
718
|
+
return "loading pose model";
|
|
719
|
+
case "ready":
|
|
720
|
+
return "running";
|
|
721
|
+
default:
|
|
722
|
+
return step;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// src/types/pose.ts
|
|
727
|
+
var COCO_KEYPOINT_NAMES = [
|
|
728
|
+
"nose",
|
|
729
|
+
"left_eye",
|
|
730
|
+
"right_eye",
|
|
731
|
+
"left_ear",
|
|
732
|
+
"right_ear",
|
|
733
|
+
"left_shoulder",
|
|
734
|
+
"right_shoulder",
|
|
735
|
+
"left_elbow",
|
|
736
|
+
"right_elbow",
|
|
737
|
+
"left_wrist",
|
|
738
|
+
"right_wrist",
|
|
739
|
+
"left_hip",
|
|
740
|
+
"right_hip",
|
|
741
|
+
"left_knee",
|
|
742
|
+
"right_knee",
|
|
743
|
+
"left_ankle",
|
|
744
|
+
"right_ankle"
|
|
745
|
+
];
|
|
746
|
+
|
|
747
|
+
// src/tf/loadTf.ts
|
|
748
|
+
var ONLINE_TFJS_VERSION = "4.22.0";
|
|
749
|
+
var JSDELIVR = "https://cdn.jsdelivr.net/npm";
|
|
750
|
+
function defaultTfjsCdnUrls(cdnBase = JSDELIVR, tfjsVersion = ONLINE_TFJS_VERSION) {
|
|
751
|
+
const base = cdnBase.replace(/\/$/, "");
|
|
752
|
+
return {
|
|
753
|
+
scriptUrls: [
|
|
754
|
+
`${base}/@tensorflow/tfjs-core@${tfjsVersion}/dist/tf-core.min.js`,
|
|
755
|
+
`${base}/@tensorflow/tfjs-converter@${tfjsVersion}/dist/tf-converter.min.js`,
|
|
756
|
+
`${base}/@tensorflow/tfjs-backend-webgl@${tfjsVersion}/dist/tf-backend-webgl.min.js`,
|
|
757
|
+
`${base}/@tensorflow/tfjs-backend-wasm@${tfjsVersion}/dist/tf-backend-wasm.min.js`
|
|
758
|
+
],
|
|
759
|
+
wasmPath: `${base}/@tensorflow/tfjs-backend-wasm@${tfjsVersion}/dist/`
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
var cached = null;
|
|
763
|
+
function applyProductFlags(tf) {
|
|
764
|
+
try {
|
|
765
|
+
tf.env().set("WEBGL_CPU_FORWARD", false);
|
|
766
|
+
} catch {
|
|
767
|
+
}
|
|
768
|
+
try {
|
|
769
|
+
tf.env().set("WEBGL_PACK", false);
|
|
770
|
+
} catch {
|
|
771
|
+
}
|
|
772
|
+
try {
|
|
773
|
+
tf.env().set("WEBGL_FORCE_F16_TEXTURES", false);
|
|
774
|
+
} catch {
|
|
775
|
+
}
|
|
776
|
+
try {
|
|
777
|
+
tf.env().set("WEBGL_RENDER_FLOAT32_ENABLED", false);
|
|
778
|
+
} catch {
|
|
779
|
+
}
|
|
780
|
+
try {
|
|
781
|
+
tf.env().set("WEBGL_FLUSH_THRESHOLD", 1.75);
|
|
782
|
+
} catch {
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
function importTfPeer() {
|
|
786
|
+
const importer = new Function("s", "return import(s)");
|
|
787
|
+
return importer("@tensorflow/tfjs");
|
|
788
|
+
}
|
|
789
|
+
async function resolveTf() {
|
|
790
|
+
if (cached) return cached;
|
|
791
|
+
if (typeof window !== "undefined" && window.tf) {
|
|
792
|
+
cached = window.tf;
|
|
793
|
+
return window.tf;
|
|
794
|
+
}
|
|
795
|
+
try {
|
|
796
|
+
const mod = await importTfPeer();
|
|
797
|
+
cached = mod;
|
|
798
|
+
return mod;
|
|
799
|
+
} catch {
|
|
800
|
+
}
|
|
801
|
+
throw new Error(
|
|
802
|
+
"TensorFlow.js not found. Install peer `@tensorflow/tfjs` or load CDN scripts (see README) so `window.tf` is available."
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
async function initWebGlBackend(tf) {
|
|
806
|
+
applyProductFlags(tf);
|
|
807
|
+
try {
|
|
808
|
+
const ok = await tf.setBackend("webgl");
|
|
809
|
+
await tf.ready();
|
|
810
|
+
if (ok !== false && tf.getBackend() === "webgl") return "webgl";
|
|
811
|
+
} catch {
|
|
812
|
+
}
|
|
813
|
+
await tf.setBackend("cpu");
|
|
814
|
+
await tf.ready();
|
|
815
|
+
return "cpu";
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// src/tf/loadPoseDetection.ts
|
|
819
|
+
var ONLINE_POSE_DETECTION_VERSION = "2.1.3";
|
|
820
|
+
var JSDELIVR2 = "https://cdn.jsdelivr.net/npm";
|
|
821
|
+
var cached2 = null;
|
|
822
|
+
var injectPromise = null;
|
|
823
|
+
function defaultPoseDetectionCdnUrl(cdnBase = JSDELIVR2, version = ONLINE_POSE_DETECTION_VERSION) {
|
|
824
|
+
const base = cdnBase.replace(/\/$/, "");
|
|
825
|
+
return `${base}/@tensorflow-models/pose-detection@${version}/dist/pose-detection.min.js`;
|
|
826
|
+
}
|
|
827
|
+
function importPoseDetectionPeer() {
|
|
828
|
+
const importer = new Function("s", "return import(s)");
|
|
829
|
+
return importer("@tensorflow-models/pose-detection");
|
|
830
|
+
}
|
|
831
|
+
function readGlobal() {
|
|
832
|
+
if (typeof window === "undefined") return null;
|
|
833
|
+
const g = window.poseDetection;
|
|
834
|
+
if (g && typeof g.createDetector === "function" && g.SupportedModels?.BlazePose) {
|
|
835
|
+
return g;
|
|
836
|
+
}
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
function injectCdnScript(url) {
|
|
840
|
+
if (typeof document === "undefined") {
|
|
841
|
+
return Promise.reject(new Error("Cannot inject pose-detection outside a browser document"));
|
|
842
|
+
}
|
|
843
|
+
const existing = readGlobal();
|
|
844
|
+
if (existing) return Promise.resolve(existing);
|
|
845
|
+
if (injectPromise) return injectPromise;
|
|
846
|
+
injectPromise = new Promise((resolve, reject) => {
|
|
847
|
+
const selector = 'script[data-pt-pose-detection="1"]';
|
|
848
|
+
const prior = document.querySelector(selector);
|
|
849
|
+
const finish = () => {
|
|
850
|
+
const mod = readGlobal();
|
|
851
|
+
if (mod) {
|
|
852
|
+
resolve(mod);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
reject(
|
|
856
|
+
new Error(
|
|
857
|
+
"pose-detection CDN script loaded but `window.poseDetection` is missing. Check script order (TF.js first) and network."
|
|
858
|
+
)
|
|
859
|
+
);
|
|
860
|
+
};
|
|
861
|
+
if (prior) {
|
|
862
|
+
if (prior.dataset.ptLoaded === "1") {
|
|
863
|
+
finish();
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
prior.addEventListener("load", finish, { once: true });
|
|
867
|
+
prior.addEventListener(
|
|
868
|
+
"error",
|
|
869
|
+
() => reject(new Error(`Failed to load pose-detection from ${prior.src}`)),
|
|
870
|
+
{ once: true }
|
|
871
|
+
);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const script = document.createElement("script");
|
|
875
|
+
script.src = url;
|
|
876
|
+
script.async = true;
|
|
877
|
+
script.dataset.ptPoseDetection = "1";
|
|
878
|
+
script.onload = () => {
|
|
879
|
+
script.dataset.ptLoaded = "1";
|
|
880
|
+
finish();
|
|
881
|
+
};
|
|
882
|
+
script.onerror = () => {
|
|
883
|
+
injectPromise = null;
|
|
884
|
+
reject(new Error(`Failed to load pose-detection from ${url}`));
|
|
885
|
+
};
|
|
886
|
+
document.head.appendChild(script);
|
|
887
|
+
});
|
|
888
|
+
return injectPromise;
|
|
889
|
+
}
|
|
890
|
+
async function resolvePoseDetection(options) {
|
|
891
|
+
if (cached2) return cached2;
|
|
892
|
+
const fromWindow = readGlobal();
|
|
893
|
+
if (fromWindow) {
|
|
894
|
+
cached2 = fromWindow;
|
|
895
|
+
return fromWindow;
|
|
896
|
+
}
|
|
897
|
+
try {
|
|
898
|
+
const mod = await importPoseDetectionPeer();
|
|
899
|
+
cached2 = mod;
|
|
900
|
+
return mod;
|
|
901
|
+
} catch {
|
|
902
|
+
}
|
|
903
|
+
const allowCdn = options?.allowCdn !== false;
|
|
904
|
+
if (allowCdn && typeof window !== "undefined" && typeof document !== "undefined") {
|
|
905
|
+
const url = defaultPoseDetectionCdnUrl(options?.cdnBase, options?.version);
|
|
906
|
+
const mod = await injectCdnScript(url);
|
|
907
|
+
cached2 = mod;
|
|
908
|
+
return mod;
|
|
909
|
+
}
|
|
910
|
+
throw new Error(
|
|
911
|
+
'BlazePose requires optional peer `@tensorflow-models/pose-detection`. Install it, load the CDN script so `window.poseDetection` exists, or use model="movenet" / pass a TF.js modelUrl.'
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// src/models/adapters/blazepose.ts
|
|
916
|
+
var BLAZE_TO_COCO = {
|
|
917
|
+
nose: "nose",
|
|
918
|
+
left_eye: "left_eye",
|
|
919
|
+
right_eye: "right_eye",
|
|
920
|
+
left_ear: "left_ear",
|
|
921
|
+
right_ear: "right_ear",
|
|
922
|
+
left_shoulder: "left_shoulder",
|
|
923
|
+
right_shoulder: "right_shoulder",
|
|
924
|
+
left_elbow: "left_elbow",
|
|
925
|
+
right_elbow: "right_elbow",
|
|
926
|
+
left_wrist: "left_wrist",
|
|
927
|
+
right_wrist: "right_wrist",
|
|
928
|
+
left_hip: "left_hip",
|
|
929
|
+
right_hip: "right_hip",
|
|
930
|
+
left_knee: "left_knee",
|
|
931
|
+
right_knee: "right_knee",
|
|
932
|
+
left_ankle: "left_ankle",
|
|
933
|
+
right_ankle: "right_ankle"
|
|
934
|
+
};
|
|
935
|
+
var BlazePoseAdapter = class {
|
|
936
|
+
constructor(_ctx) {
|
|
937
|
+
this.modelId = "blazepose";
|
|
938
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
939
|
+
this.detector = null;
|
|
940
|
+
this.backend = null;
|
|
941
|
+
this.modelType = "lite";
|
|
942
|
+
}
|
|
943
|
+
getBackend() {
|
|
944
|
+
return this.backend;
|
|
945
|
+
}
|
|
946
|
+
async load() {
|
|
947
|
+
const tf = await resolveTf();
|
|
948
|
+
this.backend = await initWebGlBackend(tf);
|
|
949
|
+
const poseDetection = await resolvePoseDetection();
|
|
950
|
+
this.detector = await poseDetection.createDetector(
|
|
951
|
+
poseDetection.SupportedModels.BlazePose,
|
|
952
|
+
{
|
|
953
|
+
runtime: "tfjs",
|
|
954
|
+
modelType: this.modelType,
|
|
955
|
+
enableSmoothing: true
|
|
956
|
+
}
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
async estimate(video, options) {
|
|
960
|
+
if (!this.detector) throw new Error("BlazePose adapter not loaded");
|
|
961
|
+
if (!(video.videoWidth > 0 && video.videoHeight > 0)) return null;
|
|
962
|
+
const t0 = performance.now();
|
|
963
|
+
const poses = await this.detector.estimatePoses(video, {
|
|
964
|
+
flipHorizontal: false,
|
|
965
|
+
maxPoses: 1
|
|
966
|
+
});
|
|
967
|
+
const inferenceMs = performance.now() - t0;
|
|
968
|
+
const pose = poses[0];
|
|
969
|
+
if (!pose?.keypoints?.length) {
|
|
970
|
+
return {
|
|
971
|
+
keypoints: COCO_KEYPOINT_NAMES.map((name) => ({ name, x: 0, y: 0, score: 0 })),
|
|
972
|
+
score: 0,
|
|
973
|
+
inferenceMs,
|
|
974
|
+
videoKeypoints: COCO_KEYPOINT_NAMES.map((name) => ({
|
|
975
|
+
name,
|
|
976
|
+
xPx: 0,
|
|
977
|
+
yPx: 0,
|
|
978
|
+
score: 0
|
|
979
|
+
})),
|
|
980
|
+
letterbox: {
|
|
981
|
+
offsetX: 0,
|
|
982
|
+
offsetY: 0,
|
|
983
|
+
drawW: video.videoWidth,
|
|
984
|
+
drawH: video.videoHeight,
|
|
985
|
+
vw: video.videoWidth,
|
|
986
|
+
vh: video.videoHeight
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
const vw = video.videoWidth || 1;
|
|
991
|
+
const vh = video.videoHeight || 1;
|
|
992
|
+
const dispW = options.displayWidth || 1;
|
|
993
|
+
const dispH = options.displayHeight || 1;
|
|
994
|
+
const byName = /* @__PURE__ */ new Map();
|
|
995
|
+
for (const kp of pose.keypoints) {
|
|
996
|
+
const name = String(kp.name || "").toLowerCase();
|
|
997
|
+
const coco = BLAZE_TO_COCO[name];
|
|
998
|
+
if (!coco) continue;
|
|
999
|
+
byName.set(coco, {
|
|
1000
|
+
x: kp.x,
|
|
1001
|
+
y: kp.y,
|
|
1002
|
+
score: typeof kp.score === "number" ? kp.score : 0
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
const videoKeypoints = COCO_KEYPOINT_NAMES.map((name) => {
|
|
1006
|
+
const k = byName.get(name);
|
|
1007
|
+
return {
|
|
1008
|
+
name,
|
|
1009
|
+
xPx: k?.x ?? 0,
|
|
1010
|
+
yPx: k?.y ?? 0,
|
|
1011
|
+
score: k?.score ?? 0
|
|
1012
|
+
};
|
|
1013
|
+
});
|
|
1014
|
+
const scale = Math.max(dispW / vw, dispH / vh);
|
|
1015
|
+
const ox = (dispW - vw * scale) / 2;
|
|
1016
|
+
const oy = (dispH - vh * scale) / 2;
|
|
1017
|
+
let scoreSum = 0;
|
|
1018
|
+
const keypoints = videoKeypoints.map((k) => {
|
|
1019
|
+
const dx = k.xPx * scale + ox;
|
|
1020
|
+
const dy = k.yPx * scale + oy;
|
|
1021
|
+
let nx = dispW > 0 ? dx / dispW : 0;
|
|
1022
|
+
const ny = dispH > 0 ? dy / dispH : 0;
|
|
1023
|
+
if (options.facingMode === "user") nx = 1 - nx;
|
|
1024
|
+
scoreSum += k.score;
|
|
1025
|
+
return {
|
|
1026
|
+
name: k.name,
|
|
1027
|
+
x: Math.min(1, Math.max(0, nx)),
|
|
1028
|
+
y: Math.min(1, Math.max(0, ny)),
|
|
1029
|
+
score: k.score
|
|
1030
|
+
};
|
|
1031
|
+
});
|
|
1032
|
+
return {
|
|
1033
|
+
keypoints,
|
|
1034
|
+
score: scoreSum / 17,
|
|
1035
|
+
inferenceMs,
|
|
1036
|
+
videoKeypoints,
|
|
1037
|
+
letterbox: {
|
|
1038
|
+
offsetX: 0,
|
|
1039
|
+
offsetY: 0,
|
|
1040
|
+
drawW: vw,
|
|
1041
|
+
drawH: vh,
|
|
1042
|
+
vw,
|
|
1043
|
+
vh
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
dispose() {
|
|
1048
|
+
try {
|
|
1049
|
+
this.detector?.dispose?.();
|
|
1050
|
+
} catch {
|
|
1051
|
+
}
|
|
1052
|
+
this.detector = null;
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
// src/models/adapters/movenet.ts
|
|
1057
|
+
var INPUT_SIZE = 192;
|
|
1058
|
+
var SMOOTH_ALPHA = 0.5;
|
|
1059
|
+
function modelNormToVideo(xNorm, yNorm, lb) {
|
|
1060
|
+
const xSq = xNorm * INPUT_SIZE;
|
|
1061
|
+
const ySq = yNorm * INPUT_SIZE;
|
|
1062
|
+
return {
|
|
1063
|
+
x: (xSq - lb.offsetX) * (lb.vw / lb.drawW),
|
|
1064
|
+
y: (ySq - lb.offsetY) * (lb.vh / lb.drawH)
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
function videoToCover(vx, vy, dispW, dispH, lb) {
|
|
1068
|
+
const scale = Math.max(dispW / lb.vw, dispH / lb.vh);
|
|
1069
|
+
const ox = (dispW - lb.vw * scale) / 2;
|
|
1070
|
+
const oy = (dispH - lb.vh * scale) / 2;
|
|
1071
|
+
return { x: vx * scale + ox, y: vy * scale + oy };
|
|
1072
|
+
}
|
|
1073
|
+
var MoveNetGraphAdapter = class {
|
|
1074
|
+
constructor(ctx) {
|
|
1075
|
+
this.tf = null;
|
|
1076
|
+
this.model = null;
|
|
1077
|
+
this.backend = null;
|
|
1078
|
+
this.offscreen = null;
|
|
1079
|
+
this.offCtx = null;
|
|
1080
|
+
this.smoothed = null;
|
|
1081
|
+
this.letterbox = {
|
|
1082
|
+
offsetX: 0,
|
|
1083
|
+
offsetY: 0,
|
|
1084
|
+
drawW: INPUT_SIZE,
|
|
1085
|
+
drawH: INPUT_SIZE,
|
|
1086
|
+
vw: 1,
|
|
1087
|
+
vh: 1
|
|
1088
|
+
};
|
|
1089
|
+
if (!ctx.resolved.modelUrl) {
|
|
1090
|
+
throw new Error(ctx.resolved.unsupportedReason ?? "MoveNet requires a modelUrl");
|
|
1091
|
+
}
|
|
1092
|
+
this.modelId = ctx.resolved.modelId;
|
|
1093
|
+
this.modelUrl = ctx.resolved.modelUrl;
|
|
1094
|
+
}
|
|
1095
|
+
getBackend() {
|
|
1096
|
+
return this.backend;
|
|
1097
|
+
}
|
|
1098
|
+
async load() {
|
|
1099
|
+
this.tf = await resolveTf();
|
|
1100
|
+
this.backend = await initWebGlBackend(this.tf);
|
|
1101
|
+
this.model = await this.tf.loadGraphModel(this.modelUrl);
|
|
1102
|
+
const z = this.tf.zeros([1, INPUT_SIZE, INPUT_SIZE, 3], "int32");
|
|
1103
|
+
const out = this.model.execute(z);
|
|
1104
|
+
const tensor = Array.isArray(out) ? out[0] : out;
|
|
1105
|
+
tensor.dataSync();
|
|
1106
|
+
z.dispose();
|
|
1107
|
+
if (Array.isArray(out)) out.forEach((t) => t.dispose());
|
|
1108
|
+
else out.dispose();
|
|
1109
|
+
}
|
|
1110
|
+
poseCanvasCtx() {
|
|
1111
|
+
if (!this.offscreen) {
|
|
1112
|
+
this.offscreen = document.createElement("canvas");
|
|
1113
|
+
this.offscreen.width = INPUT_SIZE;
|
|
1114
|
+
this.offscreen.height = INPUT_SIZE;
|
|
1115
|
+
this.offCtx = this.offscreen.getContext("2d", { willReadFrequently: true });
|
|
1116
|
+
}
|
|
1117
|
+
if (!this.offCtx) throw new Error("2d context unavailable");
|
|
1118
|
+
return this.offCtx;
|
|
1119
|
+
}
|
|
1120
|
+
prepareInput(video) {
|
|
1121
|
+
const vw = video.videoWidth || 1;
|
|
1122
|
+
const vh = video.videoHeight || 1;
|
|
1123
|
+
const scale = Math.min(INPUT_SIZE / vw, INPUT_SIZE / vh);
|
|
1124
|
+
const drawW = vw * scale;
|
|
1125
|
+
const drawH = vh * scale;
|
|
1126
|
+
const offsetX = (INPUT_SIZE - drawW) / 2;
|
|
1127
|
+
const offsetY = (INPUT_SIZE - drawH) / 2;
|
|
1128
|
+
this.letterbox = { offsetX, offsetY, drawW, drawH, vw, vh };
|
|
1129
|
+
const c2d = this.poseCanvasCtx();
|
|
1130
|
+
c2d.fillStyle = "#000";
|
|
1131
|
+
c2d.fillRect(0, 0, INPUT_SIZE, INPUT_SIZE);
|
|
1132
|
+
c2d.drawImage(video, 0, 0, vw, vh, offsetX, offsetY, drawW, drawH);
|
|
1133
|
+
return this.offscreen;
|
|
1134
|
+
}
|
|
1135
|
+
smooth(raw) {
|
|
1136
|
+
if (!this.smoothed) {
|
|
1137
|
+
this.smoothed = raw.map((k) => ({ ...k }));
|
|
1138
|
+
return this.smoothed;
|
|
1139
|
+
}
|
|
1140
|
+
for (let i = 0; i < raw.length; i++) {
|
|
1141
|
+
if (raw[i].score < 0.1) continue;
|
|
1142
|
+
this.smoothed[i].xPx = SMOOTH_ALPHA * raw[i].xPx + (1 - SMOOTH_ALPHA) * this.smoothed[i].xPx;
|
|
1143
|
+
this.smoothed[i].yPx = SMOOTH_ALPHA * raw[i].yPx + (1 - SMOOTH_ALPHA) * this.smoothed[i].yPx;
|
|
1144
|
+
this.smoothed[i].score = raw[i].score;
|
|
1145
|
+
}
|
|
1146
|
+
return this.smoothed;
|
|
1147
|
+
}
|
|
1148
|
+
async estimate(video, options) {
|
|
1149
|
+
if (!this.tf || !this.model) throw new Error("MoveNet adapter not loaded");
|
|
1150
|
+
if (!(video.videoWidth > 0 && video.videoHeight > 0)) return null;
|
|
1151
|
+
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);
|
|
1155
|
+
const tensor = Array.isArray(out) ? out[0] : out;
|
|
1156
|
+
const data = tensor.dataSync();
|
|
1157
|
+
input.dispose();
|
|
1158
|
+
if (Array.isArray(out)) out.forEach((t) => t.dispose());
|
|
1159
|
+
else out.dispose();
|
|
1160
|
+
const inferenceMs = performance.now() - t0;
|
|
1161
|
+
const lb = this.letterbox;
|
|
1162
|
+
const dispW = options.displayWidth || 1;
|
|
1163
|
+
const dispH = options.displayHeight || 1;
|
|
1164
|
+
const drawRaw = [];
|
|
1165
|
+
let scoreSum = 0;
|
|
1166
|
+
for (let i = 0; i < 17; i++) {
|
|
1167
|
+
const yNorm = Math.min(1, Math.max(0, data[i * 3]));
|
|
1168
|
+
const xNorm = Math.min(1, Math.max(0, data[i * 3 + 1]));
|
|
1169
|
+
const score = data[i * 3 + 2];
|
|
1170
|
+
const vid = modelNormToVideo(xNorm, yNorm, lb);
|
|
1171
|
+
drawRaw.push({
|
|
1172
|
+
name: COCO_KEYPOINT_NAMES[i],
|
|
1173
|
+
xPx: vid.x,
|
|
1174
|
+
yPx: vid.y,
|
|
1175
|
+
score
|
|
1176
|
+
});
|
|
1177
|
+
scoreSum += score;
|
|
1178
|
+
}
|
|
1179
|
+
const sm = this.smooth(drawRaw);
|
|
1180
|
+
const videoKeypoints = sm.map((k, i) => ({
|
|
1181
|
+
name: COCO_KEYPOINT_NAMES[i],
|
|
1182
|
+
xPx: k.xPx,
|
|
1183
|
+
yPx: k.yPx,
|
|
1184
|
+
score: k.score
|
|
1185
|
+
}));
|
|
1186
|
+
const keypoints = videoKeypoints.map((k) => {
|
|
1187
|
+
const d = videoToCover(k.xPx, k.yPx, dispW, dispH, lb);
|
|
1188
|
+
let nx = dispW > 0 ? d.x / dispW : 0;
|
|
1189
|
+
const ny = dispH > 0 ? d.y / dispH : 0;
|
|
1190
|
+
if (options.facingMode === "user") nx = 1 - nx;
|
|
1191
|
+
return {
|
|
1192
|
+
name: k.name,
|
|
1193
|
+
x: Math.min(1, Math.max(0, nx)),
|
|
1194
|
+
y: Math.min(1, Math.max(0, ny)),
|
|
1195
|
+
score: k.score
|
|
1196
|
+
};
|
|
1197
|
+
});
|
|
1198
|
+
return {
|
|
1199
|
+
raw: data,
|
|
1200
|
+
keypoints,
|
|
1201
|
+
score: scoreSum / 17,
|
|
1202
|
+
inferenceMs,
|
|
1203
|
+
videoKeypoints,
|
|
1204
|
+
letterbox: lb
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
dispose() {
|
|
1208
|
+
try {
|
|
1209
|
+
this.model?.dispose();
|
|
1210
|
+
} catch {
|
|
1211
|
+
}
|
|
1212
|
+
this.model = null;
|
|
1213
|
+
this.smoothed = null;
|
|
1214
|
+
this.offscreen = null;
|
|
1215
|
+
this.offCtx = null;
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
function mapVideoKeypointsToDisplay(videoKeypoints, letterbox, dispW, dispH) {
|
|
1219
|
+
return videoKeypoints.map((k) => {
|
|
1220
|
+
const d = videoToCover(k.xPx, k.yPx, dispW, dispH, letterbox);
|
|
1221
|
+
return { name: k.name, dx: d.x, dy: d.y, score: k.score };
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// src/models/adapters/createAdapter.ts
|
|
1226
|
+
function createPoseAdapter(ctx) {
|
|
1227
|
+
const { resolved } = ctx;
|
|
1228
|
+
if (resolved.kind === "unsupported" || !resolved.modelUrl && resolved.kind !== "blazepose") {
|
|
1229
|
+
throw new Error(
|
|
1230
|
+
resolved.unsupportedReason ?? `Model "${resolved.modelId}" is unavailable.`
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
if (resolved.kind === "blazepose") {
|
|
1234
|
+
return new BlazePoseAdapter(ctx);
|
|
1235
|
+
}
|
|
1236
|
+
return new MoveNetGraphAdapter(ctx);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// src/models/poseModels.ts
|
|
1240
|
+
var DEFAULT_MOVENET_LIGHTNING_URL = "https://app.posetracker.com/scripts/tmp_model_to_remove.json";
|
|
1241
|
+
function resolvePoseModel(options = {}) {
|
|
1242
|
+
const explicit = typeof options.modelUrl === "string" && options.modelUrl.trim().length > 0 ? options.modelUrl.trim() : null;
|
|
1243
|
+
if (explicit) {
|
|
1244
|
+
const alias = typeof options.model === "string" && options.model.trim().length > 0 ? options.model.trim() : "custom";
|
|
1245
|
+
return { modelId: alias, kind: "custom-graph", modelUrl: explicit };
|
|
1246
|
+
}
|
|
1247
|
+
const key = (options.model ?? "movenet").trim().toLowerCase();
|
|
1248
|
+
if (key === "movenet" || key === "movenet-singlepose-lightning" || key === "lightning") {
|
|
1249
|
+
return {
|
|
1250
|
+
modelId: "movenet-singlepose-lightning",
|
|
1251
|
+
kind: "movenet-graph",
|
|
1252
|
+
modelUrl: DEFAULT_MOVENET_LIGHTNING_URL
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
if (key === "blazepose") {
|
|
1256
|
+
return {
|
|
1257
|
+
modelId: "blazepose",
|
|
1258
|
+
kind: "blazepose",
|
|
1259
|
+
modelUrl: null
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
return {
|
|
1263
|
+
modelId: key,
|
|
1264
|
+
kind: "unsupported",
|
|
1265
|
+
modelUrl: null,
|
|
1266
|
+
unsupportedReason: `Unknown model "${options.model}". Use "movenet" (default), "blazepose", or pass modelUrl.`
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// src/brand.ts
|
|
1271
|
+
var DEFAULT_LOADING_TEXT = "AI Loading";
|
|
1272
|
+
var POSETRACKER_LOGO_URL = "https://docs.posetracker.com/logo/light.png";
|
|
1273
|
+
var BRAND_COLORS = {
|
|
1274
|
+
navy: "#010A73",
|
|
1275
|
+
gold: "#FFC300",
|
|
1276
|
+
bootBg: "#0a0a0a",
|
|
1277
|
+
logoPad: "#3a3a3a"
|
|
1278
|
+
};
|
|
1279
|
+
|
|
1280
|
+
// src/overlay/domShell.ts
|
|
1281
|
+
var SHELL_CSS = `
|
|
1282
|
+
.pt-root {
|
|
1283
|
+
position: relative; width: 100%; height: 100%; min-height: 240px;
|
|
1284
|
+
background: #000; overflow: hidden; isolation: isolate;
|
|
1285
|
+
}
|
|
1286
|
+
.pt-root video, .pt-root canvas.pt-overlay {
|
|
1287
|
+
position: absolute; inset: 0; width: 100%; height: 100%;
|
|
1288
|
+
object-fit: cover; opacity: 0; transition: opacity 180ms ease-out;
|
|
1289
|
+
}
|
|
1290
|
+
.pt-root video { z-index: 1; background: #000; }
|
|
1291
|
+
.pt-root canvas.pt-overlay { z-index: 2; pointer-events: none; }
|
|
1292
|
+
.pt-boot {
|
|
1293
|
+
position: absolute; inset: 0; z-index: 4;
|
|
1294
|
+
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
1295
|
+
background: #0a0a0a; color: #fff;
|
|
1296
|
+
font: 15px/1.4 -apple-system, system-ui, sans-serif;
|
|
1297
|
+
transition: opacity 180ms ease-out; gap: 0; padding: 24px; box-sizing: border-box;
|
|
1298
|
+
}
|
|
1299
|
+
.pt-boot.hide { opacity: 0; pointer-events: none; }
|
|
1300
|
+
.pt-boot .pt-boot-powered {
|
|
1301
|
+
color: rgba(255,255,255,0.55); font-size: 11px; font-weight: 600;
|
|
1302
|
+
letter-spacing: 0.14em; text-transform: uppercase; margin-bottom: 10px;
|
|
1303
|
+
}
|
|
1304
|
+
.pt-boot .pt-boot-logo {
|
|
1305
|
+
width: min(220px, 62%); height: auto; margin-bottom: 22px; display: block;
|
|
1306
|
+
box-sizing: border-box; background: #3a3a3a; padding: 14px 16px; border-radius: 10px;
|
|
1307
|
+
}
|
|
1308
|
+
.pt-boot .pt-boot-spinner {
|
|
1309
|
+
width: 28px; height: 28px; margin-bottom: 16px;
|
|
1310
|
+
border: 2px solid #333; border-top-color: #ffc300; border-radius: 50%;
|
|
1311
|
+
animation: ptspin 0.8s linear infinite;
|
|
1312
|
+
}
|
|
1313
|
+
.pt-boot .pt-boot-msg {
|
|
1314
|
+
color: #e8e8e8; font-size: 14px; font-weight: 500;
|
|
1315
|
+
text-align: center; padding: 0 12px; min-height: 1.2em;
|
|
1316
|
+
}
|
|
1317
|
+
.pt-boot .pt-boot-msg.is-error { color: #FE8370; font-size: 13px; }
|
|
1318
|
+
@keyframes ptspin { to { transform: rotate(360deg); } }
|
|
1319
|
+
.pt-wm {
|
|
1320
|
+
position: absolute; right: 10px; bottom: 10px; z-index: 3;
|
|
1321
|
+
display: none; flex-direction: column; align-items: flex-end;
|
|
1322
|
+
pointer-events: none; text-shadow: 0 1px 2px rgba(0,0,0,0.55);
|
|
1323
|
+
}
|
|
1324
|
+
.pt-wm.show { display: flex; }
|
|
1325
|
+
.pt-wm .pt-wm-powered {
|
|
1326
|
+
color: rgba(255,255,255,0.7); font: 600 11px/1.2 -apple-system, system-ui, sans-serif;
|
|
1327
|
+
letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 4px;
|
|
1328
|
+
}
|
|
1329
|
+
.pt-wm .pt-wm-logo {
|
|
1330
|
+
width: 130px; height: auto; display: block; opacity: 0.92;
|
|
1331
|
+
filter: drop-shadow(0 1px 2px rgba(0,0,0,0.45));
|
|
1332
|
+
}
|
|
1333
|
+
.pt-hud {
|
|
1334
|
+
position: absolute; left: 8px; right: 8px; bottom: 8px; z-index: 3;
|
|
1335
|
+
color: #fff; font: 11px/1.35 ui-monospace, monospace;
|
|
1336
|
+
text-shadow: 0 1px 2px #000; pointer-events: none; display: none;
|
|
1337
|
+
}
|
|
1338
|
+
.pt-hud.debug { display: block; }
|
|
1339
|
+
.pt-placement {
|
|
1340
|
+
position: absolute; z-index: 2; pointer-events: none; display: none;
|
|
1341
|
+
border: 2px dashed rgba(255, 195, 0, 0.85);
|
|
1342
|
+
box-sizing: border-box;
|
|
1343
|
+
border-radius: 4px;
|
|
1344
|
+
}
|
|
1345
|
+
.pt-placement.show { display: block; }
|
|
1346
|
+
`;
|
|
1347
|
+
var styleInjected = false;
|
|
1348
|
+
function ensureStyles() {
|
|
1349
|
+
if (styleInjected || typeof document === "undefined") return;
|
|
1350
|
+
const el = document.createElement("style");
|
|
1351
|
+
el.setAttribute("data-posetracker", "shell");
|
|
1352
|
+
el.textContent = SHELL_CSS;
|
|
1353
|
+
document.head.appendChild(el);
|
|
1354
|
+
styleInjected = true;
|
|
1355
|
+
}
|
|
1356
|
+
function mountDomShell(host, options = {}) {
|
|
1357
|
+
ensureStyles();
|
|
1358
|
+
const loadingText = options.loadingText && options.loadingText.trim() || DEFAULT_LOADING_TEXT;
|
|
1359
|
+
const logoUrl = options.logoUrl ?? POSETRACKER_LOGO_URL;
|
|
1360
|
+
const root = document.createElement("div");
|
|
1361
|
+
root.className = "pt-root";
|
|
1362
|
+
root.innerHTML = `
|
|
1363
|
+
<video playsinline muted autoplay></video>
|
|
1364
|
+
<canvas class="pt-overlay"></canvas>
|
|
1365
|
+
<div class="pt-boot">
|
|
1366
|
+
<div class="pt-boot-powered">Powered by</div>
|
|
1367
|
+
<img class="pt-boot-logo" alt="PoseTracker" src="${logoUrl}" />
|
|
1368
|
+
<div class="pt-boot-spinner" aria-hidden="true"></div>
|
|
1369
|
+
<div class="pt-boot-msg">${loadingText}</div>
|
|
1370
|
+
</div>
|
|
1371
|
+
<div class="pt-placement" aria-hidden="true"></div>
|
|
1372
|
+
<div class="pt-wm">
|
|
1373
|
+
<div class="pt-wm-powered">Powered by</div>
|
|
1374
|
+
<img class="pt-wm-logo" alt="PoseTracker" src="${logoUrl}" />
|
|
1375
|
+
</div>
|
|
1376
|
+
<div class="pt-hud"></div>
|
|
1377
|
+
`;
|
|
1378
|
+
host.appendChild(root);
|
|
1379
|
+
const video = root.querySelector("video");
|
|
1380
|
+
const canvas = root.querySelector("canvas.pt-overlay");
|
|
1381
|
+
const boot = root.querySelector(".pt-boot");
|
|
1382
|
+
const bootMsg = root.querySelector(".pt-boot-msg");
|
|
1383
|
+
const watermark = root.querySelector(".pt-wm");
|
|
1384
|
+
const hud = root.querySelector(".pt-hud");
|
|
1385
|
+
const placement = root.querySelector(".pt-placement");
|
|
1386
|
+
return {
|
|
1387
|
+
root,
|
|
1388
|
+
video,
|
|
1389
|
+
canvas,
|
|
1390
|
+
boot,
|
|
1391
|
+
bootMsg,
|
|
1392
|
+
watermark,
|
|
1393
|
+
hud,
|
|
1394
|
+
placement,
|
|
1395
|
+
setBootMessage(text, isError = false) {
|
|
1396
|
+
bootMsg.textContent = text;
|
|
1397
|
+
bootMsg.classList.toggle("is-error", isError);
|
|
1398
|
+
},
|
|
1399
|
+
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) {
|
|
1404
|
+
bootMsg.textContent = loadingText;
|
|
1405
|
+
bootMsg.classList.remove("is-error");
|
|
1406
|
+
}
|
|
1407
|
+
},
|
|
1408
|
+
setWatermarkVisible(visible) {
|
|
1409
|
+
watermark.classList.toggle("show", visible);
|
|
1410
|
+
},
|
|
1411
|
+
setHud(text, debug) {
|
|
1412
|
+
hud.classList.toggle("debug", debug);
|
|
1413
|
+
hud.textContent = text;
|
|
1414
|
+
},
|
|
1415
|
+
setPlacementVisible(visible, paddingPercent = 10) {
|
|
1416
|
+
placement.classList.toggle("show", visible);
|
|
1417
|
+
if (visible) {
|
|
1418
|
+
const p = Math.max(0, Math.min(40, paddingPercent));
|
|
1419
|
+
placement.style.left = `${p}%`;
|
|
1420
|
+
placement.style.top = `${p}%`;
|
|
1421
|
+
placement.style.right = `${p}%`;
|
|
1422
|
+
placement.style.bottom = `${p}%`;
|
|
1423
|
+
}
|
|
1424
|
+
},
|
|
1425
|
+
applyMirror(facingMode) {
|
|
1426
|
+
const t = facingMode === "user" ? "scaleX(-1)" : "none";
|
|
1427
|
+
video.style.transform = t;
|
|
1428
|
+
canvas.style.transform = t;
|
|
1429
|
+
},
|
|
1430
|
+
destroy() {
|
|
1431
|
+
root.remove();
|
|
1432
|
+
}
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// src/types/skeleton.ts
|
|
1437
|
+
var DEFAULT_SKELETON_DEFINITION = {
|
|
1438
|
+
keypoints: [
|
|
1439
|
+
"nose",
|
|
1440
|
+
"left_eye",
|
|
1441
|
+
"right_eye",
|
|
1442
|
+
"left_ear",
|
|
1443
|
+
"right_ear",
|
|
1444
|
+
"left_shoulder",
|
|
1445
|
+
"right_shoulder",
|
|
1446
|
+
"left_elbow",
|
|
1447
|
+
"right_elbow",
|
|
1448
|
+
"left_wrist",
|
|
1449
|
+
"right_wrist",
|
|
1450
|
+
"left_hip",
|
|
1451
|
+
"right_hip",
|
|
1452
|
+
"left_knee",
|
|
1453
|
+
"right_knee",
|
|
1454
|
+
"left_ankle",
|
|
1455
|
+
"right_ankle"
|
|
1456
|
+
],
|
|
1457
|
+
keypoint_lines: [
|
|
1458
|
+
"right_shoulder||left_shoulder",
|
|
1459
|
+
"right_hip||left_hip",
|
|
1460
|
+
"right_hip||right_shoulder",
|
|
1461
|
+
"right_hip||right_knee",
|
|
1462
|
+
"right_ankle||right_knee",
|
|
1463
|
+
"right_shoulder||right_elbow",
|
|
1464
|
+
"right_wrist||right_elbow",
|
|
1465
|
+
"left_hip||left_shoulder",
|
|
1466
|
+
"left_hip||left_knee",
|
|
1467
|
+
"left_ankle||left_knee",
|
|
1468
|
+
"left_shoulder||left_elbow",
|
|
1469
|
+
"left_wrist||left_elbow"
|
|
1470
|
+
],
|
|
1471
|
+
keypoint_angles: [],
|
|
1472
|
+
angles: { fontColor: "#000000", strokeColor: "#FFC300" },
|
|
1473
|
+
circles: {
|
|
1474
|
+
circleFillColor: "#010A73",
|
|
1475
|
+
circleStrokeColor: "#FFC300",
|
|
1476
|
+
strokeWidth: "4",
|
|
1477
|
+
radius: "8"
|
|
1478
|
+
},
|
|
1479
|
+
lines: { lineStrokeColor: "#010A73", strokeWidth: "4" }
|
|
1480
|
+
};
|
|
1481
|
+
|
|
1482
|
+
// src/overlay/skeleton.ts
|
|
1483
|
+
var DEFAULT_SKELETON = DEFAULT_SKELETON_DEFINITION;
|
|
1484
|
+
var SKELETON_SCORE = 0.3;
|
|
1485
|
+
function drawSkeleton(ctx, kps, dispW, dispH, enabled = true, def) {
|
|
1486
|
+
if (ctx.canvas.width !== dispW) ctx.canvas.width = dispW;
|
|
1487
|
+
if (ctx.canvas.height !== dispH) ctx.canvas.height = dispH;
|
|
1488
|
+
ctx.clearRect(0, 0, dispW, dispH);
|
|
1489
|
+
if (!enabled) return;
|
|
1490
|
+
const sk = def ?? DEFAULT_SKELETON_DEFINITION;
|
|
1491
|
+
const byName = {};
|
|
1492
|
+
for (const kp of kps) byName[kp.name] = kp;
|
|
1493
|
+
const lineStroke = sk.lines.lineStrokeColor;
|
|
1494
|
+
const lineWidth = Number(sk.lines.strokeWidth);
|
|
1495
|
+
for (const edge of sk.keypoint_lines) {
|
|
1496
|
+
const [aName, bName] = String(edge).split("||");
|
|
1497
|
+
const a = byName[aName];
|
|
1498
|
+
const b = byName[bName];
|
|
1499
|
+
if (!a || !b || a.score <= SKELETON_SCORE || b.score <= SKELETON_SCORE) continue;
|
|
1500
|
+
ctx.beginPath();
|
|
1501
|
+
ctx.moveTo(a.dx, a.dy);
|
|
1502
|
+
ctx.lineTo(b.dx, b.dy);
|
|
1503
|
+
ctx.lineWidth = lineWidth;
|
|
1504
|
+
ctx.strokeStyle = lineStroke;
|
|
1505
|
+
ctx.stroke();
|
|
1506
|
+
}
|
|
1507
|
+
const fill = sk.circles.circleFillColor;
|
|
1508
|
+
const stroke = sk.circles.circleStrokeColor;
|
|
1509
|
+
const circleStrokeW = Number(sk.circles.strokeWidth);
|
|
1510
|
+
const radius = Number(sk.circles.radius);
|
|
1511
|
+
const allowed = new Set(sk.keypoints);
|
|
1512
|
+
for (const kp of kps) {
|
|
1513
|
+
if (!allowed.has(kp.name) || kp.score <= SKELETON_SCORE) continue;
|
|
1514
|
+
ctx.beginPath();
|
|
1515
|
+
ctx.arc(kp.dx, kp.dy, radius, 0, Math.PI * 2);
|
|
1516
|
+
ctx.fillStyle = fill;
|
|
1517
|
+
ctx.fill();
|
|
1518
|
+
ctx.lineWidth = circleStrokeW;
|
|
1519
|
+
ctx.strokeStyle = stroke;
|
|
1520
|
+
ctx.stroke();
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// src/types/features.ts
|
|
1525
|
+
var DEFAULT_FEATURES = {
|
|
1526
|
+
angles: false,
|
|
1527
|
+
recommendations: false,
|
|
1528
|
+
progression: false,
|
|
1529
|
+
keypoints: false,
|
|
1530
|
+
minGrade: null
|
|
1531
|
+
};
|
|
1532
|
+
var KNOWN_FEATURE_KEYS = /* @__PURE__ */ new Set([
|
|
1533
|
+
"angles",
|
|
1534
|
+
"recommendations",
|
|
1535
|
+
"progression",
|
|
1536
|
+
"keypoints",
|
|
1537
|
+
"minGrade"
|
|
1538
|
+
]);
|
|
1539
|
+
var UNSUPPORTED_FEATURE_HINTS = {
|
|
1540
|
+
blazepose: 'BlazePose as a features flag (use options.model = "blazepose" instead)',
|
|
1541
|
+
poseEngine: "pose engine selection (MediaPipe/PoseLandmarker)",
|
|
1542
|
+
mediapipeModel: "MediaPipe model selection",
|
|
1543
|
+
poseBackend: "pose backend selection",
|
|
1544
|
+
runInWorker: "worker thread selection"
|
|
1545
|
+
};
|
|
1546
|
+
var FREE_PLAN_FEATURES_MESSAGE = "You cannot use developer features. (visit: https://posetracker.gitbook.io/posetracker-api/tracking-endpoint)";
|
|
1547
|
+
var INVALID_TOKEN_MESSAGE = "Invalid params. Please refer to the documentation and set token=YOUR API_KEY et exercise=A correct exercise. (visit: https://posetracker.gitbook.io/posetracker-api/tracking-endpoint)";
|
|
1548
|
+
var COMBINED_REFERENCE_EXERCISE_MESSAGE = "You cannot combine reference & exercise. Please check the documentation.";
|
|
1549
|
+
function featureNotSupportedMessage(key) {
|
|
1550
|
+
const hint = UNSUPPORTED_FEATURE_HINTS[key] ?? `'${key}'`;
|
|
1551
|
+
return `The '${key}' option (${hint}) is not available as a features flag. Remove the option, or use options.model / modelUrl for model selection. (visit: https://posetracker.gitbook.io/posetracker-api/tracking-endpoint)`;
|
|
1552
|
+
}
|
|
1553
|
+
function resolveFeatures(input) {
|
|
1554
|
+
if (!input) {
|
|
1555
|
+
return { features: { ...DEFAULT_FEATURES }, unsupportedKeys: [] };
|
|
1556
|
+
}
|
|
1557
|
+
const unsupportedKeys = Object.keys(input).filter((k) => !KNOWN_FEATURE_KEYS.has(k));
|
|
1558
|
+
return {
|
|
1559
|
+
features: {
|
|
1560
|
+
angles: input.angles === true,
|
|
1561
|
+
recommendations: input.recommendations === true,
|
|
1562
|
+
progression: input.progression === true,
|
|
1563
|
+
keypoints: input.keypoints === true,
|
|
1564
|
+
minGrade: input.minGrade ?? null
|
|
1565
|
+
},
|
|
1566
|
+
unsupportedKeys
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
function freeBlockedFeatures(features, options) {
|
|
1570
|
+
const blocked = [];
|
|
1571
|
+
if (features.angles) blocked.push("angles");
|
|
1572
|
+
if (features.recommendations) blocked.push("recommendations");
|
|
1573
|
+
if (features.progression) blocked.push("progression");
|
|
1574
|
+
if (features.keypoints && options.withExercise) blocked.push("keypoints");
|
|
1575
|
+
return blocked;
|
|
1576
|
+
}
|
|
1577
|
+
function isPaidPlan(planType) {
|
|
1578
|
+
return typeof planType === "string" && planType.length > 0 && planType !== "free";
|
|
1579
|
+
}
|
|
1580
|
+
function shouldShowWatermark(planType) {
|
|
1581
|
+
return !isPaidPlan(planType);
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// src/createPoseTracker.ts
|
|
1585
|
+
var MANIFEST_CACHE_KEY = "session.sealed";
|
|
1586
|
+
var ENGINE_VERSION_KEY = "engine.version";
|
|
1587
|
+
var SESSION_ERROR_STREAK_LIMIT = 30;
|
|
1588
|
+
function resolveElement(target) {
|
|
1589
|
+
if (typeof target === "string") {
|
|
1590
|
+
const el = document.querySelector(target);
|
|
1591
|
+
if (!el) throw new Error(`PoseTracker mount target not found: ${target}`);
|
|
1592
|
+
return el;
|
|
1593
|
+
}
|
|
1594
|
+
return target;
|
|
1595
|
+
}
|
|
1596
|
+
function median(arr) {
|
|
1597
|
+
if (!arr.length) return null;
|
|
1598
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
1599
|
+
return s[Math.floor(s.length / 2)] ?? null;
|
|
1600
|
+
}
|
|
1601
|
+
function facingFromOptions(opts) {
|
|
1602
|
+
if (opts.facingMode) return opts.facingMode;
|
|
1603
|
+
if (opts.position === "back") return "environment";
|
|
1604
|
+
return "user";
|
|
1605
|
+
}
|
|
1606
|
+
var PoseTrackerClient = class {
|
|
1607
|
+
constructor(apiToken, options = {}) {
|
|
1608
|
+
this.status = "idle";
|
|
1609
|
+
this.mode = "keypoints-only";
|
|
1610
|
+
this.lastError = null;
|
|
1611
|
+
this.manifest = null;
|
|
1612
|
+
this.engine = null;
|
|
1613
|
+
this.engineSource = null;
|
|
1614
|
+
this.session = null;
|
|
1615
|
+
this.currentExerciseId = null;
|
|
1616
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
1617
|
+
this.typedListeners = /* @__PURE__ */ new Map();
|
|
1618
|
+
this.messageListeners = /* @__PURE__ */ new Set();
|
|
1619
|
+
this.stateListeners = /* @__PURE__ */ new Set();
|
|
1620
|
+
this.preloadPromise = null;
|
|
1621
|
+
this.coldStartMode = "basic";
|
|
1622
|
+
this.configurePromise = null;
|
|
1623
|
+
this.handshakePromise = null;
|
|
1624
|
+
this.meteredSessionState = "idle";
|
|
1625
|
+
this.lastCameraStartInfo = null;
|
|
1626
|
+
this.sessionErrorStreak = 0;
|
|
1627
|
+
this.featureGateReported = { unsupported: false, freeBlock: false, missingToken: false };
|
|
1628
|
+
this.keypointsSuppressionLogged = false;
|
|
1629
|
+
this.shell = null;
|
|
1630
|
+
this.stream = null;
|
|
1631
|
+
this.adapter = null;
|
|
1632
|
+
this.running = false;
|
|
1633
|
+
this.busy = false;
|
|
1634
|
+
this.loopToken = 0;
|
|
1635
|
+
this.cameraRevealed = false;
|
|
1636
|
+
this.disposed = false;
|
|
1637
|
+
this.skeletonDef = null;
|
|
1638
|
+
this.acceleration = {
|
|
1639
|
+
state: "unknown",
|
|
1640
|
+
runtime: "unknown",
|
|
1641
|
+
backend: null,
|
|
1642
|
+
glRenderer: null,
|
|
1643
|
+
medianInferenceMs: null
|
|
1644
|
+
};
|
|
1645
|
+
this.inferMs = [];
|
|
1646
|
+
this.fpsWindow = 0;
|
|
1647
|
+
this.fpsTimer = 0;
|
|
1648
|
+
const token = apiToken ?? (typeof options.apiToken === "string" ? options.apiToken : null) ?? null;
|
|
1649
|
+
this.opts = { ...options, onDiagnostic: options.onDiagnostic };
|
|
1650
|
+
this.apiToken = token;
|
|
1651
|
+
const resolved = resolveFeatures(options.features);
|
|
1652
|
+
this.features = resolved.features;
|
|
1653
|
+
this.unsupportedFeatureKeys = resolved.unsupportedKeys;
|
|
1654
|
+
this.resolved = resolvePoseModel({ model: options.model, modelUrl: options.modelUrl });
|
|
1655
|
+
this.skeletonDef = options.skeletonDef ?? null;
|
|
1656
|
+
this.coldStartMode = options.coldStart === "full" ? "full" : "basic";
|
|
1657
|
+
this.files = options.fileStore !== void 0 ? options.fileStore : createWebFileStore();
|
|
1658
|
+
this.engineLoader = options.engineLoader ?? new EngineLoader({
|
|
1659
|
+
fileStore: this.files,
|
|
1660
|
+
onDiagnostic: this.opts.onDiagnostic
|
|
1661
|
+
});
|
|
1662
|
+
this.tracker = options.usageTracker ?? new UsageTracker({ baseUrl: options.baseUrl });
|
|
1663
|
+
this.opts.onDiagnostic?.(
|
|
1664
|
+
`[posetracker-web] client created hasApiToken=${Boolean(this.apiToken)} features=${JSON.stringify(this.features)}`
|
|
1665
|
+
);
|
|
1666
|
+
}
|
|
1667
|
+
// -------------------------------------------------------------------------
|
|
1668
|
+
// Introspection
|
|
1669
|
+
// -------------------------------------------------------------------------
|
|
1670
|
+
getStatus() {
|
|
1671
|
+
return this.status;
|
|
1672
|
+
}
|
|
1673
|
+
getMode() {
|
|
1674
|
+
return this.mode;
|
|
1675
|
+
}
|
|
1676
|
+
getError() {
|
|
1677
|
+
return this.lastError;
|
|
1678
|
+
}
|
|
1679
|
+
getManifest() {
|
|
1680
|
+
return this.manifest;
|
|
1681
|
+
}
|
|
1682
|
+
getPlanType() {
|
|
1683
|
+
return this.manifest?.plan?.plan ?? null;
|
|
1684
|
+
}
|
|
1685
|
+
getFeatures() {
|
|
1686
|
+
return { ...this.features };
|
|
1687
|
+
}
|
|
1688
|
+
getEngineSource() {
|
|
1689
|
+
return this.engineSource;
|
|
1690
|
+
}
|
|
1691
|
+
getAcceleration() {
|
|
1692
|
+
return this.acceleration.state;
|
|
1693
|
+
}
|
|
1694
|
+
getAccelerationDiagnostics() {
|
|
1695
|
+
return { ...this.acceleration };
|
|
1696
|
+
}
|
|
1697
|
+
getResolvedModel() {
|
|
1698
|
+
return this.resolved;
|
|
1699
|
+
}
|
|
1700
|
+
getColdStartMode() {
|
|
1701
|
+
return this.coldStartMode;
|
|
1702
|
+
}
|
|
1703
|
+
addEventListener(listener) {
|
|
1704
|
+
this.listeners.add(listener);
|
|
1705
|
+
return () => this.listeners.delete(listener);
|
|
1706
|
+
}
|
|
1707
|
+
on(type, listener) {
|
|
1708
|
+
if (type === "*") {
|
|
1709
|
+
this.listeners.add(listener);
|
|
1710
|
+
return () => this.listeners.delete(listener);
|
|
1711
|
+
}
|
|
1712
|
+
let set = this.typedListeners.get(type);
|
|
1713
|
+
if (!set) {
|
|
1714
|
+
set = /* @__PURE__ */ new Set();
|
|
1715
|
+
this.typedListeners.set(type, set);
|
|
1716
|
+
}
|
|
1717
|
+
set.add(listener);
|
|
1718
|
+
return () => set.delete(listener);
|
|
1719
|
+
}
|
|
1720
|
+
addMessageListener(listener) {
|
|
1721
|
+
this.messageListeners.add(listener);
|
|
1722
|
+
return () => this.messageListeners.delete(listener);
|
|
1723
|
+
}
|
|
1724
|
+
onStateChange(listener) {
|
|
1725
|
+
this.stateListeners.add(listener);
|
|
1726
|
+
return () => this.stateListeners.delete(listener);
|
|
1727
|
+
}
|
|
1728
|
+
// -------------------------------------------------------------------------
|
|
1729
|
+
// DOM mount / camera / inference
|
|
1730
|
+
// -------------------------------------------------------------------------
|
|
1731
|
+
mount(target) {
|
|
1732
|
+
if (this.disposed) throw new Error("PoseTracker disposed");
|
|
1733
|
+
if (this.shell) this.shell.destroy();
|
|
1734
|
+
const host = resolveElement(target);
|
|
1735
|
+
host.innerHTML = "";
|
|
1736
|
+
this.shell = mountDomShell(host, {
|
|
1737
|
+
loadingText: this.opts.loadingText ?? DEFAULT_LOADING_TEXT
|
|
1738
|
+
});
|
|
1739
|
+
this.shell.setCameraVisible(false);
|
|
1740
|
+
this.cameraRevealed = false;
|
|
1741
|
+
void this.resolveSkeletonFromOptions();
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* Warm model (and optionally camera). Default coldStart = basic (no gUM).
|
|
1745
|
+
* Alias: {@link warmup}.
|
|
1746
|
+
*/
|
|
1747
|
+
preload(options) {
|
|
1748
|
+
const mode = options?.coldStart === "full" ? "full" : "basic";
|
|
1749
|
+
this.coldStartMode = mode;
|
|
1750
|
+
if (this.status === "ready" && this.preloadPromise) {
|
|
1751
|
+
return this.preloadPromise.then(() => this.ensureColdStartMode(mode));
|
|
1752
|
+
}
|
|
1753
|
+
if (!this.preloadPromise) {
|
|
1754
|
+
this.preloadPromise = this.doPreload().then(() => this.ensureColdStartMode(mode)).catch((err) => {
|
|
1755
|
+
this.preloadPromise = null;
|
|
1756
|
+
throw err;
|
|
1757
|
+
});
|
|
1758
|
+
} else if (mode === "full") {
|
|
1759
|
+
return this.preloadPromise.then(() => this.ensureColdStartMode("full"));
|
|
1760
|
+
}
|
|
1761
|
+
return this.preloadPromise;
|
|
1762
|
+
}
|
|
1763
|
+
warmup(options) {
|
|
1764
|
+
return this.preload(options);
|
|
1765
|
+
}
|
|
1766
|
+
/** Mount + load model + open camera + start loop (camera-screen convenience). */
|
|
1767
|
+
async start() {
|
|
1768
|
+
if (this.disposed) throw new Error("PoseTracker disposed");
|
|
1769
|
+
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");
|
|
1773
|
+
}
|
|
1774
|
+
this.beginLoop();
|
|
1775
|
+
}
|
|
1776
|
+
stop() {
|
|
1777
|
+
this.running = false;
|
|
1778
|
+
this.loopToken += 1;
|
|
1779
|
+
this.busy = false;
|
|
1780
|
+
stopMediaStream(this.stream);
|
|
1781
|
+
this.stream = null;
|
|
1782
|
+
if (this.shell) {
|
|
1783
|
+
this.shell.video.srcObject = null;
|
|
1784
|
+
this.shell.setCameraVisible(false);
|
|
1785
|
+
this.shell.setPlacementVisible(false);
|
|
1786
|
+
this.cameraRevealed = false;
|
|
1787
|
+
this.applyWatermark();
|
|
1788
|
+
}
|
|
1789
|
+
if (this.status !== "error") this.setStatus("idle");
|
|
1790
|
+
}
|
|
1791
|
+
async dispose() {
|
|
1792
|
+
this.stopExercise();
|
|
1793
|
+
this.stop();
|
|
1794
|
+
this.adapter?.dispose();
|
|
1795
|
+
this.adapter = null;
|
|
1796
|
+
this.shell?.destroy();
|
|
1797
|
+
this.shell = null;
|
|
1798
|
+
this.listeners.clear();
|
|
1799
|
+
this.typedListeners.clear();
|
|
1800
|
+
this.messageListeners.clear();
|
|
1801
|
+
this.stateListeners.clear();
|
|
1802
|
+
this.preloadPromise = null;
|
|
1803
|
+
this.configurePromise = null;
|
|
1804
|
+
this.handshakePromise = null;
|
|
1805
|
+
this.meteredSessionState = "idle";
|
|
1806
|
+
this.lastCameraStartInfo = null;
|
|
1807
|
+
this.featureGateReported = { unsupported: false, freeBlock: false, missingToken: false };
|
|
1808
|
+
this.keypointsSuppressionLogged = false;
|
|
1809
|
+
this.engine = null;
|
|
1810
|
+
this.engineSource = null;
|
|
1811
|
+
this.manifest = null;
|
|
1812
|
+
this.mode = "keypoints-only";
|
|
1813
|
+
this.setStatus("idle");
|
|
1814
|
+
this.disposed = true;
|
|
1815
|
+
}
|
|
1816
|
+
// -------------------------------------------------------------------------
|
|
1817
|
+
// Configure / engine
|
|
1818
|
+
// -------------------------------------------------------------------------
|
|
1819
|
+
configure(apiToken) {
|
|
1820
|
+
if (apiToken !== void 0 && apiToken !== this.apiToken) {
|
|
1821
|
+
this.apiToken = apiToken;
|
|
1822
|
+
this.opts.apiToken = apiToken;
|
|
1823
|
+
this.configurePromise = null;
|
|
1824
|
+
this.handshakePromise = null;
|
|
1825
|
+
this.featureGateReported.freeBlock = false;
|
|
1826
|
+
this.featureGateReported.missingToken = false;
|
|
1827
|
+
}
|
|
1828
|
+
if (this.mode !== "full-engine") {
|
|
1829
|
+
this.configurePromise = null;
|
|
1830
|
+
}
|
|
1831
|
+
if (!this.configurePromise) {
|
|
1832
|
+
this.configurePromise = this.tryConfigure({
|
|
1833
|
+
silentStatus: this.status === "ready",
|
|
1834
|
+
forceEngineRetry: true
|
|
1835
|
+
}).then((ok) => {
|
|
1836
|
+
if (!ok) this.configurePromise = null;
|
|
1837
|
+
this.retryMeteredSessionIfRefused();
|
|
1838
|
+
return ok;
|
|
1839
|
+
}).catch(() => {
|
|
1840
|
+
this.configurePromise = null;
|
|
1841
|
+
return false;
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
return this.configurePromise;
|
|
1845
|
+
}
|
|
1846
|
+
getAvailableExercises() {
|
|
1847
|
+
return this.mode === "full-engine" ? this.manifest?.exercises ?? [] : [];
|
|
1848
|
+
}
|
|
1849
|
+
getAvailableCustomExercises() {
|
|
1850
|
+
if (this.mode !== "full-engine" || !this.engine?.listCustomExercises) return [];
|
|
1851
|
+
return this.engine.listCustomExercises();
|
|
1852
|
+
}
|
|
1853
|
+
startExercise(exerciseId, options = {}) {
|
|
1854
|
+
if (this.status !== "ready") {
|
|
1855
|
+
throw new Error(`Cannot start exercise while status is '${this.status}' \u2014 call preload()/start() first.`);
|
|
1856
|
+
}
|
|
1857
|
+
if (this.mode !== "full-engine" || !this.engine) {
|
|
1858
|
+
throw new Error(
|
|
1859
|
+
"Exercises require full-engine mode (validated API key). The SDK is running keypoints-only \u2014 call configure(apiToken) first."
|
|
1860
|
+
);
|
|
1861
|
+
}
|
|
1862
|
+
if (this.meteredSessionState === "refused") {
|
|
1863
|
+
throw new Error(
|
|
1864
|
+
"API-key features are not available offline: PoseTracker could not track this session. Reconnect and retry."
|
|
1865
|
+
);
|
|
1866
|
+
}
|
|
1867
|
+
if (this.unsupportedFeatureKeys.length > 0) {
|
|
1868
|
+
const message = featureNotSupportedMessage(this.unsupportedFeatureKeys[0]);
|
|
1869
|
+
this.reportError({ type: "error", code: "feature_not_supported", message });
|
|
1870
|
+
throw new Error(message);
|
|
1871
|
+
}
|
|
1872
|
+
if (this.getPlanType() === "free") {
|
|
1873
|
+
const blocked = freeBlockedFeatures(this.features, { withExercise: true });
|
|
1874
|
+
if (blocked.length > 0) {
|
|
1875
|
+
this.reportError({
|
|
1876
|
+
type: "error",
|
|
1877
|
+
code: "free_plan_feature_blocked",
|
|
1878
|
+
message: FREE_PLAN_FEATURES_MESSAGE
|
|
1879
|
+
});
|
|
1880
|
+
throw new Error(FREE_PLAN_FEATURES_MESSAGE);
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
const available = this.getAvailableExercises();
|
|
1884
|
+
const exercise = findExerciseByIdOrAlias(exerciseId, available);
|
|
1885
|
+
if (!exercise) {
|
|
1886
|
+
const customs = this.getAvailableCustomExercises();
|
|
1887
|
+
const custom = customs.find((e) => e.id === exerciseId) ?? findExerciseByIdOrAlias(exerciseId, customs);
|
|
1888
|
+
if (custom) {
|
|
1889
|
+
this.startCustomExercise(custom, options);
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
const message = `Exercise '${exerciseId}' is not available in V3 engine`;
|
|
1893
|
+
this.reportError({ type: "error", code: "invalid_exercise", message });
|
|
1894
|
+
throw new Error(message);
|
|
1895
|
+
}
|
|
1896
|
+
this.stopExercise();
|
|
1897
|
+
this.currentExerciseId = exerciseId;
|
|
1898
|
+
this.keypointsSuppressionLogged = false;
|
|
1899
|
+
this.session = this.engine.createSession(
|
|
1900
|
+
{
|
|
1901
|
+
exercise,
|
|
1902
|
+
locale: this.opts.locale ?? "en",
|
|
1903
|
+
difficulty: options.difficulty,
|
|
1904
|
+
minGrade: this.features.minGrade ?? void 0,
|
|
1905
|
+
features: {
|
|
1906
|
+
angles: this.features.angles,
|
|
1907
|
+
recommendations: this.features.recommendations,
|
|
1908
|
+
progression: this.features.progression
|
|
1909
|
+
}
|
|
1910
|
+
},
|
|
1911
|
+
(event) => this.emitFromSession(event)
|
|
1912
|
+
);
|
|
1913
|
+
}
|
|
1914
|
+
startCustomExercise(custom, options) {
|
|
1915
|
+
if (!this.engine?.createCustomSession) {
|
|
1916
|
+
throw new Error(
|
|
1917
|
+
`The cached engine bundle is too old for custom exercise '${custom.id}' \u2014 reconnect so the SDK can update it.`
|
|
1918
|
+
);
|
|
1919
|
+
}
|
|
1920
|
+
if (custom.id === "jump_analysis" && (!options.userHeightCm || options.userHeightCm <= 0)) {
|
|
1921
|
+
const message = "User height (userHeightCm) must be provided for jump_analysis exercise";
|
|
1922
|
+
this.reportError({ type: "error", code: "jump_analysis_missing_height", message });
|
|
1923
|
+
throw new Error(message);
|
|
1924
|
+
}
|
|
1925
|
+
this.stopExercise();
|
|
1926
|
+
this.currentExerciseId = custom.id;
|
|
1927
|
+
this.keypointsSuppressionLogged = false;
|
|
1928
|
+
this.session = this.engine.createCustomSession(
|
|
1929
|
+
{
|
|
1930
|
+
exerciseId: custom.id,
|
|
1931
|
+
locale: this.opts.locale ?? "en",
|
|
1932
|
+
userHeightCm: options.userHeightCm,
|
|
1933
|
+
devicePitchDeg: options.devicePitchDeg
|
|
1934
|
+
},
|
|
1935
|
+
(event) => this.emitFromSession(event)
|
|
1936
|
+
);
|
|
1937
|
+
}
|
|
1938
|
+
stopExercise() {
|
|
1939
|
+
try {
|
|
1940
|
+
this.session?.end();
|
|
1941
|
+
} catch (err) {
|
|
1942
|
+
this.opts.onDiagnostic?.(
|
|
1943
|
+
"[posetracker-web] engine session end() threw: " + (err instanceof Error ? err.message : String(err))
|
|
1944
|
+
);
|
|
1945
|
+
}
|
|
1946
|
+
this.session = null;
|
|
1947
|
+
this.currentExerciseId = null;
|
|
1948
|
+
this.sessionErrorStreak = 0;
|
|
1949
|
+
this.shell?.setPlacementVisible(false);
|
|
1950
|
+
}
|
|
1951
|
+
getCurrentExerciseId() {
|
|
1952
|
+
return this.currentExerciseId;
|
|
1953
|
+
}
|
|
1954
|
+
fetchSkeleton(uuid) {
|
|
1955
|
+
return fetchSkeletonDefinition(uuid, { baseUrl: this.opts.baseUrl });
|
|
1956
|
+
}
|
|
1957
|
+
async setModel(model, modelUrl) {
|
|
1958
|
+
const wasRunning = this.running;
|
|
1959
|
+
if (wasRunning) {
|
|
1960
|
+
this.running = false;
|
|
1961
|
+
this.loopToken += 1;
|
|
1962
|
+
this.busy = false;
|
|
1963
|
+
}
|
|
1964
|
+
this.adapter?.dispose();
|
|
1965
|
+
this.adapter = null;
|
|
1966
|
+
if (model !== void 0) this.opts.model = model;
|
|
1967
|
+
if (modelUrl !== void 0) this.opts.modelUrl = modelUrl;
|
|
1968
|
+
this.resolved = resolvePoseModel({ model: this.opts.model, modelUrl: this.opts.modelUrl });
|
|
1969
|
+
await this.ensureAdapter();
|
|
1970
|
+
if (wasRunning) this.beginLoop();
|
|
1971
|
+
}
|
|
1972
|
+
updateOptions(partial) {
|
|
1973
|
+
this.opts = { ...this.opts, ...partial };
|
|
1974
|
+
if (partial.skeletonDef !== void 0) this.skeletonDef = partial.skeletonDef;
|
|
1975
|
+
if (partial.skeletonUuid) void this.resolveSkeletonFromOptions();
|
|
1976
|
+
this.applyWatermark();
|
|
1977
|
+
if (this.shell && (partial.facingMode || partial.position)) {
|
|
1978
|
+
this.shell.applyMirror(facingFromOptions(this.opts));
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
ingestPose(pose) {
|
|
1982
|
+
if (!this.session || this.features.keypoints) {
|
|
1983
|
+
this.emit({
|
|
1984
|
+
type: "keypoints",
|
|
1985
|
+
keypoints: pose.keypoints,
|
|
1986
|
+
score: pose.score,
|
|
1987
|
+
timestampMs: pose.timestampMs
|
|
1988
|
+
});
|
|
1989
|
+
} else if (!this.keypointsSuppressionLogged) {
|
|
1990
|
+
this.keypointsSuppressionLogged = true;
|
|
1991
|
+
this.opts.onDiagnostic?.(
|
|
1992
|
+
"[posetracker-web] keypoints events paused during exercise (features.keypoints=false)"
|
|
1993
|
+
);
|
|
1994
|
+
}
|
|
1995
|
+
if (this.session) {
|
|
1996
|
+
try {
|
|
1997
|
+
this.session.processPose(pose);
|
|
1998
|
+
this.sessionErrorStreak = 0;
|
|
1999
|
+
} catch (err) {
|
|
2000
|
+
this.sessionErrorStreak += 1;
|
|
2001
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2002
|
+
if (this.sessionErrorStreak === 1) {
|
|
2003
|
+
this.opts.onDiagnostic?.(`[posetracker-web] processPose threw: ${message}`);
|
|
2004
|
+
}
|
|
2005
|
+
if (this.sessionErrorStreak >= SESSION_ERROR_STREAK_LIMIT) {
|
|
2006
|
+
const exerciseId = this.currentExerciseId;
|
|
2007
|
+
this.session = null;
|
|
2008
|
+
this.currentExerciseId = null;
|
|
2009
|
+
this.sessionErrorStreak = 0;
|
|
2010
|
+
this.reportError({
|
|
2011
|
+
type: "error",
|
|
2012
|
+
code: "internal",
|
|
2013
|
+
message: `Exercise session '${exerciseId ?? "?"}' failed repeatedly (${message}) \u2014 session stopped.`
|
|
2014
|
+
});
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
// -------------------------------------------------------------------------
|
|
2020
|
+
// Internals
|
|
2021
|
+
// -------------------------------------------------------------------------
|
|
2022
|
+
async doPreload() {
|
|
2023
|
+
this.setStatus("configuring");
|
|
2024
|
+
await this.resolveManifestOnce();
|
|
2025
|
+
this.setStatus("downloading");
|
|
2026
|
+
await this.tryConfigure({ silentStatus: true });
|
|
2027
|
+
void this.tracker.flushQueue();
|
|
2028
|
+
try {
|
|
2029
|
+
this.setStatus("warming");
|
|
2030
|
+
await this.ensureAdapter();
|
|
2031
|
+
this.acceleration = {
|
|
2032
|
+
...this.acceleration,
|
|
2033
|
+
state: "gpu",
|
|
2034
|
+
runtime: "tfjs-webgl",
|
|
2035
|
+
backend: "webgl"
|
|
2036
|
+
};
|
|
2037
|
+
} catch (err) {
|
|
2038
|
+
this.reportError({
|
|
2039
|
+
type: "error",
|
|
2040
|
+
code: "model_load_failed",
|
|
2041
|
+
message: err instanceof Error ? err.message : String(err)
|
|
2042
|
+
});
|
|
2043
|
+
this.setStatus("error");
|
|
2044
|
+
throw err;
|
|
2045
|
+
}
|
|
2046
|
+
this.setStatus("ready");
|
|
2047
|
+
this.emit({
|
|
2048
|
+
type: "initialization",
|
|
2049
|
+
step: "ready",
|
|
2050
|
+
message: "running",
|
|
2051
|
+
ready: true,
|
|
2052
|
+
mode: this.mode,
|
|
2053
|
+
acceleration: this.getAcceleration()
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
async ensureColdStartMode(mode) {
|
|
2057
|
+
if (mode !== "full") return;
|
|
2058
|
+
if (!this.shell) return;
|
|
2059
|
+
await this.ensureCamera();
|
|
2060
|
+
}
|
|
2061
|
+
async ensureAdapter() {
|
|
2062
|
+
if (this.adapter) return;
|
|
2063
|
+
this.resolved = resolvePoseModel({ model: this.opts.model, modelUrl: this.opts.modelUrl });
|
|
2064
|
+
if (this.resolved.kind === "unsupported") {
|
|
2065
|
+
throw new Error(this.resolved.unsupportedReason ?? "Unsupported model");
|
|
2066
|
+
}
|
|
2067
|
+
this.emit({
|
|
2068
|
+
type: "initialization",
|
|
2069
|
+
step: "loading_pose_model",
|
|
2070
|
+
message: "loading pose model",
|
|
2071
|
+
ready: false
|
|
2072
|
+
});
|
|
2073
|
+
this.adapter = createPoseAdapter({ resolved: this.resolved });
|
|
2074
|
+
await this.adapter.load();
|
|
2075
|
+
}
|
|
2076
|
+
async ensureCamera() {
|
|
2077
|
+
if (!this.shell) throw new Error("Call mount() before opening the camera");
|
|
2078
|
+
if (this.stream) return;
|
|
2079
|
+
this.emit({
|
|
2080
|
+
type: "initialization",
|
|
2081
|
+
step: "accessing_webcam",
|
|
2082
|
+
message: "accessing webcam",
|
|
2083
|
+
ready: false
|
|
2084
|
+
});
|
|
2085
|
+
try {
|
|
2086
|
+
this.stream = await openCameraStream({
|
|
2087
|
+
facingMode: facingFromOptions(this.opts),
|
|
2088
|
+
idealWidth: this.opts.idealWidth,
|
|
2089
|
+
idealHeight: this.opts.idealHeight
|
|
2090
|
+
});
|
|
2091
|
+
} catch (err) {
|
|
2092
|
+
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
|
+
);
|
|
2103
|
+
throw err;
|
|
2104
|
+
}
|
|
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
|
+
}
|
|
2111
|
+
const started = Date.now();
|
|
2112
|
+
const tick = () => {
|
|
2113
|
+
this.revealCamera();
|
|
2114
|
+
if (this.cameraRevealed) return;
|
|
2115
|
+
if (Date.now() - started > 8e3) {
|
|
2116
|
+
this.shell?.setBootMessage(
|
|
2117
|
+
"camera started but preview is empty \u2014 check permissions",
|
|
2118
|
+
true
|
|
2119
|
+
);
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
setTimeout(tick, 120);
|
|
2123
|
+
};
|
|
2124
|
+
setTimeout(tick, 60);
|
|
2125
|
+
void this.handleCameraStart({
|
|
2126
|
+
backend: this.acceleration.backend ?? "webgl",
|
|
2127
|
+
profileId: null
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2130
|
+
beginLoop() {
|
|
2131
|
+
this.running = true;
|
|
2132
|
+
this.fpsTimer = performance.now();
|
|
2133
|
+
void this.loop(++this.loopToken);
|
|
2134
|
+
}
|
|
2135
|
+
async loop(token) {
|
|
2136
|
+
if (token !== this.loopToken || !this.running || !this.adapter || !this.shell || this.disposed) {
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
const video = this.shell.video;
|
|
2140
|
+
if (!this.busy && video.readyState >= 2 && video.videoWidth > 0) {
|
|
2141
|
+
this.busy = true;
|
|
2142
|
+
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
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
this.revealCamera();
|
|
2201
|
+
} catch (err) {
|
|
2202
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2203
|
+
this.emit({ type: "error", code: "internal", message: `inference: ${message}` });
|
|
2204
|
+
}
|
|
2205
|
+
this.busy = false;
|
|
2206
|
+
}
|
|
2207
|
+
requestAnimationFrame(() => {
|
|
2208
|
+
void this.loop(token);
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
async resolveSkeletonFromOptions() {
|
|
2212
|
+
if (this.opts.skeletonDef) {
|
|
2213
|
+
this.skeletonDef = this.opts.skeletonDef;
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
const uuid = this.opts.skeletonUuid?.trim();
|
|
2217
|
+
if (!uuid || uuid === "true" || uuid === "false") {
|
|
2218
|
+
this.skeletonDef = null;
|
|
2219
|
+
return;
|
|
2220
|
+
}
|
|
2221
|
+
try {
|
|
2222
|
+
this.skeletonDef = await this.fetchSkeleton(uuid);
|
|
2223
|
+
} catch {
|
|
2224
|
+
this.skeletonDef = null;
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
resolveManifestOnce() {
|
|
2228
|
+
if (!this.handshakePromise) {
|
|
2229
|
+
this.handshakePromise = this.doResolveManifestOnce().then((manifest) => {
|
|
2230
|
+
if (!manifest) this.handshakePromise = null;
|
|
2231
|
+
return manifest;
|
|
2232
|
+
});
|
|
2233
|
+
}
|
|
2234
|
+
return this.handshakePromise;
|
|
2235
|
+
}
|
|
2236
|
+
async doResolveManifestOnce() {
|
|
2237
|
+
const localVersions = {
|
|
2238
|
+
poseRuntime: null,
|
|
2239
|
+
engine: await this.files?.read(ENGINE_VERSION_KEY).catch(() => null) ?? null
|
|
2240
|
+
};
|
|
2241
|
+
if (this.apiToken) {
|
|
2242
|
+
const manifest = await this.resolveManifest(this.apiToken, localVersions);
|
|
2243
|
+
if (manifest) {
|
|
2244
|
+
this.manifest = manifest;
|
|
2245
|
+
if (manifest.revoked === true) {
|
|
2246
|
+
await this.handleRevocation("Access revoked by the backend.");
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
return manifest;
|
|
2250
|
+
}
|
|
2251
|
+
try {
|
|
2252
|
+
const manifest = await configure(null, { ...this.opts, localVersions });
|
|
2253
|
+
this.manifest = manifest;
|
|
2254
|
+
return manifest;
|
|
2255
|
+
} catch {
|
|
2256
|
+
return null;
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
async handleRevocation(message) {
|
|
2260
|
+
const engineVersion = await this.files?.read(ENGINE_VERSION_KEY).catch(() => null);
|
|
2261
|
+
if (engineVersion) {
|
|
2262
|
+
await this.files?.remove(`engine-${engineVersion}.sealed`).catch(() => {
|
|
2263
|
+
});
|
|
2264
|
+
await this.files?.remove(ENGINE_VERSION_KEY).catch(() => {
|
|
2265
|
+
});
|
|
2266
|
+
}
|
|
2267
|
+
await this.files?.remove(MANIFEST_CACHE_KEY).catch(() => {
|
|
2268
|
+
});
|
|
2269
|
+
this.engine = null;
|
|
2270
|
+
this.engineSource = null;
|
|
2271
|
+
this.stopExercise();
|
|
2272
|
+
this.setMode("keypoints-only");
|
|
2273
|
+
this.reportError({ type: "error", code: "invalid_token", message });
|
|
2274
|
+
this.applyWatermark();
|
|
2275
|
+
}
|
|
2276
|
+
async handleCameraStart(info) {
|
|
2277
|
+
this.lastCameraStartInfo = info;
|
|
2278
|
+
const params = {
|
|
2279
|
+
backend: info.backend,
|
|
2280
|
+
profileId: info.profileId,
|
|
2281
|
+
poseModelProfile: this.opts.poseModelProfile ?? "AdaptiveChoice",
|
|
2282
|
+
mode: this.mode,
|
|
2283
|
+
exercise: this.currentExerciseId,
|
|
2284
|
+
model: this.opts.model ?? "movenet",
|
|
2285
|
+
modelUrl: this.opts.modelUrl ?? null
|
|
2286
|
+
};
|
|
2287
|
+
if (!this.apiToken) {
|
|
2288
|
+
void this.tracker.trackAnonymous({ event: "camera_start", params });
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
this.meteredSessionState = "pending";
|
|
2292
|
+
try {
|
|
2293
|
+
await this.tracker.trackMetered({
|
|
2294
|
+
event: "camera_start",
|
|
2295
|
+
apiToken: this.apiToken,
|
|
2296
|
+
params
|
|
2297
|
+
});
|
|
2298
|
+
this.meteredSessionState = "validated";
|
|
2299
|
+
this.opts.onDiagnostic?.("[posetracker-web] camera_start tracked (metered)");
|
|
2300
|
+
} catch (err) {
|
|
2301
|
+
this.meteredSessionState = "refused";
|
|
2302
|
+
if (err instanceof TrackError && err.code === "network") {
|
|
2303
|
+
this.reportError({
|
|
2304
|
+
type: "error",
|
|
2305
|
+
code: "offline_metered",
|
|
2306
|
+
message: "API-key features are not available offline: PoseTracker cannot count their usage. Keypoints-only keeps running."
|
|
2307
|
+
});
|
|
2308
|
+
} else if (err instanceof TrackError && err.code === "invalid_token") {
|
|
2309
|
+
await this.handleRevocation("API key invalid or revoked \u2014 engine cache purged.");
|
|
2310
|
+
} else if (err instanceof TrackError && err.code === "quota_exceeded") {
|
|
2311
|
+
this.reportError({ type: "error", code: "quota_exceeded", message: err.message });
|
|
2312
|
+
} else {
|
|
2313
|
+
this.reportError({
|
|
2314
|
+
type: "error",
|
|
2315
|
+
code: "internal",
|
|
2316
|
+
message: `Usage tracking failed: ${err instanceof Error ? err.message : String(err)}`
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
retryMeteredSessionIfRefused() {
|
|
2322
|
+
if (this.meteredSessionState === "refused" && this.apiToken && this.lastCameraStartInfo) {
|
|
2323
|
+
void this.handleCameraStart(this.lastCameraStartInfo);
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
async tryConfigure({
|
|
2327
|
+
silentStatus,
|
|
2328
|
+
forceEngineRetry = false
|
|
2329
|
+
}) {
|
|
2330
|
+
if (this.mode === "full-engine") return true;
|
|
2331
|
+
if (!this.apiToken) {
|
|
2332
|
+
this.validateRequestedFeatures();
|
|
2333
|
+
this.setMode("keypoints-only");
|
|
2334
|
+
this.applyWatermark();
|
|
2335
|
+
return false;
|
|
2336
|
+
}
|
|
2337
|
+
const manifest = await this.resolveManifestOnce();
|
|
2338
|
+
if (!manifest || manifest.revoked === true) {
|
|
2339
|
+
this.setMode("keypoints-only");
|
|
2340
|
+
this.applyWatermark();
|
|
2341
|
+
return false;
|
|
2342
|
+
}
|
|
2343
|
+
this.manifest = manifest;
|
|
2344
|
+
this.validateRequestedFeatures();
|
|
2345
|
+
if (!silentStatus) this.setStatus("downloading");
|
|
2346
|
+
const secret = deriveCacheSecret(this.apiToken);
|
|
2347
|
+
if (forceEngineRetry && manifest.engine) {
|
|
2348
|
+
await this.engineLoader.clearGuard(manifest.engine);
|
|
2349
|
+
}
|
|
2350
|
+
const result = await this.engineLoader.load(manifest.engine ?? null, secret, {
|
|
2351
|
+
forceRetry: forceEngineRetry
|
|
2352
|
+
});
|
|
2353
|
+
if (!result) {
|
|
2354
|
+
const detail = this.engineLoader.lastError;
|
|
2355
|
+
this.reportError({
|
|
2356
|
+
type: "error",
|
|
2357
|
+
code: "engine_load_failed",
|
|
2358
|
+
message: detail ? `Engine bundle unavailable \u2014 ${detail}` : "Engine bundle unavailable \u2014 running keypoints-only."
|
|
2359
|
+
});
|
|
2360
|
+
this.setMode("keypoints-only");
|
|
2361
|
+
this.applyWatermark();
|
|
2362
|
+
return false;
|
|
2363
|
+
}
|
|
2364
|
+
this.engine = result.engine;
|
|
2365
|
+
this.engineSource = result.source;
|
|
2366
|
+
if (manifest.engine?.version) {
|
|
2367
|
+
await this.files?.write(ENGINE_VERSION_KEY, manifest.engine.version).catch(() => {
|
|
2368
|
+
});
|
|
2369
|
+
}
|
|
2370
|
+
this.setMode("full-engine");
|
|
2371
|
+
this.applyWatermark();
|
|
2372
|
+
return true;
|
|
2373
|
+
}
|
|
2374
|
+
async resolveManifest(apiToken, localVersions) {
|
|
2375
|
+
const secret = deriveCacheSecret(apiToken);
|
|
2376
|
+
try {
|
|
2377
|
+
const manifest = await configure(apiToken, { ...this.opts, localVersions });
|
|
2378
|
+
await this.files?.write(MANIFEST_CACHE_KEY, sealString(JSON.stringify(manifest), secret)).catch(() => {
|
|
2379
|
+
});
|
|
2380
|
+
return manifest;
|
|
2381
|
+
} catch (err) {
|
|
2382
|
+
if (err instanceof ConfigureError && err.code === "invalid_token") {
|
|
2383
|
+
await this.handleRevocation(err.message);
|
|
2384
|
+
return null;
|
|
2385
|
+
}
|
|
2386
|
+
if (err instanceof ConfigureError && err.code === "quota_exceeded") {
|
|
2387
|
+
this.reportError({ type: "error", code: "quota_exceeded", message: err.message });
|
|
2388
|
+
return null;
|
|
2389
|
+
}
|
|
2390
|
+
const sealed = await this.files?.read(MANIFEST_CACHE_KEY);
|
|
2391
|
+
if (sealed) {
|
|
2392
|
+
const plain = openString(sealed, secret);
|
|
2393
|
+
if (plain) {
|
|
2394
|
+
try {
|
|
2395
|
+
return JSON.parse(plain);
|
|
2396
|
+
} catch {
|
|
2397
|
+
await this.files?.remove(MANIFEST_CACHE_KEY).catch(() => {
|
|
2398
|
+
});
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
this.reportError({
|
|
2403
|
+
type: "error",
|
|
2404
|
+
code: "network",
|
|
2405
|
+
message: "Handshake unreachable and no cached session \u2014 running keypoints-only."
|
|
2406
|
+
});
|
|
2407
|
+
return null;
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
validateRequestedFeatures() {
|
|
2411
|
+
if (this.unsupportedFeatureKeys.length > 0 && !this.featureGateReported.unsupported) {
|
|
2412
|
+
this.featureGateReported.unsupported = true;
|
|
2413
|
+
this.reportError({
|
|
2414
|
+
type: "error",
|
|
2415
|
+
code: "feature_not_supported",
|
|
2416
|
+
message: featureNotSupportedMessage(this.unsupportedFeatureKeys[0])
|
|
2417
|
+
});
|
|
2418
|
+
}
|
|
2419
|
+
const f = this.features;
|
|
2420
|
+
const requestsDevFeatures = f.angles || f.recommendations || f.progression || f.keypoints;
|
|
2421
|
+
if (!requestsDevFeatures) return;
|
|
2422
|
+
if (!this.apiToken) {
|
|
2423
|
+
if (!this.featureGateReported.missingToken) {
|
|
2424
|
+
this.featureGateReported.missingToken = true;
|
|
2425
|
+
this.reportError({ type: "error", code: "invalid_token", message: INVALID_TOKEN_MESSAGE });
|
|
2426
|
+
}
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
if (this.getPlanType() === "free") {
|
|
2430
|
+
const blocked = freeBlockedFeatures(f, { withExercise: false });
|
|
2431
|
+
if (blocked.length > 0 && !this.featureGateReported.freeBlock) {
|
|
2432
|
+
this.featureGateReported.freeBlock = true;
|
|
2433
|
+
this.reportError({
|
|
2434
|
+
type: "error",
|
|
2435
|
+
code: "free_plan_feature_blocked",
|
|
2436
|
+
message: FREE_PLAN_FEATURES_MESSAGE
|
|
2437
|
+
});
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
emitFromSession(event) {
|
|
2442
|
+
if (event.type === "angles" && !this.features.angles) return;
|
|
2443
|
+
if (event.type === "recommendations" && !this.features.recommendations) return;
|
|
2444
|
+
if (event.type === "progression" && !this.features.progression) return;
|
|
2445
|
+
if (event.type === "posture") {
|
|
2446
|
+
const draw = this.opts.drawPlacementBox !== false;
|
|
2447
|
+
this.shell?.setPlacementVisible(draw && !event.ready, this.opts.placementPaddingPercent ?? 10);
|
|
2448
|
+
} else if (event.type === "exercise_summary") {
|
|
2449
|
+
this.shell?.setPlacementVisible(false);
|
|
2450
|
+
}
|
|
2451
|
+
this.emit(event);
|
|
2452
|
+
}
|
|
2453
|
+
watermarkOn() {
|
|
2454
|
+
if (typeof this.opts.showWatermark === "boolean") return this.opts.showWatermark;
|
|
2455
|
+
if (this.coldStartMode === "basic" && !this.stream) return false;
|
|
2456
|
+
return shouldShowWatermark(this.getPlanType());
|
|
2457
|
+
}
|
|
2458
|
+
applyWatermark() {
|
|
2459
|
+
this.shell?.setWatermarkVisible(this.watermarkOn() && this.cameraRevealed);
|
|
2460
|
+
}
|
|
2461
|
+
revealCamera() {
|
|
2462
|
+
if (!this.shell || this.cameraRevealed) return;
|
|
2463
|
+
const v = this.shell.video;
|
|
2464
|
+
if (!(v.videoWidth > 0 && v.videoHeight > 0)) return;
|
|
2465
|
+
requestAnimationFrame(() => {
|
|
2466
|
+
requestAnimationFrame(() => {
|
|
2467
|
+
if (this.cameraRevealed || !this.shell) return;
|
|
2468
|
+
if (!(this.shell.video.videoWidth > 0)) return;
|
|
2469
|
+
this.cameraRevealed = true;
|
|
2470
|
+
this.shell.setCameraVisible(true);
|
|
2471
|
+
this.applyWatermark();
|
|
2472
|
+
});
|
|
2473
|
+
});
|
|
2474
|
+
}
|
|
2475
|
+
setStatus(status) {
|
|
2476
|
+
this.status = status;
|
|
2477
|
+
if (status === "configuring" || status === "downloading" || status === "warming") {
|
|
2478
|
+
this.emit({
|
|
2479
|
+
type: "initialization",
|
|
2480
|
+
step: status,
|
|
2481
|
+
message: status === "configuring" ? "checking you plan and access" : status === "downloading" ? "downloading engine" : "loading pose model",
|
|
2482
|
+
ready: false
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2485
|
+
this.notifyState();
|
|
2486
|
+
}
|
|
2487
|
+
setMode(mode) {
|
|
2488
|
+
if (this.mode !== mode) {
|
|
2489
|
+
this.mode = mode;
|
|
2490
|
+
this.notifyState();
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
reportError(error) {
|
|
2494
|
+
this.lastError = error;
|
|
2495
|
+
this.emit(error);
|
|
2496
|
+
this.notifyState();
|
|
2497
|
+
}
|
|
2498
|
+
notifyState() {
|
|
2499
|
+
this.stateListeners.forEach((l) => {
|
|
2500
|
+
try {
|
|
2501
|
+
l();
|
|
2502
|
+
} catch {
|
|
2503
|
+
}
|
|
2504
|
+
});
|
|
2505
|
+
}
|
|
2506
|
+
emit(event) {
|
|
2507
|
+
this.listeners.forEach((l) => {
|
|
2508
|
+
try {
|
|
2509
|
+
l(event);
|
|
2510
|
+
} catch (err) {
|
|
2511
|
+
this.opts.onDiagnostic?.(
|
|
2512
|
+
`[posetracker-web] listener threw on '${event.type}': ` + (err instanceof Error ? err.message : String(err))
|
|
2513
|
+
);
|
|
2514
|
+
}
|
|
2515
|
+
});
|
|
2516
|
+
const typed = this.typedListeners.get(event.type);
|
|
2517
|
+
typed?.forEach((l) => {
|
|
2518
|
+
try {
|
|
2519
|
+
l(event);
|
|
2520
|
+
} catch {
|
|
2521
|
+
}
|
|
2522
|
+
});
|
|
2523
|
+
const any = this.typedListeners.get("*");
|
|
2524
|
+
any?.forEach((l) => {
|
|
2525
|
+
try {
|
|
2526
|
+
l(event);
|
|
2527
|
+
} catch {
|
|
2528
|
+
}
|
|
2529
|
+
});
|
|
2530
|
+
if (this.messageListeners.size === 0) return;
|
|
2531
|
+
const classic = toClassicNativeMessage(event);
|
|
2532
|
+
this.messageListeners.forEach((l) => {
|
|
2533
|
+
try {
|
|
2534
|
+
l(classic);
|
|
2535
|
+
} catch {
|
|
2536
|
+
}
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
};
|
|
2540
|
+
function createPoseTracker(options = {}) {
|
|
2541
|
+
return new PoseTrackerClient(options.apiToken, options);
|
|
2542
|
+
}
|
|
2543
|
+
function PoseCamera_mount(target, options = {}) {
|
|
2544
|
+
const pt = createPoseTracker(options);
|
|
2545
|
+
pt.mount(target);
|
|
2546
|
+
return pt;
|
|
2547
|
+
}
|
|
2548
|
+
var PoseCamera = {
|
|
2549
|
+
mount: PoseCamera_mount
|
|
2550
|
+
};
|
|
2551
|
+
|
|
2552
|
+
exports.BRAND_COLORS = BRAND_COLORS;
|
|
2553
|
+
exports.BlazePoseAdapter = BlazePoseAdapter;
|
|
2554
|
+
exports.COCO_KEYPOINT_NAMES = COCO_KEYPOINT_NAMES;
|
|
2555
|
+
exports.COMBINED_REFERENCE_EXERCISE_MESSAGE = COMBINED_REFERENCE_EXERCISE_MESSAGE;
|
|
2556
|
+
exports.ConfigureError = ConfigureError;
|
|
2557
|
+
exports.DEFAULT_BASE_URL = DEFAULT_BASE_URL;
|
|
2558
|
+
exports.DEFAULT_FEATURES = DEFAULT_FEATURES;
|
|
2559
|
+
exports.DEFAULT_LOADING_TEXT = DEFAULT_LOADING_TEXT;
|
|
2560
|
+
exports.DEFAULT_MOVENET_LIGHTNING_URL = DEFAULT_MOVENET_LIGHTNING_URL;
|
|
2561
|
+
exports.DEFAULT_SKELETON = DEFAULT_SKELETON;
|
|
2562
|
+
exports.DEFAULT_SKELETON_DEFINITION = DEFAULT_SKELETON_DEFINITION;
|
|
2563
|
+
exports.EXERCISE_ALIASES = EXERCISE_ALIASES;
|
|
2564
|
+
exports.EngineLoader = EngineLoader;
|
|
2565
|
+
exports.FREE_PLAN_FEATURES_MESSAGE = FREE_PLAN_FEATURES_MESSAGE;
|
|
2566
|
+
exports.INVALID_TOKEN_MESSAGE = INVALID_TOKEN_MESSAGE;
|
|
2567
|
+
exports.MoveNetGraphAdapter = MoveNetGraphAdapter;
|
|
2568
|
+
exports.ONLINE_POSE_DETECTION_VERSION = ONLINE_POSE_DETECTION_VERSION;
|
|
2569
|
+
exports.ONLINE_TFJS_VERSION = ONLINE_TFJS_VERSION;
|
|
2570
|
+
exports.POSETRACKER_LOGO_URL = POSETRACKER_LOGO_URL;
|
|
2571
|
+
exports.PoseCamera = PoseCamera;
|
|
2572
|
+
exports.PoseCamera_mount = PoseCamera_mount;
|
|
2573
|
+
exports.PoseTrackerClient = PoseTrackerClient;
|
|
2574
|
+
exports.SDK_NAME = SDK_NAME;
|
|
2575
|
+
exports.SDK_VERSION = SDK_VERSION;
|
|
2576
|
+
exports.SkeletonFetchError = SkeletonFetchError;
|
|
2577
|
+
exports.TrackError = TrackError;
|
|
2578
|
+
exports.UsageTracker = UsageTracker;
|
|
2579
|
+
exports.configure = configure;
|
|
2580
|
+
exports.createLocalFileStore = createLocalFileStore;
|
|
2581
|
+
exports.createLocalKeyValueStore = createLocalKeyValueStore;
|
|
2582
|
+
exports.createMemoryFileStore = createMemoryFileStore;
|
|
2583
|
+
exports.createPoseAdapter = createPoseAdapter;
|
|
2584
|
+
exports.createPoseTracker = createPoseTracker;
|
|
2585
|
+
exports.createWebFileStore = createWebFileStore;
|
|
2586
|
+
exports.defaultPoseDetectionCdnUrl = defaultPoseDetectionCdnUrl;
|
|
2587
|
+
exports.defaultTfjsCdnUrls = defaultTfjsCdnUrls;
|
|
2588
|
+
exports.drawSkeleton = drawSkeleton;
|
|
2589
|
+
exports.featureNotSupportedMessage = featureNotSupportedMessage;
|
|
2590
|
+
exports.fetchSkeletonDefinition = fetchSkeletonDefinition;
|
|
2591
|
+
exports.findExerciseByIdOrAlias = findExerciseByIdOrAlias;
|
|
2592
|
+
exports.freeBlockedFeatures = freeBlockedFeatures;
|
|
2593
|
+
exports.initWebGlBackend = initWebGlBackend;
|
|
2594
|
+
exports.isPaidPlan = isPaidPlan;
|
|
2595
|
+
exports.mapVideoKeypointsToDisplay = mapVideoKeypointsToDisplay;
|
|
2596
|
+
exports.mountDomShell = mountDomShell;
|
|
2597
|
+
exports.openCameraStream = openCameraStream;
|
|
2598
|
+
exports.resolveExerciseName = resolveExerciseName;
|
|
2599
|
+
exports.resolveFeatures = resolveFeatures;
|
|
2600
|
+
exports.resolvePoseDetection = resolvePoseDetection;
|
|
2601
|
+
exports.resolvePoseModel = resolvePoseModel;
|
|
2602
|
+
exports.resolveTf = resolveTf;
|
|
2603
|
+
exports.shouldShowWatermark = shouldShowWatermark;
|
|
2604
|
+
exports.stopMediaStream = stopMediaStream;
|
|
2605
|
+
exports.toClassicNativeMessage = toClassicNativeMessage;
|
|
2606
|
+
//# sourceMappingURL=index.cjs.map
|
|
2607
|
+
//# sourceMappingURL=index.cjs.map
|