Events
| Callback | Fires | Rate |
|---|---|---|
onReady | camera up, and the landmarker running or failed to start | once per session start |
onError | a failure occurred; every code is in error codes | rare |
onCameraChange | switch complete and stable | per switch |
onPerformanceChange | the rate, the delegate or the reason for either changed, or heat or Low Power Mode did | rare |
onTrigger | a trigger transitioned | ~1 per event |
onPose | frame delivered | 10/s or 30/s |
onPoseBatch | buffer flushed | 2/s |
onFramesDropped | the ring buffer dropped frames | per delivery |
onLog | a batch of log entries | ~4/s while logging |
Every one is implemented on both platforms. Three of them are not native events at all: onPose, onPoseBatch and onFramesDropped are called by <PoseCamera> after it drains the native ring buffer, because an event cannot carry an ArrayBuffer and a function return can. See ADR 0008.
With the defaults (data.mode unset, no triggers) nothing fires per frame: onReady once, and onPerformanceChange or onError only when something changes.
onReady
type ReadyEvent = {
model: 'lite' | 'full' | 'heavy';
delegate: 'GPU' | 'CPU'; // what was actually used
delegateRequested: 'auto' | 'gpu' | 'cpu';
targetFps: number;
limitedBy: LimitedBy; // why targetFps is what it is
deviceTier: 'high' | 'medium' | 'low';
resolution: { width: number; height: number };
analysisResolution: { width: number; height: number };
facing: 'front' | 'back';
};A session starts on mount, and again when active goes back to true or when a resolution, analysisResolution or profile change moves the camera's sizes and restarts it, so each of those fires one more onReady. When the landmarker cannot be built, onError reports DETECTOR_INIT_FAILED first and onReady still follows, because the camera did come up. With detection={false} it fires as soon as the camera is up.
targetFps and deviceTier are what the session opened with: the cached calibration when this device has run before, the static probe's guess when it has not. The governor refines both within a couple of seconds. Each move of the rate is reported through onPerformanceChange; the refined tier is on getProfile(). limitedBy takes the values listed in performance.
On Android, a cold start under delegate="auto" reports 'CPU' here on a device whose GPU works: the session starts on the CPU landmarker, which builds in a fraction of the time, and the GPU one takes over once it has built, reported by onPerformanceChange with reason: 'delegate'. A landmarker kept from an earlier session is reused instead, so a restart, or a screen reopened within a minute, reports the delegate it was already on.
onError
type ErrorEvent = {
code: ErrorCode;
message: string;
fatal: boolean;
};Error codes
This is the complete list for onError and the file functions. Native emits nothing outside it there, so a switch on code can be exhaustive and a new failure mode has to be added here rather than appearing as a new string. ERROR_CODES is exported if you need to iterate them.
| Code | Fatal | Meaning |
|---|---|---|
PERMISSION_DENIED | ✅ | Camera permission not granted when the session started; <PoseCamera> never prompts |
MODEL_NOT_FOUND | ✅ | Plugin didn't run, or prebuild was skipped |
MODEL_LOAD_FAILED | ✅ | Reserved, not sent today: a model that is present but will not load reports DETECTOR_INIT_FAILED |
CAMERA_UNAVAILABLE | ✅ | No camera for the requested facing, including a pinned facing the device does not have |
CAMERA_START_FAILED | ✅ | The capture session could not be started |
DETECTOR_INIT_FAILED | ✅ | Landmarker could not be created; the preview keeps running without detection |
INVALID_CONFIG | ✅ | Reserved, not sent today: native reads configs leniently rather than rejecting them |
IMAGE_DECODE_FAILED | ✅ | detectOnImage could not read the source |
VIDEO_DECODE_FAILED | ✅ | detectOnVideo could not read the source |
CAMERA_SWITCH_FAILED | ❌ | Rolled back to the previous camera |
GPU_UNAVAILABLE | ❌ | Fell back to CPU: expect lower frame rates |
DETECTION_FAILED | ❌ | One frame, or one drained batch, failed; the pipeline continues |
EXPORT_FAILED | ❌ | exportPose could not read, paint or write the file |
EXPORT_CANCELLED | ❌ | exportPose was cancelled; the partial file was deleted |
The last two never arrive on onError. They are the codes exportPose rejects with, and they are in the same set so that one exhaustive switch covers every failure the camera and the file functions report. One code sits outside it: on Android, requestCameraPermission() rejects with PERMISSIONS_UNAVAILABLE in an app whose Expo modules are not fully installed, see camera permission. A configuration mistake is a thrown PoseConfigError, never a code, see functions → errors.
fatal: false is normal operation, not a bug. Only fatal: true means the camera stopped, or with DETECTOR_INIT_FAILED, that it runs without detection.
IMAGE_DECODE_FAILED and VIDEO_DECODE_FAILED never arrive on onError either: detectOnImage and detectOnVideo reject with them when the file cannot be read, with MODEL_NOT_FOUND when no model is bundled, and with DETECTION_FAILED when the file was read but inference failed. The set is closed on purpose, so a new failure mode is a deliberate addition rather than a surprise for anyone switching exhaustively.
DETECTION_FAILED also covers a frame buffer that could not be decoded: <PoseCamera> reports it here, non-fatally, and keeps draining. A batch whose joint count or angle count disagrees with the current props is dropped rather than relabelled: attaching the wrong joint names would silently hand you another joint's numbers, and dropping one drain is self-healing.
INVALID_CONFIG is never sent. Trigger configs are validated in JavaScript during render, and native reads what reaches it leniently: a condition it cannot read is logged on the triggers channel and never matches.
onCameraChange
type CameraChangeEvent = { facing: 'front' | 'back' };Fires after the session is stable, not when the switch begins.
onPerformanceChange
type PerformanceEvent = {
reason:
| 'calibration' | 'thermal' | 'lowPower' | 'idle'
| 'delegate' | 'gpu_fallback' | 'load' | 'headroom';
delegate: 'GPU' | 'CPU';
targetFps: number;
limitedBy: LimitedBy; // why targetFps is what it is
analysisResolution: { width: number; height: number };
actualFps: number;
thermalState: 'nominal' | 'fair' | 'serious' | 'critical';
lowPower: boolean; // Battery Saver or Low Power Mode
};Fires on every automatic adjustment, and whenever heat or Low Power Mode changes even if the rate stays put. Under thermalPolicy="off" the library stops acting on heat, never stops reporting it.
reason | When |
|---|---|
calibration | The measured cost of inference moved the rate, or setProfile() did |
thermal | Heat changed: it moved the rate, paused detection, or, under a policy that ignores it, only thermalState |
lowPower | Battery Saver or Low Power Mode came on or went off |
idle | Nobody in frame for a while, or somebody back |
delegate | Android, delegate="auto": the GPU took over from the CPU the session started on |
gpu_fallback | The GPU kept failing and detection moved to the CPU |
load, headroom | Reserved, not sent today |
onTrigger
type TriggerEvent = {
id: string;
phase: 'enter' | 'exit' | 'cycle'; // emit: 'while' repeats 'enter'
count: number; // completed cycles since mount
timestamp: number; // ms, monotonic
durationMs?: number; // enter → exit, on 'cycle'
snapshot?: PoseFrame; // if the trigger set snapshot: true
};How snapshot actually arrives
A PoseFrame cannot ride an event: an event payload cannot carry an ArrayBuffer through Expo Modules. So native holds the captured frame and puts a claim ticket on the event instead, and <PoseCamera> redeems it over the function-return path before it calls you. See ADR 0009.
The redemption is a synchronous call, so snapshot triggers keep their firing order with plain ones. If it fails, or the ticket was already spent, onTrigger still fires with snapshot absent rather than not firing at all.
onPose and onPoseBatch
onPose?: (frame: PoseFrame) => void;
onPoseBatch?: (frames: readonly PoseFrame[]) => void;Mutually exclusive, data.mode decides which fires. Passing only the wrong one is a no-op and warns in development.
A frame's landmarks is a subarray view into the ArrayBuffer that drain returned, not a copy. Nothing is parsed and nothing is allocated per landmark, which is the point. Two things follow. Retaining a frame past the callback retains the entire drained buffer, and the values are only guaranteed stable for as long as that buffer lives. If you keep anything beyond the call, copy it:
const history: Float32Array[] = [];
function onPose(frame: PoseFrame) {
history.push(frame.landmarks.slice()); // a copy, safe to keep
}onFramesDropped
onFramesDropped?: (count: number) => void;Frames the native ring buffer threw away because this consumer could not keep up. The buffer is bounded and drops oldest-first, which is the right behavior for live pose data, but a drop is still information and it used to be decoded on every drain and discarded.
It is reported per delivery, not cumulatively. A single spike is normal, for instance a slow first render. A steady trickle means your onPose or onPoseBatch handler is doing too much work, and the fix is to do less in the callback rather than to raise flushMs.
onLog
onLog?: (entries: readonly LogEntry[]) => void;Diagnostic entries in batches of about every 250 ms, for as long as the level set by setLogLevel() or this camera's logLevel prop lets any through. At the default 'off' nothing arrives. The batches are the ones addLogListener() receives, which also hears photo detections and exports with no camera on screen; with two cameras mounted, the first one's onLog receives them. LogEntry and the levels are under functions → diagnostics, and what each level shows in the log channel.