btape
btape is a small, VHS-inspired Ruby CLI that runs browser actions from a
.tape file and records them as an animated GIF. 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.
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
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.
Output demo.gif
Viewport 1280x720
Goto http://localhost:3000
Click "text=Login"
Type "#email" "demo@example.com"
Sleep 1s
Evaluate runs JavaScript in the current frame, which is how a tape reaches
an API the page exposes rather than clicking at it. Frame points the
commands that follow at an iframe, and Frame main returns to the page;
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.
Screenshot captures a frame there and then. With a name it also lands at a
predictable path, for picking one particular frame out of a run.
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 |
Install and run
Chromium must be installed and discoverable by Ferrum. Then:
bundle install
bundle exec btape demo.tape
bundle exec rake spec
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.
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.
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.
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
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)
Container development with dip or wip
The development image 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
examples/demo.tape drives a small static page bundled at
examples/demo_app.html, so the demo is self-contained and needs no other
service running. Edit the tape (or point Goto at a different URL) to record
something else. examples/thumbnails.tape shows the other shape of run: one
frame per page of a deck, against a browser running elsewhere.
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.