termcut 0.1.0
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/LICENSE +21 -0
- package/README.md +186 -0
- package/package.json +65 -0
- package/scripts/build-assets.ts +45 -0
- package/scripts/build-binaries.ts +41 -0
- package/src/cast.ts +40 -0
- package/src/cli.ts +312 -0
- package/src/config.ts +84 -0
- package/src/duration.ts +21 -0
- package/src/export/frames.ts +142 -0
- package/src/export/html.ts +88 -0
- package/src/export/svg.ts +171 -0
- package/src/index.ts +15 -0
- package/src/keys.ts +58 -0
- package/src/recorder.ts +362 -0
- package/src/render.ts +52 -0
- package/src/renderer/bundle.ts +78 -0
- package/src/renderer/embedded.ts +17 -0
- package/src/renderer/encoder.ts +253 -0
- package/src/renderer/generated/ghostty-vt.wasm +0 -0
- package/src/renderer/generated/page.js +1 -0
- package/src/renderer/generated/player.js +1 -0
- package/src/renderer/generated/terminal.css +195 -0
- package/src/renderer/generated.d.ts +17 -0
- package/src/renderer/page-entry.ts +109 -0
- package/src/renderer/page.ts +114 -0
- package/src/renderer/player-entry.ts +105 -0
- package/src/renderer/webview.ts +140 -0
- package/src/screen.ts +130 -0
- package/src/testing.ts +69 -0
- package/src/themes.ts +138 -0
- package/src/timeline.ts +58 -0
- package/src/types.ts +282 -0
- package/src/video.ts +127 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# tcut
|
|
2
|
+
|
|
3
|
+
[](https://github.com/AmanVarshney01/tcut/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/termcut)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Script terminal sessions in **TypeScript**, render them to **reproducible** MP4 / GIF / WebM / SVG / HTML / PNG.
|
|
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
|
+
```
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
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
|
+
```sh
|
|
48
|
+
bun add -g termcut # then: tcut demo.video.ts
|
|
49
|
+
bunx termcut init demo # or run it without installing
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Standalone binary** — no Bun required; download from
|
|
53
|
+
[Releases](https://github.com/AmanVarshney01/tcut/releases) (`tcut-<version>-<platform>`, checksums in `SHA256SUMS`):
|
|
54
|
+
|
|
55
|
+
```sh
|
|
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
|
+
```
|
|
59
|
+
|
|
60
|
+
Scripts `import { defineVideo } from "tcut"` — the CLI (binary or global install) resolves that import itself,
|
|
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**
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
git clone https://github.com/AmanVarshney01/tcut && cd tcut && bun install
|
|
68
|
+
bun src/cli.ts examples/demo.ts
|
|
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
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- **Record** drives a clean shell (no rc files, fixed prompt, `TERM=xterm-256color`) in a PTY. Output is
|
|
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.
|
|
116
|
+
|
|
117
|
+
## CLI
|
|
118
|
+
|
|
119
|
+
```
|
|
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
|
+
|
|
140
|
+
| Config | Default | |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| `output` | — | string or string[]; extension selects the format |
|
|
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)`.
|
|
170
|
+
|
|
171
|
+
## Development
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
bun test # recorder, renderer, exporters, CLI (spawns real shells + WebView)
|
|
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
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Releasing: bump `version` in `package.json`, commit, `git tag v<version> && git push --tags`. The release
|
|
182
|
+
workflow builds the binaries, publishes the GitHub Release and, when an `NPM_TOKEN` secret is configured,
|
|
183
|
+
publishes to npm.
|
|
184
|
+
|
|
185
|
+
Specs and tasks are tracked with [OpenSpec](https://github.com/Fission-AI/OpenSpec) under `openspec/`; the roadmap
|
|
186
|
+
and measurements are in `PLAN.md`.
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "termcut",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Script terminal sessions in TypeScript, render them to reproducible MP4/GIF/WebM/SVG/HTML with Bun.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Aman Varshney",
|
|
7
|
+
"homepage": "https://github.com/AmanVarshney01/tcut#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/AmanVarshney01/tcut.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/AmanVarshney01/tcut/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"terminal",
|
|
17
|
+
"recording",
|
|
18
|
+
"gif",
|
|
19
|
+
"mp4",
|
|
20
|
+
"svg",
|
|
21
|
+
"asciicast",
|
|
22
|
+
"asciinema",
|
|
23
|
+
"vhs",
|
|
24
|
+
"pty",
|
|
25
|
+
"demo",
|
|
26
|
+
"screencast",
|
|
27
|
+
"bun"
|
|
28
|
+
],
|
|
29
|
+
"type": "module",
|
|
30
|
+
"module": "src/index.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./src/index.ts"
|
|
33
|
+
},
|
|
34
|
+
"bin": {
|
|
35
|
+
"tcut": "./src/cli.ts"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"src",
|
|
39
|
+
"scripts",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
],
|
|
43
|
+
"engines": {
|
|
44
|
+
"bun": ">=1.4.0"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"demo": "bun src/cli.ts examples/demo.ts",
|
|
48
|
+
"typecheck": "bunx tsc --noEmit",
|
|
49
|
+
"test": "bun test",
|
|
50
|
+
"build:assets": "bun scripts/build-assets.ts",
|
|
51
|
+
"build": "bun run build:assets && bun build --compile --minify src/cli.ts --outfile dist/tcut",
|
|
52
|
+
"build:all": "bun run build:assets && bun scripts/build-binaries.ts"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@wterm/core": "^0.3.4",
|
|
56
|
+
"@wterm/dom": "^0.3.4",
|
|
57
|
+
"@wterm/ghostty": "^0.3.4"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/bun": "latest"
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"typescript": "^5"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Builds the browser bundles and copies static assets into src/renderer/generated/ so that
|
|
2
|
+
// `bun build --compile` can embed them. Run via `bun run build:assets`.
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const root = path.resolve(import.meta.dir, "..");
|
|
7
|
+
const rendererDir = path.join(root, "src", "renderer");
|
|
8
|
+
const outDir = path.join(rendererDir, "generated");
|
|
9
|
+
|
|
10
|
+
function packageRoot(specifier: string): string {
|
|
11
|
+
const entry = Bun.resolveSync(specifier, root);
|
|
12
|
+
let dir = path.dirname(entry);
|
|
13
|
+
for (let i = 0; i < 5; i++) {
|
|
14
|
+
if (path.basename(path.dirname(dir)) === "@wterm") return dir;
|
|
15
|
+
dir = path.dirname(dir);
|
|
16
|
+
}
|
|
17
|
+
throw new Error(`Could not locate package root for ${specifier}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
await mkdir(outDir, { recursive: true });
|
|
21
|
+
|
|
22
|
+
for (const [entry, out] of [
|
|
23
|
+
["page-entry.ts", "page.js"],
|
|
24
|
+
["player-entry.ts", "player.js"],
|
|
25
|
+
] as const) {
|
|
26
|
+
const result = await Bun.build({
|
|
27
|
+
entrypoints: [path.join(rendererDir, entry)],
|
|
28
|
+
target: "browser",
|
|
29
|
+
format: "esm",
|
|
30
|
+
minify: true,
|
|
31
|
+
});
|
|
32
|
+
if (!result.success) {
|
|
33
|
+
console.error(result.logs.map((l) => l.message).join("\n"));
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
await Bun.write(path.join(outDir, out), await result.outputs[0]!.text());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await Bun.write(path.join(outDir, "terminal.css"), Bun.file(path.join(packageRoot("@wterm/dom"), "src", "terminal.css")));
|
|
40
|
+
await Bun.write(path.join(outDir, "ghostty-vt.wasm"), Bun.file(path.join(packageRoot("@wterm/ghostty"), "wasm", "ghostty-vt.wasm")));
|
|
41
|
+
|
|
42
|
+
const sizes = await Promise.all(
|
|
43
|
+
["page.js", "player.js", "terminal.css", "ghostty-vt.wasm"].map(async (f) => `${f} ${(Bun.file(path.join(outDir, f)).size / 1024).toFixed(0)} KB`),
|
|
44
|
+
);
|
|
45
|
+
console.log(`built src/renderer/generated/: ${sizes.join(", ")}`);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Cross-compile standalone binaries for every supported platform into dist/ and write SHA-256 checksums.
|
|
2
|
+
// Run `bun run build:assets` first (done by `bun run build:all`).
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const root = path.resolve(import.meta.dir, "..");
|
|
7
|
+
const dist = path.join(root, "dist");
|
|
8
|
+
const version = (await Bun.file(path.join(root, "package.json")).json()).version as string;
|
|
9
|
+
|
|
10
|
+
const targets = [
|
|
11
|
+
"bun-darwin-arm64",
|
|
12
|
+
"bun-darwin-x64",
|
|
13
|
+
"bun-linux-x64",
|
|
14
|
+
"bun-linux-arm64",
|
|
15
|
+
"bun-windows-x64",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
await mkdir(dist, { recursive: true });
|
|
19
|
+
const checksums: string[] = [];
|
|
20
|
+
|
|
21
|
+
for (const target of targets) {
|
|
22
|
+
const platform = target.replace(/^bun-/, "");
|
|
23
|
+
const ext = target.includes("windows") ? ".exe" : "";
|
|
24
|
+
const outfile = path.join(dist, `tcut-${version}-${platform}${ext}`);
|
|
25
|
+
const proc = Bun.spawn(
|
|
26
|
+
["bun", "build", "--compile", "--minify", `--target=${target}`, path.join(root, "src", "cli.ts"), "--outfile", outfile],
|
|
27
|
+
{ cwd: root, stdout: "ignore", stderr: "pipe" },
|
|
28
|
+
);
|
|
29
|
+
const code = await proc.exited;
|
|
30
|
+
if (code !== 0) {
|
|
31
|
+
console.error(await new Response(proc.stderr).text());
|
|
32
|
+
process.exit(code);
|
|
33
|
+
}
|
|
34
|
+
const bytes = await Bun.file(outfile).arrayBuffer();
|
|
35
|
+
const hash = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
|
|
36
|
+
checksums.push(`${hash} ${path.basename(outfile)}`);
|
|
37
|
+
console.log(`${path.basename(outfile)} ${(bytes.byteLength / 1024 / 1024).toFixed(1)} MB`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await Bun.write(path.join(dist, "SHA256SUMS"), checksums.join("\n") + "\n");
|
|
41
|
+
console.log("wrote dist/SHA256SUMS");
|
package/src/cast.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { CastEvent, CastHeader, Recording } from "./types";
|
|
3
|
+
|
|
4
|
+
/** Serialise to asciicast v2 (newline-delimited JSON). Playable with `asciinema play`. */
|
|
5
|
+
export function serializeCast(rec: Recording): string {
|
|
6
|
+
const lines = [JSON.stringify(rec.header)];
|
|
7
|
+
for (const event of rec.events) lines.push(JSON.stringify(event));
|
|
8
|
+
return lines.join("\n") + "\n";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseCast(text: string): Recording {
|
|
12
|
+
const lines = text.split("\n").filter((l) => l.trim().length > 0);
|
|
13
|
+
if (lines.length === 0) throw new Error("Empty cast file");
|
|
14
|
+
const header = JSON.parse(lines[0]!) as CastHeader;
|
|
15
|
+
if (header.version !== 2) throw new Error(`Unsupported cast version ${String(header.version)} (expected 2)`);
|
|
16
|
+
const events: CastEvent[] = [];
|
|
17
|
+
for (let i = 1; i < lines.length; i++) {
|
|
18
|
+
const parsed = JSON.parse(lines[i]!) as unknown;
|
|
19
|
+
if (!Array.isArray(parsed) || parsed.length < 3) throw new Error(`Malformed cast event on line ${i + 1}`);
|
|
20
|
+
events.push([Number(parsed[0]), parsed[1] as CastEvent[1], String(parsed[2])]);
|
|
21
|
+
}
|
|
22
|
+
return { header, events };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function writeCast(file: string, rec: Recording): Promise<void> {
|
|
26
|
+
await Bun.write(file, serializeCast(rec));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function readCast(file: string): Promise<Recording> {
|
|
30
|
+
const f = Bun.file(path.resolve(file));
|
|
31
|
+
if (!(await f.exists())) throw new Error(`Cast file not found: ${file}`);
|
|
32
|
+
return parseCast(await f.text());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const MARKER = {
|
|
36
|
+
hide: "hide",
|
|
37
|
+
show: "show",
|
|
38
|
+
screenshot: "screenshot:",
|
|
39
|
+
end: "end",
|
|
40
|
+
} as const;
|