Run a .tape file, get that GIF. This one is examples/demo.tape.
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.
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.
gem install btapeor, 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.
Write demo.tape:
Output demo.gif
Viewport 1280x720
Goto https://example.com
Sleep 1s
and run it:
btape demo.tape
# Created /path/to/demo.gifOutput 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.
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.
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 |
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.
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.tapeEach 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 are normally written to a temporary directory and removed as the run
unwinds. --frames-dir keeps them:
btape --frames-dir frames examples/thumbnails.tapeGlyphs 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, IPAMinchoFontconfig 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.
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.tapeThe 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.txtWhat 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.
--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.
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 namedNothing 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) # => StringThe 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)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.tapeOr 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.tapeCI 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.
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.
Runner#run returns a Btape::Result rather than the output path. Read
result.output_path where the path was used before.
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.
Suggested GitHub topics for this repository: browser-automation, chromium,
cli, ruby, developer-tools, screen-recording, gif, ferrum, vhs.

