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 CHANGED
@@ -1,5 +1,65 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ Motion: captures that match what a person scrolling sees, a Motion check,
6
+ and `rpc record`.
7
+
8
+ ### Added
9
+
10
+ - `rpc record <url>` writes a few seconds of video (VP9 webm) of the page
11
+ or of one element (`--selector`, scrolled into view and cropped to its
12
+ box), plus a poster PNG of the first frame. `--duration` (default 5, up
13
+ to 30 s), `-d` for one device (default `desktop-1440`), `--gif`, `--mp4`
14
+ (needs an ffmpeg with H.264 on PATH; skipped with a warning otherwise),
15
+ and the same auth flags as `rpc shot`. Frames come from a screenshot loop
16
+ at device pixels, encoded at a constant 25 fps by Playwright's own ffmpeg
17
+ build (already downloaded by `rpc install`), with a system ffmpeg as the
18
+ fallback. GIFs are encoded in Node with no new dependency.
19
+ - MCP `record` tool with the same options; it returns the paths, a summary
20
+ and the poster inline.
21
+ - `rpc shot --finish-animations` (MCP `finishAnimations`) jumps GSAP tweens
22
+ and non-scrubbed ScrollTriggers to their end when `gsap` / `ScrollTrigger`
23
+ are on `window`, and finite Web Animations, CSS animations and transitions
24
+ too. Counts are in `motion.finished`.
25
+ - `rpc shot --no-unpin` (MCP `unpin: false`) keeps pinned sections as they
26
+ are in full-page shots.
27
+ - Each shot has `warnings` when motion made the capture differ from the
28
+ page in use: elements still animating, a pinned section left in place,
29
+ content still hidden by a reveal, and carousels ("use `rpc record` for
30
+ motion"). The terminal prints them under the device and the MCP headline
31
+ lists them with a fix.
32
+ - `rpc check` has a Motion group: `motion-running` and
33
+ `motion-reveal-hidden` (warn), `motion-loop`, `motion-pinned` and
34
+ `motion-carousel` (info). Never an error.
35
+ - `rpc doctor` reports the ffmpeg `rpc record` would use and whether mp4 is
36
+ available. Its exit code still depends on Chromium only.
37
+
38
+ ### Changed
39
+
40
+ - `rpc shot` waits for the visible page to settle before capturing, and at
41
+ each warm-up scroll step: no style or class change on a visible element
42
+ for a short quiet period (and at the warm-up steps no finite animation
43
+ running on one; the screenshot ends CSS and Web Animations itself),
44
+ capped (2 s before capture, 800 ms per step, 8 s for the warm-up).
45
+ The result is in `motion.settle`.
46
+ - Full-page shots collapse pinned sections (GSAP-style `.pin-spacer`
47
+ wrappers, pins `ScrollTrigger.getAll()` reports, and sticky or fixed
48
+ sections that are the only child of a much taller parent), so each
49
+ section shows once instead of over a blank spacer. Each one is listed in
50
+ `motion.unpinned` with the CSS px removed.
51
+
52
+ ### Fixed
53
+
54
+ - `rpc check`: the tap-target check skips inline links in a sentence (WCAG
55
+ 2.5.8 inline exception). A link is skipped when its computed `display` is
56
+ `inline` and its block of text has letters or digits outside any
57
+ control. Buttons, `[role=button]`, links laid out as boxes, nav links and
58
+ icon links still count, and the threshold stays 44x44.
59
+ - Test servers answer 500 with the error when a fixture file is missing
60
+ instead of hanging, and the check suite stops early with "run pnpm
61
+ install" when `@fontsource/ibm-plex-mono` isn't installed.
62
+
3
63
  ## 0.5.0
4
64
 
5
65
  Ship-check: `rpc check <url>` audits a page before launch, from the CLI, in
package/README.md CHANGED
@@ -64,6 +64,7 @@ these docs say `rpc`, or `pnpm rpc` from inside the repo.
64
64
  rpc # interactive: pick a URL, devices and options
65
65
  rpc shot <url> [options]
66
66
  rpc check <url> [options] # pre-launch audit, see Ship-check below
67
+ rpc record <url> [options] # a few seconds of video of the page or one element
67
68
  rpc login <url> [--form] # sign in once; later shots of that origin use the session
68
69
  rpc sessions [list|clear [origin]]
69
70
  rpc devices [--json]
@@ -94,6 +95,8 @@ the files it wrote:
94
95
  | `--slice-width <px>` | Maximum slice width. Default 768 |
95
96
  | `--max-height <px>` | Full-page cap in CSS px. Default 60000 |
96
97
  | `--reduced-motion` | Emulate `prefers-reduced-motion: reduce` |
98
+ | `--finish-animations` | Jump animations to their end state before capturing. See [Animation-aware capture](#animation-aware-capture) |
99
+ | `--no-unpin` | With `--full-page`, keep pinned sections as they are instead of collapsing them |
97
100
  | `-o, --out <dir>` | Output directory. Default `./.rpc-shots` |
98
101
  | `--wait-for <selector>` | Wait for a visible element before capturing |
99
102
  | `--wait <ms>` | Extra settle delay after load |
@@ -149,6 +152,90 @@ and `100vh` sections keep their real height.
149
152
  Bezels frame the above-the-fold viewport. With `--bezels --full-page` you get
150
153
  both: the full page raw, and the first screen framed.
151
154
 
155
+ ### Animation-aware capture
156
+
157
+ A stitched shot of a page with scroll animations used to come out wrong in
158
+ three ways: pinned scroll sections left tall blank bands, text was caught
159
+ halfway through its reveal, and carousels froze on whatever slide was up.
160
+
161
+ #### Settle
162
+
163
+ Before each capture, and at every step of the full-page warm-up
164
+ scroll, rpc waits until the visible part of the page stops moving: no
165
+ `style` or `class` change on a visible element for 150 ms (120 ms per
166
+ warm-up step), and, at the warm-up steps, no finite animation running on
167
+ one. The screenshot itself ends finite CSS and Web Animations (Playwright's
168
+ `animations: 'disabled'`), so the wait just before it only watches for
169
+ JS-driven changes, the kind a GSAP tween makes. It gives up after 2 s
170
+ (800 ms per step, 8 s for the whole warm-up), so a page that never stops
171
+ can't hang it. Anything still changing or animating when a step runs out
172
+ of time is treated as a loop (a JS marquee, a ticker, a long CSS
173
+ animation), and later steps stop waiting for it. A static page only pays the quiet period. The result is on each shot as
174
+ `motion.settle`: `settled`, `ms`, and the selectors still active.
175
+
176
+ #### Pinned sections
177
+
178
+ GSAP ScrollTrigger pins a section by wrapping it in a
179
+ `.pin-spacer` with padding below it, then fixing the section in place while
180
+ you scroll through that padding. A full-page capture renders the page at
181
+ scroll 0, where the section sits at the top of its spacer and the padding
182
+ is empty, so the shot gets a blank band as tall as the pin's scroll
183
+ distance. With `--full-page`, rpc collapses each pin it finds. The spacer
184
+ loses its padding, the section goes back into normal flow, and the shot
185
+ shows it once with the rest of the page right after it. A sticky section
186
+ that is the only child of a much taller parent gets the same treatment. The
187
+ rules go in an injected stylesheet as `!important`, so when the library
188
+ rewrites its inline styles during the capture, they still win. Each
189
+ collapsed pin is listed in `motion.unpinned` with the CSS px removed, and
190
+ the stitched height is measured after the collapse. `--no-unpin` turns it
191
+ off.
192
+
193
+ | `--no-unpin` (before) | default (after) |
194
+ | --- | --- |
195
+ | <img alt="A full-page shot of a fixture with a pinned orange section: a 2100px white band sits between the section and the green outro" src="https://raw.githubusercontent.com/akintola4/rpc-agent/main/docs/media/motion/pinned-before.png" width="240"> | <img alt="The same page with the pin collapsed: blue intro, orange section, green outro, no gap" src="https://raw.githubusercontent.com/akintola4/rpc-agent/main/docs/media/motion/pinned-after.png" width="240"> |
196
+
197
+ #### Finish animations
198
+
199
+ `--finish-animations` is for JS-driven motion, which the screenshot can't
200
+ end on its own. It calls `.finish()` on every finite Web Animation (CSS
201
+ animations and transitions too, so layout measured before the capture is
202
+ the end state). If `window.gsap` exists,
203
+ tweens and timelines on its global timeline that don't repeat forever go to
204
+ `progress(1)`. If `window.ScrollTrigger` exists, triggers that aren't
205
+ scrubbed or pinned get their animation set to `progress(1)`. Scrubbed and
206
+ pinned ones follow the scroll position, so they keep their scroll-0 state.
207
+ Each library step runs in its own try/catch and does nothing when the
208
+ library isn't there. Infinite loops are left alone. The counts are in
209
+ `motion.finished`.
210
+
211
+ #### Reduced motion
212
+
213
+ `--reduced-motion` still emulates
214
+ `prefers-reduced-motion: reduce`. On a site that respects it, that's the
215
+ cleanest fix, because the animations never run.
216
+
217
+ #### Warnings
218
+
219
+ Each shot gets a `warnings` list in the JSON, and the terminal
220
+ prints them under the device:
221
+
222
+ ```
223
+ ok desktop-1440 (1440x900 @1x)
224
+ .rpc-shots/localhost-3000-desktop-1440.png 1440x900
225
+ warn: 3 elements were still animating when captured: div.reveal:nth-of-type(1), div.reveal:nth-of-type(2), div.reveal:nth-of-type(3)
226
+ warn: carousel: use `rpc record` for motion: #carousel
227
+ ```
228
+
229
+ | id | When |
230
+ | --- | --- |
231
+ | `motion-running` | Elements were still changing (style or class writes, as a JS tween makes) when the settle ran out of time: "N elements were still animating when captured". CSS and Web Animations don't count here, because the screenshot ends them |
232
+ | `motion-pinned` | Full page only. A pin was left in place (`--no-unpin`, or one that couldn't be collapsed): "pinned section: the stitched shot may show blank space" |
233
+ | `motion-reveal-hidden` | Content still at opacity 0, or offset by 24px or more, that looks like a reveal (see [Motion](#motion)) |
234
+ | `motion-carousel` | A carousel or marquee is in the shot: "carousel: use `rpc record` for motion" |
235
+
236
+ Each warning has a `fix` naming the flag to try. Warnings never change the
237
+ exit code.
238
+
152
239
  ### Terminal, pipes and CI
153
240
 
154
241
  The banner, spinners, prompts and boxes only appear when stdout is an
@@ -305,8 +392,8 @@ rpc shot localhost:3000/app --storage-state auth.json
305
392
  `rpc check <url>` audits one page before you call it done. It loads the page
306
393
  on a phone and a desktop width, the way `rpc shot` does, and lists what's
307
394
  wrong: missing or broken social tags, layout shift, buttons a screen reader
308
- can't name, fonts that hide text while they load, and the mobile viewport
309
- bugs that only show up on a phone.
395
+ can't name, fonts that hide text while they load, the mobile viewport bugs
396
+ that only show up on a phone, and motion a screenshot will get wrong.
310
397
 
311
398
  ```sh
312
399
  rpc check localhost:3000
@@ -436,7 +523,7 @@ These run on phone devices only.
436
523
  | --- | --- | --- | --- |
437
524
  | `horizontal-overflow` | error | `scrollWidth` is wider than the layout viewport. Lists the outermost elements that stick out, furthest first | `max-width: 100%` on the element, or `overflow-x: auto` on its wrapper |
438
525
  | `vh-units` | warn | A rendered element sized with `100vh` in `height`, `min-height` or `max-height`, from a stylesheet rule that applies or an inline style | `100dvh` or `100svh`, with `100vh` before it as a fallback |
439
- | `tap-target-small` | warn | Links, buttons and form controls smaller than 44x44 CSS px. Links inside running text are exempt | Padding, `min-height`, or a bigger hit area |
526
+ | `tap-target-small` | warn | Links, buttons and form controls smaller than 44x44 CSS px. Inline links in a sentence are exempt, per the WCAG 2.5.8 inline exception: a link with `display: inline` and other text in the same block. Buttons and links laid out as boxes (`inline-block`, `flex`, `block`) still count | Padding, `min-height`, or a bigger hit area |
440
527
  | `input-zoom` | warn | A text field with `font-size` under 16px; iOS Safari zooms the page when it gets focus | `font-size: 16px` or more |
441
528
 
442
529
  A headless phone has no browser bars, so `100vh` and `100dvh` measure the
@@ -444,6 +531,21 @@ same there. That's why `vh-units` reads the CSS instead of measuring
444
531
  elements. The `height: 100vh; height: 100dvh` fallback pattern isn't
445
532
  flagged: the browser keeps only the last value it understands.
446
533
 
534
+ #### Motion
535
+
536
+ After the scroll, rpc gives running animations up to 1 s to stop, then
537
+ looks at the viewport plus half a viewport above and below. These findings
538
+ are about whether a screenshot comes out right, and a page can ship with
539
+ them, so they are `warn` or `info` and never `error`.
540
+
541
+ | id | Severity | When | Fix |
542
+ | --- | --- | --- | --- |
543
+ | `motion-running` | warn | Finite animations (from `document.getAnimations()`) or style changes still going on visible elements | `rpc shot --finish-animations`, `--wait`, or `--reduced-motion` |
544
+ | `motion-reveal-hidden` | warn | Elements with text or media at opacity under 0.1, `visibility: hidden`, offset by 24px or more, or scaled under 0.85, that also carry a reveal class (`reveal`, `fade`, `aos`, `in-view`, `appear`, `animated` and similar; utility words such as `scroll`, `visible` or `animate-` don't count), a reveal attribute (`data-aos`, `data-sal`, `data-scroll` and similar) or inline opacity or transform written by a library. Carousel tracks, elements inside a carousel library root, and `translate(-50%)` centring are not offsets. Only the outermost element is listed | Make sure the reveal fires, and that the content shows without JavaScript or animation |
545
+ | `motion-loop` | info | Infinite animations: spinners, pulses, CSS marquees | Stop them under `prefers-reduced-motion: reduce` |
546
+ | `motion-pinned` | info | A `.pin-spacer` taller than its section by a quarter of the viewport, a pin that `ScrollTrigger.getAll()` reports as its parent's only child, or a sticky or fixed section (half a viewport tall or more) that is the only child of a parent at least half a viewport taller | `rpc shot --full-page` collapses it; `rpc record --selector` shows it moving |
547
+ | `motion-carousel` | info | Swiper, Embla, Splide, Keen, Flickity, Slick or Glide roots, `aria-roledescription="carousel"`, horizontal scrollers with `scroll-snap-type` on x, and tracks whose scroll position or transform changes during a 300 ms sample | `rpc record <url> --selector '<the carousel>'` |
548
+
447
549
  ### JSON report
448
550
 
449
551
  `--json` prints this object, and the MCP `check` tool returns the same one.
@@ -522,9 +624,62 @@ Before you say UI work is done, run the rpc `check` tool on each page you
522
624
  changed and fix every error. Fix the warnings too, or say why one doesn't apply.
523
625
  ```
524
626
 
627
+ ## Recording motion: `rpc record`
628
+
629
+ A still can't show a carousel, a marquee, or a pinned section whose panels
630
+ slide as you scroll. `rpc record` takes a few seconds of video of the page,
631
+ or of one element on it.
632
+
633
+ ```sh
634
+ rpc record localhost:3000 --selector '.hero-carousel' # 5 s of one element
635
+ rpc record localhost:3000 -d iphone --duration 10 --gif # whole phone viewport, plus a GIF
636
+ rpc record example.com --selector '#logos' --mp4 # mp4 too, if ffmpeg is on PATH
637
+ ```
638
+
639
+ | Option | Meaning |
640
+ | --- | --- |
641
+ | `--selector <css>` | Record this element. rpc scrolls it into view, waits for reveals around it to settle, and crops the video to its box. If it's bigger than the viewport, the visible part is recorded and the result says so |
642
+ | `--duration <s>` | Seconds of video, 0.5 to 30. Default 5 |
643
+ | `-d, --device <id>` | One device id or alias. Default `desktop-1440` |
644
+ | `--gif` | Also write a GIF: 10 fps, at most 640 px wide |
645
+ | `--mp4` | Also write an mp4. This needs an ffmpeg with an H.264 encoder on PATH; without one, rpc skips the mp4 and says so |
646
+ | `-o, --out <dir>`, `--json`, `--open` | As in `rpc shot` |
647
+ | `--wait-for`, `--wait`, `--timeout`, `--color-scheme`, `--reduced-motion`, `--no-emulate` | As in `rpc shot` |
648
+ | `--cookie`, `--header`, `--storage-state`, `--no-session`, `--allow-origin` | As in `rpc shot` |
649
+
650
+ It writes `<host>[-<path>]-<device>[-<selector>].webm` (VP9) and a
651
+ `-poster.png` of the first frame, plus the `.gif` and `.mp4` when asked:
652
+
653
+ ```
654
+ ok iphone-15-pro (393x852 @3x) #carousel
655
+ .rpc-shots/localhost-3000-iphone-15-pro-carousel.webm 1080x600, 3s at 25 fps (90 frames captured, playwright ffmpeg)
656
+ .rpc-shots/localhost-3000-iphone-15-pro-carousel-poster.png 1080x600 poster
657
+ .rpc-shots/localhost-3000-iphone-15-pro-carousel.gif 640x356, 32 frames
658
+ ```
659
+
660
+ <img alt="A GIF recorded from a fixture's autoplaying carousel: four coloured slides advancing in turn" src="https://raw.githubusercontent.com/akintola4/rpc-agent/main/docs/media/motion/record-carousel.gif" width="360">
661
+
662
+ Frames come from a loop of `page.screenshot()` calls clipped to the
663
+ element's box. Headless Chromium returns those at device pixels and cropped
664
+ exactly, at about 30 fps on a DPR 2 or 3 phone and 60 fps at DPR 1. The two
665
+ obvious alternatives do worse. CDP screencast frames arrive at CSS pixel
666
+ size in headless mode (393x852 for a DPR 3 phone), so they lose the
667
+ pixel density and need a scaled crop. Playwright's `recordVideo` records the
668
+ whole viewport at CSS size with a fixed bitrate, and it can't crop.
669
+
670
+ The frames go to ffmpeg as MJPEG at a constant 25 fps. Each one is repeated
671
+ until the next one's timestamp, so the video is as long as you asked for,
672
+ whatever rate the screenshots came in at. The ffmpeg is Playwright's own
673
+ build, which `rpc install` already downloads next to Chromium. It writes
674
+ VP9 webm and PNG but has no H.264 encoder, which is why webm is the default
675
+ and mp4 needs a system ffmpeg. If Playwright's build is missing, rpc falls
676
+ back to `ffmpeg` on PATH. GIFs are encoded in Node (`src/gif.ts`) from PNG
677
+ frames that ffmpeg decodes out of the webm, so they add no dependency.
678
+ `rpc doctor` says which ffmpeg it found and whether mp4 will work.
679
+
525
680
  ## MCP server
526
681
 
527
- `rpc mcp` runs an MCP server on stdio with four tools.
682
+ `rpc mcp` runs an MCP server on stdio with five tools.
528
683
 
529
684
  ### `screenshot`
530
685
 
@@ -539,6 +694,8 @@ changed and fix every error. Fix the warnings too, or say why one doesn't apply.
539
694
  | `sliceWidth` | 768 | full page: max slice width in px (raise it for small text on desktop) |
540
695
  | `maxHeight` | 60000 | full page: CSS px cap |
541
696
  | `reducedMotion` | false | emulate `prefers-reduced-motion: reduce` |
697
+ | `finishAnimations` | false | jump animations (Web Animations, GSAP on `window`) to their end first |
698
+ | `unpin` | true | full page: collapse pinned sections so each shows once |
542
699
  | `waitFor`, `waitMs`, `colorScheme`, `outDir`, `timeoutMs` | | as in the CLI |
543
700
  | `inline` | true | return images, not just paths |
544
701
  | `cookies` | | `[{ name, value, domain?, path? }]`, set on the target origin |
@@ -550,7 +707,10 @@ changed and fix every error. Fix the warnings too, or say why one doesn't apply.
550
707
  The result starts with a plain-text block: one line per device (or the error
551
708
  and hint), followed by the JSON summary (file paths, pixel sizes,
552
709
  `scrollHeight`, `capturedHeight`, `segments`, `clipped`, `slices`, HTTP status,
553
- final URL, title, console errors, `error`/`code`/`hint`).
710
+ final URL, title, console errors, `motion`, `warnings`, `error`/`code`/`hint`).
711
+ Each motion warning also gets its own line under its device, followed by
712
+ the fix, like ``WARN [motion-carousel] carousel: use `rpc record` for motion
713
+ at #carousel``.
554
714
 
555
715
  Then the images:
556
716
 
@@ -597,6 +757,27 @@ The second is the JSON report described under [JSON report](#json-report).
597
757
  The call is a tool error only when the page failed to load on every device;
598
758
  findings, even errors, come back as a normal result for the agent to act on.
599
759
 
760
+ ### `record`
761
+
762
+ Runs [`rpc record`](#recording-motion-rpc-record) on one URL.
763
+
764
+ | Param | Default | |
765
+ | --- | --- | --- |
766
+ | `url` | required | http(s), file, bare host or local path |
767
+ | `device` | `desktop-1440` | one id or alias |
768
+ | `selector` | whole viewport | CSS selector of the element to record |
769
+ | `duration` | 5 | seconds, 0.5 to 30 |
770
+ | `gif` | false | also write a GIF |
771
+ | `mp4` | false | also write an mp4, when a system ffmpeg has H.264 |
772
+ | `reducedMotion`, `waitFor`, `waitMs`, `colorScheme`, `outDir`, `timeoutMs` | | as for `screenshot` |
773
+ | `cookies`, `headers`, `storageState`, `useSession`, `allowOrigins` | | as for `screenshot` |
774
+
775
+ The result starts with a summary line (`ok iphone-15 #carousel: 3s webm
776
+ 1080x600 at 25 fps <path>`, then the poster, GIF and mp4 paths) and the
777
+ JSON result. The poster comes back inline as a JPEG, so the agent can see
778
+ what it recorded. An element that isn't there is a tool error with the code
779
+ `SELECTOR_NOT_FOUND`.
780
+
600
781
  ### `list_devices`
601
782
 
602
783
  Takes an optional `category` (`apple-phone`, `android-phone`, `tablet`,
@@ -703,10 +884,19 @@ from the `skills` CLI, are in `docs/cli-design.md`.
703
884
  - Chromium only, portrait only. There is no landscape mode, and none of RPC's
704
885
  fake browser chrome bars.
705
886
  - Scroll-driven animations are captured in their scroll-top state. Entrance
706
- animations that run once are usually fine after the warm-up scroll; ones
887
+ animations that run once are fine after the warm-up scroll and settle. Ones
707
888
  that reverse when scrolled back (GSAP `toggleActions` with reverse, scrubbed
708
- timelines) may show their initial state. `reducedMotion` helps on sites that
709
- respect it.
889
+ timelines) may show their initial state, and a collapsed pin shows its
890
+ section's first frame. `--finish-animations` only reaches GSAP when it's on
891
+ `window`; GSAP bundled into the app's JavaScript isn't, so its tweens stay
892
+ as they are. `--reduced-motion` helps on sites that respect it.
893
+ - Pins are found by GSAP's `.pin-spacer` class, `ScrollTrigger.getAll()`, and
894
+ sticky or fixed sections that are the only child of a tall parent. A pin
895
+ built some other way is still captured with its blank spacer.
896
+ - `rpc record` records what the page does on its own. It doesn't scroll or
897
+ click, so a scrubbed section only moves if something else moves it. Frames
898
+ come at the screenshot rate (about 30 fps on high-DPR phones), and the
899
+ webm repeats frames to hold 25 fps.
710
900
  - Very tall stitched PNGs are big files; review them through the slices.
711
901
  - The bezel geometry is RPC's, unchanged. On kind-default phones the page
712
902
  covers the dynamic island or notch, and generic monitors are drawn edge to
package/dist/args.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { parseCookie, parseHeader, resolveAuth, toOrigin } from './auth.js';
3
3
  import { resolveDevices } from './devices.js';
4
- import { COLOR_SCHEMES, DEFAULT_TIMEOUT_MS, defaultCheckOptions, defaultShotOptions, FAIL_ON, normalizeUrl, } from './options.js';
5
- export const HELP_TOPICS = ['shot', 'check', 'login', 'sessions', 'devices', 'doctor', 'install', 'mcp'];
4
+ import { COLOR_SCHEMES, DEFAULT_TIMEOUT_MS, defaultCheckOptions, defaultRecordOptions, defaultShotOptions, FAIL_ON, MAX_RECORD_SECONDS, normalizeUrl, } from './options.js';
5
+ export const HELP_TOPICS = ['shot', 'check', 'record', 'login', 'sessions', 'devices', 'doctor', 'install', 'mcp'];
6
6
  function isTopic(s) {
7
7
  return HELP_TOPICS.includes(s ?? '');
8
8
  }
@@ -16,7 +16,9 @@ export const USAGE = `rpc - screenshot a URL at device viewports, optionally in
16
16
 
17
17
  Usage:
18
18
  rpc shot <url> [options] capture screenshots
19
- rpc check <url> [options] pre-launch audit: meta, layout shift, labels, fonts, mobile
19
+ rpc check <url> [options] pre-launch audit: meta, layout shift, labels, fonts, mobile,
20
+ motion
21
+ rpc record <url> [options] record a short video of the page or one element
20
22
  rpc login <url> [--form] sign in once and save the session for that origin
21
23
  rpc sessions [list|clear [origin]]
22
24
  list or delete saved sessions
@@ -35,6 +37,10 @@ Shot options:
35
37
  --slice-width <px> max slice width (default: 768)
36
38
  --max-height <px> full-page cap in CSS px (default: 60000)
37
39
  --reduced-motion emulate prefers-reduced-motion: reduce
40
+ --finish-animations jump GSAP tweens on window (and Web
41
+ Animations) to their end before capturing
42
+ --no-unpin with --full-page: keep pinned sections as they
43
+ are (default: collapse each to its section)
38
44
  -o, --out <dir> output directory (default: ./.rpc-shots)
39
45
  --wait-for <selector> wait for a CSS selector before capturing
40
46
  --wait <ms> extra settle delay after load (default: 0)
@@ -49,7 +55,15 @@ Check options (plus -d, --wait-for, --wait, --timeout, --color-scheme, -o, --jso
49
55
  --shots screenshot the elements behind layout-shift and
50
56
  overflow findings (into --out)
51
57
 
52
- Auth options (values are never printed, shot and check):
58
+ Record options (plus --wait-for, --wait, --timeout, --color-scheme,
59
+ --reduced-motion, --no-emulate, -o, --json, --open):
60
+ -d, --device <id> one device id or alias (default: desktop-1440)
61
+ --selector <css> crop to this element (scrolled into view first)
62
+ --duration <s> seconds of video, up to 30 (default: 5)
63
+ --gif also write a GIF
64
+ --mp4 also write an mp4 (needs ffmpeg with H.264 on PATH)
65
+
66
+ Auth options (values are never printed; shot, check and record):
53
67
  --cookie <c> "name=value[; Domain=…; Path=…]", repeatable;
54
68
  sent to the target origin only
55
69
  --header <h> "Name: value", repeatable; target origin only
@@ -65,6 +79,7 @@ Examples:
65
79
  rpc shot https://example.com --full-page --color-scheme dark
66
80
  rpc login localhost:3000/sign-in && rpc shot localhost:3000/dashboard
67
81
  rpc check localhost:3000 --fail-on warn
82
+ rpc record localhost:3000 --selector '.carousel' --duration 6 --gif
68
83
  `;
69
84
  export function parseCli(argv, env = process.env) {
70
85
  const [cmd, ...rest] = argv;
@@ -113,6 +128,8 @@ export function parseCli(argv, env = process.env) {
113
128
  return parseShot(rest);
114
129
  case 'check':
115
130
  return parseCheck(rest);
131
+ case 'record':
132
+ return parseRecord(rest);
116
133
  default:
117
134
  throw new ArgError(`Unknown command "${cmd}". Run \`rpc --help\`.`);
118
135
  }
@@ -134,6 +151,8 @@ function parseShot(args) {
134
151
  'slice-width': { type: 'string' },
135
152
  'max-height': { type: 'string' },
136
153
  'reduced-motion': { type: 'boolean' },
154
+ 'finish-animations': { type: 'boolean' },
155
+ 'no-unpin': { type: 'boolean' },
137
156
  json: { type: 'boolean' },
138
157
  open: { type: 'boolean' },
139
158
  ...AUTH_FLAGS,
@@ -171,6 +190,10 @@ function parseShot(args) {
171
190
  options.saveSlices = true;
172
191
  if (values['reduced-motion'])
173
192
  options.reducedMotion = true;
193
+ if (values['finish-animations'])
194
+ options.finishAnimations = true;
195
+ if (values['no-unpin'])
196
+ options.unpin = false;
174
197
  if (typeof values['slice-width'] === 'string') {
175
198
  options.sliceWidth = parseInt_('--slice-width', values['slice-width'], 256, 4096);
176
199
  }
@@ -273,6 +296,79 @@ function parseCheck(args) {
273
296
  options.auth = auth;
274
297
  return { command: 'check', options, json: Boolean(values.json) };
275
298
  }
299
+ function parseRecord(args) {
300
+ if (wantsHelp(args))
301
+ return { command: 'help', topic: 'record' };
302
+ const { values, positionals } = parse(args, {
303
+ device: { type: 'string', short: 'd' },
304
+ devices: { type: 'string' },
305
+ selector: { type: 'string' },
306
+ duration: { type: 'string' },
307
+ gif: { type: 'boolean' },
308
+ mp4: { type: 'boolean' },
309
+ out: { type: 'string', short: 'o' },
310
+ 'wait-for': { type: 'string' },
311
+ wait: { type: 'string' },
312
+ 'color-scheme': { type: 'string' },
313
+ timeout: { type: 'string' },
314
+ 'no-emulate': { type: 'boolean' },
315
+ 'reduced-motion': { type: 'boolean' },
316
+ json: { type: 'boolean' },
317
+ open: { type: 'boolean' },
318
+ ...AUTH_FLAGS,
319
+ }, true);
320
+ if (positionals.length === 0)
321
+ throw new ArgError('Missing <url>. Usage: rpc record <url> [options]');
322
+ if (positionals.length > 1)
323
+ throw new ArgError(`Expected one URL, got: ${positionals.join(' ')}`);
324
+ let options;
325
+ try {
326
+ options = defaultRecordOptions(positionals[0]);
327
+ }
328
+ catch (err) {
329
+ throw new ArgError(err.message);
330
+ }
331
+ const device = values.device ?? values.devices;
332
+ if (typeof device === 'string') {
333
+ const list = parseDevices(device);
334
+ if (list.length !== 1)
335
+ throw new ArgError('rpc record takes one device (-d desktop-1440)');
336
+ options.device = list[0];
337
+ }
338
+ if (typeof values.selector === 'string') {
339
+ if (!values.selector.trim())
340
+ throw new ArgError('--selector is empty');
341
+ options.selector = values.selector;
342
+ }
343
+ if (typeof values.duration === 'string') {
344
+ const n = Number(values.duration);
345
+ if (!Number.isFinite(n) || n < 0.5 || n > MAX_RECORD_SECONDS)
346
+ throw new ArgError(`--duration must be a number of seconds from 0.5 to ${MAX_RECORD_SECONDS}`);
347
+ options.duration = n;
348
+ }
349
+ if (values.gif)
350
+ options.gif = true;
351
+ if (values.mp4)
352
+ options.mp4 = true;
353
+ if (typeof values.out === 'string')
354
+ options.outDir = values.out;
355
+ if (typeof values['wait-for'] === 'string')
356
+ options.waitFor = values['wait-for'];
357
+ if (typeof values.wait === 'string')
358
+ options.waitMs = parseMs('--wait', values.wait);
359
+ if (typeof values.timeout === 'string')
360
+ options.timeoutMs = parseMs('--timeout', values.timeout);
361
+ if (typeof values['color-scheme'] === 'string')
362
+ options.colorScheme = parseColorScheme(values['color-scheme']);
363
+ if (values['no-emulate'])
364
+ options.emulate = false;
365
+ if (values['reduced-motion'])
366
+ options.reducedMotion = true;
367
+ const auth = parseAuth(values, options.url);
368
+ if (auth)
369
+ options.auth = auth;
370
+ return { command: 'record', options, json: Boolean(values.json), open: Boolean(values.open) };
371
+ }
276
372
  function parseColorScheme(s) {
277
373
  if (!COLOR_SCHEMES.includes(s))
278
374
  throw new ArgError(`--color-scheme must be one of ${COLOR_SCHEMES.join(', ')}`);
package/dist/browser.js CHANGED
@@ -1,7 +1,7 @@
1
- import { spawn } from 'node:child_process';
2
- import { readFileSync } from 'node:fs';
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
- import { dirname, resolve } from 'node:path';
4
+ import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
5
5
  import { chromium } from 'playwright';
6
6
  const require = createRequire(import.meta.url);
7
7
  /** Playwright's own CLI, from this package's dependency (so versions match). */
@@ -41,12 +41,66 @@ export async function launchBrowser() {
41
41
  throw err;
42
42
  }
43
43
  }
44
+ /**
45
+ * Playwright's ffmpeg build, which `playwright install chromium` downloads
46
+ * next to the browser. It reads MJPEG, writes VP9 webm and PNG, and has the
47
+ * crop, scale and pad filters: enough for `rpc record` and its GIF frames.
48
+ * Found through Playwright's registry, so PLAYWRIGHT_BROWSERS_PATH applies.
49
+ */
50
+ export function playwrightFfmpeg() {
51
+ try {
52
+ const fromPlaywright = createRequire(require.resolve('playwright/package.json'));
53
+ const core = fromPlaywright('playwright-core/lib/coreBundle');
54
+ const path = core.registry?.registry?.findExecutable('ffmpeg')?.executablePath();
55
+ return path && existsSync(path) ? path : undefined;
56
+ }
57
+ catch {
58
+ return undefined; // registry layout changed: fall back to PATH
59
+ }
60
+ }
61
+ /** `ffmpeg` on PATH, if any. Relative entries (`.`) are skipped: they would run whatever `ffmpeg` sits in the cwd. */
62
+ export function systemFfmpeg(env = process.env) {
63
+ const names = process.platform === 'win32' ? ['ffmpeg.exe'] : ['ffmpeg'];
64
+ for (const dir of (env.PATH ?? '').split(delimiter)) {
65
+ for (const n of names)
66
+ if (isAbsolute(dir) && existsSync(join(dir, n)))
67
+ return join(dir, n);
68
+ }
69
+ return undefined;
70
+ }
71
+ /** The ffmpeg `rpc record` uses: Playwright's build first, then PATH. */
72
+ export function findFfmpeg() {
73
+ const pw = playwrightFfmpeg();
74
+ if (pw)
75
+ return { path: pw, source: 'playwright' };
76
+ const sys = systemFfmpeg();
77
+ return sys ? { path: sys, source: 'system' } : undefined;
78
+ }
79
+ /** An H.264 encoder in this ffmpeg build (Playwright's has none), for --mp4. */
80
+ export function h264Encoder(ffmpeg) {
81
+ const r = spawnSync(ffmpeg, ['-hide_banner', '-encoders'], { encoding: 'utf8', timeout: 10_000 });
82
+ const list = `${r.stdout ?? ''}`;
83
+ return ['libx264', 'h264_videotoolbox', 'libopenh264'].find(e => new RegExp(`\\s${e}\\s`).test(list));
84
+ }
85
+ /** ffmpeg for `rpc record`, and H.264 for --mp4. */
86
+ export function videoReport() {
87
+ const ff = findFfmpeg();
88
+ const sys = systemFfmpeg();
89
+ const encoder = sys ? h264Encoder(sys) : undefined;
90
+ return {
91
+ ffmpeg: ff
92
+ ? { source: ff.source, path: ff.path }
93
+ : { source: 'missing', fix: 'rpc record needs ffmpeg: run `npx rpc-agent install` (Playwright downloads its ffmpeg with Chromium), or put ffmpeg on PATH.' },
94
+ mp4: encoder ? { ok: true, encoder } : { ok: false },
95
+ };
96
+ }
44
97
  /** Check that Chromium is installed and actually launches. */
45
98
  export async function doctor() {
46
99
  const base = {
47
100
  node: process.version,
48
101
  playwright: playwrightVersion(),
49
102
  browsersPath: process.env.PLAYWRIGHT_BROWSERS_PATH || '(Playwright default cache)',
103
+ ...videoReport(),
50
104
  };
51
105
  try {
52
106
  const browser = await launchBrowser();