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/options.js
CHANGED
|
@@ -19,6 +19,8 @@ export function defaultShotOptions(url) {
|
|
|
19
19
|
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
20
20
|
emulate: true,
|
|
21
21
|
reducedMotion: false,
|
|
22
|
+
finishAnimations: false,
|
|
23
|
+
unpin: true,
|
|
22
24
|
maxHeight: DEFAULT_MAX_HEIGHT,
|
|
23
25
|
maxSlices: DEFAULT_MAX_SLICES,
|
|
24
26
|
sliceWidth: DEFAULT_SLICE_WIDTH,
|
|
@@ -38,6 +40,23 @@ export function defaultCheckOptions(url) {
|
|
|
38
40
|
failOn: 'error',
|
|
39
41
|
};
|
|
40
42
|
}
|
|
43
|
+
export const DEFAULT_RECORD_DEVICE = 'desktop-1440';
|
|
44
|
+
export const DEFAULT_RECORD_SECONDS = 5;
|
|
45
|
+
export const MAX_RECORD_SECONDS = 30;
|
|
46
|
+
export function defaultRecordOptions(url) {
|
|
47
|
+
return {
|
|
48
|
+
url: normalizeUrl(url),
|
|
49
|
+
device: DEFAULT_RECORD_DEVICE,
|
|
50
|
+
duration: DEFAULT_RECORD_SECONDS,
|
|
51
|
+
gif: false,
|
|
52
|
+
mp4: false,
|
|
53
|
+
outDir: DEFAULT_OUT_DIR,
|
|
54
|
+
waitMs: 0,
|
|
55
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
56
|
+
emulate: true,
|
|
57
|
+
reducedMotion: false,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
41
60
|
/**
|
|
42
61
|
* Accept what an agent is likely to type:
|
|
43
62
|
* http(s)://…, file://… -> unchanged
|
package/dist/record.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// `rpc record`: a short video of a page, or of one element on it. Frames
|
|
2
|
+
// come from a screenshot loop on the element's box (device pixels, cropped
|
|
3
|
+
// exactly), and ffmpeg (Playwright's own build, or one on PATH) turns them
|
|
4
|
+
// into a VP9 webm at a constant frame rate. See docs/plan.md, v0.6.
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join, resolve } from 'node:path';
|
|
9
|
+
import { applyHeaders, AuthRequiredError, redact, redirectedToLogin, resolveAuth, } from './auth.js';
|
|
10
|
+
import { findFfmpeg, h264Encoder, launchBrowser, systemFfmpeg } from './browser.js';
|
|
11
|
+
import { describeFailure, fileStem, newDeviceContext, settle } from './capture.js';
|
|
12
|
+
import { resolveDevice } from './devices.js';
|
|
13
|
+
import { plainText } from './errors.js';
|
|
14
|
+
import { buildPalette, gifWriter } from './gif.js';
|
|
15
|
+
import { settleMotion } from './motion.js';
|
|
16
|
+
import { decodePng, imageSize, pngSize } from './png.js';
|
|
17
|
+
/** Output frame rate of the webm. */
|
|
18
|
+
export const RECORD_FPS = 25;
|
|
19
|
+
/** GIF frame rate and width cap. */
|
|
20
|
+
export const GIF_FPS = 10;
|
|
21
|
+
export const GIF_MAX_WIDTH = 640;
|
|
22
|
+
/** Frames sampled (evenly) for the GIF's shared palette. */
|
|
23
|
+
const GIF_PALETTE_FRAMES = 12;
|
|
24
|
+
/** A failure with its own code and hint (no element, no ffmpeg). */
|
|
25
|
+
export class RecordError extends Error {
|
|
26
|
+
code;
|
|
27
|
+
hint;
|
|
28
|
+
name = 'RecordError';
|
|
29
|
+
constructor(code, message, hint) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.code = code;
|
|
32
|
+
this.hint = hint;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function record(opts, hooks = {}) {
|
|
36
|
+
const device = resolveDevice(opts.device);
|
|
37
|
+
const outDir = resolve(opts.outDir);
|
|
38
|
+
const auth = hooks.auth ?? resolveAuth(opts.url, opts.auth ?? {});
|
|
39
|
+
if (auth.summary)
|
|
40
|
+
hooks.onAuth?.(auth.summary);
|
|
41
|
+
const result = {
|
|
42
|
+
url: opts.url,
|
|
43
|
+
outDir,
|
|
44
|
+
device: device.id,
|
|
45
|
+
name: device.name,
|
|
46
|
+
viewport: { width: device.width, height: device.height },
|
|
47
|
+
dpr: device.dpr,
|
|
48
|
+
...(opts.selector ? { selector: opts.selector } : {}),
|
|
49
|
+
...(auth.summary ? { auth: auth.summary } : {}),
|
|
50
|
+
};
|
|
51
|
+
const ffmpeg = hooks.ffmpeg ?? findFfmpeg();
|
|
52
|
+
if (!ffmpeg) {
|
|
53
|
+
return {
|
|
54
|
+
...result,
|
|
55
|
+
code: 'FFMPEG_MISSING',
|
|
56
|
+
error: 'No ffmpeg to encode the video with',
|
|
57
|
+
hint: 'Run `npx rpc-agent install` (Playwright downloads its ffmpeg next to Chromium), or put ffmpeg on PATH.',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
result.ffmpeg = ffmpeg.source;
|
|
61
|
+
await mkdir(outDir, { recursive: true });
|
|
62
|
+
const browser = hooks.browser ?? (await launchBrowser());
|
|
63
|
+
const errors = new Set();
|
|
64
|
+
try {
|
|
65
|
+
await recordDevice(browser, device, opts, auth, ffmpeg, result, errors, hooks);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
if (err instanceof RecordError)
|
|
69
|
+
Object.assign(result, { code: err.code, error: redact(err.message, auth.secrets), ...(err.hint ? { hint: err.hint } : {}) });
|
|
70
|
+
else
|
|
71
|
+
Object.assign(result, describeFailure(err, opts, auth));
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
result.consoleErrors = [...errors].slice(0, 20).map(l => redact(l, auth.secrets));
|
|
75
|
+
if (!hooks.browser)
|
|
76
|
+
await browser.close();
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
async function recordDevice(browser, device, opts, auth, ffmpeg, result, errors, hooks) {
|
|
81
|
+
const context = await newDeviceContext(browser, device, opts, auth);
|
|
82
|
+
try {
|
|
83
|
+
const page = await context.newPage();
|
|
84
|
+
await applyHeaders(context, page, auth);
|
|
85
|
+
page.on('pageerror', e => errors.add(plainText(`pageerror: ${e.message}`)));
|
|
86
|
+
page.on('console', m => {
|
|
87
|
+
if (m.type() === 'error')
|
|
88
|
+
errors.add(plainText(`console: ${m.text()}`));
|
|
89
|
+
});
|
|
90
|
+
const response = await page.goto(opts.url, { waitUntil: 'load', timeout: opts.timeoutMs });
|
|
91
|
+
result.status = response?.status() ?? null;
|
|
92
|
+
await settle(page, opts.timeoutMs);
|
|
93
|
+
const checkLogin = () => {
|
|
94
|
+
if (redirectedToLogin(opts.url, page.url()))
|
|
95
|
+
throw new AuthRequiredError(opts.url, page.url(), result.status ?? null);
|
|
96
|
+
};
|
|
97
|
+
checkLogin();
|
|
98
|
+
if (opts.waitFor)
|
|
99
|
+
await page.waitForSelector(opts.waitFor, { state: 'visible', timeout: opts.timeoutMs });
|
|
100
|
+
let clip = { x: 0, y: 0, width: device.width, height: device.height };
|
|
101
|
+
if (opts.selector) {
|
|
102
|
+
const el = page.locator(opts.selector).first();
|
|
103
|
+
try {
|
|
104
|
+
await el.waitFor({ state: 'visible', timeout: Math.min(opts.timeoutMs, 10_000) });
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
checkLogin();
|
|
108
|
+
throw new RecordError('SELECTOR_NOT_FOUND', `No visible element matches "${opts.selector}"`, 'Check the selector in the browser devtools (document.querySelector), or wait for it with --wait-for.');
|
|
109
|
+
}
|
|
110
|
+
await el.scrollIntoViewIfNeeded();
|
|
111
|
+
// Let scroll-triggered reveals around it finish, so the video shows the element itself.
|
|
112
|
+
await settleMotion(page);
|
|
113
|
+
const box = await el.boundingBox();
|
|
114
|
+
if (!box)
|
|
115
|
+
throw new RecordError('SELECTOR_NOT_FOUND', `"${opts.selector}" has no box to record`, 'The element is not rendered (display: none or detached).');
|
|
116
|
+
const x0 = Math.max(0, Math.floor(box.x));
|
|
117
|
+
const y0 = Math.max(0, Math.floor(box.y));
|
|
118
|
+
const x1 = Math.min(device.width, Math.ceil(box.x + box.width));
|
|
119
|
+
const y1 = Math.min(device.height, Math.ceil(box.y + box.height));
|
|
120
|
+
if (x1 - x0 < 2 || y1 - y0 < 2)
|
|
121
|
+
throw new RecordError('SELECTOR_NOT_FOUND', `"${opts.selector}" is outside the viewport`, 'Record a smaller element, or pick a device with a larger viewport.');
|
|
122
|
+
clip = { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
|
123
|
+
if (box.width > clip.width + 1 || box.height > clip.height + 1) {
|
|
124
|
+
result.clipped = true;
|
|
125
|
+
(result.warnings ??= []).push(`"${opts.selector}" is ${Math.round(box.width)}x${Math.round(box.height)} CSS px, larger than the ${device.width}x${device.height} viewport; only the visible part was recorded.`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (opts.waitMs > 0)
|
|
129
|
+
await page.waitForTimeout(opts.waitMs);
|
|
130
|
+
checkLogin();
|
|
131
|
+
result.finalUrl = page.url();
|
|
132
|
+
result.title = await page.title();
|
|
133
|
+
result.clip = clip;
|
|
134
|
+
const stem = recordStem(opts.url, device.id, opts.selector);
|
|
135
|
+
const posterPng = await page.screenshot({ clip, type: 'png', animations: 'allow', caret: 'initial' });
|
|
136
|
+
result.poster = { path: resolve(result.outDir, `${stem}-poster.png`), ...pngSize(posterPng) };
|
|
137
|
+
await writeFile(result.poster.path, posterPng);
|
|
138
|
+
hooks.onRecording?.(opts.duration);
|
|
139
|
+
const webm = resolve(result.outDir, `${stem}.webm`);
|
|
140
|
+
let captured;
|
|
141
|
+
try {
|
|
142
|
+
captured = await encodeFrames(ffmpeg.path, webm, opts.duration, () => page.screenshot({ clip, type: 'jpeg', quality: 90, animations: 'allow', caret: 'initial' }));
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
// No video: don't leave its poster behind next to an error result.
|
|
146
|
+
await rm(result.poster.path, { force: true });
|
|
147
|
+
delete result.poster;
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
result.video = { ...(await probeVideo(ffmpeg.path, webm)), path: webm, fps: RECORD_FPS, captured };
|
|
151
|
+
if (opts.gif)
|
|
152
|
+
result.gif = await writeGif(ffmpeg.path, webm, resolve(result.outDir, `${stem}.gif`), result.video);
|
|
153
|
+
if (opts.mp4) {
|
|
154
|
+
const mp4 = await writeMp4(webm, resolve(result.outDir, `${stem}.mp4`));
|
|
155
|
+
if ('path' in mp4)
|
|
156
|
+
result.mp4 = mp4;
|
|
157
|
+
else
|
|
158
|
+
(result.warnings ??= []).push(mp4.skipped);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
await context.close();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** `<host>[-<path>]-<device>[-<selector>]`, e.g. `localhost-3000-desktop-1440-carousel`. */
|
|
166
|
+
export function recordStem(url, deviceId, selector) {
|
|
167
|
+
const base = fileStem(url, deviceId);
|
|
168
|
+
if (!selector)
|
|
169
|
+
return base;
|
|
170
|
+
const slug = selector.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '');
|
|
171
|
+
return slug ? `${base}-${slug}` : base;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Take frames for `seconds` and pipe them to ffmpeg as MJPEG at RECORD_FPS.
|
|
175
|
+
* Each frame is repeated until the next one's timestamp (the middle of its
|
|
176
|
+
* screenshot call), and the last fills the remainder, so the video is
|
|
177
|
+
* exactly `seconds` long whatever the capture rate. Returns the number of
|
|
178
|
+
* screenshots taken. If ffmpeg can't start or dies, or a screenshot fails,
|
|
179
|
+
* the child is stopped and the partial file removed.
|
|
180
|
+
*/
|
|
181
|
+
export async function encodeFrames(ffmpeg, out, seconds, shot) {
|
|
182
|
+
const total = Math.round(seconds * RECORD_FPS);
|
|
183
|
+
const child = spawn(ffmpeg, [
|
|
184
|
+
'-loglevel', 'error', '-y',
|
|
185
|
+
'-f', 'image2pipe', '-c:v', 'mjpeg', '-framerate', String(RECORD_FPS), '-i', 'pipe:0',
|
|
186
|
+
'-an', '-c:v', 'libvpx-vp9', '-deadline', 'realtime', '-cpu-used', '8', '-crf', '30', '-b:v', '0',
|
|
187
|
+
'-pix_fmt', 'yuv420p', out,
|
|
188
|
+
], { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
189
|
+
let stderr = '';
|
|
190
|
+
child.stderr.on('data', d => (stderr += d));
|
|
191
|
+
// Settles once, on exit or on a spawn failure; never rejects, so a failed
|
|
192
|
+
// spawn can't become an unhandled rejection while frames are being taken.
|
|
193
|
+
let spawnError;
|
|
194
|
+
let dead = false;
|
|
195
|
+
const exited = new Promise(res => {
|
|
196
|
+
child.once('error', err => {
|
|
197
|
+
spawnError = err;
|
|
198
|
+
res(-1);
|
|
199
|
+
});
|
|
200
|
+
child.once('close', code => res(code ?? 1));
|
|
201
|
+
}).finally(() => {
|
|
202
|
+
dead = true;
|
|
203
|
+
});
|
|
204
|
+
// A dead ffmpeg makes writes fail (EPIPE); the exit code and stderr say why.
|
|
205
|
+
child.stdin.on('error', () => {
|
|
206
|
+
dead = true;
|
|
207
|
+
});
|
|
208
|
+
// Wait for room in the pipe, or for ffmpeg to go away ('drain' never comes then).
|
|
209
|
+
const write = (buf) => dead || child.stdin.destroyed
|
|
210
|
+
? Promise.resolve()
|
|
211
|
+
: new Promise(res => {
|
|
212
|
+
if (child.stdin.write(buf))
|
|
213
|
+
return res();
|
|
214
|
+
const done = () => {
|
|
215
|
+
child.stdin.off('drain', done);
|
|
216
|
+
child.stdin.off('close', done);
|
|
217
|
+
res();
|
|
218
|
+
};
|
|
219
|
+
child.stdin.once('drain', done);
|
|
220
|
+
child.stdin.once('close', done);
|
|
221
|
+
void exited.then(done);
|
|
222
|
+
});
|
|
223
|
+
let written = 0;
|
|
224
|
+
let last;
|
|
225
|
+
let captured = 0;
|
|
226
|
+
const fill = async (upTo) => {
|
|
227
|
+
for (; written < Math.min(upTo, total) && last && !dead; written++)
|
|
228
|
+
await write(last);
|
|
229
|
+
};
|
|
230
|
+
try {
|
|
231
|
+
const t0 = performance.now();
|
|
232
|
+
while (performance.now() - t0 < seconds * 1000 && !dead) {
|
|
233
|
+
const before = performance.now();
|
|
234
|
+
const buf = await shot();
|
|
235
|
+
const at = ((before + performance.now()) / 2 - t0) / 1000;
|
|
236
|
+
captured++;
|
|
237
|
+
await fill(Math.round(at * RECORD_FPS));
|
|
238
|
+
last = buf;
|
|
239
|
+
}
|
|
240
|
+
await fill(total);
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
child.kill('SIGKILL');
|
|
244
|
+
await exited;
|
|
245
|
+
await rm(out, { force: true });
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
child.stdin.end();
|
|
249
|
+
const code = await exited;
|
|
250
|
+
if (spawnError)
|
|
251
|
+
throw new RecordError('ENCODE_FAILED', `Could not run ffmpeg: ${spawnError.message}`, `ffmpeg: ${ffmpeg}`);
|
|
252
|
+
if (code !== 0) {
|
|
253
|
+
await rm(out, { force: true });
|
|
254
|
+
throw new RecordError('ENCODE_FAILED', `ffmpeg failed (exit ${code}): ${stderr.trim().split('\n').pop() ?? ''}`, `ffmpeg: ${ffmpeg}`);
|
|
255
|
+
}
|
|
256
|
+
return captured;
|
|
257
|
+
}
|
|
258
|
+
/** Run ffmpeg to completion; stderr in the error. */
|
|
259
|
+
function run(ffmpeg, args) {
|
|
260
|
+
return new Promise((res, rej) => {
|
|
261
|
+
const child = spawn(ffmpeg, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
262
|
+
let stderr = '';
|
|
263
|
+
child.stderr.on('data', d => (stderr += d));
|
|
264
|
+
child.on('error', rej);
|
|
265
|
+
child.on('close', code => (code === 0 ? res(stderr) : rej(new RecordError('ENCODE_FAILED', `ffmpeg failed (exit ${code}): ${stderr.trim().split('\n').pop() ?? ''}`))));
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/** Size and duration of a video, from `ffmpeg -i` (which exits 1 without an output; that's expected). */
|
|
269
|
+
export async function probeVideo(ffmpeg, path) {
|
|
270
|
+
const info = await new Promise((res, rej) => {
|
|
271
|
+
const child = spawn(ffmpeg, ['-hide_banner', '-i', path], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
272
|
+
let stderr = '';
|
|
273
|
+
child.stderr.on('data', d => (stderr += d));
|
|
274
|
+
child.on('error', rej);
|
|
275
|
+
child.on('close', () => res(stderr));
|
|
276
|
+
});
|
|
277
|
+
const size = /Video: .*?\b(\d{1,5})x(\d{1,5})\b/.exec(info);
|
|
278
|
+
const dur = /Duration: (\d+):(\d+):(\d+(?:\.\d+)?)/.exec(info);
|
|
279
|
+
if (!size || !dur)
|
|
280
|
+
throw new RecordError('ENCODE_FAILED', `Could not read the video ffmpeg wrote: ${path}`);
|
|
281
|
+
return {
|
|
282
|
+
width: Number(size[1]),
|
|
283
|
+
height: Number(size[2]),
|
|
284
|
+
duration: Math.round((Number(dur[1]) * 3600 + Number(dur[2]) * 60 + Number(dur[3])) * 100) / 100,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Decode the webm to PNG frames at GIF_FPS (Playwright's ffmpeg can), then
|
|
289
|
+
* encode them in gif.ts: the palette from a sample of frames, then one frame
|
|
290
|
+
* decoded and encoded at a time, so memory stays at about one frame.
|
|
291
|
+
*/
|
|
292
|
+
async function writeGif(ffmpeg, webm, out, video) {
|
|
293
|
+
const width = Math.min(video.width, GIF_MAX_WIDTH);
|
|
294
|
+
const height = Math.max(1, Math.round((video.height * width) / video.width));
|
|
295
|
+
// `-r` alone pads a frame or two at the edges; cap at the video's length.
|
|
296
|
+
const count = Math.max(1, Math.round(video.duration * GIF_FPS));
|
|
297
|
+
const dir = await mkdtemp(join(tmpdir(), 'rpc-gif-'));
|
|
298
|
+
try {
|
|
299
|
+
await run(ffmpeg, ['-loglevel', 'error', '-y', '-i', webm, '-r', String(GIF_FPS), '-frames:v', String(count), '-vf', `scale=${width}:${height}`, '-f', 'image2', join(dir, 'f-%04d.png')]);
|
|
300
|
+
const files = (await readdir(dir)).filter(f => f.endsWith('.png')).sort();
|
|
301
|
+
if (!files.length)
|
|
302
|
+
throw new RecordError('ENCODE_FAILED', 'ffmpeg wrote no GIF frames');
|
|
303
|
+
const frame = async (f) => decodePng(await readFile(join(dir, f)));
|
|
304
|
+
const step = Math.max(1, Math.ceil(files.length / GIF_PALETTE_FRAMES));
|
|
305
|
+
const sample = [];
|
|
306
|
+
for (let i = 0; i < files.length; i += step)
|
|
307
|
+
sample.push(await frame(files[i]));
|
|
308
|
+
const gif = gifWriter(width, height, buildPalette(sample), Math.round(100 / GIF_FPS));
|
|
309
|
+
sample.length = 0;
|
|
310
|
+
for (const f of files)
|
|
311
|
+
gif.add(await frame(f));
|
|
312
|
+
const data = gif.end();
|
|
313
|
+
await writeFile(out, data);
|
|
314
|
+
return { path: out, ...imageSize(data), frames: files.length };
|
|
315
|
+
}
|
|
316
|
+
finally {
|
|
317
|
+
await rm(dir, { recursive: true, force: true });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/** mp4 needs an H.264 encoder, which Playwright's ffmpeg doesn't have: use a system ffmpeg when there is one. */
|
|
321
|
+
async function writeMp4(webm, out) {
|
|
322
|
+
const sys = systemFfmpeg();
|
|
323
|
+
const encoder = sys ? h264Encoder(sys) : undefined;
|
|
324
|
+
if (!sys || !encoder) {
|
|
325
|
+
return { skipped: 'mp4 skipped: it needs an ffmpeg with an H.264 encoder on PATH (e.g. `brew install ffmpeg`); the webm was written.' };
|
|
326
|
+
}
|
|
327
|
+
const quality = encoder === 'h264_videotoolbox' ? ['-b:v', '8M'] : encoder === 'libx264' ? ['-crf', '20', '-preset', 'veryfast'] : [];
|
|
328
|
+
await run(sys, [
|
|
329
|
+
'-loglevel', 'error', '-y', '-i', webm,
|
|
330
|
+
'-c:v', encoder, ...quality, '-pix_fmt', 'yuv420p', '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', '-movflags', '+faststart', out,
|
|
331
|
+
]);
|
|
332
|
+
return { path: out, encoder };
|
|
333
|
+
}
|
package/dist/ui.js
CHANGED
|
@@ -12,6 +12,8 @@ import { capture } from './capture.js';
|
|
|
12
12
|
import { checkExitCode, runCheck as audit, summaryLine } from './check.js';
|
|
13
13
|
import { countsPhrase, groupFindings, groupLines } from './check-format.js';
|
|
14
14
|
import { CATEGORY_ORDER, DEVICE_ALIASES, DEVICES, } from './devices.js';
|
|
15
|
+
import { unpinnedLines, warningWhere } from './motion.js';
|
|
16
|
+
import { record } from './record.js';
|
|
15
17
|
import { defaultShotOptions, normalizeUrl } from './options.js';
|
|
16
18
|
import { openCommand, openPath, waitForEnter } from './term.js';
|
|
17
19
|
export function colors(enabled) {
|
|
@@ -101,6 +103,10 @@ export function shotCommand(o) {
|
|
|
101
103
|
parts.push('--color-scheme', o.colorScheme);
|
|
102
104
|
if (o.reducedMotion)
|
|
103
105
|
parts.push('--reduced-motion');
|
|
106
|
+
if (o.finishAnimations)
|
|
107
|
+
parts.push('--finish-animations');
|
|
108
|
+
if (o.fullPage && !o.unpin)
|
|
109
|
+
parts.push('--no-unpin');
|
|
104
110
|
if (o.outDir !== base.outDir)
|
|
105
111
|
parts.push('-o', quote(o.outDir));
|
|
106
112
|
return parts.join(' ');
|
|
@@ -187,6 +193,10 @@ export function summaryLines(o, c, auth) {
|
|
|
187
193
|
];
|
|
188
194
|
if (o.reducedMotion)
|
|
189
195
|
rows.push(['reduced motion', c.green('yes')]);
|
|
196
|
+
if (o.finishAnimations)
|
|
197
|
+
rows.push(['finish animations', c.green('yes')]);
|
|
198
|
+
if (o.fullPage && !o.unpin)
|
|
199
|
+
rows.push(['unpin', c.yellow('no')]);
|
|
190
200
|
if (o.waitFor)
|
|
191
201
|
rows.push(['wait for', o.waitFor]);
|
|
192
202
|
if (!o.emulate)
|
|
@@ -290,6 +300,12 @@ export function capturedLines(result, c) {
|
|
|
290
300
|
const msg = s.consoleErrors[0].length > width - 20 ? `${s.consoleErrors[0].slice(0, width - 21)}…` : s.consoleErrors[0];
|
|
291
301
|
out.push(` ${c.yellow('console:')} ${c.dim(msg)}`);
|
|
292
302
|
}
|
|
303
|
+
for (const u of unpinnedLines(s.motion?.unpinned))
|
|
304
|
+
out.push(` ${c.cyan('unpinned')} ${c.dim(u)}`);
|
|
305
|
+
for (const w of s.warnings ?? []) {
|
|
306
|
+
const where = warningWhere(w);
|
|
307
|
+
out.push(` ${c.yellow('▲')} ${w.message}${where ? ` ${c.dim(where)}` : ''}`);
|
|
308
|
+
}
|
|
293
309
|
}
|
|
294
310
|
for (const group of failures.values()) {
|
|
295
311
|
const first = group[0];
|
|
@@ -391,6 +407,78 @@ export async function runShot(options, run, c) {
|
|
|
391
407
|
p.outro(`${c.green('Done!')} ${c.dim(advice)}`);
|
|
392
408
|
return failed > 0 ? 1 : 0;
|
|
393
409
|
}
|
|
410
|
+
// ------------------------------------------------------------------ record
|
|
411
|
+
/** Lines for the final "Recorded" box. */
|
|
412
|
+
export function recordedLines(r, c) {
|
|
413
|
+
const out = [];
|
|
414
|
+
const file = (path, meta) => out.push(`${c.green('✓')} ${c.cyan(rel(path))} ${c.dim(meta)}`);
|
|
415
|
+
if (r.video)
|
|
416
|
+
file(r.video.path, `${r.video.width}×${r.video.height} · ${r.video.duration}s · ${r.video.fps} fps · ${size(r.video.path)}`);
|
|
417
|
+
if (r.poster)
|
|
418
|
+
file(r.poster.path, `${r.poster.width}×${r.poster.height} · poster`);
|
|
419
|
+
if (r.gif)
|
|
420
|
+
file(r.gif.path, `${r.gif.width}×${r.gif.height} · ${r.gif.frames} frames · ${size(r.gif.path)}`);
|
|
421
|
+
if (r.mp4)
|
|
422
|
+
file(r.mp4.path, `${r.mp4.encoder} · ${size(r.mp4.path)}`);
|
|
423
|
+
for (const w of r.warnings ?? [])
|
|
424
|
+
for (const [i, l] of wrap(w, textWidth() - 4).entries())
|
|
425
|
+
out.push(`${i ? ' ' : c.yellow('▲ ')}${c.dim(l)}`);
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
export async function runRecord(options, run, c) {
|
|
429
|
+
const auth = resolveAuth(options.url, options.auth ?? {});
|
|
430
|
+
printBanner(c);
|
|
431
|
+
intro(c);
|
|
432
|
+
const rows = [
|
|
433
|
+
['device', c.cyan(options.device)],
|
|
434
|
+
['element', options.selector ?? c.dim('whole viewport')],
|
|
435
|
+
['duration', `${options.duration}s`],
|
|
436
|
+
['also', [options.gif ? 'gif' : '', options.mp4 ? 'mp4' : ''].filter(Boolean).join(', ') || c.dim('webm only')],
|
|
437
|
+
];
|
|
438
|
+
if (options.reducedMotion)
|
|
439
|
+
rows.push(['reduced motion', c.green('yes')]);
|
|
440
|
+
if (auth.summary)
|
|
441
|
+
rows.push(...authRows(auth.summary, c));
|
|
442
|
+
rows.push(['output', `${rel(options.outDir)}/`]);
|
|
443
|
+
const w = Math.max(...rows.map(([k]) => k.length)) + 1;
|
|
444
|
+
p.note([c.cyan(options.url), ...rows.map(([k, v]) => ` ${c.dim(`${k}:`.padEnd(w))} ${v}`)].join('\n'), 'Record Summary');
|
|
445
|
+
const s = p.spinner();
|
|
446
|
+
s.start('Starting Chromium');
|
|
447
|
+
let browser;
|
|
448
|
+
try {
|
|
449
|
+
browser = await launchBrowser();
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
s.error(c.red(err instanceof BrowserMissingError ? "Playwright's Chromium is not installed" : `Chromium did not start: ${err.message.split('\n')[0]}`));
|
|
453
|
+
if (err instanceof BrowserMissingError)
|
|
454
|
+
p.note(wrap(installHint().replace(/^Fix: /, ''), textWidth()).join('\n'), c.yellow('Fix'));
|
|
455
|
+
p.outro(c.red('Nothing recorded.'));
|
|
456
|
+
return 1;
|
|
457
|
+
}
|
|
458
|
+
s.message(`Loading ${c.cyan(options.url)}`);
|
|
459
|
+
let result;
|
|
460
|
+
try {
|
|
461
|
+
result = await record(options, { browser, auth, onRecording: secs => s.message(`Recording ${secs}s`) });
|
|
462
|
+
}
|
|
463
|
+
finally {
|
|
464
|
+
await browser.close().catch(() => { });
|
|
465
|
+
}
|
|
466
|
+
if (result.error) {
|
|
467
|
+
s.error(c.red(result.error));
|
|
468
|
+
if (result.hint)
|
|
469
|
+
p.note(wrap(result.hint, textWidth()).join('\n'), c.yellow('Hint'));
|
|
470
|
+
p.outro(c.red('Nothing recorded.'));
|
|
471
|
+
return 1;
|
|
472
|
+
}
|
|
473
|
+
s.stop(`Recorded ${result.video?.duration ?? options.duration}s on ${result.device}`);
|
|
474
|
+
p.note(recordedLines(result, c).join('\n'), c.green('Recorded'));
|
|
475
|
+
const dir = rel(result.outDir);
|
|
476
|
+
let advice = `Open with: ${openCommand().cmd} ${dir}`;
|
|
477
|
+
if (run.open)
|
|
478
|
+
advice = openPath(result.outDir) ? `Opened ${dir}` : advice;
|
|
479
|
+
p.outro(`${c.green('Done!')} ${c.dim(advice)}`);
|
|
480
|
+
return 0;
|
|
481
|
+
}
|
|
394
482
|
// ------------------------------------------------------------------ check
|
|
395
483
|
/** Lines for the "Check Summary" box. */
|
|
396
484
|
export function checkSummaryLines(o, c, auth) {
|
|
@@ -692,6 +780,11 @@ export async function runDoctor(check, c) {
|
|
|
692
780
|
s.stop(`Node ${c.dim(r.node)}`);
|
|
693
781
|
p.log.step(`Playwright ${c.dim(r.playwright)}`);
|
|
694
782
|
p.log.step(`Browsers ${c.dim(r.browsersPath)}`);
|
|
783
|
+
if (r.ffmpeg.source === 'missing')
|
|
784
|
+
p.log.warn(`ffmpeg ${c.yellow('missing')} ${c.dim('(only rpc record needs it; rpc install fetches it)')}`);
|
|
785
|
+
else
|
|
786
|
+
p.log.step(`ffmpeg ${c.dim(`${r.ffmpeg.source} · ${r.ffmpeg.path}`)}`);
|
|
787
|
+
p.log.step(`mp4 ${r.mp4.ok ? c.dim(r.mp4.encoder ?? '') : c.dim('no (optional: ffmpeg with H.264 on PATH)')}`);
|
|
695
788
|
if (r.ok) {
|
|
696
789
|
p.log.success(`Chromium ${c.green('launches')} ${c.dim(r.version ?? '')}`);
|
|
697
790
|
p.outro(`${c.green('Ready.')} ${c.dim('Try: rpc shot localhost:3000 --bezels')}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rpc-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Screenshot a URL at device viewports, optionally framed in device bezels, signed in or not, and audit it before launch. CLI and MCP server for AI coding agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|