termcut 0.1.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -161
- package/bin/tcut.mjs +3 -0
- package/package.json +5 -3
- package/src/cli.ts +49 -11
- package/src/index.ts +2 -0
- package/src/live.ts +130 -0
- package/src/recorder.ts +3 -2
- package/LICENSE +0 -21
package/README.md
CHANGED
|
@@ -1,186 +1,53 @@
|
|
|
1
1
|
# tcut
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[](https://www.npmjs.com/package/termcut)
|
|
5
|
-
[](LICENSE)
|
|
3
|
+
Turn a terminal session into a video. Record it live or script it in TypeScript; render to MP4, GIF, SVG, HTML — identical every time.
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Built on Bun 1.4 — `Bun.Terminal` for the PTY, `Bun.WebView` for pixels, `Bun.build` for the renderer,
|
|
10
|
-
`Bun.Image` for stills, `bun build --compile` for a single binary — plus [wterm](https://github.com/vercel-labs/wterm)'s
|
|
11
|
-
libghostty WASM core as the terminal emulator. Inspired by [VHS](https://github.com/charmbracelet/vhs); scripts are
|
|
12
|
-
code, and recording is separate from rendering.
|
|
13
|
-
|
|
14
|
-

|
|
15
|
-
|
|
16
|
-
<sub>Made by tcut from [`examples/readme.ts`](examples/readme.ts). Same cast as SVG: [docs/demo.svg](docs/demo.svg).</sub>
|
|
17
|
-
|
|
18
|
-
```ts
|
|
19
|
-
// demo.video.ts
|
|
20
|
-
import { defineVideo } from "tcut";
|
|
21
|
-
|
|
22
|
-
export default defineVideo(
|
|
23
|
-
{
|
|
24
|
-
output: ["out/demo.mp4", "out/demo.gif", "out/demo.svg"],
|
|
25
|
-
theme: "catppuccin-mocha",
|
|
26
|
-
cols: 80, rows: 20,
|
|
27
|
-
typingSpeed: "40ms", typingJitter: 0.4, // jitter is seeded → reproducible
|
|
28
|
-
windowBar: "colorful", title: "tcut", margin: 32, borderRadius: 12,
|
|
29
|
-
},
|
|
30
|
-
async (t) => {
|
|
31
|
-
await t.hide(() => t.run("cd /tmp && mkdir -p demo && cd demo")); // happens, but is cut from the video
|
|
32
|
-
|
|
33
|
-
await t.run("echo 'Hello 👋'"); // type + Enter + wait for the prompt to come back
|
|
34
|
-
await t.run("ls -la");
|
|
35
|
-
await t.expect(/total \d+/); // assertion — the script doubles as an integration test
|
|
36
|
-
await t.screenshot("out/ls.png");
|
|
37
|
-
await t.sleep("1.5s");
|
|
38
|
-
},
|
|
39
|
-
);
|
|
40
|
-
```
|
|
5
|
+

|
|
41
6
|
|
|
42
7
|
## Install
|
|
43
8
|
|
|
44
|
-
**With Bun (recommended)** — Bun ≥ 1.4. The npm package is **`termcut`** (npm's typosquat filter blocks the
|
|
45
|
-
4-letter name); the command it installs is `tcut`:
|
|
46
|
-
|
|
47
9
|
```sh
|
|
48
|
-
bun add -g termcut
|
|
49
|
-
bunx termcut init demo # or run it without installing
|
|
10
|
+
bun add -g termcut # Bun ≥ 1.4 · installs the `tcut` command
|
|
50
11
|
```
|
|
51
12
|
|
|
52
|
-
|
|
53
|
-
[Releases](https://github.com/AmanVarshney01/tcut/releases) (`tcut-<version>-<platform>`, checksums in `SHA256SUMS`):
|
|
13
|
+
Standalone binaries: [Releases](https://github.com/AmanVarshney01/tcut/releases). MP4/GIF need `ffmpeg`; SVG/HTML don't.
|
|
54
14
|
|
|
55
|
-
|
|
56
|
-
curl -fsSL https://github.com/AmanVarshney01/tcut/releases/latest/download/tcut-0.1.0-darwin-arm64 -o tcut
|
|
57
|
-
chmod +x tcut && ./tcut init demo
|
|
58
|
-
```
|
|
15
|
+
## Use
|
|
59
16
|
|
|
60
|
-
|
|
61
|
-
so no `node_modules` is needed next to your script. If you add `termcut` to a project, `import … from "termcut"`
|
|
62
|
-
also works and gives your editor the types.
|
|
63
|
-
|
|
64
|
-
**From source**
|
|
17
|
+
Record what you do:
|
|
65
18
|
|
|
66
19
|
```sh
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
## Requirements
|
|
72
|
-
|
|
73
|
-
| To… | You need |
|
|
74
|
-
|---|---|
|
|
75
|
-
| run tcut | **Bun ≥ 1.4** (`bun add -g termcut`), or the standalone binary (Bun is embedded) |
|
|
76
|
-
| record / `tcut test` | a shell — `bash` (default), `zsh`, `fish` or `sh` — plus whatever CLI tools your script runs. No browser, no ffmpeg |
|
|
77
|
-
| render `.svg` / `.html` | nothing else — pure Bun |
|
|
78
|
-
| render `.png` / `.jpg` / `frames/` | a WebView: **macOS → nothing** (system WebKit). **Linux / Windows → Chrome, Chromium, Edge or Brave** installed (Windows ships Edge) |
|
|
79
|
-
| render `.mp4` / `.gif` / `.webm` | the WebView above **+ ffmpeg** — `brew install ffmpeg` · `apt install ffmpeg` · `winget install ffmpeg` |
|
|
80
|
-
| render `.webp` | an ffmpeg with libwebp — Homebrew: `brew install ffmpeg-full` (tcut finds it automatically; see below) |
|
|
81
|
-
| your chosen font | `font.family` must be installed (default stack: JetBrains Mono → Menlo → monospace). SVG output uses the *viewer's* fonts |
|
|
82
|
-
|
|
83
|
-
Notes
|
|
84
|
-
- tcut looks for ffmpeg in this order: `$TCUT_FFMPEG`, `ffmpeg` on PATH, Homebrew's keg-only `ffmpeg-full`. It picks the first one that has the encoder a given output needs.
|
|
85
|
-
- Verified on macOS (Apple Silicon, CI on GitHub's macOS runners). Linux and Windows binaries are cross-compiled but not yet exercised in CI; recording should work everywhere, rendering depends on the WebView backend above.
|
|
86
|
-
- Rendering throughput is ~30 output frames/s; idle stretches are free (unchanged frames are reused). A 60 fps, 10 s clip renders in a few seconds.
|
|
87
|
-
|
|
88
|
-
## Why not VHS?
|
|
89
|
-
|
|
90
|
-
| | VHS | tcut |
|
|
91
|
-
|---|---|---|
|
|
92
|
-
| Script format | `.tape` DSL | TypeScript: loops, imports, shared scenes, assertions |
|
|
93
|
-
| Wait for output | `Wait` regex on raw bytes | `wait()` / `expect()` / `run()` read the **rendered screen** (headless Ghostty) |
|
|
94
|
-
| Determinism | live screenshots, machine-speed dependent | record once to `.cast`, render on a virtual clock → identical frames anywhere |
|
|
95
|
-
| Re-theme | re-run everything | `tcut render demo.cast --theme dracula` — no shell is spawned |
|
|
96
|
-
| Outputs | mp4 / gif / webm / png frames | + **animated SVG**, **single-file HTML player**, PNG/JPG stills — SVG/HTML need no ffmpeg or browser |
|
|
97
|
-
| As tests | — | `tcut test` runs scripts in fast mode, exit code reflects `expect()` |
|
|
98
|
-
| Stack | ttyd + Chrome + ffmpeg | Bun + wterm (JS/WASM) + ffmpeg (only for video containers) |
|
|
99
|
-
|
|
100
|
-
## How it works
|
|
101
|
-
|
|
102
|
-
```
|
|
103
|
-
script.ts ─▶ record (Bun.Terminal PTY) ─▶ demo.cast ─▶ render (virtual clock) ─▶ mp4 / gif / webm / png
|
|
104
|
-
clean shell; every chunk asciicast v2 ├─ Bun.WebView + @wterm/dom → ffmpeg
|
|
105
|
-
also feeds a headless ├─ headless grid → animated SVG
|
|
106
|
-
Ghostty core for wait/expect └─ cast + lite core → self-contained HTML
|
|
20
|
+
tcut rec -o demo.gif # opens a shell, records until you `exit`
|
|
21
|
+
tcut rec -o demo.mp4 -- npm create vite # or just one command
|
|
107
22
|
```
|
|
108
23
|
|
|
109
|
-
|
|
110
|
-
timestamped into an asciicast v2 file *and* parsed by a headless Ghostty terminal, so `run()` knows when the
|
|
111
|
-
prompt is back and `expect()` sees what a human would see. Terminal queries (e.g. from vim) are answered.
|
|
112
|
-
- **Render** replays the cast: frame *N* is the screen at *N / fps*. Hidden sections are cut, playback speed is
|
|
113
|
-
applied, cursor blink is driven by the render clock. Unchanged frames are reused, so idle time is free.
|
|
114
|
-
- **Cache**: re-running an unchanged script reuses the cast (`--force` to re-record). `quantize: true` snaps
|
|
115
|
-
timestamps to the frame grid for byte-stable casts.
|
|
24
|
+
Or script it:
|
|
116
25
|
|
|
117
|
-
|
|
26
|
+
```ts
|
|
27
|
+
// demo.video.ts
|
|
28
|
+
import { defineVideo } from "tcut";
|
|
118
29
|
|
|
30
|
+
export default defineVideo({ output: "demo.gif" }, async (t) => {
|
|
31
|
+
await t.run("bun --version"); // type, Enter, wait for the prompt
|
|
32
|
+
await t.expect(/1\.\d+/); // assert on the screen
|
|
33
|
+
await t.sleep("1s");
|
|
34
|
+
});
|
|
119
35
|
```
|
|
120
|
-
tcut <script.ts> [options] record + render
|
|
121
|
-
tcut record <script.ts> record only (writes the .cast)
|
|
122
|
-
tcut render <file.cast> [options] render an existing .cast (tcut or asciinema)
|
|
123
|
-
tcut test <path...> run scripts in fast mode as tests (no video)
|
|
124
|
-
tcut init [name] [--template t] scaffold a script: basic | tour | test
|
|
125
|
-
tcut themes list built-in themes
|
|
126
|
-
|
|
127
|
-
-o, --output <path> .mp4 .webm .gif .webp .svg .html .png .jpg or a directory/ for PNG frames (repeatable)
|
|
128
|
-
--theme <name> catppuccin-mocha | dracula | github-dark | tokyo-night | one-dark
|
|
129
|
-
--font <family> --font-size <px> --line-height <x> --letter-spacing <px>
|
|
130
|
-
--fps <n> --speed <x>
|
|
131
|
-
--padding <px> --margin <px> --margin-fill <color> --radius <px>
|
|
132
|
-
--window-bar <none|colorful|colorfulRight|rings|ringsRight> --title <text> --no-blink
|
|
133
|
-
--core <ghostty|lite> --cast <path> --record-only --force -q, --quiet
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
## Script API
|
|
137
|
-
|
|
138
|
-
`defineVideo(config, async (t) => { … })`
|
|
139
36
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
| `shell` | `"bash"` | `"bash" \| "zsh" \| "fish" \| "sh"` or a full `string[]` command |
|
|
144
|
-
| `prompt` / `promptPattern` | `"> "` | prompt for the clean shell; regex used by `run()` / `wait()` |
|
|
145
|
-
| `cols` / `rows` / `fps` | 80 / 24 / 60 | |
|
|
146
|
-
| `typingSpeed` / `typingJitter` / `seed` | `"50ms"` / 0 / 1 | |
|
|
147
|
-
| `playbackSpeed` | 1 | applied at render time |
|
|
148
|
-
| `waitTimeout` / `endPause` | `"15s"` / `"1s"` | |
|
|
149
|
-
| `quantize` / `cache` / `core` | false / true / `"ghostty"` | `core: "lite"` = wterm's Zig core (faster, no query replies) |
|
|
150
|
-
| `font` | JetBrains Mono 20px, lh 1.2 | `{ family, size, lineHeight, letterSpacing }` |
|
|
151
|
-
| `theme` | `"catppuccin-mocha"` | name or a full `Theme` object |
|
|
152
|
-
| `cursor` | `{ blink: true, period: 1000 }` | |
|
|
153
|
-
| `padding` / `margin` / `marginFill` / `borderRadius` / `windowBar` / `title` | 24 / 0 / bg / 0 / `"none"` / `""` | window chrome |
|
|
154
|
-
| `cast` | next to the first output | where the recording is saved |
|
|
155
|
-
|
|
156
|
-
`t` (a `TerminalSession`):
|
|
157
|
-
|
|
158
|
-
- Input: `type(text, {speed})`, `run(cmd, {wait, timeout})`, `paste(text)`, `enter() tab() backspace() delete()
|
|
159
|
-
escape() space() up() down() left() right() home() end() pageUp() pageDown()` (repeat count), `ctrl("c")`,
|
|
160
|
-
`alt("b")`, `key("f5")`, `raw(bytes)`
|
|
161
|
-
- Timing: `sleep("500ms")`, `wait(/re/, { scope: "line" | "screen", timeout })`
|
|
162
|
-
- Assertions: `expect(/re/, { scope })` — throws `ExpectationError` with a screen dump
|
|
163
|
-
- Structure: `hide(async () => …)`, `screenshot(path)`, `marker(name)`, `resize(cols, rows)`, `clear()`
|
|
164
|
-
- Introspection: `screen()`, `line()`, `cursor()`, `cols`, `rows`, `config`
|
|
165
|
-
|
|
166
|
-
Durations accept milliseconds or `"500ms" | "1.5s" | "2m"`.
|
|
167
|
-
|
|
168
|
-
Programmatic use: `const v = defineVideo(...); await v.record(); await v.render(undefined, { overrides: { theme: "dracula" } })`,
|
|
169
|
-
`renderCast(file, { output })`, `buildSvg(rec, config)`, `runScriptTests(paths)`.
|
|
37
|
+
```sh
|
|
38
|
+
tcut demo.video.ts
|
|
39
|
+
```
|
|
170
40
|
|
|
171
|
-
|
|
41
|
+
Re-render any recording without re-running it:
|
|
172
42
|
|
|
173
43
|
```sh
|
|
174
|
-
|
|
175
|
-
bun run typecheck
|
|
176
|
-
bun src/cli.ts examples/demo.ts
|
|
177
|
-
bun run build # dist/tcut single binary with embedded renderer assets
|
|
178
|
-
bun run build:all # cross-compile all platforms into dist/ + SHA256SUMS
|
|
44
|
+
tcut render demo.cast --theme dracula -o demo.svg
|
|
179
45
|
```
|
|
180
46
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
47
|
+
## More
|
|
48
|
+
|
|
49
|
+
- [Examples](https://github.com/AmanVarshney01/tcut/tree/main/packages/tcut/examples) — driving an interactive TUI, recording Claude Code / Codex
|
|
50
|
+
- [Reference](https://github.com/AmanVarshney01/tcut/blob/main/packages/tcut/docs/REFERENCE.md) — every CLI flag and script option
|
|
51
|
+
- [tcut.amanv.dev](https://tcut.amanv.dev)
|
|
184
52
|
|
|
185
|
-
|
|
186
|
-
and measurements are in `PLAN.md`.
|
|
53
|
+
MIT
|
package/bin/tcut.mjs
ADDED
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "termcut",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Script terminal sessions in TypeScript, render them to reproducible MP4/GIF/WebM/SVG/HTML with Bun.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Aman Varshney",
|
|
7
7
|
"homepage": "https://github.com/AmanVarshney01/tcut#readme",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/AmanVarshney01/tcut.git"
|
|
10
|
+
"url": "git+https://github.com/AmanVarshney01/tcut.git",
|
|
11
|
+
"directory": "packages/tcut"
|
|
11
12
|
},
|
|
12
13
|
"bugs": {
|
|
13
14
|
"url": "https://github.com/AmanVarshney01/tcut/issues"
|
|
@@ -32,9 +33,10 @@
|
|
|
32
33
|
".": "./src/index.ts"
|
|
33
34
|
},
|
|
34
35
|
"bin": {
|
|
35
|
-
"tcut": "./
|
|
36
|
+
"tcut": "./bin/tcut.mjs"
|
|
36
37
|
},
|
|
37
38
|
"files": [
|
|
39
|
+
"bin",
|
|
38
40
|
"src",
|
|
39
41
|
"scripts",
|
|
40
42
|
"README.md",
|
package/src/cli.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { parseArgs } from "node:util";
|
|
5
|
+
import { writeCast } from "./cast";
|
|
6
|
+
import { resolveConfig } from "./config";
|
|
4
7
|
import * as api from "./index";
|
|
8
|
+
import { recordLive } from "./live";
|
|
9
|
+
import { renderOutputs } from "./render";
|
|
5
10
|
import { runScriptTests } from "./testing";
|
|
6
11
|
import { themeNames } from "./themes";
|
|
7
12
|
import type { CoreName, ThemeName, VideoConfig, WindowBar } from "./types";
|
|
@@ -23,6 +28,7 @@ const HELP = `tcut — script terminal sessions in TypeScript, render them to vi
|
|
|
23
28
|
|
|
24
29
|
Usage:
|
|
25
30
|
tcut <script.ts> [options] record + render
|
|
31
|
+
tcut rec [options] [-- command…] record a LIVE session you drive yourself (no script), then render
|
|
26
32
|
tcut record <script.ts> [options] record only (writes the .cast)
|
|
27
33
|
tcut render <file.cast> [options] render an existing .cast (tcut or asciinema)
|
|
28
34
|
tcut test <path...> run scripts in fast mode as tests (no video)
|
|
@@ -38,6 +44,7 @@ Options (override the script's config):
|
|
|
38
44
|
--window-bar <type> none | colorful | colorfulRight | rings | ringsRight
|
|
39
45
|
--title <text> --no-blink
|
|
40
46
|
--core <name> ghostty | lite
|
|
47
|
+
--cols <n> --rows <n> terminal size (rec: defaults to your terminal's size)
|
|
41
48
|
--cast <path> where to read/write the .cast
|
|
42
49
|
--record-only stop after writing the cast
|
|
43
50
|
--force ignore the cast cache and re-record
|
|
@@ -66,6 +73,8 @@ const { values, positionals } = parseArgs({
|
|
|
66
73
|
title: { type: "string" },
|
|
67
74
|
"no-blink": { type: "boolean" },
|
|
68
75
|
core: { type: "string" },
|
|
76
|
+
cols: { type: "string" },
|
|
77
|
+
rows: { type: "string" },
|
|
69
78
|
cast: { type: "string" },
|
|
70
79
|
"record-only": { type: "boolean" },
|
|
71
80
|
force: { type: "boolean" },
|
|
@@ -76,12 +85,19 @@ const { values, positionals } = parseArgs({
|
|
|
76
85
|
});
|
|
77
86
|
|
|
78
87
|
const quiet = values.quiet === true;
|
|
88
|
+
const useColor = process.stdout.isTTY === true && !process.env.NO_COLOR;
|
|
89
|
+
const paint = (code: string) => (s: string) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
90
|
+
const green = paint("32");
|
|
91
|
+
const dim = paint("2");
|
|
92
|
+
const red = paint("31");
|
|
93
|
+
|
|
94
|
+
// Status goes to stdout (plain informational output); only real errors go to stderr.
|
|
79
95
|
const log = (msg: string) => {
|
|
80
|
-
if (!quiet)
|
|
96
|
+
if (!quiet) process.stdout.write(`${msg}\n`);
|
|
81
97
|
};
|
|
82
98
|
|
|
83
99
|
function fail(message: string): never {
|
|
84
|
-
console.error(
|
|
100
|
+
console.error(`${red("error:")} ${message}`);
|
|
85
101
|
process.exit(1);
|
|
86
102
|
}
|
|
87
103
|
|
|
@@ -117,6 +133,8 @@ function overridesFromFlags(): Partial<VideoConfig> {
|
|
|
117
133
|
o.core = values.core as CoreName;
|
|
118
134
|
}
|
|
119
135
|
if (values.cast) o.cast = values.cast;
|
|
136
|
+
if (values.cols !== undefined) o.cols = num("cols");
|
|
137
|
+
if (values.rows !== undefined) o.rows = num("rows");
|
|
120
138
|
return o;
|
|
121
139
|
}
|
|
122
140
|
|
|
@@ -146,14 +164,14 @@ async function loadVideo(file: string): Promise<Video> {
|
|
|
146
164
|
}
|
|
147
165
|
|
|
148
166
|
function progressReporter(): (p: { frame: number; total: number }) => void {
|
|
149
|
-
if (quiet || !process.
|
|
167
|
+
if (quiet || !process.stdout.isTTY) return () => {};
|
|
150
168
|
let last = -1;
|
|
151
169
|
return ({ frame, total }) => {
|
|
152
170
|
const pct = Math.floor((frame / total) * 100);
|
|
153
171
|
if (pct === last && frame !== total) return;
|
|
154
172
|
last = pct;
|
|
155
|
-
process.
|
|
156
|
-
if (frame === total) process.
|
|
173
|
+
process.stdout.write(`\r${dim(` rendering ${frame}/${total} frames (${pct}%)`)}`);
|
|
174
|
+
if (frame === total) process.stdout.write("\n");
|
|
157
175
|
};
|
|
158
176
|
}
|
|
159
177
|
|
|
@@ -165,9 +183,11 @@ async function fileSize(file: string): Promise<string> {
|
|
|
165
183
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
166
184
|
}
|
|
167
185
|
|
|
186
|
+
const ok = (what: string, detail = "") => log(`${green("✔")} ${what}${detail ? ` ${dim(detail)}` : ""}`);
|
|
187
|
+
|
|
168
188
|
async function reportOutputs(outputs: string[], screenshots: string[]): Promise<void> {
|
|
169
|
-
for (const out of outputs)
|
|
170
|
-
for (const shot of screenshots)
|
|
189
|
+
for (const out of outputs) ok(`wrote ${out}`, await fileSize(out));
|
|
190
|
+
for (const shot of screenshots) ok(`screenshot ${shot}`);
|
|
171
191
|
}
|
|
172
192
|
|
|
173
193
|
const TEMPLATES: Record<string, (name: string) => string> = {
|
|
@@ -276,18 +296,36 @@ async function main(): Promise<void> {
|
|
|
276
296
|
console.log(`created ${file}\n\nrun it with:\n ${template === "test" ? `tcut test ${file}` : `tcut ${file}`}`);
|
|
277
297
|
return;
|
|
278
298
|
}
|
|
299
|
+
case "rec": {
|
|
300
|
+
// Live mode: the user (or a pipe) drives the PTY; everything after `--` is the command to run.
|
|
301
|
+
const overrides = overridesFromFlags();
|
|
302
|
+
const outputs = overrides.output ?? ["rec.mp4"];
|
|
303
|
+
const config = resolveConfig({ ...overrides, output: outputs, cast: overrides.cast });
|
|
304
|
+
const command = rest.length > 0 ? rest : undefined;
|
|
305
|
+
// Size: --cols/--rows if given, else the terminal tcut runs in.
|
|
306
|
+
const recording = await recordLive(config, { command, log, cols: overrides.cols, rows: overrides.rows });
|
|
307
|
+
await mkdir(path.dirname(path.resolve(config.cast)), { recursive: true });
|
|
308
|
+
await writeCast(config.cast, recording);
|
|
309
|
+
log("");
|
|
310
|
+
ok(`wrote ${config.cast}`, `${recording.events.length} events, ${(recording.header.duration ?? 0).toFixed(1)}s`);
|
|
311
|
+
if (values["record-only"]) return;
|
|
312
|
+
const result = await renderOutputs(recording, config, progressReporter());
|
|
313
|
+
await reportOutputs(result.outputs, result.screenshots);
|
|
314
|
+
log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
279
317
|
case "record": {
|
|
280
318
|
if (!rest[0]) fail("record needs a script file");
|
|
281
319
|
const video = await loadVideo(rest[0]);
|
|
282
320
|
const rec = await video.record({ log, force: values.force });
|
|
283
|
-
|
|
321
|
+
ok(`${rec.cached ? "reused" : "wrote"} ${video.config.cast}`, `${rec.events.length} events, ${(rec.header.duration ?? 0).toFixed(1)}s, ${elapsed()}`);
|
|
284
322
|
return;
|
|
285
323
|
}
|
|
286
324
|
case "render": {
|
|
287
325
|
if (!rest[0]) fail("render needs a .cast file");
|
|
288
326
|
const result = await renderCast(rest[0], overridesFromFlags(), progressReporter());
|
|
289
327
|
await reportOutputs(result.outputs, result.screenshots);
|
|
290
|
-
log(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`);
|
|
328
|
+
log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
|
|
291
329
|
return;
|
|
292
330
|
}
|
|
293
331
|
case "test": {
|
|
@@ -299,9 +337,9 @@ async function main(): Promise<void> {
|
|
|
299
337
|
default: {
|
|
300
338
|
const video = await loadVideo(first!);
|
|
301
339
|
const result = await video.run({ log, force: values.force, recordOnly: values["record-only"], onProgress: progressReporter() });
|
|
302
|
-
|
|
340
|
+
ok(`${result.cached ? "reused" : "wrote"} ${result.cast}`);
|
|
303
341
|
await reportOutputs(result.outputs, result.screenshots);
|
|
304
|
-
if (!values["record-only"]) log(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`);
|
|
342
|
+
if (!values["record-only"]) log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
|
|
305
343
|
}
|
|
306
344
|
}
|
|
307
345
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { defineVideo, Video, renderCast, isVideo } from "./video";
|
|
2
2
|
export type { RunOptions as VideoRunOptions, RunResult, VideoRecordOptions } from "./video";
|
|
3
3
|
export { renderOutputs } from "./render";
|
|
4
|
+
export { recordLive } from "./live";
|
|
5
|
+
export type { LiveOptions } from "./live";
|
|
4
6
|
export { buildSvg } from "./export/svg";
|
|
5
7
|
export { buildHtml } from "./export/html";
|
|
6
8
|
export { replayFrames } from "./export/frames";
|
package/src/live.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { MARKER } from "./cast";
|
|
2
|
+
import { shellSetup } from "./recorder";
|
|
3
|
+
import type { CastEvent, Recording, ResolvedConfig } from "./types";
|
|
4
|
+
|
|
5
|
+
export interface LiveOptions {
|
|
6
|
+
/** Run this command instead of the configured clean shell. */
|
|
7
|
+
command?: string[];
|
|
8
|
+
/** Terminal size; defaults to the size of the terminal tcut is running in, then the config. */
|
|
9
|
+
cols?: number;
|
|
10
|
+
rows?: number;
|
|
11
|
+
/** Where to mirror the session (default: this process's stdout). */
|
|
12
|
+
stdout?: { write(data: Uint8Array | string): unknown };
|
|
13
|
+
/** Keystroke source (default: this process's stdin, switched to raw mode when it is a TTY). */
|
|
14
|
+
stdin?: NodeJS.ReadStream | null;
|
|
15
|
+
log?: (message: string) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Record a *live* session: the user (or a pipe) drives the PTY, tcut mirrors it to the terminal and captures
|
|
20
|
+
* every byte with timestamps. No script, no waits — whatever happened is what gets rendered.
|
|
21
|
+
*/
|
|
22
|
+
export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {}): Promise<Recording> {
|
|
23
|
+
const log = opts.log ?? (() => {});
|
|
24
|
+
const stdin = opts.stdin === undefined ? process.stdin : opts.stdin;
|
|
25
|
+
const stdout = opts.stdout ?? { write: (d: Uint8Array | string) => process.stdout.write(d) };
|
|
26
|
+
const cols = opts.cols ?? process.stdout.columns ?? config.cols;
|
|
27
|
+
const rows = opts.rows ?? process.stdout.rows ?? config.rows;
|
|
28
|
+
const decoder = new TextDecoder("utf-8");
|
|
29
|
+
const events: CastEvent[] = [];
|
|
30
|
+
const startedAt = performance.now();
|
|
31
|
+
|
|
32
|
+
const stamp = (): number => {
|
|
33
|
+
const t = (performance.now() - startedAt) / 1000;
|
|
34
|
+
return config.quantize ? Math.ceil(t * config.fps - 1e-6) / config.fps : Number(t.toFixed(6));
|
|
35
|
+
};
|
|
36
|
+
const push = (type: CastEvent[1], data: string): void => {
|
|
37
|
+
events.push([stamp(), type, data]);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const setup = opts.command ? { cmd: opts.command, env: {} } : shellSetup(config);
|
|
41
|
+
const env: Record<string, string> = {
|
|
42
|
+
...process.env,
|
|
43
|
+
TERM: "xterm-256color",
|
|
44
|
+
COLORTERM: "truecolor",
|
|
45
|
+
...setup.env,
|
|
46
|
+
...config.env,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
let exited = false;
|
|
50
|
+
let resolveExit!: () => void;
|
|
51
|
+
const exitedPromise = new Promise<void>((r) => (resolveExit = r));
|
|
52
|
+
|
|
53
|
+
const proc = Bun.spawn(setup.cmd, {
|
|
54
|
+
cwd: config.cwd,
|
|
55
|
+
env,
|
|
56
|
+
terminal: {
|
|
57
|
+
cols,
|
|
58
|
+
rows,
|
|
59
|
+
name: "xterm-256color",
|
|
60
|
+
data(_terminal, chunk) {
|
|
61
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
62
|
+
if (!text) return;
|
|
63
|
+
stdout.write(chunk);
|
|
64
|
+
push("o", text);
|
|
65
|
+
},
|
|
66
|
+
exit() {
|
|
67
|
+
exited = true;
|
|
68
|
+
resolveExit();
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
const terminal = proc.terminal;
|
|
73
|
+
if (!terminal) throw new Error("Bun.spawn did not return a terminal. Is this Bun >= 1.4?");
|
|
74
|
+
|
|
75
|
+
const isTTY = Boolean(stdin && (stdin as NodeJS.ReadStream).isTTY);
|
|
76
|
+
const onData = (chunk: Buffer | string): void => {
|
|
77
|
+
if (exited || terminal.closed) return;
|
|
78
|
+
terminal.write(chunk);
|
|
79
|
+
push("i", typeof chunk === "string" ? chunk : chunk.toString("utf8"));
|
|
80
|
+
};
|
|
81
|
+
const onResize = (): void => {
|
|
82
|
+
const c = process.stdout.columns ?? cols;
|
|
83
|
+
const r = process.stdout.rows ?? rows;
|
|
84
|
+
if (exited || terminal.closed) return;
|
|
85
|
+
terminal.resize(c, r);
|
|
86
|
+
push("r", `${c}x${r}`);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
if (stdin) {
|
|
90
|
+
if (isTTY) stdin.setRawMode(true);
|
|
91
|
+
stdin.resume();
|
|
92
|
+
stdin.on("data", onData);
|
|
93
|
+
}
|
|
94
|
+
process.on("SIGWINCH", onResize);
|
|
95
|
+
log(`recording ${setup.cmd.join(" ")} at ${cols}×${rows} — ${opts.command ? "ends when the command exits" : "type exit to stop"}`);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
await Promise.race([exitedPromise, proc.exited]);
|
|
99
|
+
push("m", MARKER.end);
|
|
100
|
+
} finally {
|
|
101
|
+
process.off("SIGWINCH", onResize);
|
|
102
|
+
if (stdin) {
|
|
103
|
+
stdin.off("data", onData);
|
|
104
|
+
if (isTTY) stdin.setRawMode(false);
|
|
105
|
+
stdin.pause();
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
terminal.close();
|
|
109
|
+
} catch {
|
|
110
|
+
/* already closed */
|
|
111
|
+
}
|
|
112
|
+
if (!exited) proc.kill();
|
|
113
|
+
await proc.exited.catch(() => undefined);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const duration = events.length > 0 ? events[events.length - 1]![0] : 0;
|
|
117
|
+
return {
|
|
118
|
+
header: {
|
|
119
|
+
version: 2,
|
|
120
|
+
width: cols,
|
|
121
|
+
height: rows,
|
|
122
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
123
|
+
duration,
|
|
124
|
+
title: config.title || undefined,
|
|
125
|
+
env: { TERM: "xterm-256color", SHELL: setup.cmd[0]! },
|
|
126
|
+
bunVideo: { ...config, cols, rows },
|
|
127
|
+
},
|
|
128
|
+
events,
|
|
129
|
+
};
|
|
130
|
+
}
|
package/src/recorder.ts
CHANGED
|
@@ -42,12 +42,13 @@ function mulberry32(seed: number): () => number {
|
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
interface ShellSetup {
|
|
45
|
+
export interface ShellSetup {
|
|
46
46
|
cmd: string[];
|
|
47
47
|
env: Record<string, string>;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
/** Command + environment for a clean, rc-free shell with the configured prompt. */
|
|
51
|
+
export function shellSetup(config: ResolvedConfig): ShellSetup {
|
|
51
52
|
const { shell, prompt } = config;
|
|
52
53
|
if (Array.isArray(shell)) return { cmd: shell, env: {} };
|
|
53
54
|
switch (shell) {
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Aman Varshney
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|