Terminal videos,
written in TypeScript
Record a session live, or script it. Render it to MP4, GIF, WebM, SVG or HTML. Same recording, same pixels, every time.
bun add -g termcutBun ≥ 1.4. ffmpeg for MP4/GIF; SVG and HTML need nothing else. Or a standalone binary.
Write it
A script is plain TypeScript that lives next to the code it shows. Scroll: each step adds a line, and the frame on the right is what that line actually produced — rendered by tcut, as text you can select.
One file is the whole video
defineVideo takes the config and a script. Everything the video needs — size, theme, outputs — lives here, next to the code it demonstrates.
import { defineVideo } from "tcut";export default defineVideo({ output: ["demo.mp4", "demo.gif"] },async (t) => {},);
run() waits for your prompt
It types the command, presses Enter, and returns when the prompt is back — not after a guessed sleep. Fast machine or slow CI, the video looks the same.
import { defineVideo } from "tcut";export default defineVideo({ output: ["demo.mp4", "demo.gif"] },async (t) => {await t.run("bun --version");},);
expect() makes it a test
Assert on the rendered screen, including lines that already scrolled away. tcut test runs the same script with no delays and fails the build when the screen does not match.
import { defineVideo } from "tcut";export default defineVideo({ output: ["demo.mp4", "demo.gif"] },async (t) => {await t.run("bun --version");await t.run("ls");await t.expect(/package\.json/);},);
snapshot() is a still of this moment
A PNG or SVG of the exact frame, written on every render — README screenshots that can never go stale, because the script that renders the video also renders them.
import { defineVideo } from "tcut";export default defineVideo({ output: ["demo.mp4", "demo.gif"] },async (t) => {await t.run("bun --version");await t.run("ls");await t.expect(/package\.json/);await t.snapshot("files.svg");},);
timelapse() compresses the boring part
An install plays 8× faster — output included, not just the silence. chapter() marks it as an mp4 chapter and a cut point for --split-chapters.
import { defineVideo } from "tcut";export default defineVideo({ output: ["demo.mp4", "demo.gif"] },async (t) => {await t.run("bun --version");await t.run("ls");await t.expect(/package\.json/);await t.snapshot("files.svg");await t.chapter("Install");await t.timelapse(() => t.run("bun add zod"), { speed: 8 });},);
print() is a caption, not a command
Markdown, rendered into the terminal without typing anything — headings, bold, links that stay clickable in SVG and HTML.
import { defineVideo } from "tcut";export default defineVideo({ output: ["demo.mp4", "demo.gif"] },async (t) => {await t.run("bun --version");await t.run("ls");await t.expect(/package\.json/);await t.snapshot("files.svg");await t.chapter("Install");await t.timelapse(() => t.run("bun add zod"), { speed: 8 });await t.print("## Done\n\nRender it: `tcut demo.video.ts`");},);
import { defineVideo } from "tcut";export default defineVideo({ output: ["demo.mp4", "demo.gif"] },async (t) => {},);
Then tcut demo.video.ts. Every method, one line each:
run()- waits for your prompt to come back, not for a timer
expect()- asserts on the screen, including lines that already scrolled away
hide()- runs setup off-camera; the state stays
snapshot()- a PNG or SVG still of that exact moment, on every render
chapter()- mp4 chapters, and cut points for --chapters / --split-chapters
print()- Markdown captions rendered into the terminal, nothing typed
zoom()- magnifies a region; keys: true shows what was pressed
timelapse()- fast-forwards an install or a build, not just the silence
browser- a real browser window beside or over the terminal
tcut test- runs every script as a test: no delays, just the assertions
The full surface is in the reference.
Or just record
Your own shell opens — prompt, config, aliases, in your terminal's colours and font. Type, exit. You get the recording and a script of what you typed, with run() calls that wait for your prompt. -- command runs through your shell too, so tcut rec -- ls is your ls.
tcut rec -o demo.giftcut rec -o demo.mp4 -- npm create vite
Render again
Recording and rendering are separate. A recording is an asciicast; frames are computed on a virtual clock. So a new theme, size or format never re-runs a shell, and cuts, joins and chapter splits happen on the recording — which is why they work for SVG as well as MP4.
- Change the theme
607 themes, Ghostty's collection. Same recording, new colours.
- Size and speed
Pixel dimensions for the platform; playback speed on the render clock.
- Cut, shadow, watermark
A window of the recording, polished. Cuts happen on the cast, so this works for SVG as well as GIF.
- Split by chapter, transparent
One file per
t.chapter(), with a real alpha channel. - Join recordings
Same-size casts, a pause between them, one video.
- Publish
To your own S3-compatible bucket. There is no hosted service.
tcut render demo.cast --theme "Gruvbox Dark" -o demo.svg -o demo.htmltcut render demo.cast --width 1280 --height 720 --speed 1.5 -o demo.mp4tcut render demo.cast --from 2s --to 10s --shadow --watermark "@you" -o clip.giftcut render demo.cast --split-chapters --margin-fill transparent -o demo.webmtcut concat intro.cast demo.cast --gap 500ms -o launch.mp4tcut publish demo.gif
mp4 · gif · webm · webp · svg · html · png · txt · log
demo.svg (96 KB, 48 unique frames).Faithful to the terminal
The emulator is Ghostty's core, so what tcut sees is what your terminal would show — and what it records is what the program actually received.
- Arrow keys and pastes arrive exactly as the running program asked: application cursor mode, bracketed paste.
- Links printed with OSC 8 stay clickable in SVG and HTML.
- Frames are never torn: synchronized-output blocks are captured whole.
- Symbols the font lacks — progress blocks, Nerd Font icons — stay on their cell, so status bars never drift.
- theme: "auto" and font: "auto" render with the colours and font of the terminal you record in — asked from the terminal itself — so the video looks like your terminal, not like a default. tcut rec does this by itself.
- tcut doctor demo.cast explains what a recording used, and what would not survive a GIF.
Test it
expect() makes a demo a test. tcut test runs it with no delays and exits non-zero when the screen does not match, so the script that renders your README video can guard it in CI. tcut diff catches output changes between two recordings.
tcut test demo.video.tstcut diff a.cast b.cast
Or from code
Everything the CLI does is an exported function. Define a video in your own build script, record it, render it, read the results back — this site records its walkthrough frames that way. Bun only, like the CLI.
import { defineVideo, renderCast } from "termcut";const video = defineVideo({ output: ["demo.mp4", "demo.gif"] }, async (t) => {await t.run("bun --version");await t.expect(/1\.\d+/);});const { outputs, screenshots } = await video.run({ log: console.log });await renderCast("old.cast", { output: ["old.webm"], width: 1280, height: 720 });
A browser next to the terminal
For dev-server demos: the page is recorded on the same clock and composited beside or over the terminal. t.focus("browser") brings it to the front.
defineVideo({ output: "demo.mp4", browser: { position: "overlay" } }, async (t) => {await t.run("bun run dev </dev/null >/tmp/dev.log 2>&1 &");await t.browser.goto("http://localhost:5173");await t.run("sed -i '' 's/Hello/Hi/' src/App.tsx");await t.focus("browser");});
Examples
tcut waits on the rendered screen, so it can drive anything a person can: menus, prompts, agents that think for ten seconds. Scripts in examples/.




Compared with VHS
VHS is the reference point and the inspiration. A `.tape` is a fixed script with sleeps, recorded live in Chrome; a tcut script is TypeScript that watches the screen. The notes say where that matters:
import { defineVideo } from "tcut";export default defineVideo({ output: "demo.gif" }, async (t) => {await t.run("bun install");returns when your prompt is back — VHS sleeps for a guessed durationawait t.expect(/installed/);asserts on the rendered screen; `tcut test` runs it in CIfor (const file of ["a.ts", "b.ts"]) await t.run(`bun ${file}`);plain TypeScript: loops, imports, shared scenes, autocomplete});
- Rendering never re-runs the shell — a new theme, size or format is computed from the recording. VHS screenshots Chrome live, so output depends on machine speed.
- Same emulator as your terminal — Ghostty's core in WASM, its themes, plus SVG and HTML outputs that need no ffmpeg or browser.
How it works
- Record. Bun.Terminal runs your shell in a PTY. Every byte is timestamped into a .cast.
- Watch. The same bytes feed a headless Ghostty (via wterm). That is how run() knows the prompt is back and expect() sees what you see.
- Render. The cast replays into the same terminal inside Bun.WebView, one frame per tick, straight to ffmpeg. SVG and HTML are built from the terminal grid, no browser involved.
Built for agents as much as people: no prompts, exit codes, --json, an llms.txt. npx skills add AmanVarshney01/tcut teaches any coding agent tcut, plus a tcut-remotion skill for cutting the footage into a launch video.