rpc-agent 0.5.0 → 0.6.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/dist/capture.js CHANGED
@@ -3,8 +3,9 @@ import { basename, extname, resolve } from 'node:path';
3
3
  import { applyHeaders, AuthRequiredError, redact, redirectedToLogin, resolveAuth, } from './auth.js';
4
4
  import { launchBrowser } from './browser.js';
5
5
  import { renderFramed, renderSlices } from './composite.js';
6
- import { explainError } from './errors.js';
6
+ import { explainError, plainText } from './errors.js';
7
7
  import { emulationFor, resolveDevices } from './devices.js';
8
+ import { detectMotion, finishAnimations, settleMotion, shotWarnings, unpin, warmUpScrollJs, } from './motion.js';
8
9
  import { pngSize, stitchPngs } from './png.js';
9
10
  import { fitsOneCapture, planSegments, planSlices, sliceScale } from './slices.js';
10
11
  export { MAX_CAPTURE_PX } from './slices.js';
@@ -88,10 +89,10 @@ async function captureDevice(browser, device, opts, outDir, hooks, auth) {
88
89
  try {
89
90
  const page = await context.newPage();
90
91
  await applyHeaders(context, page, auth);
91
- page.on('pageerror', e => errors.add(`pageerror: ${e.message}`));
92
+ page.on('pageerror', e => errors.add(plainText(`pageerror: ${e.message}`)));
92
93
  page.on('console', m => {
93
94
  if (m.type() === 'error')
94
- errors.add(`console: ${m.text()}`);
95
+ errors.add(plainText(`console: ${m.text()}`));
95
96
  });
96
97
  const response = await page.goto(opts.url, { waitUntil: 'load', timeout: opts.timeoutMs });
97
98
  result.status = response?.status() ?? null;
@@ -111,16 +112,35 @@ async function captureDevice(browser, device, opts, outDir, hooks, auth) {
111
112
  throw err;
112
113
  }
113
114
  }
115
+ let warmUp;
114
116
  if (opts.fullPage) {
115
- await warmUpScroll(page, opts.maxHeight);
117
+ warmUp = await warmUpScroll(page, opts.maxHeight);
116
118
  await settle(page, opts.timeoutMs);
117
119
  }
120
+ const finished = opts.finishAnimations ? await finishAnimations(page) : undefined;
121
+ // Collapse pins before measuring, so the stitched height is the unpinned one.
122
+ const unpinned = opts.fullPage && opts.unpin ? await unpin(page) : [];
118
123
  if (opts.waitMs > 0)
119
124
  await page.waitForTimeout(opts.waitMs);
125
+ // The screenshot ends CSS and Web Animations itself (animations: 'disabled'), so only wait for JS-driven changes.
126
+ const motion = { settle: await settleMotion(page, undefined, undefined, { waapi: false }) };
127
+ if (warmUp)
128
+ motion.warmUp = warmUp;
129
+ if (finished)
130
+ motion.finished = finished;
131
+ if (unpinned.length)
132
+ motion.unpinned = unpinned;
133
+ result.motion = motion;
120
134
  checkLogin();
121
135
  result.finalUrl = page.url();
122
136
  result.title = await page.title();
123
137
  result.scrollHeight = await page.evaluate(SCROLL_HEIGHT_JS);
138
+ const warnings = shotWarnings(await detectMotion(page, opts.fullPage ? 'page' : 'viewport'), motion.settle, {
139
+ fullPage: opts.fullPage,
140
+ unpinned,
141
+ });
142
+ if (warnings.length)
143
+ result.warnings = warnings;
124
144
  const base = fileStem(opts.url, device.id);
125
145
  const shotOpts = { animations: 'disabled', caret: 'hide', type: 'png' };
126
146
  // Viewport shot: the raw output when !fullPage, and the bezel input either way.
@@ -221,26 +241,6 @@ const WAIT_FOR_ASSETS_JS = `(async () => {
221
241
  new Promise(r => setTimeout(r, 5000)),
222
242
  ]);
223
243
  })()`;
224
- /**
225
- * Walk the page one viewport at a time so loading="lazy" images and
226
- * IntersectionObserver-driven content render, then return to the top.
227
- * Replaces the 120 ms per-segment pause in RPC's scroll-and-stitch.
228
- * Bounded to 100 steps and the full-page height cap.
229
- */
230
- const warmUpScrollJs = (maxHeight) => `(async () => {
231
- const sleep = ms => new Promise(r => setTimeout(r, ms));
232
- const root = document.scrollingElement || document.documentElement;
233
- const step = Math.max(200, Math.floor(window.innerHeight * 0.9));
234
- for (let i = 0, y = 0; i < 100 && y < ${maxHeight}; i++, y += step) {
235
- if (y > root.scrollHeight - window.innerHeight) break;
236
- window.scrollTo({ top: y, behavior: 'instant' });
237
- await sleep(100);
238
- }
239
- window.scrollTo({ top: Math.min(root.scrollHeight, ${maxHeight}), behavior: 'instant' });
240
- await sleep(150);
241
- window.scrollTo({ top: 0, behavior: 'instant' });
242
- await sleep(150);
243
- })()`;
244
244
  const SCROLL_WIDTH_JS = `Math.max(document.documentElement.scrollWidth, document.body ? document.body.scrollWidth : 0)`;
245
245
  const SCROLL_HEIGHT_JS = `Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0)`;
246
246
  /**
@@ -254,7 +254,7 @@ export async function settle(page, timeoutMs) {
254
254
  await page.evaluate(WAIT_FOR_ASSETS_JS).catch(() => { });
255
255
  }
256
256
  async function warmUpScroll(page, maxHeight) {
257
- await page.evaluate(warmUpScrollJs(Math.round(maxHeight)));
257
+ return (await page.evaluate(warmUpScrollJs(Math.round(maxHeight))));
258
258
  }
259
259
  /** `<host>[-<path>]-<device>`, e.g. `localhost-3000-about-iphone-15`. */
260
260
  export function fileStem(url, deviceId) {
@@ -2,7 +2,7 @@
2
2
  // capture.ts: under tsx/vitest, esbuild wraps named functions in a
3
3
  // `__name()` helper that doesn't exist inside the page.
4
4
  /** Shared helpers: a short unique selector, rendering tests and accessible names. */
5
- const HELPERS = `
5
+ export const HELPERS = `
6
6
  const esc = s => (window.CSS && CSS.escape ? CSS.escape(s) : String(s).replace(/[^\\w-]/g, '\\\\$&'));
7
7
  const uniq = s => { try { return document.querySelectorAll(s).length === 1; } catch (e) { return false; } };
8
8
  const sel = el => {
@@ -289,20 +289,35 @@ export const MOBILE_JS = `(() => {
289
289
  }
290
290
  }
291
291
 
292
- // Tap targets under 44x44 CSS px. Links inside running text are exempt.
292
+ // Tap targets under 44x44 CSS px. Inline links in a sentence are exempt
293
+ // (WCAG 2.5.8): display inline, with letters or digits outside any control
294
+ // in the same block of text. Buttons and links laid out as boxes count.
293
295
  const interactive = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [role="button"], [role="link"], [role="checkbox"], [role="switch"], [role="tab"], [role="menuitem"]';
296
+ const blockOf = node => {
297
+ let p = node.parentElement;
298
+ while (p && p !== document.body && ['inline', 'contents'].includes(getComputedStyle(p).display)) p = p.parentElement;
299
+ return p;
300
+ };
301
+ const inSentence = el => {
302
+ const isLink = (el.localName === 'a' && !el.hasAttribute('role')) || el.getAttribute('role') === 'link';
303
+ if (!isLink || getComputedStyle(el).display !== 'inline') return false;
304
+ const block = blockOf(el);
305
+ if (!block) return false;
306
+ const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
307
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
308
+ if (!/[\\p{L}\\p{N}]/u.test(n.textContent)) continue;
309
+ if (n.parentElement.closest(interactive) || blockOf(n) !== block) continue;
310
+ return true;
311
+ }
312
+ return false;
313
+ };
294
314
  const small = [];
295
315
  let tapTargets = 0;
296
316
  for (const el of document.querySelectorAll(interactive)) {
297
317
  if (!painted(el) || el.closest('[aria-hidden="true"], [inert]')) continue;
298
318
  const outerTarget = el.parentElement && el.parentElement.closest(interactive);
299
319
  if (outerTarget) continue;
300
- const cs = getComputedStyle(el);
301
- if (cs.display === 'inline' && el.parentElement) {
302
- const own = clean(el.textContent).length;
303
- const around = clean(el.parentElement.textContent).length;
304
- if (around > own + 10) continue;
305
- }
320
+ if (inSentence(el)) continue;
306
321
  tapTargets++;
307
322
  let { width, height } = el.getBoundingClientRect();
308
323
  if (el.labels) for (const l of el.labels) {
package/dist/check.js CHANGED
@@ -9,6 +9,8 @@ import { launchBrowser } from './browser.js';
9
9
  import { describeFailure, fileStem, newDeviceContext, settle } from './capture.js';
10
10
  import { FONTS_JS, LABELS_JS, LAYOUT_SHIFT_INIT_JS, LAYOUT_SHIFTS_JS, META_JS, MOBILE_JS, SCROLL_ONCE_JS, } from './check-page.js';
11
11
  import { resolveDevices } from './devices.js';
12
+ import { plainText } from './errors.js';
13
+ import { detectMotion, motionCheckFindings, settleMotion } from './motion.js';
12
14
  import { imageSize } from './png.js';
13
15
  /** Report order and headings. */
14
16
  export const CHECKS = [
@@ -18,6 +20,7 @@ export const CHECKS = [
18
20
  { id: 'labels', title: 'Unlabelled controls' },
19
21
  { id: 'fonts', title: 'Fonts' },
20
22
  { id: 'mobile', title: 'Mobile viewport' },
23
+ { id: 'motion', title: 'Motion' },
21
24
  ];
22
25
  /** Bumped when a field changes meaning or goes away; new fields don't bump it. */
23
26
  export const REPORT_SCHEMA = 1;
@@ -28,6 +31,8 @@ export const CLS_POOR = 0.25;
28
31
  export const CLS_TOLERANCE = 0.001;
29
32
  /** Fixed idle after load and settle, before the scroll. */
30
33
  export const IDLE_MS = 500;
34
+ /** Motion: how long the settle probe waits for animations to stop before reporting what is still moving. */
35
+ export const MOTION_PROBE_MS = 1000;
31
36
  export const MIN_TAP = 44;
32
37
  export async function runCheck(opts, hooks = {}) {
33
38
  const devices = resolveDevices(opts.devices);
@@ -103,7 +108,7 @@ async function checkDevice(browser, device, opts, auth, cache, result) {
103
108
  const page = await context.newPage();
104
109
  await applyHeaders(context, page, auth);
105
110
  await page.addInitScript(LAYOUT_SHIFT_INIT_JS);
106
- page.on('pageerror', e => uncaught.add(e.message.split('\n')[0] ?? e.message));
111
+ page.on('pageerror', e => uncaught.add(plainText(e.message.split('\n')[0] ?? e.message)));
107
112
  page.on('console', m => {
108
113
  const text = m.text();
109
114
  if (/Mixed Content/i.test(text)) {
@@ -113,7 +118,7 @@ async function checkDevice(browser, device, opts, auth, cache, result) {
113
118
  return;
114
119
  }
115
120
  if (m.type() === 'error')
116
- consoleErrors.add(text);
121
+ consoleErrors.add(plainText(text));
117
122
  });
118
123
  page.on('response', r => {
119
124
  if (r.request().resourceType() === 'font')
@@ -173,6 +178,9 @@ async function checkDevice(browser, device, opts, auth, cache, result) {
173
178
  await attachShots(page, mobile, opts, device, 'horizontal-overflow');
174
179
  findings.push(...mobile);
175
180
  }
181
+ const still = await settleMotion(page, undefined, MOTION_PROBE_MS);
182
+ for (const f of motionCheckFindings(await detectMotion(page, 'viewport'), still))
183
+ findings.push({ ...f, check: 'motion' });
176
184
  return { findings, ...(og ? { ogImage: og } : {}) };
177
185
  }
178
186
  finally {
@@ -603,7 +611,7 @@ export function mobileFindings(d) {
603
611
  check: 'mobile',
604
612
  severity: 'warn',
605
613
  message: `${d.smallTargets.length} of ${d.tapTargets} tap targets are smaller than ${MIN_TAP}×${MIN_TAP}px`,
606
- fix: `Make each control at least ${MIN_TAP}×${MIN_TAP} CSS px: padding, min-height/min-width, or a larger hit area with a ::before inset.`,
614
+ fix: `Make each control at least ${MIN_TAP}×${MIN_TAP} CSS px: padding, min-height/min-width, or a larger hit area with a ::before inset. Inline links in a sentence are exempt (WCAG 2.5.8).`,
607
615
  selector: d.smallTargets[0].selector,
608
616
  items: d.smallTargets.slice(0, 15).map(({ selector, text, value }) => ({ selector, text, value })),
609
617
  });
package/dist/cli.js CHANGED
@@ -8,6 +8,8 @@ import { capture } from './capture.js';
8
8
  import { checkExitCode, runCheck, summaryLine } from './check.js';
9
9
  import { deviceLine, plainReport } from './check-format.js';
10
10
  import { CATEGORY_LABELS, CATEGORY_ORDER, DEVICE_ALIASES, DEVICES } from './devices.js';
11
+ import { unpinnedLines, warningWhere } from './motion.js';
12
+ import { record } from './record.js';
11
13
  import { currentMode, openPath, waitForEnter } from './term.js';
12
14
  const NO_PAINT = { ok: s => s, bad: s => s, warn: s => s };
13
15
  let paint = NO_PAINT;
@@ -124,6 +126,25 @@ async function main(argv) {
124
126
  printCheck(report);
125
127
  return checkExitCode(report, cmd.options.failOn);
126
128
  }
129
+ case 'record': {
130
+ if (mode.rich) {
131
+ const { ui, c } = await loadUi();
132
+ return ui.runRecord(cmd.options, { open: cmd.open }, c);
133
+ }
134
+ const result = await record(cmd.options, cmd.json
135
+ ? {}
136
+ : {
137
+ onAuth: a => console.log(`auth: ${describeAuth(a)}`),
138
+ onRecording: s => console.log(`recording ${s}s of ${cmd.options.selector ?? 'the viewport'} on ${cmd.options.device}`),
139
+ });
140
+ if (cmd.json)
141
+ console.log(JSON.stringify(result, null, 2));
142
+ else
143
+ printRecord(result);
144
+ if (cmd.open && !result.error)
145
+ openPath(result.outDir);
146
+ return result.error ? 1 : 0;
147
+ }
127
148
  case 'login': {
128
149
  if (cmd.passwordFromFlag) {
129
150
  console.error(`rpc: ${paint.warn('warning:')} --password is visible in shell history and the process list; set RPC_LOGIN_PASSWORD instead`);
@@ -222,8 +243,13 @@ function printDoctor(r) {
222
243
  console.log(`node ${r.node}`);
223
244
  console.log(`playwright ${r.playwright}`);
224
245
  console.log(`browsers ${r.browsersPath}`);
246
+ const ff = r.ffmpeg;
247
+ console.log(`ffmpeg ${ff.source === 'missing' ? `${paint.warn('missing')} (only rpc record needs it)` : `${paint.ok('ok')} (${ff.source}, ${ff.path})`}`);
248
+ console.log(`mp4 ${r.mp4.ok ? `${paint.ok('ok')} (${r.mp4.encoder})` : 'no (optional: ffmpeg with H.264 on PATH, for rpc record --mp4)'}`);
225
249
  if (r.ok) {
226
250
  console.log(`chromium ${paint.ok('ok')} (${r.version})`);
251
+ if (ff.fix)
252
+ console.error(`rpc: ${ff.fix}`);
227
253
  return;
228
254
  }
229
255
  console.log(`chromium ${paint.bad(r.chromium)}`);
@@ -255,6 +281,36 @@ function printShot(s) {
255
281
  }
256
282
  if (s.consoleErrors?.length)
257
283
  lines.push(` ${s.consoleErrors.length} console error(s), first: ${s.consoleErrors[0]}`);
284
+ for (const u of unpinnedLines(s.motion?.unpinned))
285
+ lines.push(` unpinned ${u}`);
286
+ for (const w of s.warnings ?? []) {
287
+ const where = warningWhere(w);
288
+ lines.push(` ${paint.warn('warn:')} ${w.message}${where ? `: ${where}` : ''}`);
289
+ }
290
+ console.log(lines.join('\n'));
291
+ }
292
+ function printRecord(r) {
293
+ const label = `${r.device} (${r.viewport.width}x${r.viewport.height} @${r.dpr}x)`;
294
+ if (r.error) {
295
+ console.error(`${paint.bad('x')} ${label}: ${r.error}`);
296
+ if (r.hint)
297
+ console.error(` ${paint.warn('hint:')} ${r.hint}`);
298
+ return;
299
+ }
300
+ const lines = [`${paint.ok('ok')} ${label}${r.selector ? ` ${r.selector}` : ''}${r.status && r.status >= 400 ? ` HTTP ${r.status}` : ''}`];
301
+ const v = r.video;
302
+ if (v)
303
+ lines.push(` ${rel(v.path)} ${v.width}x${v.height}, ${v.duration}s at ${v.fps} fps (${v.captured} frames captured, ${r.ffmpeg} ffmpeg)`);
304
+ if (r.poster)
305
+ lines.push(` ${rel(r.poster.path)} ${r.poster.width}x${r.poster.height} poster`);
306
+ if (r.gif)
307
+ lines.push(` ${rel(r.gif.path)} ${r.gif.width}x${r.gif.height}, ${r.gif.frames} frames`);
308
+ if (r.mp4)
309
+ lines.push(` ${rel(r.mp4.path)} ${r.mp4.encoder}`);
310
+ for (const w of r.warnings ?? [])
311
+ lines.push(` ${paint.warn('warn:')} ${w}`);
312
+ if (r.consoleErrors?.length)
313
+ lines.push(` ${r.consoleErrors.length} console error(s), first: ${r.consoleErrors[0]}`);
258
314
  console.log(lines.join('\n'));
259
315
  }
260
316
  export function formatDeviceTable() {
package/dist/errors.js CHANGED
@@ -113,3 +113,11 @@ export function explainError(err, ctx) {
113
113
  }
114
114
  return { code: 'UNKNOWN', error: first };
115
115
  }
116
+ /**
117
+ * Page-supplied text (console errors, uncaught exceptions) with control
118
+ * characters removed, so a page can't send terminal escape sequences through
119
+ * rpc's output. Newlines and tabs stay.
120
+ */
121
+ export function plainText(text) {
122
+ return text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '');
123
+ }
package/dist/gif.js ADDED
@@ -0,0 +1,212 @@
1
+ /** Palette entries for colours; the last index is transparent. */
2
+ const COLOURS = 255;
3
+ const TRANSPARENT = 255;
4
+ /** Pixels sampled for the palette, across all frames. */
5
+ const SAMPLES = 250_000;
6
+ const bucket = (r, g, b) => ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3);
7
+ /** Median cut over a 15-bit colour histogram: up to COLOURS RGB triples. */
8
+ export function buildPalette(frames) {
9
+ const counts = new Uint32Array(32768);
10
+ const sums = new Float64Array(32768 * 3);
11
+ const total = frames.reduce((n, f) => n + f.width * f.height, 0);
12
+ const step = Math.max(1, Math.floor(total / SAMPLES));
13
+ for (const f of frames) {
14
+ for (let i = 0; i < f.width * f.height; i += step) {
15
+ const o = i * f.channels;
16
+ const r = f.data[o], g = f.data[o + 1], b = f.data[o + 2];
17
+ const k = bucket(r, g, b);
18
+ counts[k]++;
19
+ sums[k * 3] = sums[k * 3] + r;
20
+ sums[k * 3 + 1] = sums[k * 3 + 1] + g;
21
+ sums[k * 3 + 2] = sums[k * 3 + 2] + b;
22
+ }
23
+ }
24
+ const used = [];
25
+ for (let k = 0; k < 32768; k++)
26
+ if (counts[k])
27
+ used.push(k);
28
+ const chan = (k, c) => (k >> (10 - 5 * c)) & 31;
29
+ const measure = (keys) => {
30
+ let best = 0;
31
+ let channel = 0;
32
+ for (let c = 0; c < 3; c++) {
33
+ let lo = 31, hi = 0;
34
+ for (const k of keys) {
35
+ const v = chan(k, c);
36
+ if (v < lo)
37
+ lo = v;
38
+ if (v > hi)
39
+ hi = v;
40
+ }
41
+ if (hi - lo > best) {
42
+ best = hi - lo;
43
+ channel = c;
44
+ }
45
+ }
46
+ return { keys, range: best, channel };
47
+ };
48
+ const boxes = [measure(used)];
49
+ while (boxes.length < COLOURS) {
50
+ let pick = -1;
51
+ for (let i = 0; i < boxes.length; i++)
52
+ if (boxes[i].range > 0 && (pick < 0 || boxes[i].range > boxes[pick].range))
53
+ pick = i;
54
+ if (pick < 0)
55
+ break;
56
+ const box = boxes[pick];
57
+ box.keys.sort((a, b) => chan(a, box.channel) - chan(b, box.channel));
58
+ const half = box.keys.reduce((n, k) => n + counts[k], 0) / 2;
59
+ let acc = 0;
60
+ let cut = 1;
61
+ for (; cut < box.keys.length - 1; cut++) {
62
+ acc += counts[box.keys[cut - 1]];
63
+ if (acc >= half)
64
+ break;
65
+ }
66
+ boxes.splice(pick, 1, measure(box.keys.slice(0, cut)), measure(box.keys.slice(cut)));
67
+ }
68
+ const palette = new Uint8Array(256 * 3);
69
+ boxes.forEach((box, i) => {
70
+ let n = 0, r = 0, g = 0, b = 0;
71
+ for (const k of box.keys) {
72
+ n += counts[k];
73
+ r += sums[k * 3];
74
+ g += sums[k * 3 + 1];
75
+ b += sums[k * 3 + 2];
76
+ }
77
+ palette[i * 3] = Math.round(r / n);
78
+ palette[i * 3 + 1] = Math.round(g / n);
79
+ palette[i * 3 + 2] = Math.round(b / n);
80
+ });
81
+ return palette.subarray(0, Math.max(1, boxes.length) * 3);
82
+ }
83
+ /** Nearest palette entry per 15-bit bucket, filled on first use. */
84
+ function mapper(palette) {
85
+ const cache = new Int16Array(32768).fill(-1);
86
+ const n = palette.length / 3;
87
+ return (r, g, b) => {
88
+ const k = bucket(r, g, b);
89
+ let hit = cache[k];
90
+ if (hit >= 0)
91
+ return hit;
92
+ const cr = ((k >> 10) << 3) | 4, cg = (((k >> 5) & 31) << 3) | 4, cb = ((k & 31) << 3) | 4;
93
+ let best = Infinity;
94
+ for (let i = 0; i < n; i++) {
95
+ const dr = palette[i * 3] - cr, dg = palette[i * 3 + 1] - cg, db = palette[i * 3 + 2] - cb;
96
+ const d = dr * dr * 2 + dg * dg * 4 + db * db * 3;
97
+ if (d < best) {
98
+ best = d;
99
+ hit = i;
100
+ }
101
+ }
102
+ cache[k] = hit;
103
+ return hit;
104
+ };
105
+ }
106
+ /** GIF LZW with 8-bit minimum code size, split into data sub-blocks. */
107
+ export function lzw(indices) {
108
+ const min = 8;
109
+ const clear = 1 << min;
110
+ const eoi = clear + 1;
111
+ const out = [];
112
+ let acc = 0;
113
+ let bits = 0;
114
+ let size = min + 1;
115
+ const emit = (code) => {
116
+ acc |= code << bits;
117
+ bits += size;
118
+ while (bits >= 8) {
119
+ out.push(acc & 0xff);
120
+ acc >>>= 8;
121
+ bits -= 8;
122
+ }
123
+ };
124
+ let table = new Map();
125
+ let next = eoi + 1;
126
+ emit(clear);
127
+ let prefix = indices[0] ?? 0;
128
+ for (let i = 1; i < indices.length; i++) {
129
+ const k = indices[i];
130
+ const key = prefix * 256 + k;
131
+ const code = table.get(key);
132
+ if (code !== undefined) {
133
+ prefix = code;
134
+ continue;
135
+ }
136
+ emit(prefix);
137
+ if (next === 4096) {
138
+ emit(clear);
139
+ table = new Map();
140
+ next = eoi + 1;
141
+ size = min + 1;
142
+ }
143
+ else {
144
+ if (next >= 1 << size)
145
+ size++;
146
+ table.set(key, next++);
147
+ }
148
+ prefix = k;
149
+ }
150
+ emit(prefix);
151
+ emit(eoi);
152
+ if (bits > 0)
153
+ out.push(acc & 0xff);
154
+ const blocks = [min];
155
+ for (let i = 0; i < out.length; i += 255) {
156
+ const chunk = out.slice(i, i + 255);
157
+ blocks.push(chunk.length, ...chunk);
158
+ }
159
+ blocks.push(0);
160
+ return Buffer.from(blocks);
161
+ }
162
+ /**
163
+ * A looping GIF built one frame at a time, `delayCs` hundredths of a second
164
+ * each, with a palette made beforehand (buildPalette on all or some frames).
165
+ * Only the previous frame's indices are kept, not the frames themselves.
166
+ */
167
+ export function gifWriter(width, height, palette, delayCs) {
168
+ const nearest = mapper(palette);
169
+ const table = Buffer.alloc(256 * 3);
170
+ table.set(palette);
171
+ const u16 = (n) => [n & 0xff, (n >> 8) & 0xff];
172
+ const parts = [
173
+ Buffer.from('GIF89a', 'latin1'),
174
+ Buffer.from([...u16(width), ...u16(height), 0xf7, 0, 0]), // global table, 256 entries
175
+ table,
176
+ Buffer.from([0x21, 0xff, 11, ...Buffer.from('NETSCAPE2.0', 'latin1'), 3, 1, 0, 0, 0]), // loop forever
177
+ ];
178
+ let shown;
179
+ return {
180
+ add(f) {
181
+ if (f.width !== width || f.height !== height)
182
+ throw new Error('GIF frames must all be the same size');
183
+ const idx = new Uint8Array(width * height);
184
+ const now = new Uint8Array(width * height);
185
+ for (let i = 0; i < width * height; i++) {
186
+ const o = i * f.channels;
187
+ now[i] = nearest(f.data[o], f.data[o + 1], f.data[o + 2]);
188
+ idx[i] = shown && shown[i] === now[i] ? TRANSPARENT : now[i];
189
+ }
190
+ // Graphic control: keep the previous frame under this one, transparent index after the first frame.
191
+ parts.push(Buffer.from([0x21, 0xf9, 4, shown ? 0x05 : 0x04, ...u16(delayCs), TRANSPARENT, 0]));
192
+ parts.push(Buffer.from([0x2c, 0, 0, 0, 0, ...u16(width), ...u16(height), 0]));
193
+ parts.push(lzw(idx));
194
+ shown = now;
195
+ },
196
+ end() {
197
+ if (!shown)
198
+ throw new Error('No frames to encode');
199
+ return Buffer.concat([...parts, Buffer.from([0x3b])]);
200
+ },
201
+ };
202
+ }
203
+ /** Encode same-sized frames as a looping GIF, `delayCs` hundredths of a second each. */
204
+ export function encodeGif(frames, delayCs) {
205
+ if (!frames.length)
206
+ throw new Error('No frames to encode');
207
+ const { width, height } = frames[0];
208
+ const gif = gifWriter(width, height, buildPalette(frames), delayCs);
209
+ for (const f of frames)
210
+ gif.add(f);
211
+ return gif.end();
212
+ }
package/dist/help.js CHANGED
@@ -6,6 +6,8 @@ const SHOT_OPTIONS = [
6
6
  [' --slice-width <px>', 'max slice width (default: 768)'],
7
7
  [' --max-height <px>', 'full-page cap in CSS px (default: 60000)'],
8
8
  [' --reduced-motion', 'emulate prefers-reduced-motion: reduce'],
9
+ [' --finish-animations', 'jump GSAP tweens on window (and Web Animations) to their end first'],
10
+ [' --no-unpin', 'with --full-page: keep pinned sections (default: collapse each once)'],
9
11
  ['-o, --out <dir>', 'output directory (default: ./.rpc-shots)'],
10
12
  [' --wait-for <selector>', 'wait for a CSS selector before capturing'],
11
13
  [' --wait <ms>', 'extra settle delay after load (default: 0)'],
@@ -33,6 +35,22 @@ const CHECK_OPTIONS = [
33
35
  [' --timeout <ms>', 'navigation timeout (default: 30000)'],
34
36
  [' --json', 'print the report as JSON (schema in the README)'],
35
37
  ];
38
+ const RECORD_OPTIONS = [
39
+ ['-d, --device <id>', 'one device id or alias (default: desktop-1440)'],
40
+ [' --selector <css>', 'crop to this element; it is scrolled into view first'],
41
+ [' --duration <s>', 'seconds of video, 0.5 to 30 (default: 5)'],
42
+ [' --gif', 'also write a GIF (10 fps, at most 640px wide)'],
43
+ [' --mp4', 'also write an mp4; needs ffmpeg with H.264 on PATH, skipped otherwise'],
44
+ ['-o, --out <dir>', 'output directory (default: ./.rpc-shots)'],
45
+ [' --wait-for <selector>', 'wait for a CSS selector before recording'],
46
+ [' --wait <ms>', 'extra delay before recording (default: 0)'],
47
+ [' --color-scheme <s>', 'light | dark | no-preference'],
48
+ [' --reduced-motion', 'emulate prefers-reduced-motion: reduce'],
49
+ [' --timeout <ms>', 'navigation timeout (default: 30000)'],
50
+ [' --no-emulate', 'viewport + DPR only (no mobile UA / touch)'],
51
+ [' --open', 'open the output folder when done'],
52
+ [' --json', 'print the result as JSON'],
53
+ ];
36
54
  const LOGIN_OPTIONS = [
37
55
  [' --save <file>', 'write the storage state here instead of the per-origin session'],
38
56
  [' --form', 'headless: fill an email + password form (CI, agents)'],
@@ -70,6 +88,17 @@ export const COMMANDS = {
70
88
  ['rpc check localhost:3000/billing', 'uses the session saved by rpc login'],
71
89
  ],
72
90
  },
91
+ record: {
92
+ usage: 'rpc record <url> [options]',
93
+ summary: 'Record a short video of a page, or of one element, for what a still cannot show: carousels, marquees, ' +
94
+ 'pinned scroll sections, hover loops. Writes a VP9 webm and a poster PNG, plus a GIF or mp4 on request.',
95
+ options: [...RECORD_OPTIONS, ...AUTH_OPTIONS],
96
+ examples: [
97
+ ["rpc record localhost:3000 --selector '.hero-carousel'", '5 s of one element'],
98
+ ['rpc record localhost:3000 -d iphone --duration 10 --gif', 'whole phone viewport, plus a GIF'],
99
+ ["rpc record example.com --selector '#logos' --mp4", 'mp4 too, when ffmpeg is on PATH'],
100
+ ],
101
+ },
73
102
  login: {
74
103
  usage: 'rpc login <url> [--form] [--save <file>]',
75
104
  summary: 'Open a browser window at the URL, sign in, then press Enter (or close the window). The session is saved for that ' +
@@ -102,7 +131,7 @@ export const COMMANDS = {
102
131
  },
103
132
  doctor: {
104
133
  usage: 'rpc doctor [--json]',
105
- summary: "Check that Playwright's Chromium is installed and launches.",
134
+ summary: "Check that Playwright's Chromium is installed and launches, and which ffmpeg `rpc record` would use.",
106
135
  options: [[' --json', 'print the report as JSON']],
107
136
  examples: [['rpc doctor', 'exits 1 when Chromium is missing']],
108
137
  },
@@ -117,13 +146,14 @@ export const COMMANDS = {
117
146
  },
118
147
  mcp: {
119
148
  usage: 'rpc mcp',
120
- summary: 'Run the MCP server on stdio (tools: screenshot, check, list_devices, list_sessions).',
149
+ summary: 'Run the MCP server on stdio (tools: screenshot, check, record, list_devices, list_sessions).',
121
150
  examples: [['claude mcp add --scope user rpc -- node /abs/path/dist/cli.js mcp', 'add to Claude Code']],
122
151
  },
123
152
  };
124
153
  const TOP = [
125
154
  ['shot <url>', 'capture screenshots'],
126
- ['check <url>', 'pre-launch audit: meta, layout shift, labels, fonts, mobile'],
155
+ ['check <url>', 'pre-launch audit: meta, layout shift, labels, fonts, mobile, motion'],
156
+ ['record <url>', 'record a short video of the page or one element'],
127
157
  ['login <url>', 'sign in once; later shots of that origin use the session'],
128
158
  ['sessions', 'list or clear saved sessions'],
129
159
  ['devices', 'list device ids and aliases'],