@keyframerdev/vitals 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,145 @@
1
+ // src/runtime.ts
2
+ function __kfvAnim(fn, site, ...args) {
3
+ "worklet";
4
+ const hook = globalThis.__KF_VITALS__;
5
+ if (hook === undefined || hook === null)
6
+ return fn(...args);
7
+ return hook.anim(fn, site, args, null, null);
8
+ }
9
+ function __kfvAnimVia(via, site, fn, viaSite, ...args) {
10
+ "worklet";
11
+ const hook = globalThis.__KF_VITALS__;
12
+ if (hook === undefined || hook === null)
13
+ return via(fn, viaSite, ...args);
14
+ return hook.anim(fn, site, args, via, viaSite);
15
+ }
16
+ function __kfvScroll() {
17
+ "worklet";
18
+ const hook = globalThis.__KF_VITALS__;
19
+ if (hook !== undefined && hook !== null)
20
+ hook.scroll();
21
+ }
22
+ var scrollWrappers = new WeakMap;
23
+ function __kfvScrollJs(fn) {
24
+ if (typeof fn !== "function")
25
+ return fn;
26
+ const cached = scrollWrappers.get(fn);
27
+ if (cached)
28
+ return cached;
29
+ const wrapped = function(...args) {
30
+ const hook = globalThis.__KF_VITALS__;
31
+ if (hook)
32
+ hook.scroll();
33
+ return fn.apply(this, args);
34
+ };
35
+ scrollWrappers.set(fn, wrapped);
36
+ return wrapped;
37
+ }
38
+ var g = globalThis;
39
+ var builders = g.__KFV_BUILDERS__ ??= new Map;
40
+ var refs = g.__KFV_REFS__ ??= new Map;
41
+ var moves = g.__KFV_MOVES__ ??= new Map;
42
+ var liveViews = new WeakSet;
43
+ var pendingExits = new Map;
44
+ var laidOut = new Set;
45
+ var idOf = (site) => site.key ?? JSON.stringify(site.ref);
46
+ var jsHook = () => globalThis.__KF_VITALS__ ?? null;
47
+ function __kfvLayout(builder, kind, site) {
48
+ if (builder == null)
49
+ return builder;
50
+ const id = idOf(site);
51
+ let byKind = builders.get(id);
52
+ if (!byKind)
53
+ builders.set(id, byKind = {});
54
+ byKind[kind] = builder;
55
+ return builder;
56
+ }
57
+ function startLayout(site, kind) {
58
+ const hook = jsHook();
59
+ const builder = builders.get(idOf(site))?.[kind];
60
+ if (hook?.layout && builder != null)
61
+ hook.layout(site, kind, builder);
62
+ }
63
+ function passRef(prev, instance) {
64
+ if (typeof prev === "function")
65
+ return prev(instance);
66
+ if (prev && typeof prev === "object" && "current" in prev)
67
+ prev.current = instance;
68
+ return;
69
+ }
70
+ function exitLater(id, exiting) {
71
+ const n = (pendingExits.get(id) ?? 0) + 1;
72
+ pendingExits.set(id, n);
73
+ if (n > 1)
74
+ return;
75
+ Promise.resolve().then(() => {
76
+ const k = pendingExits.get(id) ?? 0;
77
+ pendingExits.delete(id);
78
+ if (exiting)
79
+ for (let i = 0;i < k; i++)
80
+ startLayout(exiting, "exiting");
81
+ });
82
+ }
83
+ function __kfvMotionRef(entering, exiting, prev) {
84
+ const id = `${entering ? idOf(entering) : ""}|${exiting ? idOf(exiting) : ""}`;
85
+ const cached = refs.get(id);
86
+ if (cached && cached.prev === prev)
87
+ return cached.ref;
88
+ const ref = (instance) => {
89
+ if (!instance || typeof instance !== "object") {
90
+ if (instance === null)
91
+ exitLater(id, exiting);
92
+ return passRef(prev, instance);
93
+ }
94
+ if (liveViews.has(instance)) {
95
+ const n = pendingExits.get(id) ?? 0;
96
+ if (n > 0)
97
+ pendingExits.set(id, n - 1);
98
+ } else {
99
+ liveViews.add(instance);
100
+ if (entering)
101
+ startLayout(entering, "entering");
102
+ }
103
+ const theirs = passRef(prev, instance);
104
+ return () => {
105
+ exitLater(id, exiting);
106
+ if (typeof theirs === "function")
107
+ theirs();
108
+ else
109
+ passRef(prev, null);
110
+ };
111
+ };
112
+ refs.set(id, { prev, ref });
113
+ return ref;
114
+ }
115
+ function __kfvLayoutMoved(site, prev) {
116
+ const id = idOf(site);
117
+ const cached = moves.get(id);
118
+ if (cached && cached.prev === prev)
119
+ return cached.fn;
120
+ const fn = (e) => {
121
+ const ev = e;
122
+ const target = ev?.target ?? ev?.nativeEvent?.target;
123
+ if (target != null) {
124
+ if (!laidOut.has(target)) {
125
+ if (laidOut.size > 1000)
126
+ laidOut.clear();
127
+ laidOut.add(target);
128
+ } else
129
+ startLayout(site, "layout");
130
+ }
131
+ if (typeof prev === "function")
132
+ prev(e);
133
+ };
134
+ moves.set(id, { prev, fn });
135
+ return fn;
136
+ }
137
+ export {
138
+ __kfvScrollJs,
139
+ __kfvScroll,
140
+ __kfvMotionRef,
141
+ __kfvLayoutMoved,
142
+ __kfvLayout,
143
+ __kfvAnimVia,
144
+ __kfvAnim
145
+ };
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The on-device aggregation of one session window (production.md 1 and 2.1), pure so it runs and is
3
+ * tested outside React Native. Fed with animation starts, touches, scrolls, navigations and frames on
4
+ * one clock (ms, the JS thread's `performance.now()`); `snapshot()` returns the counters a report holds.
5
+ *
6
+ * Attribution is Motion Scan's rule (`MotionTracker` in the inspector): every animation call is ACTIVE
7
+ * from its start to its estimated end, and a frame counts against every animation active then, plus
8
+ * 32 ms of slack. Frames while no tracked animation ran are the screen's idle baseline. The 5 longest
9
+ * frames carry a cause, from the light-mode rules of production.md 1 ("Attribution").
10
+ */
11
+ import { type VitalsCause, type VitalsSiteRef, type VitalsThread, type VitalsThreadStats } from './protocol';
12
+ /** What the Babel plugin bakes into each call site (or a runtime names). */
13
+ export type SiteInfo = {
14
+ ref: VitalsSiteRef;
15
+ key?: string;
16
+ fn: string;
17
+ component?: string | null;
18
+ prop?: string | null;
19
+ };
20
+ /** A window's frame stays charged to an animation this long after its estimated end (MotionTracker). */
21
+ export declare const SLACK_MS = 32;
22
+ /** How far before a long frame began an animation start counts as having started it (trace.ts). */
23
+ export declare const LEAD_MS = 50;
24
+ /** A frame that ends this soon after a navigation is a screen mount. */
25
+ export declare const MOUNT_MS = 500;
26
+ /** Distinct animations starting together that make a burst (trace.ts hintsFor). */
27
+ export declare const BURST = 6;
28
+ /** A touch keeps its window open this long after the finger lifts (scroll deceleration). */
29
+ export declare const TOUCH_TAIL_MS = 1500;
30
+ /** A scroll handler keeps its window open this long after its last event. */
31
+ export declare const SCROLL_TAIL_MS = 250;
32
+ /** Field caps (production.md 2.2: MotionTracker's 400 windows and 2000 events drop to 100 and 500). */
33
+ export declare const MAX_WINDOWS = 100;
34
+ export declare const MAX_EVENTS = 500;
35
+ export type SiteStats = {
36
+ info: SiteInfo;
37
+ runs: number;
38
+ runsReduceMotion: number;
39
+ activeMs: number;
40
+ frames: number;
41
+ missed: number;
42
+ hitchMs: number;
43
+ worstMs: number;
44
+ /** Runs per screen name: the site's screen is where it ran most. */
45
+ screens: Map<string, number>;
46
+ /** End of the latest run counted into activeMs, so overlapping runs count once. */
47
+ activeUntil: number;
48
+ };
49
+ export type WorstFrame = {
50
+ thread: VitalsThread;
51
+ ms: number;
52
+ missed: number;
53
+ screen: string;
54
+ sites: SiteStats[];
55
+ cause: VitalsCause;
56
+ afterNavMs?: number;
57
+ };
58
+ export type ScreenStats = {
59
+ name: string;
60
+ ms: number;
61
+ threads: Partial<Record<VitalsThread, VitalsThreadStats>>;
62
+ };
63
+ export type Snapshot = {
64
+ screens: ScreenStats[];
65
+ sites: SiteStats[];
66
+ worst: WorstFrame[];
67
+ foregroundMs: number;
68
+ measuredMs: number;
69
+ /** The refresh rate most frames were counted against, and the fastest seen. */
70
+ targetHz: number;
71
+ maxHz: number;
72
+ reduceMotion: boolean;
73
+ };
74
+ export declare function bucketOf(missed: number): number;
75
+ export declare class Collector {
76
+ private readonly keepWorst;
77
+ private screens;
78
+ private sites;
79
+ private windows;
80
+ private starts;
81
+ private touches;
82
+ private scrolls;
83
+ /** Navigations, for screen mounts. */
84
+ private navs;
85
+ /** Every screen change (navigations and renames), for the screen a late frame was drawn on. */
86
+ private shown;
87
+ private before;
88
+ private worst;
89
+ private touching;
90
+ private screen;
91
+ private fgSince;
92
+ private fgMs;
93
+ private measuring;
94
+ private measuredMs;
95
+ private latest;
96
+ private hzFrames;
97
+ /** Sum of frame lengths per thread: measured time as the meters saw it. */
98
+ private frameMs;
99
+ private reduceMotion;
100
+ private reduceMotionSeen;
101
+ constructor(keepWorst?: number);
102
+ /** The app came to the foreground (true) or left it. Screen time and foreground time accrue while in it. */
103
+ foreground(on: boolean, now: number): void;
104
+ /** A navigation (or the first screen). `nav` false: a rename that is not a mount (setScreen at start). */
105
+ setScreen(name: string, now: number, nav?: boolean): void;
106
+ setReduceMotion(on: boolean): void;
107
+ /** An animation started at `now` and runs about `ms`. */
108
+ animStart(info: SiteInfo, now: number, ms: number, thread: VitalsThread, eid?: number): void;
109
+ /**
110
+ * An outer call took an inner one in (`withDelay(200, withTiming(…))`): the inner one runs as part
111
+ * of it, so its window and its run go (MotionTracker drops the inner window the same way).
112
+ */
113
+ consume(eid: number): void;
114
+ touch(down: boolean, now: number): void;
115
+ scroll(now: number): void;
116
+ /** The frame meters started (true) or stopped: measured time. */
117
+ measure(on: boolean, now: number): void;
118
+ /** One frame of `thread` that ended at `t`, took `dt` ms, against a refresh interval of `budget` ms. */
119
+ frame(thread: VitalsThread, t: number, dt: number, budget: number): void;
120
+ snapshot(now: number): Snapshot;
121
+ /**
122
+ * A new session window starts at `now`: the counters empty, what is running and where the app is
123
+ * carry over (an animation that spans the cut keeps its window, counted in the new one from here).
124
+ */
125
+ reset(now: number): void;
126
+ /** Nothing is kept (setVitalsEnabled(false), a session that ends). */
127
+ clear(): void;
128
+ get currentScreen(): string;
129
+ get foregroundMs(): number;
130
+ /** Foreground time of this window so far, at `now`. */
131
+ foregroundAt(now: number): number;
132
+ private accrue;
133
+ /** A screen name, or `other` once VITALS_LIMITS.screens names are taken. */
134
+ private screenKey;
135
+ private screenStats;
136
+ /** The screen shown when a frame that ended at `t` was drawn: frames reach here late. */
137
+ private screenAt;
138
+ private site;
139
+ private pushSpan;
140
+ private open;
141
+ private considerWorst;
142
+ /** The light-mode cause of a long frame (production.md 1, "Attribution"), in the table's order. */
143
+ causeOf(thread: VitalsThread, t: number, dt: number): {
144
+ cause: VitalsCause;
145
+ afterNavMs?: number;
146
+ };
147
+ /** Windows long over (frames of the other thread arrive up to a few hundred ms late). */
148
+ private prune;
149
+ }
150
+ /**
151
+ * When the frame meters run: inside motion windows only, plus 250 ms after the last one closes
152
+ * (production.md 2.1). Nothing here keeps a timer: the meters ask `open(now)` on each frame and stop
153
+ * themselves.
154
+ */
155
+ export declare const GATE_TAIL_MS = 250;
156
+ export declare class MotionGate {
157
+ until: number;
158
+ private touching;
159
+ /** Something moves until `end`. True when the gate was closed at `now` (the meters must start). */
160
+ note(end: number, now: number): boolean;
161
+ /** A touch went down (true) or up. True when the gate opened. */
162
+ touch(down: boolean, now: number): boolean;
163
+ open(now: number): boolean;
164
+ /** When the gate closes if nothing else happens (Infinity while a finger is down). */
165
+ closesAt(): number;
166
+ reset(): void;
167
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Motion Vitals' session lifecycle (production.md 2.1), with everything it touches injected so it is
3
+ * tested without React Native: the clocks, randomness, fetch, timers, the storage adapter and the
4
+ * frame meters.
5
+ *
6
+ * - Nothing runs before `start`. Then, per session (a foreground period; it ends after 30 s in the
7
+ * background): a nonce made in memory, sampling decided once, at most 3 reported sessions per device
8
+ * per day (counted in storage, never sent).
9
+ * - A sampled session measures inside motion windows only (`MotionGate`), cuts a report when it goes
10
+ * to the background or fills 10 minutes of foreground, and checkpoints the open window at most once
11
+ * a minute while something moves (a checkpoint left behind is sent on the next launch with
12
+ * `ended: 'unknown'`).
13
+ * - Failed uploads wait in a retry store (5 reports, 100 KB, 7 days), retried 10 s after start, then
14
+ * after 1, 5 and 30 minutes, only in the foreground.
15
+ * - Config comes from every upload response and from `GET /v1/config/<key>` at most once per its TTL;
16
+ * `stop`, a 401 or a 410 turn the SDK off on this device for 7 days; `minSdk` above this version and
17
+ * `enabled: false` turn it off.
18
+ * - `setEnabled(false)` stops at once and drops what is pending.
19
+ */
20
+ import { type VitalsConfig, type VitalsReport, type VitalsThread, type VitalsTier } from './protocol';
21
+ import { Collector, MotionGate, type SiteInfo } from './collector';
22
+ /** Where the SDK keeps its config cache, checkpoint, retry store and day counter: AsyncStorage, an MMKV wrapper… */
23
+ export type VitalsStorage = {
24
+ getItem(key: string): string | null | undefined | Promise<string | null | undefined>;
25
+ setItem(key: string, value: string): void | Promise<void>;
26
+ removeItem(key: string): void | Promise<void>;
27
+ };
28
+ export type VitalsDevice = {
29
+ /** Raw model identifier (`Device.modelId` on iOS, `Device.modelName` on Android from expo-device). */
30
+ model?: string | null;
31
+ memoryBytes?: number | null;
32
+ memoryGb?: number | null;
33
+ tier?: VitalsTier;
34
+ /** The display's refresh rate, when the app knows it. */
35
+ displayHz?: number;
36
+ };
37
+ export type ControllerOptions = {
38
+ /** The project's public field key, `kfp_live_…`. */
39
+ key: string;
40
+ appVersion: string;
41
+ build?: string;
42
+ environment?: 'production' | 'development';
43
+ /** Share of sessions that report, until the project's config says otherwise. Default 0.1. */
44
+ sampleRate?: number;
45
+ storage?: VitalsStorage;
46
+ device?: VitalsDevice;
47
+ /** Ingest origin. Default https://vitals.keyframer.dev. */
48
+ endpoint?: string;
49
+ /** Bundle id or package name. */
50
+ appId?: string;
51
+ };
52
+ type HttpResponse = {
53
+ status: number;
54
+ json(): Promise<unknown>;
55
+ };
56
+ export type ControllerEnv = {
57
+ /** Monotonic ms, the clock frames and animations are stamped with. */
58
+ now(): number;
59
+ /** Wall-clock ms: day counting, config age, retry age. Never sent. */
60
+ wall(): number;
61
+ random(): number;
62
+ fetch(url: string, init: {
63
+ method: string;
64
+ headers: Record<string, string>;
65
+ body?: string;
66
+ }): Promise<HttpResponse>;
67
+ setTimeout(fn: () => void, ms: number): unknown;
68
+ clearTimeout(handle: unknown): void;
69
+ platform: {
70
+ os: 'ios' | 'android';
71
+ osMajor: number;
72
+ };
73
+ /** Local calendar day of a wall time, `YYYY-MM-DD`. */
74
+ day?(wall: number): string;
75
+ log?(message: string): void;
76
+ };
77
+ /** The frame meters and the instrumentation hooks, as the platform runs them. */
78
+ export type Meters = {
79
+ /** Install the animation hooks (JS and UI runtimes): call sites start reporting. */
80
+ install(): void;
81
+ uninstall(): void;
82
+ /** Start the JS and UI frame loops; each stops itself when the gate closes. */
83
+ start(): void;
84
+ /** Keep the UI loop running at least `ms` more (it keeps its own clock). */
85
+ extendUi(ms: number): void;
86
+ stop(): void;
87
+ };
88
+ export declare const STORAGE_KEYS: {
89
+ readonly config: "kfv:config";
90
+ readonly stop: "kfv:stop";
91
+ readonly day: "kfv:day";
92
+ readonly checkpoint: "kfv:checkpoint";
93
+ readonly queue: "kfv:queue";
94
+ };
95
+ export declare const CHECKPOINT_MS = 60000;
96
+ export declare const STOP_MS: number;
97
+ export declare const RETRY: {
98
+ readonly max: 5;
99
+ readonly bytes: number;
100
+ readonly ageMs: number;
101
+ readonly delays: readonly [10000, 60000, number, number];
102
+ };
103
+ /** First config fetch and first retry happen this long after start, never blocking it. */
104
+ export declare const START_DELAY_MS = 10000;
105
+ /** The UI runtime numbers its animation calls from here, so its ids never meet the JS thread's. */
106
+ export declare const UI_EVENT_BASE = 1000000000;
107
+ /** Compares dotted versions numerically: -1, 0 or 1. Pre-release tags are ignored. */
108
+ export declare function compareVersions(a: string, b: string): number;
109
+ export declare class VitalsController {
110
+ private readonly env;
111
+ readonly collector: Collector;
112
+ readonly gate: MotionGate;
113
+ private opts;
114
+ private enabled;
115
+ /** Bumped by every start and stop: async work from an older run checks it and gives up. */
116
+ private run;
117
+ private config;
118
+ private sampled;
119
+ private nonce;
120
+ private seq;
121
+ private sampleRate;
122
+ private foreground;
123
+ private backgroundAt;
124
+ private dayCount;
125
+ private events;
126
+ private nextEid;
127
+ private uiKnownUntil;
128
+ private checkpointTimer;
129
+ private retryTimer;
130
+ private retryStep;
131
+ /** Reports waiting in the retry store (no retry timer while it is empty). */
132
+ private queued;
133
+ private configTimer;
134
+ private meters;
135
+ private screen;
136
+ private reduceMotion;
137
+ constructor(env: ControllerEnv);
138
+ get isSampled(): boolean;
139
+ get isEnabled(): boolean;
140
+ get session(): {
141
+ nonce: string;
142
+ seq: number;
143
+ sampleRate: number;
144
+ };
145
+ /** Starts reporting. Resolves when the session is set up (storage read, sampling decided). */
146
+ start(opts: ControllerOptions, meters: Meters): Promise<void>;
147
+ /** setVitalsEnabled: false stops at once and drops what is pending; true starts again. */
148
+ setEnabled(on: boolean): Promise<void>;
149
+ /** Stops measuring and reporting. `drop`: the open window, the checkpoint and the retry store go too. */
150
+ stop(drop: boolean): Promise<void>;
151
+ private beginSession;
152
+ private endSampling;
153
+ /** AppState: 'active' or anything else (background, inactive counts as foreground on iOS). */
154
+ appState(active: boolean): void;
155
+ setScreen(name: string): void;
156
+ setReduceMotion(on: boolean): void;
157
+ /** A new id for an animation call on the JS thread (the UI runtime numbers its own from UI_EVENT_BASE). */
158
+ eid(): number;
159
+ /**
160
+ * An animation call started at `t` (the JS clock). `args` are slimmed (`slimArg`): numbers, nested
161
+ * calls as `{ $anim: eid }`, and config timing keys.
162
+ */
163
+ anim(site: SiteInfo, args: unknown[], eid: number, thread: VitalsThread, t?: number): void;
164
+ /**
165
+ * A layout animation started (entering, exiting, layout). Reanimated's builders keep their timing in
166
+ * `durationV` / `delayV`; a builder class passed as it is (`entering={FadeIn}`) runs its default 300 ms.
167
+ */
168
+ layout(site: SiteInfo, kind: 'entering' | 'exiting' | 'layout', builder: unknown, t?: number): void;
169
+ scroll(t?: number): void;
170
+ touch(down: boolean, t?: number): void;
171
+ private motion;
172
+ private opened;
173
+ /** The UI loop keeps its own time: tell it how long the gate stays open when that grows. */
174
+ private keepUi;
175
+ /**
176
+ * One JS-thread frame. Returns whether the JS loop goes on: false once the gate has closed (the
177
+ * meters stop themselves, nothing keeps a timer).
178
+ */
179
+ jsFrame(t: number, dt: number, budget: number): boolean;
180
+ /** UI-thread frames as `[t, dt, budget, …]`, `t` already on the JS clock. */
181
+ uiFrames(buf: number[]): void;
182
+ private checkWindow;
183
+ /** The report the open window would send now, or null when it holds nothing. */
184
+ report(ended: VitalsReport['session']['ended'], now?: number): VitalsReport | null;
185
+ private closeWindow;
186
+ private scheduleCheckpoint;
187
+ /** Writes the open window to storage, sent on the next launch if this process never sends it. */
188
+ checkpoint(): Promise<void>;
189
+ private context;
190
+ private endpoint;
191
+ /** Posts a report; on a failure that may pass (offline, 429, 5xx), into the retry store. */
192
+ private send;
193
+ private enqueue;
194
+ /** The retry store's limits: newest first kept, at most 5 reports, 100 KB, 7 days old. */
195
+ private trim;
196
+ private scheduleRetry;
197
+ /** Sends what waits in the retry store, oldest first; what fails again stays for the next step. */
198
+ retry(): Promise<void>;
199
+ private sendOnce;
200
+ private fetchConfig;
201
+ /** A config from the server: cached, and acted on (stop, disabled, a minimum SDK version). */
202
+ applyConfig(config: VitalsConfig): Promise<void>;
203
+ private killSwitch;
204
+ private allowed;
205
+ private rate;
206
+ private clearTimers;
207
+ private log;
208
+ }
209
+ export {};
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A session window's counters as the report the ingest Worker takes (`VitalsReport`), within every
3
+ * limit of `VITALS_LIMITS`: 40 screens (the collector folds the rest into `other`), 200 sites (the ones
4
+ * that drew the most frames), 5 worst frames, 64-character screen names and 32 KB of JSON (sites with
5
+ * the fewest frames go first until it fits).
6
+ */
7
+ import { type VitalsReport } from './protocol';
8
+ import type { Snapshot } from './collector';
9
+ /** What the report says about the app, the device and the session, beside the counters. */
10
+ export type ReportContext = {
11
+ app: VitalsReport['app'];
12
+ sdk: VitalsReport['sdk'];
13
+ device: VitalsReport['device'];
14
+ /** The display's rate when the app knows it; else the fastest the meters saw. */
15
+ display?: {
16
+ hz?: number;
17
+ adaptive?: boolean;
18
+ };
19
+ state?: {
20
+ lowPower?: boolean;
21
+ thermal?: VitalsReport['state']['thermal'];
22
+ };
23
+ /** The project collects Reduce Motion (config `collect.reduceMotion`). */
24
+ collectReduceMotion: boolean;
25
+ session: Pick<VitalsReport['session'], 'nonce' | 'seq' | 'sampleRate' | 'ended'>;
26
+ /** Worst frames sent (the protocol allows 10, SDKs send 5). */
27
+ worst?: number;
28
+ };
29
+ /** True when the window holds anything worth a request: frames measured or animations run. */
30
+ export declare function hasData(s: Snapshot): boolean;
31
+ export declare function encodeReport(snap: Snapshot, ctx: ReportContext): VitalsReport;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * How long an animation call runs, copied from the inspector's `motionStats.ts` (`springSettleMs`,
3
+ * `estimateMs`) so a field window and a local Motion Scan window are the same length.
4
+ * `estimate.parity.test.ts` runs both copies on the same calls: change them together.
5
+ *
6
+ * Every animation call opens an ACTIVE WINDOW from when it was called until its estimated end (a
7
+ * timing's duration, a spring's settle time, delays and sequences added up).
8
+ */
9
+ /** The part of an animation event the estimate reads. Nested calls are `{ $anim: id }` in `args`. */
10
+ export type EstimateEvent = {
11
+ site: {
12
+ fn: string;
13
+ };
14
+ args: unknown[];
15
+ durationMs?: number;
16
+ };
17
+ /** How long a spring takes to settle (ms), from its physics: 4 time constants of its slowest decay. */
18
+ export declare function springSettleMs(c: Record<string, unknown>): number;
19
+ /**
20
+ * Estimated run time of one animation call (ms). Nested animations are read through their ids
21
+ * (`withDelay(200, withTiming(…))` reports the inner call as `{ $anim: id }`); one whose inner call is
22
+ * not known falls back to a short default.
23
+ */
24
+ export declare function estimateMs<E extends EstimateEvent>(ev: E, byEid: (eid: number) => E | undefined, depth?: number): number;
25
+ /**
26
+ * An argument reduced to what the estimate needs: a number, a nested call's id, or a config's
27
+ * numeric timing keys. Values, colors, callbacks and easing functions never leave the thread they
28
+ * were passed on (and could not be copied between runtimes anyway).
29
+ */
30
+ export declare function slimArg(a: unknown, idOf: (x: object) => number | undefined): unknown;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The frame meter math, copied from the inspector (`packages/inspector/src/frameMeter.ts` and
3
+ * `motionStats.ts` `droppedFrames`) so the field and a local recording count frames the same way.
4
+ * `frameMeter.parity.test.ts` runs both copies on the same frames: change them together.
5
+ *
6
+ * Plain state and a worklet tick, so the same code measures the JS thread (requestAnimationFrame) and
7
+ * the UI thread (the UI runtime's requestAnimationFrame). The refresh interval is what most frames of
8
+ * the last window took, snapped to 60/90/120 Hz: not the shortest, because a jittery loop sometimes
9
+ * delivers two callbacks a few ms apart.
10
+ */
11
+ export type MeterStat = {
12
+ t: number;
13
+ thread: 'ui' | 'js';
14
+ fps: number;
15
+ dropped: number;
16
+ };
17
+ export type MeterDrop = {
18
+ t: number;
19
+ thread: 'ui' | 'js';
20
+ ms: number;
21
+ dropped: number;
22
+ };
23
+ export type FrameMeter = {
24
+ thread: 'ui' | 'js';
25
+ windowMs: number;
26
+ interval: number;
27
+ last: number;
28
+ windowStart: number;
29
+ frames: number;
30
+ dropped: number;
31
+ fast: number;
32
+ mid: number;
33
+ stats: MeterStat[];
34
+ drops: MeterDrop[];
35
+ };
36
+ export declare function createFrameMeter(thread: 'ui' | 'js', windowMs?: number): FrameMeter;
37
+ /** A frame was drawn at `t` (ms). Finished windows land in `m.stats`, long frames in `m.drops`. */
38
+ export declare function tickFrameMeter(m: FrameMeter, t: number): void;
39
+ /** Frames a single frame of `dt` ms skipped at an `interval` ms refresh. */
40
+ export declare function droppedFrames(dt: number, interval: number): number;
41
+ /**
42
+ * One measured thread in the field: the meter above, plus every frame since the last flush as flat
43
+ * `[t, dt, budget, …]` (the frame's end, its length, the refresh interval it was counted against), so
44
+ * the JS side can attribute each frame to its screen and to the animations running then. The meter's
45
+ * own stats and drops are emptied as it goes: the field never keeps a recording.
46
+ */
47
+ export type FieldMeter = {
48
+ m: FrameMeter;
49
+ buf: number[];
50
+ };
51
+ export declare function createFieldMeter(thread: 'ui' | 'js'): FieldMeter;
52
+ /** A frame at `t`. Returns true when the buffer holds a flush's worth (about a quarter second). */
53
+ export declare function tickFieldMeter(f: FieldMeter, t: number): boolean;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The JS runtime's hook: what `__kfvAnim` and the layout and scroll helpers call into once startVitals
3
+ * installed it. Runs the app's call first (its result is what the app gets, whatever happens here),
4
+ * then notes the start with the arguments slimmed to timing numbers.
5
+ */
6
+ import type { VitalsController } from './controller';
7
+ import type { VitalsHook } from './runtime';
8
+ export declare function createJsHook(controller: VitalsController, onMotion?: () => void): VitalsHook;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Motion Vitals for React Native: slow frames and janky animations from production, by screen and
3
+ * animation call site. MIT.
4
+ *
5
+ * import { startVitals } from '@keyframerdev/vitals'
6
+ * startVitals({ key: 'kfp_live_…', appVersion: '3.4.0', build: '412', navigationRef })
7
+ *
8
+ * Nothing runs before startVitals (an app that needs consent calls it after); setVitalsEnabled(false)
9
+ * stops and drops what is pending. Add `@keyframerdev/vitals/babel` to babel.config.js for call sites;
10
+ * without it, frames are still counted per screen. Never sent: screenshots, text, values, touches,
11
+ * identifiers, absolute times.
12
+ */
13
+ import { type ReactNode } from 'react';
14
+ import { View, type StyleProp, type ViewStyle } from 'react-native';
15
+ export { setVitalsEnabled, setVitalsScreen, startVitals, type NavigationRefLike, type VitalsOptions } from './native';
16
+ export type { VitalsDevice, VitalsStorage } from './controller';
17
+ export { SDK_VERSION } from './version';
18
+ /**
19
+ * Optional, around the app: touches open motion windows (a touch keeps the meters running until 1.5 s
20
+ * after the finger lifts), so a list without scroll handlers is measured while it scrolls. It only
21
+ * listens: it never becomes the responder and reads no coordinates.
22
+ */
23
+ export declare function VitalsRoot({ children, style }: {
24
+ children?: ReactNode;
25
+ style?: StyleProp<ViewStyle>;
26
+ }): import("react").CElement<import("react-native").ViewProps, View>;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Screen names before they leave the device. A route name can carry data (`/users/42`,
3
+ * `Profile ada@example.com`): the same masks as the inspector's share links (`maskText` in
4
+ * `packages/inspector/src/redact.ts`, copied here; `mask.parity.test.ts` keeps them identical), then
5
+ * cut to VITALS_LIMITS.screenName characters.
6
+ */
7
+ /** Replaces emails, long numbers, ids and tokens in `text`. */
8
+ export declare function maskText(text: string): string;
9
+ /** A screen name as it is sent: masked, trimmed, at most 64 characters, never empty. */
10
+ export declare function screenName(raw: unknown): string;