paparazzi paparazzi website

Polished demo videos and screenshots of anything that runs in a browser, from reusable R scripts.

A demo of a task tracker. The view zooms in on the new-task form, a cursor clicks the title field and types Prepare release notes, then clicks Add. The view pulls back as the task appears at the top of the list, outlined in red, with the caption New tasks go to the top of the list. Under the caption Drag to reorder, the cursor drags the new task down the list and drops it just below the two high-priority tasks. Under the caption Check it off when you're done, the cursor clicks the task's Done button and the task is crossed out.

paparazzi opens a page in headless Chrome and works through your R script one step at a time: click this button, type into that field, move the camera to the form, show a caption. While it records, paparazzi moves the cursor and types text at the pace a person would, then saves the video as an MP4, WebM, or GIF.

Your script is the source of the demo, so when your app or document changes you can run it again to get the same demo of the new version. paparazzi works with any page Chrome can open, including Shiny apps, Quarto documents and slides, pkgdown sites, and live websites.

paparazzi does more than record. You can use the same functions and syntax to take product screenshots with framing and annotations, or to write front-end tests in testthat. In a test, paparazzi waits for each action’s target and retries each expectation until the page catches up.

Installation

You can install the development version of paparazzi from GitHub:

# install.packages("pak")
# pak::pak("posit-dev/paparazzi")
install.packages("paparazzi", repos = c('https://posit-dev.r-universe.dev', 'https://cloud.r-project.org'))

Additional requirements

paparazzi controls Chrome (or another Chromium-based browser) through chromote, so you’ll need one installed. If chromote can’t find it, see ?chromote::find_chrome.

paparazzi routes each recording to the encoder that suits it best: MP4 and WebM videos go to the av package, GIFs to gifski. Both are suggested dependencies, and paparazzi checks for them only when a recording needs them. Simple GIFs need only gifski, but two features call in extra packages: cropping a GIF to a framed region of the page needs png, and camera movement or burned-in captions need av to composite the frames.

Agent quickstart

paparazzi includes an agent skill that teaches coding agents how to write paparazzi scripts. You can give your agent that skill in one of three ways:

The code that made the video

Here’s the whole script for the video at the top of this page. It records a small task-tracker page that ships with paparazzi:

library(paparazzi)

page <- pz_open(
  pz_example("tasks"),
  width = 1000,
  height = 720,
  color_scheme = "light"
)

task_release <- pz_loc(".task", has_text = "Prepare release notes")
task_dentist <- pz_loc(".task", has_text = "Book dentist appointment")

page |>
  pz_stage(enter = "bottom", pause = 0.3) |>
  pz_stage_annotate(caption_side = "top") |>
  pz_record_start(
    frame = pz_frame(list("h1", ".task-list"), pad = 24, ratio = 16 / 9),
    format = "gif",
    scale = 800
  ) |>
  pz_annotate_caption("Add a task") |>
  pz_camera("#new_task", wait = TRUE) |>
  pz_act_type("Prepare release notes", target = "#task_title") |>
  pz_act_click("#add_task") |>
  pz_expect_visible(task_release) |>
  pz_annotate(task_release, type = "box", pad = 4, id = "new") |>
  pz_annotate_caption("New tasks go to the top of the list") |>
  pz_record_hold(1.2) |>
  pz_camera_reset() |>
  pz_annotate_clear(id = "new") |>
  pz_annotate_caption("Drag to reorder") |>
  pz_act_drag(
    pz_loc(".task-drag-handle", within = task_release),
    to = pz_loc(".task-drag-handle", within = task_dentist)
  ) |>
  pz_record_hold(1) |>
  pz_annotate_caption("Check it off when you're done") |>
  pz_find(task_release) |>
  pz_act_click(".task-done") |>
  pz_expect_class("done") |>
  pz_cursor_leave() |>
  pz_record_hold(1)

pz_close(page)

pz_open() opens the page at the size you want to record. At the top of the pipeline, pz_stage() sets defaults for the recording: the cursor enters from the bottom, and paparazzi pauses for a moment after each action so viewers can follow. pz_stage_annotate() tells paparazzi to place captions at the top.

pz_record_start() starts the recording, and pz_close() stops it and writes the file. The frame is the part of the page the video shows: the task card, from its heading to the bottom of the list, padded and widened to 16:9. With format and scale, you ask for an 800-pixel-wide GIF, small enough for a README.

Between those two calls, each line is one step, and the steps fall into three groups:

Features

Additional features

The demo above covers the basics: acting on the page, moving the camera, annotating, and captioning. paparazzi can also:

Screenshots

You can use the same annotations, framing, and devices in screenshots. Here’s the task tracker on a phone in dark mode, with a callout:

phone <- pz_open(
  pz_example("tasks"),
  width = 390,
  height = 700,
  mobile = TRUE,
  color_scheme = "dark"
)

phone |>
  pz_annotate_callout(
    "Tap Done to finish a task",
    target = pz_loc(".task-done", which = "first"),
    side = "bottom"
  ) |>
  pz_screenshot()
The task tracker on a narrow phone screen in dark mode. A red-bordered callout under the first task's Done button reads Tap Done to finish a task.

R Markdown and Quarto

You can also run paparazzi right inside an R Markdown or Quarto document. Put your script in a code chunk, leave out the file path, and end the chunk with pz_screenshot() or with the call that ends a recording, like pz_record() or pz_record_stop(). paparazzi saves the image or video with the document’s other figures and shows it right where the chunk is. When your app or document changes, render again and paparazzi takes fresh screenshots and records new videos.

Shiny apps

Give pz_open() the path to a Shiny app, and paparazzi runs the app in a background R process, opens it, and waits until it’s ready. When you close the page, paparazzi stops the app. In between, you use the same functions you would on any other page:

app <- pz_open(pz_example("tasks-app"))

app |>
  pz_set_shiny_input("priority", "high") |>
  pz_set_value("Buy milk", target = "#title") |>
  pz_act_click("#add") |>
  pz_expect_text("3 tasks", target = "#summary")

pz_close(app)

The one Shiny-specific function in that example is pz_set_shiny_input(). It sets an input the way Shiny sees it, so even selectize inputs and sliders update on the page and the server responds. Everything else works just as it does on any other page, so you can record a demo of your app, take screenshots, or write browser tests with what you already know.

Browser tests

You can write browser tests with the same functions you use to record a demo. paparazzi animates the cursor and typing only while it records, so outside a recording your steps run at full speed.

Browser tests are tricky because the page keeps changing as you use it. When you click a button, the page might fetch data, re-render a list, or start an animation, and you rarely know how long that will take. If your test checks too early, it fails for no reason; if it sleeps for a fixed time, it’s slow and probably still flaky.

paparazzi handles the waiting for you. You state what should happen, and paparazzi waits until it does or stops if it doesn’t. Before pz_act_click() clicks on an element, paparazzi waits for that element to be ready. An expectation like pz_expect_text() retries until the page shows what you expect, or times out with an error that says what it last saw.

paparazzi also works inside testthat, with the same syntax. testthat counts each paparazzi expectation as a test expectation, and pz_local_page() closes the page, and stops its app, when the test ends:

test_that("adding a task updates the summary", {
  page <- pz_local_page(pz_example("tasks-app"))

  page |>
    pz_set_value("Buy milk", target = "#title") |>
    pz_act_click("#add") |>
    pz_expect_text("3 tasks", target = "#summary")
})

If you’re testing a Shiny app, you might also consider shinytest2. Pick the tool by what you want to check:

Learn more