evp — a small Rust CLI that ingests VHS-format scripts and produces GIF, SVG, or JSON outputs using libghostty as the underlying terminal emulator.
evp runs a real shell inside an embedded Ghostty VT, schedules typed
input from your .tape script onto an absolute timeline, snapshots the
terminal at the configured framerate, then streams frames to one or more
renderer threads.
| Architecture deep-dive | architecture.md |
| Examples | examples/ |
| Docker image | ghcr.io/halfrgrd/evp:latest |
| Pre-rendered example GIFs | assets release (linked below) |
These GIFs are produced by the ci
workflow on every push to master and uploaded as assets on the rolling
assets release.
Grab a prebuilt binary into the current directory — no Rust, no Zig, no Docker:
curl -sSfL https://raw.githubusercontent.com/HalFrgrd/evp/master/install.sh | shThis drops an evp executable into $PWD. Override the destination
with EVP_INSTALL_DIR=~/.local/bin sh or pin a release with
EVP_VERSION=v0.2.0 sh. The binary is a single fully static
(musl + static-pie) ELF — see System requirements
below.
Verify the install with the built-in demo (no script files needed):
./evp --run-test-script
# → writes ./evp-test.gifThen render your own scripts:
./evp examples/hello.tape -o hello.gifThe prebuilt binary is statically linked against musl libc as a
static-pie executable, so it has no dynamic library dependencies
at all:
$ ldd evp
statically linked
$ file evp
evp: ELF 64-bit LSB pie executable, x86-64, ..., static-pie linked
It runs unmodified on any x86_64 Linux kernel — Alpine, Debian (any version), Ubuntu, RHEL/CentOS (any version), distroless, scratch — no glibc version requirement.
Not required: fontconfig, freetype, ImageMagick, ffmpeg, a display server, or any installed fonts. JetBrains Mono is embedded into the binary.
If you don't want a binary on the host, the published image is fully self-contained:
docker run --rm -v "$PWD:/work" \
ghcr.io/halfrgrd/evp:latest \
examples/hello.tape -o hello.gifBuild dependencies (only needed at build time — the resulting binary does not need either):
- a Rust toolchain,
- Zig 0.15.x on
$PATH(libghostty's build system).
Both libghostty-vt and libghostty-vt-sys are pulled directly from
the upstream git repo by Cargo — there's no sibling repo to clone.
cargo build --release
./target/release/evp examples/hello.tape -o hello.gifThe resulting binary has no dynamic dependency on libghostty-vt.so
and no runtime requirement on Zig:
$ ldd ./target/release/evp | grep ghostty || echo "statically linked"
statically linkedIf you'd rather not install Rust or Zig locally, the project ships a
docker buildx bake recipe that produces the same fully-static
musl binary that the GitHub release uses. One command:
docker buildx bake extract-binary
# → /tmp/evp-build/evp (≈6 MiB, static-pie, stripped)The extract-binary target writes the binary directly to /tmp/evp-build
on the host via buildx's local output writer — no docker create /
docker cp plumbing. Other targets in the same bake file:
docker buildx bake test # workspace cargo test (CI parity)
docker buildx bake runtime # builds evp:local container image
docker buildx bake build-env # Rust+Zig base image
docker buildx bake builder # intermediate builder image.github/workflows/copilot-setup-steps.yml now does one thing only:
install Zig 0.15.2 into the Copilot environment.
For clean builds inside a restricted environment, whitelist these domains:
ziglang.org— Zig 0.15.2 toolchain downloads.github.com— Cargo'slibghostty-rsgit dependency andlibghostty-vt-syscloning the pinned Ghostty commit.deps.files.ghostty.org— most Ghostty Zig package tarballs.gitlab.freedesktop.org— thewayland-protocolsZig dependency.
Those hosts come directly from the pinned dependency sources in
crates/libghostty-vt-sys/build.rs and Ghostty's build.zig.zon.
evp ships both a [lib] and a [[bin]]. The library exposes the full
pipeline so other Rust tools can embed it:
let script = evp::parse_script(include_str!("../examples/hello.tape"))?;
let out = evp::run_and_return_recording(&script)?;
let json = evp::recording_to_json(&out.recording)?;
evp::render_gif(&out.recording, &evp::RenderOptions {
font_path: None,
font_size: 20.0,
frame_style: out.recording.frame_style,
}, std::path::Path::new("hello.gif"))?;The integration tests in tests/recording_json.rs use this same API end-to-end.
evp <script> [-o <output.gif|output.svg|output.json>] [--font <path.ttf>] [--dump-json <path.json>] [--log-level <level>]
evp --run-test-script [-o <output.gif|output.svg|output.json>]
evp themes
evp validate <script>
evp completion <shell>
| Flag | Meaning |
|---|---|
<script> |
Path to a .tape file. Required unless --run-test-script is set. |
--run-test-script |
Run a small built-in demo tape embedded in the binary. Writes ./evp-test.gif by default. Useful for verifying an install end-to-end with no external files. |
-o, --output |
Override the script's Output directives. Output extension picks the renderer (.gif, .svg, or .json). |
--font |
Path to a TTF/OTF/TTC used by the GIF renderer. Defaults to embedded JetBrains Mono family files in assets/fonts/. |
--dump-json |
Also render the intermediate Recording to JSON for later re-rendering or inspection. |
--log-level |
Explicit level override: error, warn, info, debug, trace. |
themes |
Print the bundled VHS theme preset names supported by Set Theme "<name>". |
validate <script> |
Parse a tape and exit without running the PTY or renderer. |
completion <shell> |
Print a shell completion script generated by clap. |
--version |
Print extended build metadata (git SHA/branch/date/dirty flag, build timestamp, rustc, target triple, opt-level). |
--log-level overrides the default info level. If it is not provided,
RUST_LOG is still honored.
The embedded font family is distributed under SIL OFL 1.1; see licenses/JETBRAINSMONO-OFL-1.1.txt.
GIF rendering uses style-specific JetBrains Mono faces when available:
- regular:
JetBrainsMono-Regular.ttf - bold:
JetBrainsMono-Bold.ttf - italic:
JetBrainsMono-Italic.ttf - bold italic:
JetBrainsMono-BoldItalic.ttf
Additional JetBrains Mono weights are also embedded under assets/fonts/.
If a requested style face is unavailable, rendering logs a warning and
falls back to the regular face.
The recommended path is the bundled composite action, which downloads a
prebuilt linux-amd64 binary from the matching GitHub Release and adds it
to $PATH. No Docker daemon, no Rust toolchain, no Zig — just one
uses: step.
- uses: HalFrgrd/evp@v1
with:
script: docs/demo.tape
output: docs/demo.gif
- uses: stefanzweifel/git-auto-commit-action@v5
with:
file_pattern: docs/demo.gifPin to a specific release for reproducibility:
- uses: HalFrgrd/evp@v0.2.0
with:
script: docs/demo.tape
output: docs/demo.gif
version: v0.2.0| Input | Default | Meaning |
|---|---|---|
script |
(required) | Path to the .tape script. |
output |
from script's Output directive |
Output file path. Extension picks the renderer (.gif, .svg, or .json). |
version |
latest |
evp release tag to install. |
font |
embedded JetBrains Mono | Optional path to a TTF/OTF font for the GIF renderer. |
log-level |
info |
One of error, warn, info, debug, trace. |
install-dir |
/usr/local/bin |
Where to install the evp binary. Added to $GITHUB_PATH. |
The release tarball is a fully static musl + static-pie x86_64
binary, so it runs on every GitHub-hosted Linux runner (ubuntu-22.04,
ubuntu-24.04, etc.) without any glibc version concern.
Required at runtime: nothing — ldd reports statically linked. No
fontconfig, no display server, no ImageMagick, no ffmpeg,
no Zig. Fonts are embedded into the binary. See the top-level
System requirements section for details.
The prebuilt tarball is x86_64-only. On non-amd64 hosts (arm64, macOS, Windows) you have two options:
- Use the published Docker image — see Docker fallback below.
- Build from source — install Zig 0.15.x and Rust, then
cargo install --git https://github.com/HalFrgrd/evp evp.
A multi-arch image is published on every push to master and on tag
pushes:
- name: Render terminal demo
run: |
docker run --rm -v "$PWD:/work" \
ghcr.io/halfrgrd/evp:latest \
docs/demo.tape --output docs/demo.gifThe image is fully self-contained — libghostty-vt.a is statically
linked into the binary — so no extra --volume for fonts or libraries
is needed.
| Feature | State |
|---|---|
.tape parsing (Set / Type / Sleep / Wait / Hide / Show / Ctrl+X / Output / Env / Source / Require) |
working |
PTY-backed shell, libghostty VT, diff-encoded Recording |
working |
| GIF renderer | working |
JSON renderer / serialisation of Recording |
working |
| Animated SVG renderer | working (selectable text, ~10× smaller than GIF) |
Screenshot, Copy / Paste, Set Theme, Set Margin*, Set WindowBar, Set BorderRadius, Set CursorBlink |
working |
Multiple Output directives for .gif / .svg / .json |
working |
Set LetterSpacing, Set LoopOffset, .mp4 / .webm / .txt / .ascii / PNG-frames outputs |
not implemented — tape will fail loudly with a clear error |
See architecture.md for the design rationale. See the next section for the full VHS-vs-evp parity matrix.
evp consumes the same .tape script format as
charmbracelet/vhs, but it is a
much smaller project — it only implements the subset of VHS that maps
cleanly onto an embedded libghostty + Rust renderer. Anything not in
that subset fails loudly at parse or run time rather than silently
no-op'ing, so a tape that produces a GIF on evp is guaranteed to have
exercised every directive it contains.
| VHS directive | evp |
|---|---|
Output <path> (.gif / .svg / .json; multiple allowed) |
✅ |
Set Shell <command...> |
✅ accepts a full command line (e.g. bash, /bin/bash, bash --norc, bash --rcfile my.rc) |
Set FontFamily <path-or-name> (path to a TTF/OTF/TTC) |
--font /path/to/font.ttf on the CLI for the same effect. |
Set FontSize <n> |
✅ |
Set Width <px> / Set Height <px> |
✅ |
Set Padding <px> |
✅ |
Set Margin <px> / Set MarginFill <color> |
✅ |
Set WindowBar <style> / Set WindowBarSize <px> |
✅ |
Set BorderRadius <px> |
✅ |
Set LineHeight <f> |
✅ |
Set Framerate <n> (also FrameRate, FPS) |
✅ |
Set PlaybackSpeed <f> |
✅ |
Set TypingSpeed <duration> |
✅ |
| `Set Theme <name | json>` |
Set CursorBlink <bool> |
✅ |
Set WaitTimeout <duration> |
✅ |
Set WaitPattern /regex/ |
✅ |
Type[@<duration>] "text" ... (single + double quotes + raw backticks) |
✅ |
Sleep <duration> |
✅ |
| `Wait[+Screen | +Line][@] [/regex/]` |
Hide / Show |
✅ |
Backspace, Delete, Insert, Enter, Tab, Space, Escape (with optional @<duration> <count>) |
✅ |
Up / Down / Left / Right / PageUp / PageDown / Home / End |
✅ |
ScrollUp / ScrollDown (modelled as keys; see "Differences" below) |
|
Ctrl[+Alt][+Shift]+<char> |
✅ |
Screenshot <path.png> |
✅ |
Copy "..." / Paste |
✅ (tape-local clipboard) |
Env <KEY> <VALUE> |
✅ |
Require <program> (checked against $PATH; missing programs abort the run) |
✅ |
Source <path> (recursive, cycle-detected) |
✅ |
Comments (# to end-of-line) |
✅ |
evp extras: Set Cols <n> / Set Rows <n> (explicit cell-grid override) |
✅ (no VHS equivalent) |
If a tape uses any of the following, evp aborts with an error pointing back to this section, instead of silently dropping the directive on the floor:
| VHS directive | evp behaviour |
|---|---|
Set LetterSpacing <px> |
parse-time error |
Set LoopOffset <pct> |
parse-time error |
Output out.mp4 / .webm / .txt / .ascii / PNG frames directory |
parse-time error (only .gif, .svg, and .json are written) |
VHS ships several subcommands that evp does not provide:
vhs new <file>— tape scaffoldervhs record— interactive ttyd recorder that writes a.tapevhs publish <file>— uploads a GIF tovhs.charm.shvhs serve— SSH server that renders tapes for remote clientsvhs manual— built-in command reference
These behaviours match VHS's documented semantics but rely on a different implementation, so the visual or runtime output may not be byte-identical:
- Renderer. VHS records ttyd in a headless browser and re-encodes
with ffmpeg/gifski. evp drives an embedded libghostty VT and rasterises
cell-by-cell with
ab_glyph+gifski. Antialiasing, glyph metrics, and dithering will differ slightly from a VHS recording of the same tape — the content is the same, the pixels may not be. - Font resolution. VHS uses the system font stack via the browser /
fontconfig. evp ships JetBrains Mono embedded into the binary and
treats
Set FontFamilyas a file path (or accepts--font path/to/font.ttfon the CLI). Passing a bare family name like"JetBrains Mono"will fall back to the embedded faces with a warning. - Default palette. VHS defaults to its built-in dark theme; evp now
uses the same default theme and also accepts the full bundled VHS
preset list from
THEMES.md. ScrollUp/ScrollDown. VHS implements these as smooth multi-frame mouse-wheel scrolls. evp models them as discrete key presses fed to the PTY; programs that respond to mouse wheel events in mouse-tracking mode (e.g.less) won't react to them.- GitHub Action. VHS has
charmbracelet/vhs-action; evp ships its own composite action — see Usingevpin GitHub Actions. Their inputs are not interchangeable. - Zero runtime dependencies. VHS requires
ttydandffmpegon$PATH. evp's release binary is statically linked and needs neither.
evp is based on the vhs project.
They share little code but evp does try use the same .tape file format.
The color themes in assets/vhs-themes.json are taken from the VHS project and are licensed under the MIT License. See licenses/VHS-MIT.txt for the full license text.
Copyright (c) 2022-2023 Charmbracelet, Inc





