@houwert/conductor 0.27.2 → 0.29.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.
@@ -0,0 +1,793 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.PHASE_KEYS = void 0;
40
+ exports.parseGfxinfoSummary = parseGfxinfoSummary;
41
+ exports.parseGfxinfoUptimeMs = parseGfxinfoUptimeMs;
42
+ exports.parseGfxinfoHistogram = parseGfxinfoHistogram;
43
+ exports.parseFramestats = parseFramestats;
44
+ exports.toFrameSample = toFrameSample;
45
+ exports.summariseFrames = summariseFrames;
46
+ exports.resetFrameCounters = resetFrameCounters;
47
+ exports.buildFramesDiff = buildFramesDiff;
48
+ exports.windowVariance = windowVariance;
49
+ exports.profileFramesReset = profileFramesReset;
50
+ exports.profileFramesReport = profileFramesReport;
51
+ const node_fs_1 = require("node:fs");
52
+ const node_os_1 = __importDefault(require("node:os"));
53
+ const node_path_1 = __importDefault(require("node:path"));
54
+ const output_js_1 = require("../output.js");
55
+ const bootstrap_js_1 = require("../drivers/bootstrap.js");
56
+ const device_js_1 = require("../android/device.js");
57
+ const stats_js_1 = require("../stats.js");
58
+ const PHASE_HINTS = {
59
+ vsyncDelayMs: 'UI thread missed its wake-up — blocked elsewhere (on RN, usually JS)',
60
+ inputMs: 'input handling in the app',
61
+ animationMs: 'animation callbacks / Choreographer work',
62
+ traversalMs: 'measure/layout — deep view trees, expensive onLayout',
63
+ drawMs: 'display-list recording — overdraw, shadows, complex clipping',
64
+ syncMs: 'sync to the render thread — often large texture uploads',
65
+ issueDrawMs: 'render-thread GPU command issue',
66
+ swapMs: 'buffer queue / display back-pressure, not app work',
67
+ };
68
+ // ── Parsing ───────────────────────────────────────────────────────────────────
69
+ function num(out, re) {
70
+ const m = out.match(re);
71
+ return m ? Number(m[1]) : undefined;
72
+ }
73
+ function parseGfxinfoSummary(out) {
74
+ const header = out.match(/\*\* Graphics info for pid (\d+) \[([^\]]+)\]/);
75
+ const janky = out.match(/Janky frames:\s*(\d+)\s*\(([\d.]+)%\)/);
76
+ // The GPU block repeats these labels as "50th gpu percentile", so matching the
77
+ // exact phrase keeps us on the UI-thread figures.
78
+ return {
79
+ pid: header ? Number(header[1]) : undefined,
80
+ packageName: header ? header[2] : undefined,
81
+ totalFrames: num(out, /Total frames rendered:\s*(\d+)/),
82
+ jankyFrames: janky ? Number(janky[1]) : undefined,
83
+ jankyPercent: janky ? Number(janky[2]) : undefined,
84
+ platformP50Ms: num(out, /50th percentile:\s*(\d+)ms/),
85
+ platformP90Ms: num(out, /90th percentile:\s*(\d+)ms/),
86
+ platformP95Ms: num(out, /95th percentile:\s*(\d+)ms/),
87
+ platformP99Ms: num(out, /99th percentile:\s*(\d+)ms/),
88
+ missedVsync: num(out, /Number Missed Vsync:\s*(\d+)/),
89
+ highInputLatency: num(out, /Number High input latency:\s*(\d+)/),
90
+ slowUiThread: num(out, /Number Slow UI thread:\s*(\d+)/),
91
+ slowBitmapUploads: num(out, /Number Slow bitmap uploads:\s*(\d+)/),
92
+ slowDrawCommands: num(out, /Number Slow issue draw commands:\s*(\d+)/),
93
+ frameDeadlineMissed: num(out, /Number Frame deadline missed:\s*(\d+)/),
94
+ };
95
+ }
96
+ /**
97
+ * `Uptime: <ms>` from the dumpsys header. This is CLOCK_MONOTONIC, the same
98
+ * domain as the framestats vsync timestamps, and it is contemporaneous with the
99
+ * dump — so it anchors the frames without costing an extra round trip.
100
+ */
101
+ function parseGfxinfoUptimeMs(out) {
102
+ return num(out, /^Uptime:\s*(\d+)/m);
103
+ }
104
+ /** `HISTOGRAM: 5ms=1 6ms=0 ...` — the UI-thread histogram, not the GPU one. */
105
+ function parseGfxinfoHistogram(out) {
106
+ const line = out.match(/^HISTOGRAM:\s*(.+)$/m);
107
+ if (!line)
108
+ return [];
109
+ const buckets = [];
110
+ for (const [, ms, count] of line[1].matchAll(/(\d+)ms=(\d+)/g)) {
111
+ buckets.push({ ms: Number(ms), count: Number(count) });
112
+ }
113
+ return buckets;
114
+ }
115
+ /**
116
+ * Rows from every `---PROFILEDATA---` block, keyed by the block's own header so
117
+ * we survive the column set changing between Android versions.
118
+ */
119
+ function parseFramestats(out) {
120
+ const rows = [];
121
+ for (const [, body] of out.matchAll(/---PROFILEDATA---\r?\n([\s\S]*?)---PROFILEDATA---/g)) {
122
+ const lines = body.split(/\r?\n/).filter((l) => l.trim().length > 0);
123
+ if (lines.length < 2)
124
+ continue;
125
+ const columns = lines[0]
126
+ .split(',')
127
+ .map((c) => c.trim())
128
+ .filter(Boolean);
129
+ if (!columns.includes('IntendedVsync'))
130
+ continue;
131
+ for (const line of lines.slice(1)) {
132
+ const cells = line.split(',');
133
+ if (cells.length < columns.length)
134
+ continue;
135
+ const row = {};
136
+ columns.forEach((col, i) => {
137
+ row[col] = Number(cells[i]);
138
+ });
139
+ if (Number.isFinite(row.IntendedVsync))
140
+ rows.push(row);
141
+ }
142
+ }
143
+ return rows;
144
+ }
145
+ const NS_PER_MS = 1e6;
146
+ /** Devices write INT64_MAX into input-event columns that were never filled in. */
147
+ const UNSET_SENTINEL = 9.2e18;
148
+ function span(row, from, to) {
149
+ const a = row[from];
150
+ const b = row[to];
151
+ if (!Number.isFinite(a) || !Number.isFinite(b) || a === 0 || b === 0 || b < a)
152
+ return 0;
153
+ if (a >= UNSET_SENTINEL || b >= UNSET_SENTINEL)
154
+ return 0;
155
+ return (0, stats_js_1.round)((b - a) / NS_PER_MS);
156
+ }
157
+ /**
158
+ * A row is usable when Flags is 0 — a non-zero Flags marks a frame the platform
159
+ * itself says not to measure (first draw after a window change, etc.). Real
160
+ * captures do contain such rows, so this filter is load-bearing.
161
+ */
162
+ function toFrameSample(row, anchor) {
163
+ if (row.Flags !== 0)
164
+ return null;
165
+ const start = row.IntendedVsync;
166
+ const end = row.FrameCompleted;
167
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start)
168
+ return null;
169
+ const totalMs = (0, stats_js_1.round)((end - start) / NS_PER_MS);
170
+ // Frames idle for a long time show absurd deltas; treat >10s as a bad row.
171
+ if (totalMs > 10000)
172
+ return null;
173
+ const newestInput = row.NewestInputEvent;
174
+ const hasInput = Number.isFinite(newestInput) &&
175
+ newestInput > 0 &&
176
+ newestInput < UNSET_SENTINEL &&
177
+ end > newestInput;
178
+ return {
179
+ vsyncNs: start,
180
+ completedNs: end,
181
+ totalMs,
182
+ inputLatencyMs: hasInput ? (0, stats_js_1.round)((end - newestInput) / NS_PER_MS) : null,
183
+ atDeviceRealtimeMs: anchor ? (0, stats_js_1.round)((0, device_js_1.toDeviceRealtimeMs)(anchor, end)) : undefined,
184
+ vsyncDelayMs: span(row, 'IntendedVsync', 'Vsync'),
185
+ inputMs: span(row, 'HandleInputStart', 'AnimationStart'),
186
+ animationMs: span(row, 'AnimationStart', 'PerformTraversalsStart'),
187
+ traversalMs: span(row, 'PerformTraversalsStart', 'DrawStart'),
188
+ drawMs: span(row, 'DrawStart', 'SyncQueued'),
189
+ syncMs: span(row, 'SyncStart', 'IssueDrawCommandsStart'),
190
+ issueDrawMs: span(row, 'IssueDrawCommandsStart', 'SwapBuffers'),
191
+ swapMs: span(row, 'SwapBuffers', 'FrameCompleted'),
192
+ };
193
+ }
194
+ exports.PHASE_KEYS = [
195
+ 'vsyncDelayMs',
196
+ 'inputMs',
197
+ 'animationMs',
198
+ 'traversalMs',
199
+ 'drawMs',
200
+ 'syncMs',
201
+ 'issueDrawMs',
202
+ 'swapMs',
203
+ ];
204
+ function phasePercentile(frames, p) {
205
+ const out = {};
206
+ for (const key of exports.PHASE_KEYS) {
207
+ const sorted = frames.map((f) => f[key]).sort((a, b) => a - b);
208
+ out[key] = (0, stats_js_1.round)((0, stats_js_1.percentile)(sorted, p) ?? 0);
209
+ }
210
+ return out;
211
+ }
212
+ function attribute(p50, p95, frameP95) {
213
+ let dominant = null;
214
+ let steady = null;
215
+ for (const key of exports.PHASE_KEYS) {
216
+ if (dominant === null || p95[key] > p95[dominant])
217
+ dominant = key;
218
+ if (steady === null || p50[key] > p50[steady])
219
+ steady = key;
220
+ }
221
+ if (dominant === null || steady === null || p95[dominant] <= 0)
222
+ return null;
223
+ return {
224
+ dominantPhase: dominant,
225
+ steadyPhase: steady,
226
+ p95OverP50: p50[dominant] > 0 ? (0, stats_js_1.round)(p95[dominant] / p50[dominant]) : null,
227
+ intermittent: p50[dominant] <= 0,
228
+ shareOfFrameP95: frameP95 && frameP95 > 0 ? (0, stats_js_1.round)(p95[dominant] / frameP95, 3) : null,
229
+ hint: PHASE_HINTS[dominant],
230
+ steadyHint: PHASE_HINTS[steady],
231
+ };
232
+ }
233
+ function summariseFrames(frames, top) {
234
+ if (frames.length === 0)
235
+ return undefined;
236
+ const totals = frames.map((f) => f.totalMs);
237
+ const distribution = (0, stats_js_1.describe)(totals);
238
+ const inputLatencies = frames.map((f) => f.inputLatencyMs).filter((v) => v !== null);
239
+ const phaseP50Ms = phasePercentile(frames, 50);
240
+ const phaseP95Ms = phasePercentile(frames, 95);
241
+ return {
242
+ distribution,
243
+ over16ms: totals.filter((t) => t > 16.67).length,
244
+ over33ms: totals.filter((t) => t > 33.33).length,
245
+ over50ms: totals.filter((t) => t > 50).length,
246
+ phaseP50Ms,
247
+ phaseP95Ms,
248
+ attribution: attribute(phaseP50Ms, phaseP95Ms, distribution.p95Ms),
249
+ inputLatency: inputLatencies.length > 0 ? (0, stats_js_1.describe)(inputLatencies) : undefined,
250
+ worst: [...frames].sort((a, b) => b.totalMs - a.totalMs).slice(0, top),
251
+ };
252
+ }
253
+ // ── Collection ────────────────────────────────────────────────────────────────
254
+ async function readGfxinfo(deviceId, appId) {
255
+ const res = await (0, device_js_1.adbShell)(deviceId, ['dumpsys', 'gfxinfo', appId, 'framestats']);
256
+ if (!res.success)
257
+ throw new Error(res.stderr.trim() || 'adb shell dumpsys gfxinfo failed');
258
+ return res.stdout;
259
+ }
260
+ /**
261
+ * Read the frames and the clock anchor in one adb invocation, so the anchor is
262
+ * contemporaneous with the frames by construction and its error is the
263
+ * on-device dumpsys duration rather than the network round trip.
264
+ */
265
+ async function readGfxinfoAnchored(deviceId, appId) {
266
+ const bracket = await (0, device_js_1.shellBracketed)(deviceId, `dumpsys gfxinfo ${appId} framestats`);
267
+ if (!bracket)
268
+ return { dump: await readGfxinfo(deviceId, appId) };
269
+ const monotonicMs = parseGfxinfoUptimeMs(bracket.stdout);
270
+ return {
271
+ dump: bracket.stdout,
272
+ anchor: monotonicMs === undefined ? undefined : (0, device_js_1.buildClockAnchor)(bracket, monotonicMs),
273
+ };
274
+ }
275
+ async function resetFrameCounters(deviceId, appId) {
276
+ const res = await (0, device_js_1.adbShell)(deviceId, ['dumpsys', 'gfxinfo', appId, 'reset']);
277
+ if (!res.success)
278
+ throw new Error(res.stderr.trim() || 'adb shell dumpsys gfxinfo reset failed');
279
+ }
280
+ /**
281
+ * Poll framestats for `durationMs`, de-duplicating frames by IntendedVsync.
282
+ *
283
+ * The on-device PROFILEDATA ring buffer holds ~120 frames (about 2s at 60fps),
284
+ * so a single read at the end of a long window would only show the tail.
285
+ * Polling and merging keeps the whole window — at the cost of one dumpsys per
286
+ * interval, which over networked adb is itself a few hundred ms.
287
+ */
288
+ async function trackFrames(deviceId, appId, durationMs, intervalMs) {
289
+ const byVsync = new Map();
290
+ const end = Date.now() + durationMs;
291
+ let lastDump = '';
292
+ let polls = 0;
293
+ let fullBufferPolls = 0;
294
+ let slowPolls = 0;
295
+ while (Date.now() < end) {
296
+ const wait = Math.min(intervalMs, Math.max(0, end - Date.now()));
297
+ if (wait > 0)
298
+ await new Promise((r) => setTimeout(r, wait));
299
+ const readStart = Date.now();
300
+ lastDump = await readGfxinfo(deviceId, appId);
301
+ polls++;
302
+ const rows = parseFramestats(lastDump);
303
+ if (rows.length >= 120)
304
+ fullBufferPolls++;
305
+ if (Date.now() - readStart > intervalMs)
306
+ slowPolls++;
307
+ for (const row of rows)
308
+ byVsync.set(row.IntendedVsync, row);
309
+ }
310
+ if (!lastDump) {
311
+ lastDump = await readGfxinfo(deviceId, appId);
312
+ polls++;
313
+ for (const row of parseFramestats(lastDump))
314
+ byVsync.set(row.IntendedVsync, row);
315
+ }
316
+ return { rows: [...byVsync.values()], lastDump, polls, fullBufferPolls, slowPolls };
317
+ }
318
+ // ── Baselines ─────────────────────────────────────────────────────────────────
319
+ function baselinesDir() {
320
+ return node_path_1.default.join(node_os_1.default.homedir(), '.conductor', 'frame-baselines');
321
+ }
322
+ function baselinePath(name) {
323
+ if (name.endsWith('.json') || name.includes('/'))
324
+ return name;
325
+ return node_path_1.default.join(baselinesDir(), `${name}.json`);
326
+ }
327
+ async function saveBaseline(name, report) {
328
+ await node_fs_1.promises.mkdir(baselinesDir(), { recursive: true });
329
+ const file = baselinePath(name);
330
+ await node_fs_1.promises.writeFile(file, JSON.stringify(report, null, 2));
331
+ return file;
332
+ }
333
+ async function loadBaseline(name) {
334
+ return JSON.parse(await node_fs_1.promises.readFile(baselinePath(name), 'utf8'));
335
+ }
336
+ async function listBaselines() {
337
+ let files;
338
+ try {
339
+ files = await node_fs_1.promises.readdir(baselinesDir());
340
+ }
341
+ catch {
342
+ return [];
343
+ }
344
+ const out = [];
345
+ for (const f of files.filter((n) => n.endsWith('.json'))) {
346
+ try {
347
+ const r = JSON.parse(await node_fs_1.promises.readFile(node_path_1.default.join(baselinesDir(), f), 'utf8'));
348
+ out.push({
349
+ name: f.replace(/\.json$/, ''),
350
+ capturedAt: r.capturedAt,
351
+ appId: r.appId,
352
+ windows: r.windows?.length,
353
+ });
354
+ }
355
+ catch {
356
+ /* skip malformed */
357
+ }
358
+ }
359
+ return out.sort((a, b) => (a.capturedAt ?? '').localeCompare(b.capturedAt ?? ''));
360
+ }
361
+ /** Which direction is an improvement for each diffed key. */
362
+ const POLARITY = {
363
+ jankyPercent: 'lower',
364
+ jankyFrames: 'lower',
365
+ // More frames drawn in the same window means fewer were dropped.
366
+ totalFrames: 'higher',
367
+ missedVsync: 'lower',
368
+ slowUiThread: 'lower',
369
+ slowDrawCommands: 'lower',
370
+ p50Ms: 'lower',
371
+ p90Ms: 'lower',
372
+ p95Ms: 'lower',
373
+ p99Ms: 'lower',
374
+ maxMs: 'lower',
375
+ over16ms: 'lower',
376
+ over33ms: 'lower',
377
+ };
378
+ function diffKeys(r) {
379
+ return {
380
+ jankyPercent: r.summary.jankyPercent ?? null,
381
+ totalFrames: r.summary.totalFrames ?? null,
382
+ jankyFrames: r.summary.jankyFrames ?? null,
383
+ missedVsync: r.summary.missedVsync ?? null,
384
+ slowUiThread: r.summary.slowUiThread ?? null,
385
+ slowDrawCommands: r.summary.slowDrawCommands ?? null,
386
+ p50Ms: r.frames?.distribution.p50Ms ?? null,
387
+ p90Ms: r.frames?.distribution.p90Ms ?? null,
388
+ p95Ms: r.frames?.distribution.p95Ms ?? null,
389
+ p99Ms: r.frames?.distribution.p99Ms ?? null,
390
+ maxMs: r.frames?.distribution.maxMs ?? null,
391
+ over16ms: r.frames?.over16ms ?? null,
392
+ over33ms: r.frames?.over33ms ?? null,
393
+ };
394
+ }
395
+ function buildFramesDiff(before, after) {
396
+ const a = diffKeys(before);
397
+ const b = diffKeys(after);
398
+ return Object.keys(a).map((key) => {
399
+ const beforeV = a[key];
400
+ const afterV = b[key];
401
+ const delta = beforeV !== null && afterV !== null ? (0, stats_js_1.round)(afterV - beforeV) : null;
402
+ const better = POLARITY[key] ?? 'neutral';
403
+ const stddev = before.variance?.[key]?.stddevMs ?? null;
404
+ // Two standard deviations of the baseline's own window-to-window spread.
405
+ const significant = delta === null || stddev === null ? null : Math.abs(delta) > 2 * stddev;
406
+ let verdict = 'unknown';
407
+ if (delta !== null) {
408
+ if (better === 'neutral' || delta === 0 || significant === false)
409
+ verdict = 'neutral';
410
+ else if (better === 'lower')
411
+ verdict = delta < 0 ? 'improvement' : 'regression';
412
+ else
413
+ verdict = delta > 0 ? 'improvement' : 'regression';
414
+ }
415
+ return {
416
+ key,
417
+ before: beforeV,
418
+ after: afterV,
419
+ delta,
420
+ better,
421
+ verdict,
422
+ significant,
423
+ baselineStddev: stddev,
424
+ };
425
+ });
426
+ }
427
+ /** Spread of each window-summary key across the captured windows. */
428
+ function windowVariance(windows) {
429
+ const keys = [
430
+ 'totalFrames',
431
+ 'jankyFrames',
432
+ 'jankyPercent',
433
+ 'p50Ms',
434
+ 'p90Ms',
435
+ 'p95Ms',
436
+ 'p99Ms',
437
+ 'over16ms',
438
+ ];
439
+ const out = {};
440
+ for (const key of keys) {
441
+ const values = windows.map((w) => w[key]).filter((v) => typeof v === 'number');
442
+ out[key] = (0, stats_js_1.describe)(values);
443
+ }
444
+ return out;
445
+ }
446
+ /**
447
+ * gfxinfo lives in Android's HWUI, so it covers real Fire TV / Android TV
448
+ * hardware over adb but not the Vega VVD, which is not Android.
449
+ */
450
+ async function requireAndroid(opts, sessionName) {
451
+ if (sessionName === 'default') {
452
+ const { detectFirstDevice } = await Promise.resolve().then(() => __importStar(require('../runner.js')));
453
+ const detected = await detectFirstDevice().catch(() => undefined);
454
+ if (!detected) {
455
+ (0, output_js_1.printError)('profile frames requires a --device', opts);
456
+ return null;
457
+ }
458
+ sessionName = detected;
459
+ }
460
+ const platform = await (0, bootstrap_js_1.detectPlatform)(sessionName).catch(() => 'unknown');
461
+ if (platform !== 'android') {
462
+ (0, output_js_1.printError)(`profile frames needs Android's dumpsys gfxinfo — not available on platform ${platform}.` +
463
+ (platform === 'vega'
464
+ ? '\nA physical Fire TV Stick runs Fire OS (Android) and is reachable over adb, which does work;' +
465
+ ' only the Vega VVD does not.'
466
+ : ''), opts);
467
+ return null;
468
+ }
469
+ // Check the device answers before blaming a missing app for an adb failure.
470
+ const probe = await (0, device_js_1.adbShell)(sessionName, ['true']);
471
+ if (!probe.success) {
472
+ (0, output_js_1.printError)(`profile frames — adb cannot reach ${sessionName}: ${probe.stderr.trim() || 'no response'}`, opts);
473
+ return null;
474
+ }
475
+ return { deviceId: sessionName, platform };
476
+ }
477
+ async function profileFramesReset(opts, sessionName, appIdArg) {
478
+ const target = await requireAndroid(opts, sessionName);
479
+ if (!target)
480
+ return 1;
481
+ try {
482
+ const appId = appIdArg ?? (await (0, device_js_1.resolveAndroidForegroundApp)(target.deviceId));
483
+ if (!appId) {
484
+ (0, output_js_1.printError)('profile frames reset — could not resolve the foreground app; pass <appId>', opts);
485
+ return 1;
486
+ }
487
+ await resetFrameCounters(target.deviceId, appId);
488
+ if (opts.json)
489
+ (0, output_js_1.printData)({ status: 'ok', appId, reset: true }, opts);
490
+ else
491
+ (0, output_js_1.printSuccess)(`profile frames reset — counters zeroed for ${appId}`, opts);
492
+ return 0;
493
+ }
494
+ catch (err) {
495
+ (0, output_js_1.printError)(`profile frames reset — ${err instanceof Error ? err.message : String(err)}`, opts);
496
+ return 1;
497
+ }
498
+ }
499
+ function toWindowSummary(index, windowMs, summary, stats) {
500
+ return {
501
+ index,
502
+ windowMs,
503
+ totalFrames: summary.totalFrames ?? null,
504
+ jankyFrames: summary.jankyFrames ?? null,
505
+ jankyPercent: summary.jankyPercent ?? null,
506
+ p50Ms: stats?.distribution.p50Ms ?? null,
507
+ p90Ms: stats?.distribution.p90Ms ?? null,
508
+ p95Ms: stats?.distribution.p95Ms ?? null,
509
+ p99Ms: stats?.distribution.p99Ms ?? null,
510
+ over16ms: stats?.over16ms ?? null,
511
+ };
512
+ }
513
+ async function profileFramesReport(opts, sessionName, frameOpts) {
514
+ if (frameOpts.listBaselines) {
515
+ const list = await listBaselines();
516
+ if (opts.json)
517
+ (0, output_js_1.printData)({ status: 'ok', baselines: list }, opts);
518
+ else if (list.length === 0)
519
+ console.log('No baselines saved. Try: --save-baseline <name>');
520
+ else
521
+ for (const b of list) {
522
+ console.log(` ${b.name} ${b.capturedAt ?? ''} ${b.appId ?? ''}` +
523
+ (b.windows ? ` (${b.windows} windows)` : ''));
524
+ }
525
+ return 0;
526
+ }
527
+ const target = await requireAndroid(opts, sessionName);
528
+ if (!target)
529
+ return 1;
530
+ const top = frameOpts.top ?? 10;
531
+ // 1000ms measured against the ~120-frame ring buffer, which drains in ~2s at
532
+ // 60fps. One framestats dump costs ~73ms over USB and ~307ms over networked
533
+ // adb, so a 1s interval stays comfortably inside the buffer on both while
534
+ // spending far less of the window dumping than a tighter interval would.
535
+ const intervalMs = frameOpts.intervalMs ?? 1000;
536
+ const repeat = Math.max(1, frameOpts.repeat ?? 1);
537
+ const notes = [];
538
+ try {
539
+ const appId = frameOpts.appId ?? (await (0, device_js_1.resolveAndroidForegroundApp)(target.deviceId));
540
+ if (!appId) {
541
+ (0, output_js_1.printError)('profile frames report — could not resolve the foreground app; pass <appId>', opts);
542
+ return 1;
543
+ }
544
+ let dump = '';
545
+ let rows = [];
546
+ let clockAnchor;
547
+ let windowMs;
548
+ const windows = [];
549
+ let track;
550
+ if (frameOpts.trackSec !== undefined) {
551
+ for (let i = 0; i < repeat; i++) {
552
+ await resetFrameCounters(target.deviceId, appId);
553
+ announceWindow(i, repeat, frameOpts.trackSec, appId);
554
+ const started = Date.now();
555
+ track = await trackFrames(target.deviceId, appId, frameOpts.trackSec * 1000, intervalMs);
556
+ const elapsed = Date.now() - started;
557
+ announceWindowEnd(i, repeat);
558
+ const windowSummary = parseGfxinfoSummary(track.lastDump);
559
+ const windowFrames = track.rows
560
+ .map((r) => toFrameSample(r))
561
+ .filter((f) => f !== null);
562
+ windows.push(toWindowSummary(i, elapsed, windowSummary, summariseFrames(windowFrames, 1)));
563
+ // The last window is the one reported in detail.
564
+ windowMs = elapsed;
565
+ dump = track.lastDump;
566
+ rows = track.rows;
567
+ }
568
+ // One extra anchored read after the window so frames carry a realtime
569
+ // stamp; the counters it reports are the same ones just captured.
570
+ const anchored = await readGfxinfoAnchored(target.deviceId, appId);
571
+ clockAnchor = anchored.anchor;
572
+ }
573
+ else {
574
+ const anchored = await readGfxinfoAnchored(target.deviceId, appId);
575
+ dump = anchored.dump;
576
+ clockAnchor = anchored.anchor;
577
+ rows = parseFramestats(dump);
578
+ if (rows.length > 0) {
579
+ notes.push({
580
+ code: 'buffer-window-only',
581
+ message: 'Per-frame stats cover only the ~120 frames still in the on-device buffer. Use ' +
582
+ '--track <s> for a full window; the summary counters are cumulative since reset.',
583
+ });
584
+ }
585
+ }
586
+ const summary = parseGfxinfoSummary(dump);
587
+ if (summary.totalFrames === undefined) {
588
+ (0, output_js_1.printError)(`profile frames report — no gfxinfo for ${appId}. Is it running and drawing? ` +
589
+ `(hardware acceleration must be on; WebView-only or SurfaceView-only apps report nothing)`, opts);
590
+ return 1;
591
+ }
592
+ if (!clockAnchor) {
593
+ notes.push({
594
+ code: 'no-clock-anchor',
595
+ message: 'Could not read the device clocks, so frames carry no atDeviceRealtimeMs and cannot ' +
596
+ 'be joined to React commit timestamps.',
597
+ });
598
+ }
599
+ const frames = rows
600
+ .map((r) => toFrameSample(r, clockAnchor))
601
+ .filter((f) => f !== null);
602
+ const stats = summariseFrames(frames, top);
603
+ if (track && summary.totalFrames > 0) {
604
+ // Only actual loss matters. A poll seeing a full buffer is normal on a
605
+ // busy device and means nothing on its own — frames are lost only if the
606
+ // buffer wrapped *between* polls, which is what coverage measures.
607
+ const coverage = frames.length / summary.totalFrames;
608
+ if (coverage < 0.98) {
609
+ // Scale the interval down by the shortfall so the next run's polls land
610
+ // inside one buffer's worth of frames.
611
+ const suggested = Math.max(100, Math.floor(intervalMs * coverage * 0.8));
612
+ notes.push({
613
+ code: 'poll-gap',
614
+ message: `Captured ${frames.length} of ${summary.totalFrames} frames the platform counted, ` +
615
+ `so the per-frame stats and percentiles below are over a subset. ` +
616
+ `${track.fullBufferPolls} of ${track.polls} poll(s) saw a full buffer and ` +
617
+ `${track.slowPolls} overran the interval. The summary counters are still exact. ` +
618
+ `Re-run with --interval ${suggested} for full coverage.`,
619
+ coveragePercent: (0, stats_js_1.round)(coverage * 100, 1),
620
+ suggestedIntervalMs: suggested,
621
+ });
622
+ }
623
+ }
624
+ if (stats && stats.inputLatency === undefined && frames.length > 0) {
625
+ notes.push({
626
+ code: 'no-input-timestamps',
627
+ message: 'No frame in this capture carried a NewestInputEvent timestamp. Many devices never ' +
628
+ 'populate it, so this is not evidence that no input occurred — use ' +
629
+ '`press-key --measure` for input latency instead of inferring it from frames.',
630
+ });
631
+ }
632
+ if (frameOpts.trackSec !== undefined && repeat === 1) {
633
+ notes.push({
634
+ code: 'single-window',
635
+ message: 'One window carries no run-to-run variance, so a later --diff cannot tell a real ' +
636
+ 'change from noise. Capture a baseline with --repeat 5 to record its own spread.',
637
+ });
638
+ }
639
+ const report = {
640
+ platform: target.platform,
641
+ deviceId: target.deviceId,
642
+ appId,
643
+ capturedAt: new Date().toISOString(),
644
+ windowMs,
645
+ summary,
646
+ histogram: parseGfxinfoHistogram(dump),
647
+ frames: stats,
648
+ windows: windows.length > 1 ? windows : undefined,
649
+ variance: windows.length > 1 ? windowVariance(windows) : undefined,
650
+ clockAnchor,
651
+ notes,
652
+ };
653
+ if (frameOpts.saveBaseline) {
654
+ const file = await saveBaseline(frameOpts.saveBaseline, report);
655
+ notes.push({ code: 'baseline-saved', message: `baseline saved → ${file}` });
656
+ }
657
+ if (frameOpts.diff) {
658
+ const before = await loadBaseline(frameOpts.diff);
659
+ const diff = buildFramesDiff(before, report);
660
+ if (opts.json) {
661
+ (0, output_js_1.printData)({ status: 'ok', diff, baseline: frameOpts.diff, current: report }, opts);
662
+ }
663
+ else {
664
+ printFramesDiff(diff, frameOpts.diff, before.variance !== undefined);
665
+ }
666
+ return 0;
667
+ }
668
+ if (opts.json)
669
+ (0, output_js_1.printData)({ status: 'ok', ...report }, opts);
670
+ else
671
+ printFramesReport(report);
672
+ return 0;
673
+ }
674
+ catch (err) {
675
+ (0, output_js_1.printError)(`profile frames report — ${err instanceof Error ? err.message : String(err)}`, opts);
676
+ return 1;
677
+ }
678
+ }
679
+ /**
680
+ * Tell a watching human when the window opens and closes.
681
+ *
682
+ * TV navigation has no momentum — focus moves one step per keypress and stops —
683
+ * so there is no such thing as capturing navigation frames without something
684
+ * driving input. Where that something is a person with the physical remote,
685
+ * they need to know when to start, and they are the only input path with no
686
+ * harness load at all. Goes to stderr so `--json` on stdout stays clean, and
687
+ * only when stderr is a terminal so piped runs stay quiet.
688
+ */
689
+ function announceWindow(index, repeat, sec, appId) {
690
+ if (!process.stderr.isTTY)
691
+ return;
692
+ const which = repeat > 1 ? ` (window ${index + 1}/${repeat})` : '';
693
+ process.stderr.write(`\n▶ measuring ${appId} for ${sec}s${which} — drive the device now\n`);
694
+ }
695
+ function announceWindowEnd(index, repeat) {
696
+ if (!process.stderr.isTTY)
697
+ return;
698
+ const more = index + 1 < repeat ? ' — next window starts shortly' : '';
699
+ process.stderr.write(`■ window closed${more}\n`);
700
+ }
701
+ function pct(n) {
702
+ return n === undefined ? 'n/a' : `${n.toFixed(2)}%`;
703
+ }
704
+ function printFramesReport(r) {
705
+ const s = r.summary;
706
+ console.log(`profile frames — ${r.appId}${r.windowMs ? ` over ${(r.windowMs / 1000).toFixed(1)}s` : ''}` +
707
+ (r.windows ? ` × ${r.windows.length} windows` : ''));
708
+ console.log(` frames rendered: ${s.totalFrames ?? 'n/a'}`);
709
+ console.log(` janky frames: ${s.jankyFrames ?? 'n/a'} (${pct(s.jankyPercent)})`);
710
+ console.log(` platform pctiles: p50 ${(0, stats_js_1.fmt)(s.platformP50Ms)} p90 ${(0, stats_js_1.fmt)(s.platformP90Ms)} ` +
711
+ `p95 ${(0, stats_js_1.fmt)(s.platformP95Ms)} p99 ${(0, stats_js_1.fmt)(s.platformP99Ms)}`);
712
+ console.log(` counters: missedVsync=${s.missedVsync ?? 0} slowUiThread=${s.slowUiThread ?? 0} ` +
713
+ `slowDraw=${s.slowDrawCommands ?? 0} slowBitmapUpload=${s.slowBitmapUploads ?? 0} ` +
714
+ `highInputLatency=${s.highInputLatency ?? 0} deadlineMissed=${s.frameDeadlineMissed ?? 0}`);
715
+ if (r.frames) {
716
+ const d = r.frames.distribution;
717
+ console.log(`\n per-frame (${d.count} frames from framestats)`);
718
+ console.log(` p50 ${(0, stats_js_1.fmt)(d.p50Ms)} p90 ${(0, stats_js_1.fmt)(d.p90Ms)} p95 ${(0, stats_js_1.fmt)(d.p95Ms)} ` +
719
+ `p99 ${(0, stats_js_1.fmt)(d.p99Ms)} max ${(0, stats_js_1.fmt)(d.maxMs)}`);
720
+ console.log(` >16ms ${r.frames.over16ms} >33ms ${r.frames.over33ms} >50ms ${r.frames.over50ms}`);
721
+ if (r.frames.attribution) {
722
+ const a = r.frames.attribution;
723
+ const share = a.shareOfFrameP95 === null ? '' : `, ${Math.round(a.shareOfFrameP95 * 100)}% of frame p95`;
724
+ console.log(`\n worst frames: ${a.dominantPhase.replace(/Ms$/, '')} ` +
725
+ (a.intermittent
726
+ ? `(intermittent — 0ms on a typical frame${share})`
727
+ : `(p95 ${a.p95OverP50}× its p50${share})`));
728
+ console.log(` → ${a.hint}`);
729
+ if (a.steadyPhase !== a.dominantPhase) {
730
+ console.log(` every frame: ${a.steadyPhase.replace(/Ms$/, '')} ` +
731
+ `(${r.frames.phaseP50Ms[a.steadyPhase]}ms on the median frame)`);
732
+ console.log(` → ${a.steadyHint}`);
733
+ }
734
+ }
735
+ console.log(`\n phase p50 p95`);
736
+ for (const key of exports.PHASE_KEYS) {
737
+ console.log(` ${key.replace(/Ms$/, '').padEnd(13)}${String(r.frames.phaseP50Ms[key]).padStart(6)}ms ` +
738
+ `${String(r.frames.phaseP95Ms[key]).padStart(7)}ms`);
739
+ }
740
+ if (r.frames.inputLatency) {
741
+ const i = r.frames.inputLatency;
742
+ console.log(`\n input→frame: p50 ${(0, stats_js_1.fmt)(i.p50Ms)} p90 ${(0, stats_js_1.fmt)(i.p90Ms)} p99 ${(0, stats_js_1.fmt)(i.p99Ms)} ` +
743
+ `(${i.count} frames carried input)`);
744
+ }
745
+ if (r.frames.worst.length > 0) {
746
+ console.log(`\n worst frames`);
747
+ for (const f of r.frames.worst) {
748
+ const at = f.atDeviceRealtimeMs !== undefined ? ` at=${Math.round(f.atDeviceRealtimeMs)}` : '';
749
+ console.log(` ${String(f.totalMs).padStart(7)}ms vsyncDelay=${f.vsyncDelayMs} ` +
750
+ `traversal=${f.traversalMs} draw=${f.drawMs} issueDraw=${f.issueDrawMs} ` +
751
+ `swap=${f.swapMs}${at}`);
752
+ }
753
+ }
754
+ }
755
+ if (r.variance) {
756
+ console.log('\n run-to-run spread across windows');
757
+ for (const [key, d] of Object.entries(r.variance)) {
758
+ if (!(0, stats_js_1.hasSamples)(d))
759
+ continue;
760
+ console.log(` ${key.padEnd(14)} p50 ${d.p50Ms} min ${d.minMs} max ${d.maxMs} σ ${d.stddevMs}`);
761
+ }
762
+ }
763
+ if (r.clockAnchor) {
764
+ console.log(`\n clock anchor: device monotonic ${r.clockAnchor.deviceMonotonicMs}ms ↔ ` +
765
+ `realtime ${Math.round(r.clockAnchor.deviceRealtimeMs)}ms (±${r.clockAnchor.anchorErrorMs}ms)` +
766
+ (r.clockAnchor.clockStepped ? ' [clock stepped mid-read — suspect]' : ''));
767
+ console.log(" frame at= values are in the app's Date.now() domain — joinable to React commits.");
768
+ }
769
+ for (const note of r.notes)
770
+ console.log(`\n note [${note.code}]: ${note.message}`);
771
+ }
772
+ function printFramesDiff(diff, baselineName, hasVariance) {
773
+ console.log(`profile frames diff — ${baselineName} → current`);
774
+ const mark = (row) => {
775
+ if (row.verdict === 'regression')
776
+ return 'WORSE';
777
+ if (row.verdict === 'improvement')
778
+ return 'better';
779
+ if (row.verdict === 'neutral')
780
+ return '—';
781
+ return '?';
782
+ };
783
+ for (const row of diff) {
784
+ const sign = row.delta === null ? '' : row.delta > 0 ? '+' : '';
785
+ const sig = row.significant === false ? ' (within noise)' : '';
786
+ console.log(` ${row.key.padEnd(16)} ${(0, stats_js_1.fmt)(row.before, '').padStart(10)} → ${(0, stats_js_1.fmt)(row.after, '').padStart(10)} ` +
787
+ `${sign}${(0, stats_js_1.fmt)(row.delta, '')} ${mark(row).padEnd(7)}${sig}`);
788
+ }
789
+ if (!hasVariance) {
790
+ console.log('\n note [single-window]: the baseline carries no run-to-run variance, so nothing here ' +
791
+ 'can be called significant. Re-capture it with --repeat 5.');
792
+ }
793
+ }