Skip to content
slidictPublic

About

Ruby CLI for scripting browser interactions and exporting them as animated GIFs.

Topics

Resources

Code of conduct

Contributing

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

btape

btape logo

btape recording a login flow into an animated GIF

Run a .tape file, get that GIF. This one is examples/demo.tape.

What is btape

A .tape file lists the browser actions you would otherwise click through by hand. btape performs them against a real browser and records what the page did as an animated GIF:

Output demo.gif
Viewport 1280x720

Goto https://example.com
Click "text=Login"
Type "#email" "demo@example.com"
Sleep 2s

It is a small, VHS-inspired Ruby CLI. Ferrum controls Chromium and captures PNG frames, and a pure-Ruby encoder produces the GIF. It does not require Playwright, Selenium, ffmpeg, or an external service.

Tapes are written by hand, or asked of a language model running on the same machine: btape generate describes the language to LM Studio, Ollama or anything else speaking their API, and holds the answer to the parser before handing it over.

Demo

The GIF above was recorded by btape itself, from examples/demo.tape driving examples/demo_app.html — a static page bundled with the repository, so the demo needs no other service running:

Output demo.gif
Viewport 1280x720

Goto file:///app/examples/demo_app.html
Sleep 500ms

Click "text=Login"
Sleep 300ms
Type "#email" "demo@example.com"
Type "#password" "password"
Click "text=Sign in"
Sleep 2s

file:///app/… is where the development container mounts the checkout, so dip demo records it as it stands. Outside the container, point Goto at your own copy of that file — or at any URL at all, which is the point.

Install

gem install btape

or, in a Gemfile:

gem 'btape'

Chromium — or Chrome — must be installed and discoverable by Ferrum: brew install --cask chromium on macOS, apt-get install chromium on Debian or Ubuntu. A browser installed somewhere unusual is named by BROWSER_PATH, and one running on another machine by --ws-url; see A browser running somewhere else.

Quick start

Write demo.tape:

Output demo.gif
Viewport 1280x720

Goto https://example.com
Sleep 1s

and run it:

btape demo.tape
# Created /path/to/demo.gif

Output is where the GIF goes, resolved relative to the tape file. Point Goto at your own application and add Click, Type and WaitFor lines until the recording shows what you want it to. The whole language is below.

Commands

Output <path>
Viewport <width>x<height>
Set <name> <value>
Goto <url>
Click <CSS selector or text=Text>
Type <CSS selector> <text>
Press <key> [count]
Frame <CSS selector or main>
Evaluate <javascript>
WaitFor <CSS selector or text=Text> [duration]
WaitForJS <javascript> [duration]
Screenshot [name]
Sleep <number>ms|s
Command What it does
Output Where the GIF is written. Required, and read before the run wherever it appears
Viewport The size the page is rendered at, and so the size of the GIF before Scale
Set Configures the run rather than acting on the page; the names are in Settings
Goto Navigates the page, and puts the following commands back in the main frame
Click Clicks the first element the selector matches
Type Focuses the first element the selector matches and types the text into it
Press Presses a key by name — Enter, Tab, Right — once, or count times
Frame Points the following commands at an iframe; Frame main returns to the page
Evaluate Runs JavaScript in the current frame
WaitFor Polls until the selector matches something, then carries on
WaitForJS Polls until the JavaScript returns something truthy
Screenshot Captures one frame now, and with a name puts it at a predictable path too
Sleep Holds where it is for the duration, recording all the while

Arguments containing spaces must be quoted. Empty lines and lines beginning with # are ignored. Output is required; Viewport defaults to 1280x720. Output paths are resolved relative to the tape file. Selectors are CSS, or text=Some text to match an element by the text it shows. Durations are a number followed by ms or s — 500ms, 1.5s.

Evaluate is how a tape reaches an API the page exposes rather than clicking at it. It runs in whatever frame is current, which is the page until a Frame says otherwise; navigating returns to the page too, since the frame belonged to the page that was left.

WaitFor and WaitForJS poll instead of guessing at a Sleep. Both check every WaitInterval, and are satisfied only once WaitStable checks in a row have passed; a check that fails puts the count back to zero, so a page that reports itself ready before it has settled is caught by raising WaitStable above 1. A duration on the line is how long that one wait gets, and WaitTimeout is how long the ones without get. Neither raises for a check that failed — only running out of time does, against the line that was waiting. An expression that raises counts as not-yet-true rather than as an error, but the last thing it raised is named in the timeout, so an expression that could never be true still says why.

Screenshot captures a frame there and then. It is what Set CaptureMode manual records with — one frame per page of a deck, rather than a few hundred near-identical ones — and a name makes that one frame findable afterwards, as frame-NAME.png under --frames-dir and as result.named_frames['NAME'] from Ruby.

Sleep is for holding a finished frame on screen long enough to be seen, which is a different job from waiting for the page: interval recording carries on throughout one, so a Sleep is what gives a GIF its pauses.

Settings

Set NAME VALUE configures a run. Every setting can also be given on the command line with --set NAME=VALUE, which wins over the tape, so one tape can run in more than one place.

Name Default Meaning
WsUrl — Connect to a browser already running at this CDP url instead of launching one
CaptureMode interval interval records continuously; manual captures only where Screenshot says to
Framerate 10 Captures per second in interval mode
FrameDelay 100ms How long each frame is shown in the GIF
Loop 0 Times to loop; 0 is forever
Scale 1.0 Scale the output down
OutputWidth — Output width in pixels; overrides Scale and keeps the aspect ratio
Quantizer adaptive adaptive picks the palette from the frames; rgb332 uses a fixed one
Timeout 120s Give up on the whole run after this
WaitTimeout 10s Give up on a WaitFor or WaitForJS after this
WaitInterval 100ms How often those two check
WaitStable 1 How many checks in a row must pass before a wait is satisfied
MaxFrames 600 Stop rather than record a hung page until the disk fills

Command line

Usage: btape [options] SCRIPT.tape

  --ws-url URL     Connect to a browser already running at this CDP url
  --set NAME=VALUE Override a setting, as a Set line would
  --frames-dir DIR Write the PNG frames here and keep them
  --verbose        Report each command on stderr as it runs

Subcommands:
  generate DESCRIPTION  Write a tape by asking a local model; btape generate --help

BTAPE_WS_URL is used when neither --ws-url nor --set WsUrl= is given.

Advanced usage

A browser running somewhere else

btape launches its own Chromium by default. Point it at one that is already running — a browserless/chrome container, say — and no browser needs to be in the image btape runs from:

btape --ws-url ws://chrome:3000 examples/thumbnails.tape

Each connection gets its own browser context, so concurrent runs against one shared browser do not see each other. The viewport is applied over the wire, since a browser that is already running cannot be told its window size at launch.

examples/thumbnails.tape is the other shape of run: one frame per page of a deck, captured with Set CaptureMode manual against a remote browser.

Frames, not just the GIF

Frames are normally written to a temporary directory and removed as the run unwinds. --frames-dir keeps them:

btape --frames-dir frames examples/thumbnails.tape

Fonts, and text that is not Latin

Glyphs come from the fonts the browser can see, which is not necessarily the machine btape runs on: the host when btape launches Chromium itself, the other machine when Set WsUrl points at one, and the image when either of those is a container. Nothing raises when a script has no coverage — the page records as rows of tofu boxes instead — so a font missing from a headless image shows up in the GIF and nowhere earlier.

Install fonts covering the scripts the tapes visit. On Debian or Ubuntu:

apt-get install fonts-noto-core                # most scripts, Latin included
apt-get install fonts-noto-cjk                 # Chinese, Japanese, Korean
apt-get install fonts-ipafont fonts-ipaexfont  # Japanese, as IPAGothic, IPAexGothic, IPAMincho

Fontconfig reads /usr/share/fonts, /usr/local/share/fonts, ~/.local/share/fonts and ~/.fonts, and font files copied in by hand need an fc-cache -f after them. The home directory in that list is the one belonging to whoever launches the browser, which under a service manager is often not the user who installed the font — sudo -u deploy fc-list settles that faster than another recording does. Chromium reads the configuration as it starts, and btape starts one browser per run; a browser shared over WsUrl keeps the fonts it was launched with until it is restarted.

Where one installed font covers a script, that is the whole job: the browser falls back to it even for a page that asked for sans-serif. Naming a family matters when several cover the same script — Noto CJK and IPA together, or a developer's macOS with Hiragino already on it. Either force it from the tape:

Evaluate "document.head.insertAdjacentHTML('beforeend', '<style>*{font-family:IPAexGothic!important}</style>')"

which lasts as long as the document it ran in, so it is repeated after each Goto and inside a Frame; or prefer it for everything the machine renders, in /etc/fonts/local.conf:

<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
  <alias><family>sans-serif</family><prefer><family>IPAexGothic</family></prefer></alias>
  <alias><family>serif</family><prefer><family>IPAexMincho</family></prefer></alias>
  <alias><family>monospace</family><prefer><family>IPAGothic</family></prefer></alias>
</fontconfig>

An alias only answers for the generic families. A page naming Helvetica, Arial ahead of sans-serif keeps whatever those resolve to — Liberation Sans, in an image that has it — so such a page is served by forcing the family from the tape, or by matching those names in the fontconfig file as well. macOS has no fontconfig at all, and there the tape is the only route.

A tape can check that the font arrived rather than trust the image it runs in, since a family that is not installed measures the same as one that does not exist:

WaitForJS "(() => { const c = document.createElement('canvas').getContext('2d'); const w = (f) => { c.font = '48px ' + f; return c.measureText('AあÄ0').width; }; return w('IPAexGothic') !== w('__missing__'); })()" 3s

Tape files themselves are read as UTF-8 whatever the locale says, so a Type line or a text= selector can be written in any script.

Writing a tape with a local model

btape generate describes the language to a model running on your own machine and asks it for a tape:

btape generate "record signing in at localhost:3000 and landing on the dashboard" -o signin.tape
btape signin.tape

The default is http://localhost:1234/v1, which is where LM Studio serves. Anything else speaking the same API answers just as well — Ollama on http://localhost:11434/v1, llama.cpp's server, vLLM — so nothing about the description or the page being recorded leaves the machine unless you point --llm-url somewhere that it does.

Usage: btape generate [options] DESCRIPTION

  --llm-url URL    The OpenAI-compatible model server to ask
  --model NAME     Ask for this model rather than whichever one is loaded
  --temperature N  How freely the model writes; 0.2 by default
  --context FILE   Give the model this file as context: selectors, notes, markup
  -o, --out FILE   Write the tape here rather than to standard output
  --verbose        Report each attempt on stderr

BTAPE_LLM_URL, BTAPE_LLM_MODEL and BTAPE_LLM_KEY stand in for the first two flags and for a key, which a local server rarely wants and a proxy in front of one usually does. With no model named, the server is asked which one it has loaded — the name a download was given is not worth remembering.

The description can be piped in rather than quoted, which is easier for anything longer than a line:

btape generate --context app/views/sessions/new.html.erb < what-to-record.txt

What comes back is parsed before you see it, and a tape that does not parse goes back to the model with the parser's own complaint — line 4: unknown command "Navigate" — for it to fix, up to three times. That loop is why this is worth more than pasting the command list into a chat window: a small model reliably invents a command or drops a quote, and just as reliably repairs it when told which line. What it cannot know is your markup, so a tape it wrote still names selectors that have to be checked against the page. Read it before you run it, the way you would read anything else generated for you.

A model that is not on this machine

--llm-url is the whole of the configuration, so a hosted endpoint speaking the same API works as well as a local one. Name the model rather than leaving it to be discovered: these servers answer /v1/models with a catalogue rather than with the one thing they have loaded, and the first entry of it is not necessarily something that holds a conversation.

BTAPE_LLM_KEY=sk-... btape generate --llm-url https://api.openai.com/v1 --model gpt-4.1 "record signing in"

Anthropic serves an OpenAI-compatible layer on the same host as its own API, so --llm-url https://api.anthropic.com/v1 --model claude-opus-5 records too. It is meant for trying models rather than for living on, but nothing btape asks of it is among the parts that are missing. A model that refuses --temperature at anything but its default wants --temperature 1.

Sending the work somewhere else is the thing to weigh, not the flag. A local model keeps the description and the --context file on the machine that ran the command; a hosted one is handed both, and a context file is usually a page of your own markup rather than something you would have published.

From Ruby

Runner#run returns a Btape::Result:

commands = Btape::Parser.new.parse(File.read('deck.tape'))

result = Btape::Runner.new(logger: Rails.logger).run(
  commands,
  base_directory: File.dirname('deck.tape'),
  settings: { ws_url: ENV['CHROME_WS_URL'] },
  frames_directory: frames,
  on_frame: ->(path, index) { logger.debug("captured #{index}: #{path}") }
)

result.output_path            # where the GIF went
result.frame_count            # frames that went into it
result.frame_paths            # the frames, when frames_directory was given
result.named_frames['page-01'] # the frame a Screenshot named

Nothing has to touch the filesystem. Pass an IO to write the GIF into:

buffer = StringIO.new(+''.b)
Btape::Runner.new.run(commands, base_directory: '.', output: buffer)

# `buffer.string` is the GIF. Hand it to whatever holds on to it — an Active
# Storage attachment on one of your own records, say:
deck = Deck.find(params[:id])
deck.animation.attach(io: StringIO.new(buffer.string), filename: 'deck.gif')

or use the encoder on its own, with PNG paths or ChunkyPNG images:

Btape::GifEncoder.new(delay: 150, width: 640).encode(frame_paths) # => String

The generator is a plain object too, so an application that already knows what it wants recorded can go from a sentence to a GIF without a file in between:

generator = Btape::LLM::Generator.new(client: Btape::LLM::Client.new(base_url: ENV['BTAPE_LLM_URL']))
tape = generator.call('record the dashboard loading', context: page_markup)

Btape::Runner.new.run(Btape::Parser.new.parse(tape), base_directory: '.', output: buffer)

Development

Working on btape itself, rather than using it, starts from a checkout:

bundle install
bundle exec rake spec
bundle exec rake rubocop
bundle exec exe/btape examples/demo.tape

Or from the development image, which contains Ruby, Chromium and Latin fonts; tapes that record other scripts need fonts for them added to it:

dip provision
dip test
dip demo

wip up
wip dispatch demo
wip dispatch btape examples/demo.tape

CI runs the specs and RuboCop across Ruby 3.2 to 4.0, and records examples/demo.tape against a real Chromium to check that a GIF still comes out the other end. CONTRIBUTING.md has the rest.

Limitations

The palette is chosen from the frames being encoded, which tracks gradients and text edges far more closely than the fixed RGB332 palette earlier versions used — but banding compresses well and fidelity does not, so the files are larger than they were. Set Scale or Set OutputWidth are the levers to pull back; identical consecutive frames are already collapsed into one held for longer. Set Quantizer rgb332 restores the old palette.

The first matching element is used for Click and Type.

Upgrading to 0.2

Runner#run returns a Btape::Result rather than the output path. Read result.output_path where the path was used before.

Contributing

Bug reports and pull requests are welcome. See CONTRIBUTING.md for the development setup, PR conventions, and how releases are generated. This project follows the Code of Conduct.

License

MIT

Repository topics

Suggested GitHub topics for this repository: browser-automation, chromium, cli, ruby, developer-tools, screen-recording, gif, ferrum, vhs.

About

Ruby CLI for scripting browser interactions and exporting them as animated GIFs.

Topics

Resources

Code of conduct

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages