termcut 0.2.0 → 0.3.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/README.md +25 -139
- package/bin/tcut.mjs +3 -0
- package/package.json +3 -2
- package/scripts/build-themes.ts +86 -0
- package/src/cli.ts +98 -18
- package/src/index.ts +5 -1
- package/src/live.ts +1 -1
- package/src/publish.ts +226 -0
- package/src/recorder.ts +4 -1
- package/src/scriptgen.ts +230 -0
- package/src/themes.generated.json +1 -0
- package/src/themes.ts +35 -6
- package/src/types.ts +5 -6
package/README.md
CHANGED
|
@@ -1,176 +1,62 @@
|
|
|
1
1
|
# tcut
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
[](https://www.npmjs.com/package/termcut)
|
|
6
|
-
[](https://github.com/AmanVarshney01/tcut/actions/workflows/ci.yml)
|
|
7
|
-
[](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.
|
|
8
4
|
|
|
9
5
|

|
|
10
6
|
|
|
11
|
-
Website: **[tcut.amanv.dev](https://tcut.amanv.dev)**
|
|
12
|
-
|
|
13
7
|
## Install
|
|
14
8
|
|
|
15
9
|
```sh
|
|
16
|
-
bun add -g termcut
|
|
17
|
-
bunx termcut --help # or run it without installing
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
No Bun? Grab a standalone binary from [Releases](https://github.com/AmanVarshney01/tcut/releases) (macOS, Linux, Windows):
|
|
21
|
-
|
|
22
|
-
```sh
|
|
23
|
-
curl -fsSL https://github.com/AmanVarshney01/tcut/releases/latest/download/tcut-0.2.0-darwin-arm64 -o tcut && chmod +x tcut
|
|
10
|
+
bun add -g termcut # Bun ≥ 1.4 · installs the `tcut` command
|
|
24
11
|
```
|
|
25
12
|
|
|
26
|
-
|
|
13
|
+
Standalone binaries: [Releases](https://github.com/AmanVarshney01/tcut/releases). MP4/GIF need `ffmpeg`; SVG/HTML don't.
|
|
27
14
|
|
|
28
|
-
##
|
|
15
|
+
## Use
|
|
29
16
|
|
|
30
|
-
|
|
17
|
+
Record what you do:
|
|
31
18
|
|
|
32
19
|
```sh
|
|
33
|
-
tcut rec -o demo.gif
|
|
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
|
|
34
22
|
```
|
|
35
23
|
|
|
36
|
-
|
|
37
|
-
Everything is captured — output, timing, colours, arrow keys in TUIs, window resizes.
|
|
24
|
+
You get `demo.gif`, the exact recording (`demo.cast`) and an editable script (`demo.video.ts`) of what you typed.
|
|
38
25
|
|
|
39
|
-
|
|
40
|
-
tcut rec -o demo.mp4 -- bun create better-t-stack # record just one command (you still drive it)
|
|
41
|
-
tcut rec -o demo.svg -o demo.gif # several formats from one session
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
### Scripted — write it once, re-record forever
|
|
26
|
+
Or script it:
|
|
45
27
|
|
|
46
28
|
```ts
|
|
47
29
|
// demo.video.ts
|
|
48
30
|
import { defineVideo } from "tcut";
|
|
49
31
|
|
|
50
|
-
export default defineVideo(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
await t.expect(/package\.json/); // assert on the screen — the demo is also a test
|
|
56
|
-
await t.sleep("1.5s");
|
|
57
|
-
},
|
|
58
|
-
);
|
|
32
|
+
export default defineVideo({ output: "demo.gif" }, async (t) => {
|
|
33
|
+
await t.run("bun --version"); // type, Enter, wait for the prompt
|
|
34
|
+
await t.expect(/1\.\d+/); // assert on the screen
|
|
35
|
+
await t.sleep("1s");
|
|
36
|
+
});
|
|
59
37
|
```
|
|
60
38
|
|
|
61
39
|
```sh
|
|
62
|
-
tcut demo.video.ts
|
|
63
|
-
tcut init demo # scaffold a script to start from
|
|
40
|
+
tcut demo.video.ts
|
|
64
41
|
```
|
|
65
42
|
|
|
66
|
-
|
|
67
|
-
real programs; the script only provides the key presses a person would make.
|
|
68
|
-
|
|
69
|
-
## Render again, differently
|
|
70
|
-
|
|
71
|
-
Recording and rendering are separate. Every recording is saved as a standard [asciicast](https://docs.asciinema.org/manual/asciicast/v2/) (`demo.cast`) and frames are computed on a virtual clock, so the same cast renders to the same pixels on any machine — and you can re-render without re-running anything:
|
|
43
|
+
Re-render any recording without re-running it — ~600 themes ([Ghostty's collection](https://github.com/mbadolato/iTerm2-Color-Schemes)), `tcut themes` lists them:
|
|
72
44
|
|
|
73
45
|
```sh
|
|
74
|
-
tcut render demo.cast --theme
|
|
75
|
-
tcut render demo.cast -o demo.svg -o demo.html # animated SVG for a README, single-file HTML player
|
|
76
|
-
tcut render demo.cast --font-size 24 --speed 1.5 -o demo.mp4
|
|
46
|
+
tcut render demo.cast --theme "Gruvbox Dark" -o demo.svg
|
|
77
47
|
```
|
|
78
48
|
|
|
79
|
-
|
|
80
|
-
|---|---|---|
|
|
81
|
-
| `.mp4` `.webm` | ffmpeg | H.264 / VP9 |
|
|
82
|
-
| `.gif` `.webp` | ffmpeg | animated, palette-optimised |
|
|
83
|
-
| `.svg` | nothing | animated vector — crisp at any size, ~20 KB, renders on GitHub |
|
|
84
|
-
| `.html` | nothing | self-contained player with play / pause / loop |
|
|
85
|
-
| `.png` `.jpg` | — | the final frame (`t.screenshot()` for any moment) |
|
|
86
|
-
| `frames/` | — | one PNG per frame |
|
|
87
|
-
|
|
88
|
-
## Examples
|
|
89
|
-
|
|
90
|
-
| | |
|
|
91
|
-
|---|---|
|
|
92
|
-
| **Driving an interactive TUI** — answers `bun create better-t-stack` with arrow keys, picks options by reading the screen. [`better-t-stack.ts`](packages/tcut/examples/better-t-stack.ts) |  |
|
|
93
|
-
| **Recording AI agents** — `claude -p` explains a file, `codex exec` edits it. [`ai-agents.ts`](packages/tcut/examples/ai-agents.ts) |  |
|
|
94
|
-
| **README media** — the GIF at the top of this page. [`readme.ts`](packages/tcut/examples/readme.ts) | |
|
|
95
|
-
|
|
96
|
-
More in [`packages/tcut/examples/`](packages/tcut/examples).
|
|
97
|
-
|
|
98
|
-
## Use scripts as tests
|
|
49
|
+
Share it:
|
|
99
50
|
|
|
100
51
|
```sh
|
|
101
|
-
tcut
|
|
52
|
+
tcut publish --setup # once: your S3-compatible bucket (RustFS, MinIO, R2, S3)
|
|
53
|
+
tcut publish demo.gif # → https://…/3f9a1c2b7d4e/demo.gif
|
|
102
54
|
```
|
|
103
55
|
|
|
104
|
-
|
|
105
|
-
`expect()` fails — so the demo in your README is also the integration test for your CLI.
|
|
106
|
-
|
|
107
|
-
## CLI
|
|
108
|
-
|
|
109
|
-
```
|
|
110
|
-
tcut <script.ts> record + render
|
|
111
|
-
tcut rec [-- command…] record a live session, then render
|
|
112
|
-
tcut record <script.ts> record only (.cast)
|
|
113
|
-
tcut render <file.cast> render a cast (tcut's or asciinema's)
|
|
114
|
-
tcut test <paths…> run scripts as tests
|
|
115
|
-
tcut init [name] [--template basic|tour|test]
|
|
116
|
-
tcut themes
|
|
117
|
-
|
|
118
|
-
-o, --output <path> repeatable: .mp4 .webm .gif .webp .svg .html .png .jpg or a directory/
|
|
119
|
-
--theme <name> catppuccin-mocha · dracula · github-dark · tokyo-night · one-dark
|
|
120
|
-
--font <family> --font-size <px> --line-height <x> --letter-spacing <px>
|
|
121
|
-
--fps <n> --speed <x> --padding <px> --margin <px> --margin-fill <color> --radius <px>
|
|
122
|
-
--window-bar <none|colorful|colorfulRight|rings|ringsRight> --title <text> --no-blink
|
|
123
|
-
--core <ghostty|lite> --cast <path> --record-only --force -q
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
## Script reference
|
|
127
|
-
|
|
128
|
-
`defineVideo(config, async (t) => { … })`
|
|
129
|
-
|
|
130
|
-
**Config** (all optional except `output`)
|
|
131
|
-
|
|
132
|
-
| | default | |
|
|
133
|
-
|---|---|---|
|
|
134
|
-
| `output` | — | string or array; extension picks the format |
|
|
135
|
-
| `shell` | `"bash"` | `bash` · `zsh` · `fish` · `sh` · or a `string[]` command |
|
|
136
|
-
| `prompt` | `"> "` | prompt of the clean shell; `run()` waits for it |
|
|
137
|
-
| `cols` · `rows` · `fps` | 80 · 24 · 60 | |
|
|
138
|
-
| `typingSpeed` · `typingJitter` · `seed` | `"50ms"` · 0 · 1 | jitter is seeded, so it's reproducible |
|
|
139
|
-
| `theme` | `"catppuccin-mocha"` | a name or a full theme object |
|
|
140
|
-
| `font` | JetBrains Mono 20 px | `{ family, size, lineHeight, letterSpacing }` |
|
|
141
|
-
| `windowBar` · `title` · `padding` · `margin` · `marginFill` · `borderRadius` | `"none"` · `""` · 24 · 0 · bg · 0 | window chrome |
|
|
142
|
-
| `cursor` | `{ blink: true, period: 1000 }` | |
|
|
143
|
-
| `playbackSpeed` · `waitTimeout` · `endPause` | 1 · `"15s"` · `"1s"` | |
|
|
144
|
-
| `cache` · `quantize` · `core` | true · false · `"ghostty"` | skip re-recording when unchanged · frame-grid timestamps · emulator |
|
|
145
|
-
|
|
146
|
-
**`t`**
|
|
147
|
-
|
|
148
|
-
- Type: `run(cmd)` · `type(text)` · `paste(text)` · `enter()` `tab()` `backspace()` `escape()` `space()` `up()` `down()` `left()` `right()` `home()` `end()` `pageUp()` `pageDown()` (all take a count) · `ctrl("c")` · `alt("b")` · `key("f5")` · `raw(bytes)`
|
|
149
|
-
- Wait: `sleep("500ms")` · `wait(/re/, { scope: "line" | "screen" })` — default waits for the prompt
|
|
150
|
-
- Assert: `expect(/re/)` — throws with a screen dump
|
|
151
|
-
- Shape the video: `hide(async () => …)` cuts a section · `screenshot("x.png")` · `marker("name")` · `resize(cols, rows)` · `clear()`
|
|
152
|
-
- Look: `screen()` · `line()` · `cursor()` · `cols` · `rows`
|
|
153
|
-
|
|
154
|
-
Durations accept `500`, `"500ms"`, `"1.5s"`, `"2m"`.
|
|
155
|
-
|
|
156
|
-
## Requirements
|
|
157
|
-
|
|
158
|
-
| To… | You need |
|
|
159
|
-
|---|---|
|
|
160
|
-
| run tcut | Bun ≥ 1.4, or the standalone binary |
|
|
161
|
-
| record (`rec`, scripts, `test`) | a shell — nothing else |
|
|
162
|
-
| render `.svg` / `.html` | nothing else |
|
|
163
|
-
| render `.png` / `frames/` | macOS: nothing (built-in WebKit) · Linux / Windows: Chrome, Chromium, Edge or Brave |
|
|
164
|
-
| render `.mp4` / `.gif` / `.webm` | the above + ffmpeg |
|
|
165
|
-
| render `.webp` | ffmpeg with libwebp (`brew install ffmpeg-full`; found automatically) |
|
|
166
|
-
|
|
167
|
-
Verified on macOS. Linux and Windows binaries are cross-compiled and not yet exercised in CI.
|
|
168
|
-
|
|
169
|
-
## How it works, briefly
|
|
56
|
+
## More
|
|
170
57
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
frames to ffmpeg; SVG and HTML are built straight from the terminal grid. Inspired by [VHS](https://github.com/charmbracelet/vhs).
|
|
58
|
+
- [Examples](https://github.com/AmanVarshney01/tcut/tree/main/packages/tcut/examples) — driving an interactive TUI, recording Claude Code / Codex
|
|
59
|
+
- [Reference](https://github.com/AmanVarshney01/tcut/blob/main/packages/tcut/docs/REFERENCE.md) — every CLI flag and script option
|
|
60
|
+
- [tcut.amanv.dev](https://tcut.amanv.dev)
|
|
175
61
|
|
|
176
|
-
|
|
62
|
+
MIT
|
package/bin/tcut.mjs
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "termcut",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
@@ -33,9 +33,10 @@
|
|
|
33
33
|
".": "./src/index.ts"
|
|
34
34
|
},
|
|
35
35
|
"bin": {
|
|
36
|
-
"tcut": "
|
|
36
|
+
"tcut": "bin/tcut.mjs"
|
|
37
37
|
},
|
|
38
38
|
"files": [
|
|
39
|
+
"bin",
|
|
39
40
|
"src",
|
|
40
41
|
"scripts",
|
|
41
42
|
"README.md",
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Generates src/themes.generated.json from the Ghostty-format themes in mbadolato/iTerm2-Color-Schemes (MIT) —
|
|
2
|
+
// the same collection Ghostty bundles. Run: `bun scripts/build-themes.ts` (network). Output is committed.
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
|
|
7
|
+
const SOURCE = "https://github.com/mbadolato/iTerm2-Color-Schemes/archive/refs/heads/master.tar.gz";
|
|
8
|
+
const out = path.resolve(import.meta.dir, "..", "src", "themes.generated.json");
|
|
9
|
+
|
|
10
|
+
const KEYS = [
|
|
11
|
+
"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
|
|
12
|
+
"brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
const slug = (name: string) =>
|
|
16
|
+
name
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
19
|
+
.replace(/^-+|-+$/g, "");
|
|
20
|
+
|
|
21
|
+
const hex = (v: string) => {
|
|
22
|
+
const h = v.trim().replace(/^#/, "");
|
|
23
|
+
return /^[0-9a-f]{6}$/i.test(h) ? `#${h.toLowerCase()}` : null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function parse(name: string, text: string): Record<string, string> | null {
|
|
27
|
+
const palette: Record<number, string> = {};
|
|
28
|
+
const props: Record<string, string> = {};
|
|
29
|
+
for (const raw of text.split("\n")) {
|
|
30
|
+
const line = raw.trim();
|
|
31
|
+
if (!line || line.startsWith("#")) continue;
|
|
32
|
+
const eq = line.indexOf("=");
|
|
33
|
+
if (eq < 0) continue;
|
|
34
|
+
const key = line.slice(0, eq).trim();
|
|
35
|
+
const value = line.slice(eq + 1).trim();
|
|
36
|
+
if (key === "palette") {
|
|
37
|
+
const m = /^(\d+)\s*=\s*(.+)$/.exec(value);
|
|
38
|
+
if (m) {
|
|
39
|
+
const c = hex(m[2]!);
|
|
40
|
+
if (c) palette[Number(m[1])] = c;
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
props[key] = value;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const background = hex(props.background ?? "");
|
|
47
|
+
const foreground = hex(props.foreground ?? "");
|
|
48
|
+
if (!background || !foreground) return null;
|
|
49
|
+
for (let i = 0; i < 16; i++) if (!palette[i]) return null;
|
|
50
|
+
const theme: Record<string, string> = { name, background, foreground };
|
|
51
|
+
const cursor = hex(props["cursor-color"] ?? "");
|
|
52
|
+
if (cursor) theme.cursor = cursor;
|
|
53
|
+
const cursorText = hex(props["cursor-text"] ?? "");
|
|
54
|
+
if (cursorText) theme.cursorAccent = cursorText;
|
|
55
|
+
const selection = hex(props["selection-background"] ?? "");
|
|
56
|
+
if (selection) theme.selectionBackground = selection;
|
|
57
|
+
KEYS.forEach((k, i) => (theme[k] = palette[i]!));
|
|
58
|
+
return theme;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const tmp = await mkdtemp(path.join(tmpdir(), "tcut-themes-"));
|
|
62
|
+
const tgz = path.join(tmp, "schemes.tgz");
|
|
63
|
+
const res = await fetch(SOURCE);
|
|
64
|
+
if (!res.ok) throw new Error(`download failed: ${res.status}`);
|
|
65
|
+
await Bun.write(tgz, await res.arrayBuffer());
|
|
66
|
+
const untar = Bun.spawn(["tar", "-xzf", tgz, "-C", tmp], { stdout: "ignore", stderr: "pipe" });
|
|
67
|
+
if ((await untar.exited) !== 0) throw new Error(await new Response(untar.stderr).text());
|
|
68
|
+
const root = (await readdir(tmp)).find((d) => d.startsWith("iTerm2-Color-Schemes"));
|
|
69
|
+
if (!root) throw new Error("unexpected archive layout");
|
|
70
|
+
const dir = path.join(tmp, root, "ghostty");
|
|
71
|
+
|
|
72
|
+
const themes: Record<string, Record<string, string>> = {};
|
|
73
|
+
let skipped = 0;
|
|
74
|
+
for (const file of (await readdir(dir)).sort()) {
|
|
75
|
+
const text = await Bun.file(path.join(dir, file)).text();
|
|
76
|
+
const theme = parse(file, text);
|
|
77
|
+
if (!theme) {
|
|
78
|
+
skipped++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
themes[slug(file)] = theme;
|
|
82
|
+
}
|
|
83
|
+
await rm(tmp, { recursive: true, force: true });
|
|
84
|
+
|
|
85
|
+
await Bun.write(out, JSON.stringify(themes) + "\n");
|
|
86
|
+
console.log(`wrote ${path.relative(process.cwd(), out)}: ${Object.keys(themes).length} themes (${skipped} skipped), ${(Bun.file(out).size / 1024).toFixed(0)} KB`);
|
package/src/cli.ts
CHANGED
|
@@ -6,9 +6,11 @@ import { writeCast } from "./cast";
|
|
|
6
6
|
import { resolveConfig } from "./config";
|
|
7
7
|
import * as api from "./index";
|
|
8
8
|
import { recordLive } from "./live";
|
|
9
|
+
import { ensurePublicBucket, loadPublishConfig, publicUrlFor, publishFiles, savePublishConfig, type PublishConfig } from "./publish";
|
|
9
10
|
import { renderOutputs } from "./render";
|
|
11
|
+
import { generateScript } from "./scriptgen";
|
|
10
12
|
import { runScriptTests } from "./testing";
|
|
11
|
-
import { themeNames } from "./themes";
|
|
13
|
+
import { findThemes, themeNames } from "./themes";
|
|
12
14
|
import type { CoreName, ThemeName, VideoConfig, WindowBar } from "./types";
|
|
13
15
|
import { Video, isVideo, renderCast } from "./video";
|
|
14
16
|
|
|
@@ -32,8 +34,10 @@ Usage:
|
|
|
32
34
|
tcut record <script.ts> [options] record only (writes the .cast)
|
|
33
35
|
tcut render <file.cast> [options] render an existing .cast (tcut or asciinema)
|
|
34
36
|
tcut test <path...> run scripts in fast mode as tests (no video)
|
|
37
|
+
tcut publish <files...> [--open] upload to your S3-compatible bucket and print share links
|
|
38
|
+
tcut publish --setup configure the bucket (RustFS, MinIO, R2, S3 …) — once
|
|
35
39
|
tcut init [name] [--template t] scaffold a new script (basic | tour | test)
|
|
36
|
-
tcut themes
|
|
40
|
+
tcut themes [query] list the ~600 bundled themes (Ghostty collection)
|
|
37
41
|
|
|
38
42
|
Options (override the script's config):
|
|
39
43
|
-o, --output <path> .mp4 .webm .gif .webp .svg .html .png .jpg or dir/ for PNG frames — repeatable
|
|
@@ -44,9 +48,14 @@ Options (override the script's config):
|
|
|
44
48
|
--window-bar <type> none | colorful | colorfulRight | rings | ringsRight
|
|
45
49
|
--title <text> --no-blink
|
|
46
50
|
--core <name> ghostty | lite
|
|
51
|
+
--cols <n> --rows <n> terminal size (rec: defaults to your terminal's size)
|
|
47
52
|
--cast <path> where to read/write the .cast
|
|
48
53
|
--record-only stop after writing the cast
|
|
54
|
+
--no-script rec: don't write the editable <name>.video.ts next to the cast
|
|
49
55
|
--force ignore the cast cache and re-record
|
|
56
|
+
--open publish: open the first link in the browser
|
|
57
|
+
--name <file> publish: object name (default: the file's basename)
|
|
58
|
+
--endpoint --bucket --access-key --secret-key --public-url --region publish --setup values
|
|
50
59
|
--template <name> for init: basic | tour | test
|
|
51
60
|
-q, --quiet
|
|
52
61
|
-h, --help
|
|
@@ -72,9 +81,21 @@ const { values, positionals } = parseArgs({
|
|
|
72
81
|
title: { type: "string" },
|
|
73
82
|
"no-blink": { type: "boolean" },
|
|
74
83
|
core: { type: "string" },
|
|
84
|
+
cols: { type: "string" },
|
|
85
|
+
rows: { type: "string" },
|
|
75
86
|
cast: { type: "string" },
|
|
76
87
|
"record-only": { type: "boolean" },
|
|
88
|
+
"no-script": { type: "boolean" },
|
|
77
89
|
force: { type: "boolean" },
|
|
90
|
+
setup: { type: "boolean" },
|
|
91
|
+
open: { type: "boolean" },
|
|
92
|
+
name: { type: "string" },
|
|
93
|
+
endpoint: { type: "string" },
|
|
94
|
+
bucket: { type: "string" },
|
|
95
|
+
"access-key": { type: "string" },
|
|
96
|
+
"secret-key": { type: "string" },
|
|
97
|
+
"public-url": { type: "string" },
|
|
98
|
+
region: { type: "string" },
|
|
78
99
|
template: { type: "string" },
|
|
79
100
|
quiet: { type: "boolean", short: "q" },
|
|
80
101
|
help: { type: "boolean", short: "h" },
|
|
@@ -82,12 +103,19 @@ const { values, positionals } = parseArgs({
|
|
|
82
103
|
});
|
|
83
104
|
|
|
84
105
|
const quiet = values.quiet === true;
|
|
106
|
+
const useColor = process.stdout.isTTY === true && !process.env.NO_COLOR;
|
|
107
|
+
const paint = (code: string) => (s: string) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
108
|
+
const green = paint("32");
|
|
109
|
+
const dim = paint("2");
|
|
110
|
+
const red = paint("31");
|
|
111
|
+
|
|
112
|
+
// Status goes to stdout (plain informational output); only real errors go to stderr.
|
|
85
113
|
const log = (msg: string) => {
|
|
86
|
-
if (!quiet)
|
|
114
|
+
if (!quiet) process.stdout.write(`${msg}\n`);
|
|
87
115
|
};
|
|
88
116
|
|
|
89
117
|
function fail(message: string): never {
|
|
90
|
-
console.error(
|
|
118
|
+
console.error(`${red("error:")} ${message}`);
|
|
91
119
|
process.exit(1);
|
|
92
120
|
}
|
|
93
121
|
|
|
@@ -123,6 +151,8 @@ function overridesFromFlags(): Partial<VideoConfig> {
|
|
|
123
151
|
o.core = values.core as CoreName;
|
|
124
152
|
}
|
|
125
153
|
if (values.cast) o.cast = values.cast;
|
|
154
|
+
if (values.cols !== undefined) o.cols = num("cols");
|
|
155
|
+
if (values.rows !== undefined) o.rows = num("rows");
|
|
126
156
|
return o;
|
|
127
157
|
}
|
|
128
158
|
|
|
@@ -152,14 +182,14 @@ async function loadVideo(file: string): Promise<Video> {
|
|
|
152
182
|
}
|
|
153
183
|
|
|
154
184
|
function progressReporter(): (p: { frame: number; total: number }) => void {
|
|
155
|
-
if (quiet || !process.
|
|
185
|
+
if (quiet || !process.stdout.isTTY) return () => {};
|
|
156
186
|
let last = -1;
|
|
157
187
|
return ({ frame, total }) => {
|
|
158
188
|
const pct = Math.floor((frame / total) * 100);
|
|
159
189
|
if (pct === last && frame !== total) return;
|
|
160
190
|
last = pct;
|
|
161
|
-
process.
|
|
162
|
-
if (frame === total) process.
|
|
191
|
+
process.stdout.write(`\r${dim(` rendering ${frame}/${total} frames (${pct}%)`)}`);
|
|
192
|
+
if (frame === total) process.stdout.write("\n");
|
|
163
193
|
};
|
|
164
194
|
}
|
|
165
195
|
|
|
@@ -171,9 +201,11 @@ async function fileSize(file: string): Promise<string> {
|
|
|
171
201
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
172
202
|
}
|
|
173
203
|
|
|
204
|
+
const ok = (what: string, detail = "") => log(`${green("✔")} ${what}${detail ? ` ${dim(detail)}` : ""}`);
|
|
205
|
+
|
|
174
206
|
async function reportOutputs(outputs: string[], screenshots: string[]): Promise<void> {
|
|
175
|
-
for (const out of outputs)
|
|
176
|
-
for (const shot of screenshots)
|
|
207
|
+
for (const out of outputs) ok(`wrote ${out}`, await fileSize(out));
|
|
208
|
+
for (const shot of screenshots) ok(`screenshot ${shot}`);
|
|
177
209
|
}
|
|
178
210
|
|
|
179
211
|
const TEMPLATES: Record<string, (name: string) => string> = {
|
|
@@ -267,7 +299,47 @@ async function main(): Promise<void> {
|
|
|
267
299
|
|
|
268
300
|
switch (first) {
|
|
269
301
|
case "themes": {
|
|
270
|
-
|
|
302
|
+
const names = rest[0] ? findThemes(rest[0]) : themeNames;
|
|
303
|
+
if (names.length === 0) fail(`No theme matches "${rest[0]}"`);
|
|
304
|
+
for (const name of names) console.log(name);
|
|
305
|
+
if (!rest[0]) log(dim(`${names.length} themes · use any name with --theme, e.g. --theme "Gruvbox Dark"`));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
case "publish": {
|
|
309
|
+
if (values.setup) {
|
|
310
|
+
const ask = async (label: string, flag: string | undefined, fallback: string, secret = false): Promise<string> => {
|
|
311
|
+
if (flag) return flag;
|
|
312
|
+
if (!process.stdin.isTTY) return fallback;
|
|
313
|
+
const answer = prompt(`${label}${fallback ? ` [${fallback}]` : ""}:`) ?? "";
|
|
314
|
+
return answer.trim() || fallback;
|
|
315
|
+
};
|
|
316
|
+
const existing = await loadPublishConfig().catch(() => null);
|
|
317
|
+
const cfg: PublishConfig = {
|
|
318
|
+
endpoint: await ask("S3 endpoint", values.endpoint, existing?.endpoint ?? "https://s3.amanv.cloud"),
|
|
319
|
+
bucket: await ask("Bucket", values.bucket, existing?.bucket ?? "tcut"),
|
|
320
|
+
accessKeyId: await ask("Access key", values["access-key"], existing?.accessKeyId ?? ""),
|
|
321
|
+
secretAccessKey: await ask("Secret key", values["secret-key"], existing?.secretAccessKey ?? "", true),
|
|
322
|
+
region: values.region ?? existing?.region ?? "us-east-1",
|
|
323
|
+
...(values["public-url"] || existing?.publicUrl ? { publicUrl: values["public-url"] ?? existing?.publicUrl } : {}),
|
|
324
|
+
};
|
|
325
|
+
if (!cfg.accessKeyId || !cfg.secretAccessKey) fail("publish --setup needs --access-key and --secret-key (or run it in a terminal to be prompted)");
|
|
326
|
+
const result = await ensurePublicBucket(cfg, log);
|
|
327
|
+
const file = await savePublishConfig(cfg);
|
|
328
|
+
ok(`saved ${file}`, "mode 600");
|
|
329
|
+
ok(`bucket ${cfg.bucket} on ${cfg.endpoint}`, result.bucketCreated ? "created" : "exists");
|
|
330
|
+
if (result.publicReadOk) ok("public read verified", `links will look like ${publicUrlFor(cfg, "x").replace(/\/x$/, "/<hash>/demo.gif")}`);
|
|
331
|
+
else log(`${red("✘")} anonymous read failed — set a public-read policy on the bucket or pass --public-url for a CDN/proxy in front of it`);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (rest.length === 0) fail("publish needs at least one file (or --setup)");
|
|
335
|
+
const cfg = await loadPublishConfig();
|
|
336
|
+
if (!cfg) fail("publish is not configured yet — run `tcut publish --setup` (or set TCUT_S3_ENDPOINT/BUCKET/ACCESS_KEY/SECRET_KEY)");
|
|
337
|
+
const published = await publishFiles(rest, cfg, { name: values.name, log });
|
|
338
|
+
for (const p of published) ok(p.url, dim(path.basename(p.file)));
|
|
339
|
+
if (values.open && published[0]) {
|
|
340
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
341
|
+
Bun.spawn([opener, published[published.length - 1]!.url], { stdout: "ignore", stderr: "ignore" });
|
|
342
|
+
}
|
|
271
343
|
return;
|
|
272
344
|
}
|
|
273
345
|
case "init": {
|
|
@@ -285,31 +357,39 @@ async function main(): Promise<void> {
|
|
|
285
357
|
case "rec": {
|
|
286
358
|
// Live mode: the user (or a pipe) drives the PTY; everything after `--` is the command to run.
|
|
287
359
|
const overrides = overridesFromFlags();
|
|
288
|
-
const
|
|
360
|
+
const rawOutputs = overrides.output ?? ["rec.mp4"];
|
|
361
|
+
const outputs = Array.isArray(rawOutputs) ? rawOutputs : [rawOutputs];
|
|
289
362
|
const config = resolveConfig({ ...overrides, output: outputs, cast: overrides.cast });
|
|
290
363
|
const command = rest.length > 0 ? rest : undefined;
|
|
291
|
-
|
|
364
|
+
// Size: --cols/--rows if given, else the terminal tcut runs in.
|
|
365
|
+
const recording = await recordLive(config, { command, log, cols: overrides.cols, rows: overrides.rows });
|
|
292
366
|
await mkdir(path.dirname(path.resolve(config.cast)), { recursive: true });
|
|
293
367
|
await writeCast(config.cast, recording);
|
|
294
|
-
log(
|
|
368
|
+
log("");
|
|
369
|
+
ok(`wrote ${config.cast}`, `${recording.events.length} events, ${(recording.header.duration ?? 0).toFixed(1)}s`);
|
|
370
|
+
if (!values["no-script"]) {
|
|
371
|
+
const scriptPath = config.cast.replace(/\.cast$/, "") + ".video.ts";
|
|
372
|
+
await Bun.write(scriptPath, generateScript(recording, { output: outputs, cleanShell: !command, command, castPath: config.cast }));
|
|
373
|
+
ok(`wrote ${scriptPath}`, "editable script — tweak it, then `tcut " + scriptPath + "`");
|
|
374
|
+
}
|
|
295
375
|
if (values["record-only"]) return;
|
|
296
376
|
const result = await renderOutputs(recording, config, progressReporter());
|
|
297
377
|
await reportOutputs(result.outputs, result.screenshots);
|
|
298
|
-
log(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`);
|
|
378
|
+
log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
|
|
299
379
|
return;
|
|
300
380
|
}
|
|
301
381
|
case "record": {
|
|
302
382
|
if (!rest[0]) fail("record needs a script file");
|
|
303
383
|
const video = await loadVideo(rest[0]);
|
|
304
384
|
const rec = await video.record({ log, force: values.force });
|
|
305
|
-
|
|
385
|
+
ok(`${rec.cached ? "reused" : "wrote"} ${video.config.cast}`, `${rec.events.length} events, ${(rec.header.duration ?? 0).toFixed(1)}s, ${elapsed()}`);
|
|
306
386
|
return;
|
|
307
387
|
}
|
|
308
388
|
case "render": {
|
|
309
389
|
if (!rest[0]) fail("render needs a .cast file");
|
|
310
390
|
const result = await renderCast(rest[0], overridesFromFlags(), progressReporter());
|
|
311
391
|
await reportOutputs(result.outputs, result.screenshots);
|
|
312
|
-
log(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`);
|
|
392
|
+
log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
|
|
313
393
|
return;
|
|
314
394
|
}
|
|
315
395
|
case "test": {
|
|
@@ -321,9 +401,9 @@ async function main(): Promise<void> {
|
|
|
321
401
|
default: {
|
|
322
402
|
const video = await loadVideo(first!);
|
|
323
403
|
const result = await video.run({ log, force: values.force, recordOnly: values["record-only"], onProgress: progressReporter() });
|
|
324
|
-
|
|
404
|
+
ok(`${result.cached ? "reused" : "wrote"} ${result.cast}`);
|
|
325
405
|
await reportOutputs(result.outputs, result.screenshots);
|
|
326
|
-
if (!values["record-only"]) log(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`);
|
|
406
|
+
if (!values["record-only"]) log(dim(` ${result.frames} frames, ${result.durationSeconds.toFixed(1)}s of video in ${elapsed()}`));
|
|
327
407
|
}
|
|
328
408
|
}
|
|
329
409
|
}
|
package/src/index.ts
CHANGED
|
@@ -9,7 +9,11 @@ export { replayFrames } from "./export/frames";
|
|
|
9
9
|
export type { GridFrame, GridCell, GridReplay } from "./export/frames";
|
|
10
10
|
export { runScriptTests, discoverScripts } from "./testing";
|
|
11
11
|
export type { TestResult, TestSummary } from "./testing";
|
|
12
|
-
export { themes, themeNames, resolveTheme } from "./themes";
|
|
12
|
+
export { themes, themeNames, resolveTheme, findThemes, themeSlug, builtinThemes } from "./themes";
|
|
13
|
+
export { generateScript, eventsToOps, tokenize } from "./scriptgen";
|
|
14
|
+
export type { ScriptGenOptions } from "./scriptgen";
|
|
15
|
+
export { publishFiles, loadPublishConfig, savePublishConfig, ensurePublicBucket, publicUrlFor, keyFor } from "./publish";
|
|
16
|
+
export type { PublishConfig, Published, PublishOptions } from "./publish";
|
|
13
17
|
export { readCast, writeCast, parseCast, serializeCast } from "./cast";
|
|
14
18
|
export { buildTimeline } from "./timeline";
|
|
15
19
|
export { resolveConfig } from "./config";
|
package/src/live.ts
CHANGED
|
@@ -92,7 +92,7 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
|
|
|
92
92
|
stdin.on("data", onData);
|
|
93
93
|
}
|
|
94
94
|
process.on("SIGWINCH", onResize);
|
|
95
|
-
log(`recording ${setup.cmd.join(" ")} at ${cols}
|
|
95
|
+
log(`recording ${setup.cmd.join(" ")} at ${cols}×${rows} — ${opts.command ? "ends when the command exits" : "type exit to stop"}`);
|
|
96
96
|
|
|
97
97
|
try {
|
|
98
98
|
await Promise.race([exitedPromise, proc.exited]);
|