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/CHANGELOG.md +60 -0
- package/README.md +198 -8
- package/dist/args.js +100 -4
- package/dist/browser.js +57 -3
- package/dist/capture.js +25 -25
- package/dist/check-page.js +23 -8
- package/dist/check.js +11 -3
- package/dist/cli.js +56 -0
- package/dist/errors.js +8 -0
- package/dist/gif.js +212 -0
- package/dist/help.js +33 -3
- package/dist/mcp.js +127 -3
- package/dist/motion.js +487 -0
- package/dist/options.js +19 -0
- package/dist/record.js +333 -0
- package/dist/ui.js +93 -0
- package/package.json +1 -1
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) {
|
package/dist/check-page.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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'],
|