@fabi-play/motion 0.0.0-stage → 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 ADDED
@@ -0,0 +1,76 @@
1
+ Fabi Play Kit License
2
+ Version 1.0 (DRAFT, not yet reviewed by a lawyer)
3
+
4
+ Copyright (c) 2026 Fabi Play. All rights reserved.
5
+
6
+ This license applies to the Fabi Play Kit: the packages @fabi-play/protocol, @fabi-play/motion,
7
+ @fabi-play/avatars, @fabi-play/sdk and @fabi-play/engine, in source and built form, as published on npm, served
8
+ from https://cdn.fabiplay.app or found in this repository, including their code, type declarations, documentation,
9
+ avatar artwork and other assets (together, the "Kit").
10
+
11
+ 1. Free of charge, for Fabi Play only
12
+
13
+ Fabi Play grants you a worldwide, non-exclusive, non-transferable, non-sublicensable, royalty-free license to
14
+ use, copy, and modify the Kit, and to distribute it as part of a Permitted Work, solely to make, test, run and
15
+ distribute Permitted Works.
16
+
17
+ A "Permitted Work" is a game, tool, or other software made to run on, be published on, or support the
18
+ development of games for the Fabi Play platform (https://fabiplay.app), including games submitted to Fabi Play
19
+ and tools used to build, test or preview them.
20
+
21
+ 2. What is not allowed
22
+
23
+ You may not, without Fabi Play's prior written permission:
24
+
25
+ a. use the Kit, or any part or modified version of it, in software that is not a Permitted Work, including in
26
+ any other game platform, product or service;
27
+ b. distribute the Kit, or a modified version of it, on its own or as a library, SDK, engine or framework for
28
+ others to build with, other than as part of a Permitted Work (installing it from npm or loading it from
29
+ https://cdn.fabiplay.app is not a distribution by you);
30
+ c. sell, rent or sublicense the Kit itself;
31
+ d. remove or alter this license, copyright notices, or the license banners in the built files;
32
+ e. use the avatars, the mascot "Fabi", or the name, logos or other trademarks of Fabi Play, except as the Kit
33
+ does when it runs inside a Permitted Work. This license grants no trademark rights;
34
+ f. use the Kit to circumvent, interfere with or misrepresent the Fabi Play platform, its safety, privacy or
35
+ parental controls, or its rules for games.
36
+
37
+ 3. Visible, not open source
38
+
39
+ The Kit's code is published so that developers can read it, debug with it and build Permitted Works. Being
40
+ visible does not make it open source; no rights are granted beyond those stated here.
41
+
42
+ 4. Your works
43
+
44
+ You keep all rights in your own code and content. This license does not give Fabi Play any rights in your
45
+ Permitted Works; publishing a game on Fabi Play is governed by Fabi Play's separate developer terms.
46
+
47
+ 5. Feedback
48
+
49
+ If you send Fabi Play suggestions or changes for the Kit, Fabi Play may use them without obligation to you.
50
+
51
+ 6. Termination
52
+
53
+ This license ends automatically if you break its terms. If you fix the breach within 30 days of becoming aware
54
+ of it, the license is reinstated, unless Fabi Play has already told you in writing that it is terminated. On
55
+ termination you must stop using and distributing the Kit. Permitted Works already distributed to end users in
56
+ compliance with this license may continue to be used by those end users.
57
+
58
+ 7. No warranty
59
+
60
+ THE KIT IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
61
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT.
62
+
63
+ 8. Limitation of liability
64
+
65
+ TO THE MAXIMUM EXTENT PERMITTED BY LAW, IN NO EVENT SHALL FABI PLAY OR ITS CONTRIBUTORS BE LIABLE FOR ANY CLAIM,
66
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
67
+ CONNECTION WITH THE KIT OR THE USE OR OTHER DEALINGS IN THE KIT.
68
+
69
+ 9. Changes to this license
70
+
71
+ Fabi Play may publish new versions of this license. Each version of the Kit is governed by the license version
72
+ it is distributed with, unless you choose to accept a later version.
73
+
74
+ 10. Contact
75
+
76
+ For permissions beyond this license: https://fabiplay.app
package/README.md CHANGED
@@ -1,3 +1,25 @@
1
- # Temporary Holding Version
1
+ # @fabi-play/motion
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Body movements from pose landmarks, as [Fabi Play](https://fabiplay.app) reads them: jumps, squats, claps, shapes
4
+ (arms up, T, V, star), lanes, freezing, the distance from the screen, and hands as pointers.
5
+
6
+ ```sh
7
+ npm install @fabi-play/motion
8
+ ```
9
+
10
+ ```ts
11
+ import { MotionTracker, shapeOf, hands } from '@fabi-play/motion'
12
+ import type { Body } from '@fabi-play/protocol'
13
+
14
+ declare const body: Body // 33 landmarks × (x, y, visibility), in player space
15
+ const shape = shapeOf(body) // 'star', 'arms-up', … or null
16
+ const { left, right } = hands(body)
17
+ ```
18
+
19
+ Games get these movements as events from [`@fabi-play/sdk`](https://www.npmjs.com/package/@fabi-play/sdk); this
20
+ package is for tools and for the Fabi Play apps. ESM only, types included; depends on `@fabi-play/protocol`.
21
+ Docs: https://docs.fabiplay.app.
22
+
23
+ ## License
24
+
25
+ Fabi Play Kit License: free to use, for making games and tools for Fabi Play only. See [LICENSE](./LICENSE).
@@ -0,0 +1,403 @@
1
+ // @fabi-play/motion: Body movements from pose landmarks: jumps, squats, shapes, lanes, freezing, hands as pointers. See https://docs.fabiplay.app.
2
+ // Generated by scripts/build-lib.mjs (fabiplay-kit) from the sources: do not edit.
3
+
4
+ import { AgeRange, Body as Body$1, Fov, Gesture, Point, Shape, Zone } from '@fabi-play/protocol';
5
+
6
+ /** Every coefficient of the distances, in one place (see the sources above). */
7
+ export declare const DISTANCE: {
8
+ /** The closest the eyes should be: this many diagonals (between THX's 1.2 and SMPTE's 1.6). */
9
+ readonly eyeMinPerDiagonal: 1.5;
10
+ /** The farthest the screen stays readable: this many diagonals. */
11
+ readonly readMaxPerDiagonal: 3;
12
+ /** The farthest the camera tracks reliably, metres (BlazePose's 2–4 m). */
13
+ readonly trackingMaxM: 4;
14
+ /** The camera must see the whole body plus this much (feet and raised hands, a jump). */
15
+ readonly cameraMargin: 1.15;
16
+ /** When the screen is too small for both (a tablet), the zone starts where the camera sees everyone and is this deep, metres. */
17
+ readonly smallScreenDepthM: 0.5;
18
+ /** Typical heights of each age band, metres (WHO, median at the band's middle). */
19
+ readonly heightM: Record<AgeRange, number>;
20
+ /** Field of view across the long side of the image when the phone can't say (the web phone), degrees. */
21
+ readonly defaultFovLongDeg: 65;
22
+ /** Where the landmarks are, as fractions of stature from the floor (children). */
23
+ readonly eyes: 0.91;
24
+ readonly nose: 0.885;
25
+ readonly ankles: 0.04;
26
+ /** Shoulders to hips (midpoints), as a fraction of stature. */
27
+ readonly torso: 0.29;
28
+ /** Shoulder to shoulder, as a fraction of stature. */
29
+ readonly shoulders: 0.22;
30
+ /**
31
+ * The feet are doubted (taken as out of the picture) when the torso puts the child nearer than this fraction of
32
+ * what the eyes-to-ankles span says: the two agree within a few per cent on a whole body.
33
+ */
34
+ readonly feetDoubt: 0.75;
35
+ /** A landmark counts as seen from this visibility. */
36
+ readonly visible: 0.5;
37
+ };
38
+ /**
39
+ * The field of view of the image a phone sends, from its sensor's (degrees across the long and the short side) and
40
+ * the image's aspect (width / height, upright: below 1 when the phone stands upright). The image is the largest
41
+ * centred crop of the sensor with its aspect; upright, the long side is the height.
42
+ */
43
+ export declare function imageFov(sensor: {
44
+ long: number;
45
+ short: number;
46
+ }, aspect: number): Fov;
47
+ /** A 4:3 sensor whose long side sees longDeg. */
48
+ export declare function sensorFromLong(longDeg: number): {
49
+ long: number;
50
+ short: number;
51
+ };
52
+ /** The field of view to assume for an image of this aspect when the phone didn't send one (DEFAULT_FOV). */
53
+ export declare const defaultFov: (aspect: number) => Fov;
54
+ /** The tallest typical child among these bands (the tallest band when none). */
55
+ export declare const tallestHeight: (ranges: readonly AgeRange[]) => number;
56
+ export interface ZoneDetail extends Zone {
57
+ /** The eyes' minimum: DISTANCE.eyeMinPerDiagonal diagonals. */
58
+ eyeMin: number;
59
+ /** Where the camera sees the whole body with its margin. */
60
+ cameraMin: number;
61
+ /** The readability maximum: DISTANCE.readMaxPerDiagonal diagonals. */
62
+ readMax: number;
63
+ /** The screen is too small for both (max < min): the zone is where the camera sees everyone, smallScreenDepthM deep. */
64
+ small: boolean;
65
+ }
66
+ /**
67
+ * The green zone, metres from the screen: min = max(eyes' minimum, camera's minimum), max = min(readability maximum,
68
+ * tracking limit). When max < min (a tablet), [cameraMin, cameraMin + 0.5 m]. heightM: the tallest child playing
69
+ * (DISTANCE.heightM of the tallest band by default).
70
+ */
71
+ export declare function zoneFor(o: {
72
+ inches: number;
73
+ fovV: number;
74
+ heightM?: number;
75
+ }): ZoneDetail;
76
+ export type DistanceSource = "body" | "torso" | "shoulders";
77
+ export interface DistanceEstimate {
78
+ /** Metres from the camera; null when the body gives nothing to measure. */
79
+ distance: number | null;
80
+ /** What it was measured on: the whole body (head to ankles), the torso, or the shoulders' width (the closest). */
81
+ source: DistanceSource | null;
82
+ /** The feet are not seen: the camera does not see the whole child (ask them to step back). */
83
+ partial: boolean;
84
+ }
85
+ /**
86
+ * One body's distance from the camera, metres. fov: the image's (degrees); ageRange: the child's band, for their
87
+ * typical height. Measured on the span from the eyes (or the nose) to the ankles when both feet are seen in the image,
88
+ * else on the torso (shoulders to hips), else on the shoulders' width; partial when the feet are not seen.
89
+ */
90
+ export declare function estimateDistance(body: Body$1, fov: Fov, ageRange: AgeRange): DistanceEstimate;
91
+ /** Where a child stands against the zone: nearer than its minimum, in it, farther than its maximum; unknown when not seen. */
92
+ export type ZonePlace = "near" | "in" | "far" | "unknown";
93
+ export interface DistanceTrackerOptions {
94
+ /** The median is over this much time, ms. */
95
+ windowMs?: number;
96
+ /**
97
+ * Half the hysteresis band, metres: a child enters "near" below min − margin and leaves it above min + margin (and
98
+ * the same at max). Larger when the field of view is a guess (the web phone).
99
+ */
100
+ margin?: number;
101
+ /** A child not seen for this long is unknown, ms. */
102
+ forgetMs?: number;
103
+ }
104
+ export interface DistanceReading {
105
+ /** The smoothed distance, metres, or null when unknown. */
106
+ distance: number | null;
107
+ place: ZonePlace;
108
+ /** The feet are not seen: ask the child to step back. */
109
+ partial: boolean;
110
+ }
111
+ /**
112
+ * One child's distance over time: the median of the estimates of the last windowMs, and their place against the
113
+ * zone with hysteresis.
114
+ */
115
+ export declare class DistanceTracker {
116
+ private readonly samples;
117
+ private placeNow;
118
+ private lastSeen;
119
+ private partialNow;
120
+ private readonly windowMs;
121
+ private readonly margin;
122
+ private readonly forgetMs;
123
+ constructor(o?: DistanceTrackerOptions);
124
+ /** A frame: this child's estimate (null: not seen), against the zone. */
125
+ update(estimate: DistanceEstimate | null, zone: Zone, now: number): DistanceReading;
126
+ reset(): void;
127
+ }
128
+ /** What the guard says: nothing, a small warning (option off), or the game paused. */
129
+ export type GuardState = "ok" | "warn" | "paused";
130
+ export interface TooCloseOptions {
131
+ /** Pause the game (the grown-up's option, on by default); off: only warn. */
132
+ pause?: boolean;
133
+ /** Too close for this long, and the game pauses (or the warning shows), ms. */
134
+ enterMs?: number;
135
+ /** Every child back for this long, and the game resumes, ms. */
136
+ resumeMs?: number;
137
+ /**
138
+ * A child near who then can't be measured (walking up to the screen: the feet, then the hips, then the shoulders
139
+ * leave the picture) still counts as near for this long, ms: their 2 s go on.
140
+ */
141
+ lostMs?: number;
142
+ }
143
+ export interface GuardResult {
144
+ state: GuardState;
145
+ /** The slots (0-based) too close now, for the overlay's names and avatars (while paused: also those not back yet). */
146
+ who: number[];
147
+ /** state changed with this update. */
148
+ changed: boolean;
149
+ }
150
+ /**
151
+ * The play-time rule. Each update gets, per playing child (slot), their place against the zone. A child "near" for
152
+ * enterMs straight makes it 'paused' (option on) or 'warn' (option off); a child near and then not measurable any
153
+ * more (too close for the camera to see enough of them) stays near for lostMs. 'paused' holds until every child has been
154
+ * seen and not near for resumeMs (an unknown child keeps it paused: the camera may not see someone right in front
155
+ * of it); 'warn' goes as soon as nobody is near.
156
+ */
157
+ export declare class TooCloseGuard {
158
+ private nearSince;
159
+ /** Since when each child near before is not measurable any more (see lostMs). */
160
+ private lostSince;
161
+ private clearSince;
162
+ private stateNow;
163
+ private pause;
164
+ private readonly enterMs;
165
+ private readonly resumeMs;
166
+ private readonly lostMs;
167
+ constructor(o?: TooCloseOptions);
168
+ get state(): GuardState;
169
+ /** The grown-up's option changed: a pause in progress becomes a warning (off), or a warning a pause (on). */
170
+ setPause(pause: boolean): void;
171
+ update(places: readonly ZonePlace[], now: number): GuardResult;
172
+ /** Back to nothing (a new game, a grown-up's resume): the times start again. */
173
+ reset(): void;
174
+ private result;
175
+ }
176
+ /** What the bubble over a child says in calibration. */
177
+ export type ZoneAdvice = "two-steps-back" | "step-back" | "come-closer" | "perfect" | "none";
178
+ /**
179
+ * The advice for a child: two steps back when more than 0.6 m too close (a child's step is about 0.4–0.5 m), one
180
+ * step back when too close or the feet are not seen, closer when too far, perfect in the zone.
181
+ */
182
+ export declare function zoneAdvice(reading: DistanceReading, zone: Zone): ZoneAdvice;
183
+ /**
184
+ * A body as a camera would see a child of heightM standing distance metres away (for tests, the simulator and the
185
+ * calibration harness): standing straight, arms down, centred at x (0–1 of the image), the camera at 0.6 m looking
186
+ * level. Points outside the image get visibility 0, as the pose model would not see them.
187
+ */
188
+ export declare function bodyAtDistance(o: {
189
+ heightM: number;
190
+ distance: number;
191
+ fov: Fov;
192
+ x?: number;
193
+ }): Body$1;
194
+ /**
195
+ * Any body (a puppet's, in the simulator) moved to look distance metres away: scaled about the point at the camera's
196
+ * height so the span from the eyes to the ankles is what a child of heightM shows there, its x kept. Points pushed out
197
+ * of the image get visibility 0. The body's own feet and eyes must be in it; otherwise it is returned as is.
198
+ * reference: the same child standing still, to scale by (so a jump or a squat still moves the body).
199
+ */
200
+ export declare function placeAtDistance(body: Body$1, o: {
201
+ heightM: number;
202
+ distance: number;
203
+ fov: Fov;
204
+ reference?: Body$1;
205
+ }): Body$1;
206
+ export interface Landmark extends Point {
207
+ visibility: number;
208
+ }
209
+ export declare const landmark: (body: Body$1, i: number) => Landmark;
210
+ /** The landmarks the movements are read from. */
211
+ export declare function skeleton(body: Body$1): {
212
+ shoulders: Point;
213
+ hips: Point;
214
+ ankles: Point;
215
+ torso: number;
216
+ shoulderWidth: number;
217
+ nose: Landmark;
218
+ leftShoulder: Landmark;
219
+ rightShoulder: Landmark;
220
+ leftElbow: Landmark;
221
+ rightElbow: Landmark;
222
+ leftWrist: Landmark;
223
+ rightWrist: Landmark;
224
+ leftHip: Landmark;
225
+ rightHip: Landmark;
226
+ leftAnkle: Landmark;
227
+ rightAnkle: Landmark;
228
+ };
229
+ export type Skeleton = ReturnType<typeof skeleton>;
230
+ /** The shape the arms (and legs) make, or null when it's none of them. */
231
+ export declare function shapeOf(body: Body$1): Shape | null;
232
+ /**
233
+ * Arms crossed in an X in front of the chest: Fabi Play's system gesture (held 1.5 s: pause, exit), reserved, so
234
+ * games never get it as a move. In player space, mirrored: the child's left shoulder is on the left of the image, so
235
+ * crossing means the left wrist past the midline towards the right shoulder and the right wrist towards the left.
236
+ * The side is read from the shoulders, not assumed. Measured in torsos (shoulders to hips; estimated from the
237
+ * shoulders when the hips are out of view).
238
+ */
239
+ export declare function armsCrossed(body: Body$1): boolean;
240
+ /** The thresholds of armsCrossed, in torsos (t: shoulders to hips). */
241
+ export declare const CROSS: {
242
+ /** Each wrist at least this far past the midline (between the shoulders)… */
243
+ readonly past: 0.12;
244
+ /** …and no farther than the opposite shoulder plus this. */
245
+ readonly pastShoulder: 0.1;
246
+ /** Each elbow at least this far from the midline, on its own side. */
247
+ readonly elbowOut: 0.35;
248
+ /** Wrists between this far above the shoulder line… */
249
+ readonly above: 0.35;
250
+ /** …and this far below it (mid-chest). */
251
+ readonly below: 0.6;
252
+ /** Each wrist at least this far above its own elbow (the forearm rises)… */
253
+ readonly rise: 0.2;
254
+ /** …and this far above the other arm's elbow. */
255
+ readonly overElbow: 0.15;
256
+ /** Elbows no higher than this above the shoulder line (arms crossed over the head are not it)… */
257
+ readonly elbowAbove: 0.1;
258
+ /** …and no lower than this below the hip line. */
259
+ readonly elbowBelowHips: 0.3;
260
+ /** Shoulders at least this wide: facing the camera. */
261
+ readonly minShoulderWidth: 0.3;
262
+ /** Shoulder width over torso, to estimate the torso when the hips are out of view. */
263
+ readonly shoulderPerTorso: 0.8;
264
+ };
265
+ /**
266
+ * Something held for a while: progress 0–1 while active, fires once when full. Brief drop-outs (under graceMs, e.g.
267
+ * a frame where the pose model loses a wrist) don't reset it. After firing it is spent (progress 0, never fires) until
268
+ * `active` has been false for longer than graceMs, so holding on doesn't fire again.
269
+ */
270
+ export declare class Hold {
271
+ readonly ms: number;
272
+ readonly graceMs: number;
273
+ private start;
274
+ private lastActive;
275
+ private progress;
276
+ private spent;
277
+ constructor(ms?: number, graceMs?: number);
278
+ update(active: boolean, now: number): {
279
+ progress: number;
280
+ fired: boolean;
281
+ };
282
+ reset(): void;
283
+ }
284
+ /**
285
+ * Staying on a target (a cursor on a button): progress 0–1 on the current target, restarts from 0 when the target
286
+ * changes (null: none), fires the target once per stay. After firing, progress is 0 until the target changes.
287
+ * Targets are compared with ===.
288
+ */
289
+ export declare class Dwell<T> {
290
+ readonly ms: number;
291
+ private target;
292
+ private start;
293
+ private spent;
294
+ constructor(ms?: number);
295
+ update(target: T | null, now: number): {
296
+ target: T | null;
297
+ progress: number;
298
+ fired: T | null;
299
+ };
300
+ reset(): void;
301
+ }
302
+ /** A raised hand: the wrist above its shoulder's line, or at least this many torsos above its elbow. */
303
+ export declare const RAISED = 0.4;
304
+ /**
305
+ * The hand used as a cursor in menus: the clearly raised hand (wrist above the shoulder line, or at least RAISED
306
+ * torsos above its elbow; the higher wrist if both are), mapped with handSpace to the whole screen (0–1, clamped).
307
+ * Hands at the chest or holding a toy are not raised. Null when no hand is raised, and while the arms are crossed
308
+ * (the system gesture must not also press a button).
309
+ *
310
+ * The wrist on the shoulder line is at y ≈ 0.53 of the screen (handSpace): lower targets need the forearm upright.
311
+ */
312
+ export declare function menuPointer(body: Body$1): Point | null;
313
+ export type Placement = "no-body" | "too-close" | "too-far" | "go-left" | "go-right" | "ok";
314
+ /**
315
+ * Whether the child stands in the circle on the screen: whole body in frame, at the right distance, near the
316
+ * centre. "go-left"/"go-right" are as the child sees the mirror.
317
+ */
318
+ export declare function placement(body: Body$1 | null, center?: number, radius?: number): Placement;
319
+ /**
320
+ * The hands as pointers on the screen (0–1): the space the arms reach, around the shoulders, stretched to the
321
+ * whole screen, so a child reaches every corner without walking.
322
+ */
323
+ export declare function hands(body: Body$1): {
324
+ left: Point | null;
325
+ right: Point | null;
326
+ };
327
+ /**
328
+ * The same stretch as hands(), for any point of the body: a game drawing the body with it finds the wrists right
329
+ * under the hand pointers. Not clamped: legs go below the screen.
330
+ */
331
+ export declare function handSpace(body: Body$1): (p: Point) => Point;
332
+ export interface TrackerOptions {
333
+ /** Where the child stands when in the middle lane (the circle's centre). */
334
+ center?: number;
335
+ }
336
+ /**
337
+ * Turns a stream of bodies of one player into movements. Keeps what it needs: where the hips are when standing,
338
+ * whether the child is in the air, crouched, still.
339
+ */
340
+ export declare class MotionTracker {
341
+ private hipBase;
342
+ private ankleBase;
343
+ private airborne;
344
+ private crouched;
345
+ private lane;
346
+ private clapOpen;
347
+ private last;
348
+ private energy;
349
+ private stillSince;
350
+ private frozen;
351
+ private smooth;
352
+ /** The body update() read and its skeleton, so hands() on the same frame reuses it (once). */
353
+ private read;
354
+ private readonly center;
355
+ constructor(options?: TrackerOptions);
356
+ /** The current lane: -1 left, 0 middle, 1 right. */
357
+ get currentLane(): 0 | 1 | -1;
358
+ /** One frame; t in ms. Returns the movements that started in it. */
359
+ update(body: Body$1, t: number): Gesture[];
360
+ /** The hands as pointers, smoothed so they don't tremble. */
361
+ hands(body: Body$1): {
362
+ left: Point | null;
363
+ right: Point | null;
364
+ };
365
+ }
366
+ export interface BodyTrackerOptions {
367
+ /**
368
+ * The farthest a player's body may move between two frames and still be the same child, in torsos (shoulders to
369
+ * hips). Default 1.5.
370
+ */
371
+ maxJump?: number;
372
+ }
373
+ /**
374
+ * Who is who across frames: the pose model lists the bodies in no fixed order, so each body is matched to the
375
+ * player it was in the previous frame (the nearest shoulder/hip midpoint, within maxJump). Names, avatars, scores
376
+ * then stay with the child when children swap places or one leaves the frame and comes back.
377
+ *
378
+ * - The first assignment, and any after all players were lost, is left to right (as calibration lays the circles).
379
+ * - A player missing from a frame is null; when a body shows up again it takes the free slot whose player was last
380
+ * seen nearest to it (slots never seen go in order).
381
+ * - Bodies beyond the number of players are not players: they are in `extras` (e.g. for a faint drawing). An extra
382
+ * stays an extra while it stays in view: a grown-up behind the children never takes the place of one who stepped
383
+ * out for a moment.
384
+ */
385
+ export declare class BodyTracker {
386
+ readonly players: number;
387
+ /** The last place of each slot's player; null if never seen (or forgotten after all were lost). */
388
+ private last;
389
+ /** Whether each slot's player was in the previous frame. */
390
+ private present;
391
+ private readonly maxJump;
392
+ private extraBodies;
393
+ private extraAnchors;
394
+ constructor(players: number, options?: BodyTrackerOptions);
395
+ /** The bodies of the last update that are not players (more bodies than players), left to right. */
396
+ get extras(): Body$1[];
397
+ /** Forgets everyone: the next update assigns left to right. */
398
+ reset(): void;
399
+ /** One frame's bodies, in any order → one entry per player slot (0-based; player id = slot + 1), null if not seen. */
400
+ update(bodies: readonly Body$1[]): (Body$1 | null)[];
401
+ }
402
+
403
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,691 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
+
5
+ // src/index.ts
6
+ import { P as P2 } from "@fabi-play/protocol";
7
+
8
+ // src/distance.ts
9
+ import { P } from "@fabi-play/protocol";
10
+ var DISTANCE = {
11
+ /** The closest the eyes should be: this many diagonals (between THX's 1.2 and SMPTE's 1.6). */
12
+ eyeMinPerDiagonal: 1.5,
13
+ /** The farthest the screen stays readable: this many diagonals. */
14
+ readMaxPerDiagonal: 3,
15
+ /** The farthest the camera tracks reliably, metres (BlazePose's 2–4 m). */
16
+ trackingMaxM: 4,
17
+ /** The camera must see the whole body plus this much (feet and raised hands, a jump). */
18
+ cameraMargin: 1.15,
19
+ /** When the screen is too small for both (a tablet), the zone starts where the camera sees everyone and is this deep, metres. */
20
+ smallScreenDepthM: 0.5,
21
+ /** Typical heights of each age band, metres (WHO, median at the band's middle). */
22
+ heightM: { "3-5": 1.05, "6-8": 1.25, "9-12": 1.45 },
23
+ /** Field of view across the long side of the image when the phone can't say (the web phone), degrees. */
24
+ defaultFovLongDeg: 65,
25
+ /** Where the landmarks are, as fractions of stature from the floor (children). */
26
+ eyes: 0.91,
27
+ nose: 0.885,
28
+ ankles: 0.04,
29
+ /** Shoulders to hips (midpoints), as a fraction of stature. */
30
+ torso: 0.29,
31
+ /** Shoulder to shoulder, as a fraction of stature. */
32
+ shoulders: 0.22,
33
+ /**
34
+ * The feet are doubted (taken as out of the picture) when the torso puts the child nearer than this fraction of
35
+ * what the eyes-to-ankles span says: the two agree within a few per cent on a whole body.
36
+ */
37
+ feetDoubt: 0.75,
38
+ /** A landmark counts as seen from this visibility. */
39
+ visible: 0.5
40
+ };
41
+ var INCH_M = 0.0254;
42
+ var rad = (deg2) => deg2 * Math.PI / 180;
43
+ var deg = (r) => r * 180 / Math.PI;
44
+ function imageFov(sensor, aspect) {
45
+ const tl = Math.tan(rad(sensor.long) / 2);
46
+ const ts = Math.tan(rad(sensor.short) / 2);
47
+ const sensorRatio = tl / ts;
48
+ const ratio = aspect >= 1 ? aspect : 1 / aspect;
49
+ const long = ratio >= sensorRatio ? tl : ts * ratio;
50
+ const short = ratio >= sensorRatio ? tl / ratio : ts;
51
+ const longDeg = deg(2 * Math.atan(long));
52
+ const shortDeg = deg(2 * Math.atan(short));
53
+ return aspect >= 1 ? { h: longDeg, v: shortDeg } : { h: shortDeg, v: longDeg };
54
+ }
55
+ function sensorFromLong(longDeg) {
56
+ return { long: longDeg, short: deg(2 * Math.atan(Math.tan(rad(longDeg) / 2) * 0.75)) };
57
+ }
58
+ var defaultFov = (aspect) => imageFov(sensorFromLong(DISTANCE.defaultFovLongDeg), aspect);
59
+ var tallestHeight = (ranges) => Math.max(...(ranges.length ? ranges : ["9-12"]).map((r) => DISTANCE.heightM[r]));
60
+ function zoneFor(o) {
61
+ const diagonal = o.inches * INCH_M;
62
+ const height = (o.heightM ?? DISTANCE.heightM["9-12"]) * DISTANCE.cameraMargin;
63
+ const cameraMin = height / (2 * Math.tan(rad(o.fovV) / 2));
64
+ const eyeMin = DISTANCE.eyeMinPerDiagonal * diagonal;
65
+ const readMax = DISTANCE.readMaxPerDiagonal * diagonal;
66
+ const min = Math.max(eyeMin, cameraMin);
67
+ const max = Math.min(readMax, DISTANCE.trackingMaxM);
68
+ const round = (n) => Math.round(n * 10) / 10;
69
+ if (max < min) return { min: round(cameraMin), max: round(cameraMin + DISTANCE.smallScreenDepthM), eyeMin, cameraMin, readMax, small: true };
70
+ return { min: round(min), max: round(max), eyeMin, cameraMin, readMax, small: false };
71
+ }
72
+ var point = (b, i) => ({ x: b[i * 3], y: b[i * 3 + 1], v: b[i * 3 + 2] });
73
+ function estimateDistance(body, fov, ageRange) {
74
+ const H = DISTANCE.heightM[ageRange];
75
+ const seen2 = (p) => p.v >= DISTANCE.visible;
76
+ const perV = 2 * Math.tan(rad(fov.v) / 2);
77
+ const perH = 2 * Math.tan(rad(fov.h) / 2);
78
+ const nose = point(body, P.nose);
79
+ const le = point(body, 2);
80
+ const re = point(body, 5);
81
+ const la = point(body, P.leftAnkle);
82
+ const ra = point(body, P.rightAnkle);
83
+ const ls = point(body, P.leftShoulder);
84
+ const rs = point(body, P.rightShoulder);
85
+ const lh = point(body, P.leftHip);
86
+ const rh = point(body, P.rightHip);
87
+ const inImage = (p) => p.y < 0.995;
88
+ const feet = seen2(la) && seen2(ra) && inImage(la) && inImage(ra);
89
+ const eyes = seen2(le) && seen2(re) && le.y > 5e-3 && re.y > 5e-3;
90
+ const top = eyes ? { y: (le.y + re.y) / 2, at: DISTANCE.eyes } : seen2(nose) && nose.y > 5e-3 ? { y: nose.y, at: DISTANCE.nose } : null;
91
+ let torso = null;
92
+ if (seen2(ls) && seen2(rs) && seen2(lh) && seen2(rh)) {
93
+ const length = Math.hypot((ls.x + rs.x - lh.x - rh.x) / 2 * (perH / perV), (ls.y + rs.y - lh.y - rh.y) / 2);
94
+ if (length > 0.01) torso = DISTANCE.torso * H / (perV * length);
95
+ }
96
+ if (feet && top) {
97
+ const span = (la.y + ra.y) / 2 - top.y;
98
+ const body2 = span > 0.02 ? (top.at - DISTANCE.ankles) * H / (perV * span) : null;
99
+ if (body2 !== null && !(torso !== null && torso < body2 * DISTANCE.feetDoubt)) return { distance: body2, source: "body", partial: false };
100
+ }
101
+ if (torso !== null) return { distance: torso, source: "torso", partial: true };
102
+ if (seen2(ls) && seen2(rs)) {
103
+ const width = Math.abs(ls.x - rs.x);
104
+ if (width > 0.01) return { distance: DISTANCE.shoulders * H / (perH * width), source: "shoulders", partial: true };
105
+ }
106
+ return { distance: null, source: null, partial: true };
107
+ }
108
+ var DistanceTracker = class {
109
+ constructor(o = {}) {
110
+ __publicField(this, "samples", []);
111
+ __publicField(this, "placeNow", "unknown");
112
+ __publicField(this, "lastSeen", -Infinity);
113
+ __publicField(this, "partialNow", false);
114
+ __publicField(this, "windowMs");
115
+ __publicField(this, "margin");
116
+ __publicField(this, "forgetMs");
117
+ this.windowMs = o.windowMs ?? 500;
118
+ this.margin = o.margin ?? 0.1;
119
+ this.forgetMs = o.forgetMs ?? 700;
120
+ }
121
+ /** A frame: this child's estimate (null: not seen), against the zone. */
122
+ update(estimate, zone, now) {
123
+ if (estimate && estimate.distance !== null && Number.isFinite(estimate.distance)) {
124
+ this.samples.push({ t: now, d: estimate.distance });
125
+ this.lastSeen = now;
126
+ this.partialNow = estimate.partial;
127
+ }
128
+ while (this.samples.length && now - this.samples[0].t > this.windowMs) this.samples.shift();
129
+ if (now - this.lastSeen > this.forgetMs || !this.samples.length) {
130
+ this.placeNow = "unknown";
131
+ return { distance: null, place: "unknown", partial: false };
132
+ }
133
+ const d = median(this.samples.map((s) => s.d));
134
+ const m = this.margin;
135
+ const was = this.placeNow;
136
+ let place;
137
+ if (was === "near") place = d > zone.min + m ? d > zone.max + m ? "far" : "in" : "near";
138
+ else if (was === "far") place = d < zone.max - m ? d < zone.min - m ? "near" : "in" : "far";
139
+ else if (was === "in") place = d < zone.min - m ? "near" : d > zone.max + m ? "far" : "in";
140
+ else place = d < zone.min ? "near" : d > zone.max ? "far" : "in";
141
+ this.placeNow = place;
142
+ return { distance: d, place, partial: this.partialNow };
143
+ }
144
+ reset() {
145
+ this.samples.length = 0;
146
+ this.placeNow = "unknown";
147
+ this.lastSeen = -Infinity;
148
+ this.partialNow = false;
149
+ }
150
+ };
151
+ function median(values) {
152
+ const s = values.slice().sort((a, b) => a - b);
153
+ const mid2 = s.length >> 1;
154
+ return s.length % 2 ? s[mid2] : (s[mid2 - 1] + s[mid2]) / 2;
155
+ }
156
+ var TooCloseGuard = class {
157
+ constructor(o = {}) {
158
+ __publicField(this, "nearSince", []);
159
+ /** Since when each child near before is not measurable any more (see lostMs). */
160
+ __publicField(this, "lostSince", []);
161
+ __publicField(this, "clearSince", null);
162
+ __publicField(this, "stateNow", "ok");
163
+ __publicField(this, "pause");
164
+ __publicField(this, "enterMs");
165
+ __publicField(this, "resumeMs");
166
+ __publicField(this, "lostMs");
167
+ this.pause = o.pause ?? true;
168
+ this.enterMs = o.enterMs ?? 2e3;
169
+ this.resumeMs = o.resumeMs ?? 1e3;
170
+ this.lostMs = o.lostMs ?? 2500;
171
+ }
172
+ get state() {
173
+ return this.stateNow;
174
+ }
175
+ /** The grown-up's option changed: a pause in progress becomes a warning (off), or a warning a pause (on). */
176
+ setPause(pause) {
177
+ this.pause = pause;
178
+ }
179
+ update(places, now) {
180
+ const was = this.stateNow;
181
+ this.nearSince.length = this.lostSince.length = places.length;
182
+ places.forEach((p, i) => {
183
+ var _a;
184
+ const since = this.nearSince[i] ?? null;
185
+ if (p === "near") {
186
+ this.nearSince[i] = since ?? now;
187
+ this.lostSince[i] = null;
188
+ } else if (p === "unknown" && since !== null) {
189
+ const lost = (_a = this.lostSince)[i] ?? (_a[i] = now);
190
+ if (now - lost > this.lostMs) this.nearSince[i] = this.lostSince[i] = null;
191
+ } else this.nearSince[i] = this.lostSince[i] = null;
192
+ });
193
+ const near = places.flatMap((_, i) => this.nearSince[i] != null ? [i] : []);
194
+ const tooLong = near.filter((i) => now - this.nearSince[i] >= this.enterMs);
195
+ if (was === "paused") {
196
+ const clear = places.length > 0 && places.every((p) => p === "in" || p === "far");
197
+ if (!clear) this.clearSince = null;
198
+ else this.clearSince ?? (this.clearSince = now);
199
+ if (clear && now - this.clearSince >= this.resumeMs) {
200
+ this.stateNow = "ok";
201
+ this.clearSince = null;
202
+ } else if (!this.pause) this.stateNow = near.length ? "warn" : "ok";
203
+ const who = places.flatMap((p, i) => p === "in" || p === "far" ? [] : [i]);
204
+ return this.result(was, this.stateNow === "paused" ? who : near);
205
+ }
206
+ if (tooLong.length) this.stateNow = this.pause ? "paused" : "warn";
207
+ else if (was === "warn" && !near.length) this.stateNow = "ok";
208
+ this.clearSince = null;
209
+ return this.result(was, this.stateNow === "ok" ? [] : near);
210
+ }
211
+ /** Back to nothing (a new game, a grown-up's resume): the times start again. */
212
+ reset() {
213
+ this.nearSince = [];
214
+ this.lostSince = [];
215
+ this.clearSince = null;
216
+ this.stateNow = "ok";
217
+ }
218
+ result(was, who) {
219
+ return { state: this.stateNow, who, changed: was !== this.stateNow };
220
+ }
221
+ };
222
+ function zoneAdvice(reading, zone) {
223
+ if (reading.place === "unknown" || reading.distance === null) return "none";
224
+ if (reading.place === "near") return reading.distance < zone.min - 0.6 ? "two-steps-back" : "step-back";
225
+ if (reading.partial) return "step-back";
226
+ if (reading.place === "far") return "come-closer";
227
+ return "perfect";
228
+ }
229
+ var CAMERA_HEIGHT_M = 0.6;
230
+ function bodyAtDistance(o) {
231
+ const H = o.heightM;
232
+ const d = Math.max(o.distance, 0.05);
233
+ const perV = 2 * Math.tan(rad(o.fov.v) / 2) * d;
234
+ const perH = 2 * Math.tan(rad(o.fov.h) / 2) * d;
235
+ const cx = o.x ?? 0.5;
236
+ const at = {
237
+ 0: [0, DISTANCE.nose],
238
+ 2: [-0.025, DISTANCE.eyes],
239
+ 5: [0.025, DISTANCE.eyes],
240
+ 7: [-0.055, 0.9],
241
+ 8: [0.055, 0.9],
242
+ [P.leftShoulder]: [-DISTANCE.shoulders / 2, 0.8],
243
+ [P.rightShoulder]: [DISTANCE.shoulders / 2, 0.8],
244
+ [P.leftElbow]: [-0.13, 0.64],
245
+ [P.rightElbow]: [0.13, 0.64],
246
+ [P.leftWrist]: [-0.14, 0.48],
247
+ [P.rightWrist]: [0.14, 0.48],
248
+ [P.leftHip]: [-0.06, 0.51],
249
+ [P.rightHip]: [0.06, 0.51],
250
+ [P.leftKnee]: [-0.06, 0.28],
251
+ [P.rightKnee]: [0.06, 0.28],
252
+ [P.leftAnkle]: [-0.06, DISTANCE.ankles],
253
+ [P.rightAnkle]: [0.06, DISTANCE.ankles],
254
+ 29: [-0.065, 0.02],
255
+ 30: [0.065, 0.02],
256
+ 31: [-0.06, 5e-3],
257
+ 32: [0.06, 5e-3]
258
+ };
259
+ const out = [];
260
+ for (let i = 0; i < 33; i++) {
261
+ const [side, z] = at[i] ?? [0, DISTANCE.eyes];
262
+ const x = cx + side * H / perH;
263
+ const y = 0.5 - (z * H - CAMERA_HEIGHT_M) / perV;
264
+ const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1;
265
+ out.push(Math.min(Math.max(x, 0), 1), Math.min(Math.max(y, 0), 1), inside ? 0.95 : 0);
266
+ }
267
+ return out;
268
+ }
269
+ function placeAtDistance(body, o) {
270
+ const ref = o.reference ?? body;
271
+ const y = (i) => ref[i * 3 + 1];
272
+ const top = (y(2) + y(5)) / 2;
273
+ const feet = (y(P.leftAnkle) + y(P.rightAnkle)) / 2;
274
+ const span = feet - top;
275
+ if (!(span > 0.02)) return body;
276
+ const perV = 2 * Math.tan(rad(o.fov.v) / 2) * Math.max(o.distance, 0.05);
277
+ const want = (DISTANCE.eyes - DISTANCE.ankles) * o.heightM / perV;
278
+ const k = want / span;
279
+ const anchorY = feet - (CAMERA_HEIGHT_M / o.heightM - DISTANCE.ankles) / (DISTANCE.eyes - DISTANCE.ankles) * span;
280
+ const anchorX = (ref[P.leftHip * 3] + ref[P.rightHip * 3]) / 2;
281
+ const out = body.slice();
282
+ for (let i = 0; i < body.length; i += 3) {
283
+ const x = anchorX + (body[i] - anchorX) * k;
284
+ const yy = 0.5 + (body[i + 1] - anchorY) * k;
285
+ const inside = x >= 0 && x <= 1 && yy >= 0 && yy <= 1;
286
+ out[i] = Math.min(Math.max(x, 0), 1);
287
+ out[i + 1] = Math.min(Math.max(yy, 0), 1);
288
+ if (!inside) out[i + 2] = 0;
289
+ }
290
+ return out;
291
+ }
292
+
293
+ // src/index.ts
294
+ var landmark = (body, i) => ({ x: body[i * 3], y: body[i * 3 + 1], visibility: body[i * 3 + 2] });
295
+ var VISIBLE = 0.5;
296
+ var mid = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
297
+ var dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
298
+ function skeleton(body) {
299
+ const l = (i) => landmark(body, i);
300
+ const s = {
301
+ nose: l(P2.nose),
302
+ leftShoulder: l(P2.leftShoulder),
303
+ rightShoulder: l(P2.rightShoulder),
304
+ leftElbow: l(P2.leftElbow),
305
+ rightElbow: l(P2.rightElbow),
306
+ leftWrist: l(P2.leftWrist),
307
+ rightWrist: l(P2.rightWrist),
308
+ leftHip: l(P2.leftHip),
309
+ rightHip: l(P2.rightHip),
310
+ leftAnkle: l(P2.leftAnkle),
311
+ rightAnkle: l(P2.rightAnkle)
312
+ };
313
+ const shoulders = mid(s.leftShoulder, s.rightShoulder);
314
+ const hips = mid(s.leftHip, s.rightHip);
315
+ return {
316
+ ...s,
317
+ shoulders,
318
+ hips,
319
+ ankles: mid(s.leftAnkle, s.rightAnkle),
320
+ torso: Math.max(dist(shoulders, hips), 0.01),
321
+ shoulderWidth: Math.max(dist(s.leftShoulder, s.rightShoulder), 0.01)
322
+ };
323
+ }
324
+ var seen = (...points) => points.every((p) => p.visibility >= VISIBLE);
325
+ function shapeOf(body) {
326
+ return shapeIn(skeleton(body));
327
+ }
328
+ function shapeIn(s) {
329
+ if (!seen(s.leftWrist, s.rightWrist, s.leftShoulder, s.rightShoulder)) return null;
330
+ if (crossedIn(s)) return null;
331
+ const t = s.torso;
332
+ const upL = (s.leftShoulder.y - s.leftWrist.y) / t;
333
+ const upR = (s.rightShoulder.y - s.rightWrist.y) / t;
334
+ const spread = Math.abs(s.leftWrist.x - s.rightWrist.x) / s.shoulderWidth;
335
+ const legsApart = seen(s.leftAnkle, s.rightAnkle) && Math.abs(s.leftAnkle.x - s.rightAnkle.x) / s.shoulderWidth > 1.6;
336
+ if (upL > 0.6 && upR > 0.6) return spread > 2.2 ? "arms-v" : "arms-up";
337
+ if (Math.abs(upL) < 0.3 && Math.abs(upR) < 0.3 && spread > 3) return legsApart ? "star" : "arms-wide";
338
+ if (upL < -0.6 && upR < -0.6 && spread < 2) return "arms-down";
339
+ return null;
340
+ }
341
+ function armsCrossed(body) {
342
+ return crossedIn(skeleton(body));
343
+ }
344
+ function crossedIn(s) {
345
+ if (!seen(s.leftShoulder, s.rightShoulder, s.leftElbow, s.rightElbow, s.leftWrist, s.rightWrist)) return false;
346
+ const hipsSeen = seen(s.leftHip, s.rightHip);
347
+ const t = hipsSeen ? s.torso : s.shoulderWidth / CROSS.shoulderPerTorso;
348
+ if (s.shoulderWidth < CROSS.minShoulderWidth * t) return false;
349
+ const dir = Math.sign(s.rightShoulder.x - s.leftShoulder.x);
350
+ const m = s.shoulders.x;
351
+ const top = s.shoulders.y;
352
+ const bottom = hipsSeen ? s.hips.y : top + t;
353
+ const crossL = dir * (s.leftWrist.x - m) / t;
354
+ const crossR = dir * (m - s.rightWrist.x) / t;
355
+ const elbowL = dir * (m - s.leftElbow.x) / t;
356
+ const elbowR = dir * (s.rightElbow.x - m) / t;
357
+ const reach = 0.5 * s.shoulderWidth / t + CROSS.pastShoulder;
358
+ if (crossL < CROSS.past || crossR < CROSS.past || crossL > reach || crossR > reach) return false;
359
+ if (elbowL < CROSS.elbowOut || elbowR < CROSS.elbowOut) return false;
360
+ for (const w of [s.leftWrist, s.rightWrist]) if (w.y < top - CROSS.above * t || w.y > top + CROSS.below * t) return false;
361
+ for (const e of [s.leftElbow, s.rightElbow]) if (e.y < top - CROSS.elbowAbove * t || e.y > bottom + CROSS.elbowBelowHips * t) return false;
362
+ const rises = (w, e) => (e.y - w.y) / t >= CROSS.rise;
363
+ const clear = (w, otherElbow) => (otherElbow.y - w.y) / t >= CROSS.overElbow;
364
+ return rises(s.leftWrist, s.leftElbow) && rises(s.rightWrist, s.rightElbow) && clear(s.leftWrist, s.rightElbow) && clear(s.rightWrist, s.leftElbow);
365
+ }
366
+ var CROSS = {
367
+ /** Each wrist at least this far past the midline (between the shoulders)… */
368
+ past: 0.12,
369
+ /** …and no farther than the opposite shoulder plus this. */
370
+ pastShoulder: 0.1,
371
+ /** Each elbow at least this far from the midline, on its own side. */
372
+ elbowOut: 0.35,
373
+ /** Wrists between this far above the shoulder line… */
374
+ above: 0.35,
375
+ /** …and this far below it (mid-chest). */
376
+ below: 0.6,
377
+ /** Each wrist at least this far above its own elbow (the forearm rises)… */
378
+ rise: 0.2,
379
+ /** …and this far above the other arm's elbow. */
380
+ overElbow: 0.15,
381
+ /** Elbows no higher than this above the shoulder line (arms crossed over the head are not it)… */
382
+ elbowAbove: 0.1,
383
+ /** …and no lower than this below the hip line. */
384
+ elbowBelowHips: 0.3,
385
+ /** Shoulders at least this wide: facing the camera. */
386
+ minShoulderWidth: 0.3,
387
+ /** Shoulder width over torso, to estimate the torso when the hips are out of view. */
388
+ shoulderPerTorso: 0.8
389
+ };
390
+ var Hold = class {
391
+ constructor(ms = 1500, graceMs = 200) {
392
+ __publicField(this, "ms", ms);
393
+ __publicField(this, "graceMs", graceMs);
394
+ __publicField(this, "start", null);
395
+ __publicField(this, "lastActive", -Infinity);
396
+ __publicField(this, "progress", 0);
397
+ __publicField(this, "spent", false);
398
+ }
399
+ update(active, now) {
400
+ if (active) this.lastActive = now;
401
+ const released = !active && now - this.lastActive > this.graceMs;
402
+ if (released) {
403
+ this.start = null;
404
+ this.progress = 0;
405
+ this.spent = false;
406
+ return { progress: 0, fired: false };
407
+ }
408
+ if (this.spent) return { progress: 0, fired: false };
409
+ if (!active) return { progress: this.progress, fired: false };
410
+ this.start ?? (this.start = now);
411
+ this.progress = Math.min(Math.max((now - this.start) / this.ms, 0), 1);
412
+ if (this.progress < 1) return { progress: this.progress, fired: false };
413
+ this.spent = true;
414
+ this.progress = 0;
415
+ return { progress: 1, fired: true };
416
+ }
417
+ reset() {
418
+ this.start = null;
419
+ this.lastActive = -Infinity;
420
+ this.progress = 0;
421
+ this.spent = false;
422
+ }
423
+ };
424
+ var Dwell = class {
425
+ constructor(ms = 1500) {
426
+ __publicField(this, "ms", ms);
427
+ __publicField(this, "target", null);
428
+ __publicField(this, "start", 0);
429
+ __publicField(this, "spent", false);
430
+ }
431
+ update(target, now) {
432
+ if (target !== this.target) {
433
+ this.target = target;
434
+ this.start = now;
435
+ this.spent = false;
436
+ }
437
+ if (target === null || this.spent) return { target, progress: 0, fired: null };
438
+ const progress = Math.min(Math.max((now - this.start) / this.ms, 0), 1);
439
+ if (progress < 1) return { target, progress, fired: null };
440
+ this.spent = true;
441
+ return { target, progress: 1, fired: target };
442
+ }
443
+ reset() {
444
+ this.target = null;
445
+ this.start = 0;
446
+ this.spent = false;
447
+ }
448
+ };
449
+ var RAISED = 0.4;
450
+ function menuPointer(body) {
451
+ const s = skeleton(body);
452
+ if (crossedIn(s)) return null;
453
+ const raised = (w, e, shoulder) => seen(w, e, shoulder) && (w.y < shoulder.y || e.y - w.y >= RAISED * s.torso);
454
+ const candidates = [
455
+ raised(s.leftWrist, s.leftElbow, s.leftShoulder) ? s.leftWrist : null,
456
+ raised(s.rightWrist, s.rightElbow, s.rightShoulder) ? s.rightWrist : null
457
+ ].filter((w) => w !== null);
458
+ if (!candidates.length) return null;
459
+ const wrist = candidates.reduce((a, b) => b.y < a.y ? b : a);
460
+ const p = spaceIn(s)(wrist);
461
+ return { x: clamp01(p.x), y: clamp01(p.y) };
462
+ }
463
+ var clamp01 = (n) => Math.min(Math.max(n, 0), 1);
464
+ function placement(body, center = 0.5, radius = 0.12) {
465
+ if (!body) return "no-body";
466
+ const s = skeleton(body);
467
+ if (!seen(s.nose, s.leftHip, s.rightHip)) return "no-body";
468
+ const head = s.nose.y - s.torso * 0.6;
469
+ if (!seen(s.leftAnkle, s.rightAnkle) || s.ankles.y > 0.98 || head < 0.02) return "too-close";
470
+ const height = s.ankles.y - head;
471
+ if (height > 0.9) return "too-close";
472
+ if (height < 0.4) return "too-far";
473
+ if (s.hips.x < center - radius) return "go-right";
474
+ if (s.hips.x > center + radius) return "go-left";
475
+ return "ok";
476
+ }
477
+ function hands(body) {
478
+ return handsIn(skeleton(body));
479
+ }
480
+ function handsIn(s) {
481
+ const toScreen = spaceIn(s);
482
+ const at = (w) => {
483
+ if (w.visibility < VISIBLE) return null;
484
+ const p = toScreen(w);
485
+ return { x: clamp01(p.x), y: clamp01(p.y) };
486
+ };
487
+ return { left: at(s.leftWrist), right: at(s.rightWrist) };
488
+ }
489
+ function handSpace(body) {
490
+ return spaceIn(skeleton(body));
491
+ }
492
+ function spaceIn(s) {
493
+ const box = { x: s.shoulders.x - 1.6 * s.torso, y: s.shoulders.y - 1.6 * s.torso, w: 3.2 * s.torso, h: 3 * s.torso };
494
+ return (p) => ({ x: (p.x - box.x) / box.w, y: (p.y - box.y) / box.h });
495
+ }
496
+ var MotionTracker = class {
497
+ constructor(options = {}) {
498
+ __publicField(this, "hipBase", null);
499
+ __publicField(this, "ankleBase", null);
500
+ __publicField(this, "airborne", false);
501
+ __publicField(this, "crouched", false);
502
+ __publicField(this, "lane", 0);
503
+ __publicField(this, "clapOpen", false);
504
+ __publicField(this, "last", null);
505
+ __publicField(this, "energy", 0);
506
+ __publicField(this, "stillSince", null);
507
+ __publicField(this, "frozen", false);
508
+ __publicField(this, "smooth", { left: null, right: null });
509
+ /** The body update() read and its skeleton, so hands() on the same frame reuses it (once). */
510
+ __publicField(this, "read", null);
511
+ __publicField(this, "center");
512
+ this.center = options.center ?? 0.5;
513
+ }
514
+ /** The current lane: -1 left, 0 middle, 1 right. */
515
+ get currentLane() {
516
+ return this.lane;
517
+ }
518
+ /** One frame; t in ms. Returns the movements that started in it. */
519
+ update(body, t) {
520
+ const s = skeleton(body);
521
+ this.read = { body, s };
522
+ if (!seen(s.leftHip, s.rightHip, s.leftShoulder, s.rightShoulder)) return [];
523
+ const out = [];
524
+ const t0 = s.torso;
525
+ if (this.hipBase === null) this.hipBase = s.hips.y;
526
+ if (this.ankleBase === null && seen(s.leftAnkle, s.rightAnkle)) this.ankleBase = s.ankles.y;
527
+ const rise = (this.hipBase - s.hips.y) / t0;
528
+ const feetUp = this.ankleBase !== null && seen(s.leftAnkle, s.rightAnkle) ? (this.ankleBase - s.ankles.y) / t0 : rise;
529
+ if (!this.airborne && rise > 0.22 && feetUp > 0.12) {
530
+ this.airborne = true;
531
+ out.push("jump");
532
+ } else if (this.airborne && rise < 0.08) {
533
+ this.airborne = false;
534
+ out.push("land");
535
+ }
536
+ if (!this.crouched && !this.airborne && rise < -0.3) {
537
+ this.crouched = true;
538
+ out.push("squat");
539
+ } else if (this.crouched && rise > -0.12) {
540
+ this.crouched = false;
541
+ out.push("stand");
542
+ }
543
+ if (!this.airborne && !this.crouched) {
544
+ this.hipBase += (s.hips.y - this.hipBase) * 0.05;
545
+ if (this.ankleBase !== null && seen(s.leftAnkle, s.rightAnkle)) this.ankleBase += (s.ankles.y - this.ankleBase) * 0.05;
546
+ }
547
+ const side = (s.hips.x - this.center) / t0;
548
+ const lane = this.lane === 0 ? side < -0.7 ? -1 : side > 0.7 ? 1 : 0 : Math.abs(side) < 0.35 ? 0 : this.lane;
549
+ if (lane !== this.lane) {
550
+ this.lane = lane;
551
+ out.push(lane === -1 ? "lane-left" : lane === 1 ? "lane-right" : "lane-center");
552
+ }
553
+ if (seen(s.leftWrist, s.rightWrist)) {
554
+ const gap = dist(s.leftWrist, s.rightWrist) / s.shoulderWidth;
555
+ if (gap > 1) this.clapOpen = true;
556
+ else if (gap < 0.35 && this.clapOpen) {
557
+ this.clapOpen = false;
558
+ out.push("clap");
559
+ }
560
+ }
561
+ const points = [s.nose, s.leftWrist, s.rightWrist, s.hips, s.leftAnkle, s.rightAnkle];
562
+ if (this.last && t > this.last.t) {
563
+ const moved = points.reduce((sum, p, i) => sum + dist(p, this.last.points[i]), 0) / points.length / t0;
564
+ const speed = moved / ((t - this.last.t) / 1e3);
565
+ this.energy += (speed - this.energy) * 0.3;
566
+ if (this.energy < 0.25) {
567
+ this.stillSince ?? (this.stillSince = t);
568
+ if (!this.frozen && t - this.stillSince >= 600) {
569
+ this.frozen = true;
570
+ out.push("freeze");
571
+ }
572
+ } else {
573
+ this.stillSince = null;
574
+ if (this.frozen && this.energy > 0.6) {
575
+ this.frozen = false;
576
+ out.push("move");
577
+ }
578
+ }
579
+ }
580
+ this.last = { t, points };
581
+ return out;
582
+ }
583
+ /** The hands as pointers, smoothed so they don't tremble. */
584
+ hands(body) {
585
+ const s = this.read?.body === body ? this.read.s : skeleton(body);
586
+ this.read = null;
587
+ const raw = handsIn(s);
588
+ const ease = (prev, next) => next && prev ? { x: prev.x + (next.x - prev.x) * 0.5, y: prev.y + (next.y - prev.y) * 0.5 } : next;
589
+ this.smooth = { left: ease(this.smooth.left, raw.left), right: ease(this.smooth.right, raw.right) };
590
+ return this.smooth;
591
+ }
592
+ };
593
+ var anchor = (body) => {
594
+ const s = skeleton(body);
595
+ return { x: (s.shoulders.x + s.hips.x) / 2, y: (s.shoulders.y + s.hips.y) / 2, torso: s.torso };
596
+ };
597
+ var cost = (was, now) => dist(was, now) / was.torso + Math.abs(Math.log(now.torso / was.torso));
598
+ var BodyTracker = class {
599
+ constructor(players, options = {}) {
600
+ __publicField(this, "players", players);
601
+ /** The last place of each slot's player; null if never seen (or forgotten after all were lost). */
602
+ __publicField(this, "last");
603
+ /** Whether each slot's player was in the previous frame. */
604
+ __publicField(this, "present");
605
+ __publicField(this, "maxJump");
606
+ __publicField(this, "extraBodies", []);
607
+ __publicField(this, "extraAnchors", []);
608
+ if (!Number.isInteger(players) || players < 0) throw new RangeError("players: a whole number \u2265 0");
609
+ this.maxJump = options.maxJump ?? 1.5;
610
+ this.last = Array.from({ length: players }, () => null);
611
+ this.present = Array.from({ length: players }, () => false);
612
+ }
613
+ /** The bodies of the last update that are not players (more bodies than players), left to right. */
614
+ get extras() {
615
+ return this.extraBodies;
616
+ }
617
+ /** Forgets everyone: the next update assigns left to right. */
618
+ reset() {
619
+ this.last.fill(null);
620
+ this.present.fill(false);
621
+ this.extraBodies = [];
622
+ this.extraAnchors = [];
623
+ }
624
+ /** One frame's bodies, in any order → one entry per player slot (0-based; player id = slot + 1), null if not seen. */
625
+ update(bodies) {
626
+ const out = Array.from({ length: this.players }, () => null);
627
+ const anchors = bodies.map(anchor);
628
+ const free = new Set(anchors.keys());
629
+ const slots = out.map((_, s) => s);
630
+ const take = (slot, b) => {
631
+ out[slot] = bodies[b];
632
+ this.last[slot] = anchors[b];
633
+ free.delete(b);
634
+ };
635
+ if (!this.present.some(Boolean)) this.last.fill(null);
636
+ nearest(slots.filter((s) => this.present[s]).map((s) => [s, this.last[s]]), free, anchors, this.maxJump, take);
637
+ const stayExtra = /* @__PURE__ */ new Set();
638
+ nearest([...this.extraAnchors.entries()], free, anchors, this.maxJump, (_, b) => stayExtra.add(b));
639
+ stayExtra.forEach((b) => free.delete(b));
640
+ nearest(slots.filter((s) => !out[s] && this.last[s]).map((s) => [s, this.last[s]]), free, anchors, Infinity, take);
641
+ const leftToRight = (set) => [...set].sort((a, b) => anchors[a].x - anchors[b].x);
642
+ const rest = leftToRight(free);
643
+ for (const s of slots) if (!out[s] && !this.last[s] && rest.length) take(s, rest.shift());
644
+ slots.forEach((s) => this.present[s] = out[s] !== null);
645
+ const extras = leftToRight(/* @__PURE__ */ new Set([...free, ...stayExtra]));
646
+ this.extraAnchors = extras.map((b) => anchors[b]);
647
+ this.extraBodies = extras.map((b) => bodies[b]);
648
+ return out;
649
+ }
650
+ };
651
+ function nearest(from, free, anchors, limit, pair) {
652
+ const pairs = [];
653
+ for (const [key, was] of from) for (const b of free) pairs.push({ key, b, c: cost(was, anchors[b]) });
654
+ pairs.sort((p, q) => p.c - q.c);
655
+ const keys = /* @__PURE__ */ new Set();
656
+ const used = /* @__PURE__ */ new Set();
657
+ for (const p of pairs) {
658
+ if (p.c > limit || keys.has(p.key) || used.has(p.b)) continue;
659
+ keys.add(p.key);
660
+ used.add(p.b);
661
+ pair(p.key, p.b);
662
+ }
663
+ }
664
+ export {
665
+ BodyTracker,
666
+ CROSS,
667
+ DISTANCE,
668
+ DistanceTracker,
669
+ Dwell,
670
+ Hold,
671
+ MotionTracker,
672
+ RAISED,
673
+ TooCloseGuard,
674
+ armsCrossed,
675
+ bodyAtDistance,
676
+ defaultFov,
677
+ estimateDistance,
678
+ handSpace,
679
+ hands,
680
+ imageFov,
681
+ landmark,
682
+ menuPointer,
683
+ placeAtDistance,
684
+ placement,
685
+ sensorFromLong,
686
+ shapeOf,
687
+ skeleton,
688
+ tallestHeight,
689
+ zoneAdvice,
690
+ zoneFor
691
+ };
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@fabi-play/motion",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Body movements from pose landmarks: jumps, squats, shapes, lanes, freezing, hands as pointers.",
5
+ "keywords": [
6
+ "fabi-play",
7
+ "motion",
8
+ "pose",
9
+ "gestures",
10
+ "games"
11
+ ],
12
+ "homepage": "https://docs.fabiplay.app",
13
+ "bugs": "https://github.com/fabiplay/fabiplay-kit/issues",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/fabiplay/fabiplay-kit.git",
17
+ "directory": "packages/motion"
18
+ },
19
+ "license": "SEE LICENSE IN LICENSE",
20
+ "author": "Fabi Play (https://fabiplay.app)",
21
+ "type": "module",
22
+ "main": "./dist/index.js",
23
+ "types": "./dist/index.d.ts",
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "sideEffects": false,
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "dependencies": {
35
+ "@fabi-play/protocol": "^0.1.0"
36
+ },
37
+ "devDependencies": {
38
+ "dts-bundle-generator": "9.5.1",
39
+ "esbuild": "0.28.2",
40
+ "tsx": "4.23.15",
41
+ "typescript": "5.7.3"
42
+ },
43
+ "scripts": {
44
+ "typecheck": "tsc --noEmit",
45
+ "test": "tsx --test test/*.test.ts",
46
+ "build": "node ../../scripts/build-lib.mjs"
47
+ },
48
+ "module": "./dist/index.js",
49
+ "exports": {
50
+ ".": {
51
+ "types": "./dist/index.d.ts",
52
+ "import": "./dist/index.js",
53
+ "default": "./dist/index.js"
54
+ },
55
+ "./package.json": "./package.json"
56
+ }
6
57
  }