Trigger schema
Conceptual guide: guides/triggers.md.
Both platforms run these, natively, on the camera thread.
Trigger
type Trigger = {
id: string;
enter: Condition;
exit?: Condition; // required for emit: 'cycle' and emit: 'exit'
emit: 'enter' | 'exit' | 'cycle' | 'while';
debounceMs?: number; // suppress re-fire, default 0
minDurationMs?: number; // must hold before firing, default 0
snapshot?: boolean; // attach the PoseFrame from the moment it fired
throttleMs?: number; // 'while' only, default 250
};emit | Fires |
|---|---|
enter | when enter becomes true |
exit | when exit becomes true |
cycle | once per full enter → exit, with durationMs |
while | repeatedly, throttled, as long as enter holds, each with phase: 'enter' |
snapshot: true costs one more native call: the frame cannot ride the event, so native holds it and sends a ticket that <PoseCamera> redeems synchronously before calling onTrigger, so events keep their firing order. The frame is the one the trigger fired on, narrowed by data.select like any other. See events → how snapshot actually arrives.
Condition
type Condition =
| { angle: AngleJointName; below?: number; above?: number; between?: readonly [number, number] }
| { landmarkX: JointName; below?: number | JointName; above?: number | JointName }
| { landmarkY: JointName; below?: number | JointName; above?: number | JointName }
| { velocityX: 'centerOfMass' | JointName; below?: number; above?: number }
| { velocityY: 'centerOfMass' | JointName; below?: number; above?: number }
| { visibility: JointName; above: number }
| { all: readonly Condition[] }
| { any: readonly Condition[] };A condition carries exactly one of those keys. Mixing two, { angle: 'leftKnee', landmarkY: 'nose' }, is a validation error rather than one of them being silently ignored.
| Field | Unit |
|---|---|
angle | degrees, 0 to 180. Vertex must be an AngleJointName |
landmarkX / landmarkY | normalized 0 to 1, origin top-left, so a raised hand has the smaller y. A JointName compares against that joint |
velocityX / velocityY | normalized units per second |
visibility | 0 to 1 |
below and above are strict: below: 90 means < 90. between is inclusive at both ends, because it names the range you want to be inside rather than a boundary you want to be past.
between is angle-only. On a landmark or velocity condition it is rejected rather than ignored, because those are unbounded scales where a range is better written as below plus above and a silently dropped key is worse than a message.
An angle condition is what turns that joint's angle on. A joint used as a comparison bound, { landmarkY: 'leftWrist', below: 'leftShoulder' }, is a position: it does not cause an angle to be computed. Neither does listing a joint in data.select.
Conditions describe a body, never an activity, that's what keeps them reusable.
Evaluation
Per frame, natively, per trigger:
IDLE + enter matches → ACTIVE ; emit if 'enter'
ACTIVE + exit matches → IDLE ; count++ ; emit if 'cycle' or 'exit'
ACTIVE + still matches → emit if 'while' (throttled)debounceMssuppresses re-entry after a fire. It does not suspend measurement: the condition keeps being evaluated, it just cannot fire again until the window passesminDurationMsrequires the condition to hold before the state change counts, on both transitions. The hold has to be continuous, so a frame where it stops matching restarts the clock- With no
exit, leavingenteris what returns the trigger to idle. Otherwise a trigger with only anenterwould go active once and have nothing that could ever fire it again - A frame with no pose in it breaks a hold without ending an active trigger. Somebody who steps out of shot mid-rep has not finished the rep, and has not abandoned it either
- A value nobody could measure never matches. An angle with a zero-length side, a velocity with no previous frame: those are
NaN, and every comparison againstNaNis false. See types - With
maxPoses > 1, evaluation runs against the primary pose, largest bounding box, ties broken by distance from frame center countresets on unmount, not on camera switch
Validation
Configs are validated in JavaScript before reaching native. These are errors, not silent failures:
triggersthat is not an array, or a trigger or a condition that is not an object- an unknown key anywhere on a trigger or a condition
- unknown
JointName, or a velocity subject that is neither'centerOfMass'nor a joint - an
angleon a joint that has none,nosefor instance - missing or empty
id, or a duplicateid - unknown
emit - missing
enter emit: 'cycle'oremit: 'exit'withoutexit- a condition with no key, or with more than one
- a condition with no bound at all, no
below,above, orbetween - a
below,aboveorbetweenvalue that is not a finite number:NaN,Infinity, a string - a landmark bound that is neither a number nor a joint name
betweenon a landmark or velocity condition, where it does not belongbetweenthat is not a[min, max]pair, or wheremin >= max- any angle bound outside 0 to 180, including both ends of a
between { below: 90, above: 160 }, which nothing can satisfy, on any of the three bounded condition kinds, not just anglesvisibilitywithoutabove, oraboveoutside 0 to 1- a
debounceMs,minDurationMs, orthrottleMsthat is negative or not finite - a non-boolean
snapshot all/anythat is not an array, is empty, or nests deeper than 8 levels
A bound that is present but explicitly undefined counts as absent, so a condition assembled by spreading optional fields is judged by what it actually has rather than by which keys exist.
A cyclic or BigInt-carrying config is reported as an issue, not thrown from inside the validator. Building the error message must not itself be the thing that fails while reporting someone else's mistake, and the depth limit is what makes the walk terminate.
Each issue reports the path that caused it:
2 configuration problems:
triggers[0].enter.angle: "nose" has no angle, only joints where two limb segments meet do
triggers[0].exit: is required when emit is 'cycle'<PoseCamera> runs these during render, not in an effect, so the throw lands at the call site before any other code walks the config. You can run them yourself with validateTriggers(), see types → validation.