termcut 1.0.0 → 1.0.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 +95 -38
- package/package.json +1 -1
- package/src/cli.ts +21 -1
- package/src/export/html.ts +3 -1
- package/src/export/svg.ts +11 -3
- package/src/live.ts +8 -2
- package/src/renderer/generated/page.js +1 -1
- package/src/renderer/generated/player.js +1 -1
- package/src/renderer/page-entry.ts +7 -0
- package/src/renderer/page.ts +3 -0
- package/src/renderer/pin.ts +49 -0
- package/src/renderer/player-entry.ts +2 -0
- package/src/scriptgen.ts +21 -2
- package/src/usershell.ts +69 -0
package/README.md
CHANGED
|
@@ -1,38 +1,45 @@
|
|
|
1
1
|
# tcut
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Terminal videos, written in TypeScript. Record a session live or script it, then render it to MP4, GIF, WebM, SVG or HTML — the same recording gives the same pixels every time.
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
7
|
+
[tcut.amanv.dev](https://tcut.amanv.dev) · [Reference](https://github.com/AmanVarshney01/tcut/blob/main/packages/tcut/docs/REFERENCE.md) · [Examples](https://github.com/AmanVarshney01/tcut/tree/main/packages/tcut/examples) · [llms.txt](https://tcut.amanv.dev/llms.txt)
|
|
8
|
+
|
|
7
9
|
## Install
|
|
8
10
|
|
|
9
11
|
```sh
|
|
10
|
-
bun add -g termcut
|
|
12
|
+
bun add -g termcut # Bun ≥ 1.4 · installs the `tcut` command
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
Or a standalone binary for macOS, Linux or Windows from [Releases](https://github.com/AmanVarshney01/tcut/releases) — all three are tested in CI.
|
|
16
|
+
|
|
17
|
+
- MP4, GIF, WebM, WebP need `ffmpeg` on the PATH. SVG, HTML and text outputs need nothing.
|
|
18
|
+
- Linux and Windows render pixels through Chrome or Chromium (`BUN_CHROME_PATH` to point at one).
|
|
14
19
|
|
|
15
|
-
##
|
|
20
|
+
## Record
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
**Live.** Your shell opens — your prompt, config and aliases; type; `exit`. You get the video, the exact recording (`demo.cast`) and an editable script of what you typed (`demo.video.ts`). `--clean` opens a plain shell with a `>` prompt instead.
|
|
18
23
|
|
|
19
24
|
```sh
|
|
20
|
-
tcut rec -o demo.gif
|
|
21
|
-
tcut rec -o demo.mp4 -- npm create vite
|
|
25
|
+
tcut rec -o demo.gif
|
|
26
|
+
tcut rec -o demo.mp4 -- npm create vite # one command, ends when it exits
|
|
22
27
|
```
|
|
23
28
|
|
|
24
|
-
|
|
29
|
+
`-- command` runs through that same shell, so `tcut rec -- ls` records what *your* `ls` shows — aliases, functions and colours included. `--raw` runs the binary directly.
|
|
25
30
|
|
|
26
|
-
|
|
31
|
+
**Scripted.** Plain TypeScript, so loops, helpers and imports work, and the script lives in the repo next to the code it shows.
|
|
27
32
|
|
|
28
33
|
```ts
|
|
29
34
|
// demo.video.ts
|
|
30
35
|
import { defineVideo } from "tcut";
|
|
31
36
|
|
|
32
|
-
export default defineVideo({ output: "demo.gif" }, async (t) => {
|
|
33
|
-
await t.run("bun --version");
|
|
34
|
-
await t.
|
|
35
|
-
await t.
|
|
37
|
+
export default defineVideo({ output: ["demo.mp4", "demo.gif"] }, async (t) => {
|
|
38
|
+
await t.run("bun --version"); // types it, presses Enter, waits for the prompt
|
|
39
|
+
await t.run("ls -la");
|
|
40
|
+
await t.expect(/package\.json/); // asserts on the screen — the demo is a test
|
|
41
|
+
await t.snapshot("files.png"); // a still of this exact moment
|
|
42
|
+
await t.sleep("1.5s");
|
|
36
43
|
});
|
|
37
44
|
```
|
|
38
45
|
|
|
@@ -40,57 +47,107 @@ export default defineVideo({ output: "demo.gif" }, async (t) => {
|
|
|
40
47
|
tcut demo.video.ts
|
|
41
48
|
```
|
|
42
49
|
|
|
43
|
-
|
|
50
|
+
What a script can do, one line each:
|
|
44
51
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
| | |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `run(cmd)` | waits for your prompt to come back, not for a timer |
|
|
55
|
+
| `wait(/re/)` · `expect(/re/)` | observe or assert the screen — including lines that already scrolled away (`{ scope: "scrollback" }`) |
|
|
56
|
+
| `type` · `enter` · arrows · `ctrl("c")` · `key("f5")` | keys, sent the way the running program asked for them |
|
|
57
|
+
| `hide(fn)` | runs setup off-camera; the state stays |
|
|
58
|
+
| `snapshot("x.png" \| "x.svg")` | a pixel or vector still of that exact moment, written on every render |
|
|
59
|
+
| `chapter(name)` | mp4 chapters, and cut points for `--chapters` / `--split-chapters` |
|
|
60
|
+
| `print(markdown)` · `title(text)` | captions rendered into the terminal, nothing typed |
|
|
61
|
+
| `zoom({ rows, cols })` | magnifies a region; `keys: true` shows what was pressed |
|
|
62
|
+
| `timelapse(fn, { speed })` | fast-forwards an install or a build, not just the silence |
|
|
63
|
+
| `browser` | a real browser window beside or over the terminal (below) |
|
|
55
64
|
|
|
56
|
-
|
|
65
|
+
The full surface is in the [reference](https://github.com/AmanVarshney01/tcut/blob/main/packages/tcut/docs/REFERENCE.md).
|
|
57
66
|
|
|
58
|
-
|
|
67
|
+
## Render again
|
|
59
68
|
|
|
60
|
-
|
|
69
|
+
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.
|
|
61
70
|
|
|
62
71
|
```sh
|
|
63
|
-
tcut render demo.cast --
|
|
64
|
-
tcut render demo.cast --
|
|
72
|
+
tcut render demo.cast --theme "Gruvbox Dark" -o demo.svg -o demo.html
|
|
73
|
+
tcut render demo.cast --width 1280 --height 720 --speed 1.5 -o demo.mp4
|
|
74
|
+
tcut render demo.cast --from 2s --to 10s --shadow --watermark "@you" -o clip.gif
|
|
75
|
+
tcut render demo.cast --split-chapters --margin-fill transparent -o demo.webm
|
|
65
76
|
tcut concat intro.cast demo.cast --gap 500ms -o launch.mp4
|
|
77
|
+
tcut themes # ~600 themes, Ghostty's collection
|
|
66
78
|
```
|
|
67
79
|
|
|
68
|
-
|
|
80
|
+
Outputs by extension: `.mp4` `.gif` `.webm` `.webp` · `.svg` (animated, real text) · `.html` (single-file player) · `.png` `.jpg` (final frame) · `.txt` (final screen) · `.log` (full transcript) · `dir/` (PNG frames).
|
|
81
|
+
|
|
82
|
+
## Faithful to the terminal
|
|
83
|
+
|
|
84
|
+
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.
|
|
85
|
+
|
|
86
|
+
- Arrow keys and pastes arrive exactly as the running program asked: application cursor mode, bracketed paste.
|
|
87
|
+
- Links printed with OSC 8 — including Markdown links in `print()` captions — stay clickable in SVG and HTML.
|
|
88
|
+
- Frames are never torn: synchronized-output blocks are captured whole.
|
|
89
|
+
- Symbols the font lacks (progress blocks, Nerd Font icons) stay on their cell, so status bars never drift.
|
|
90
|
+
- `tcut doctor demo.cast` explains what a recording used, and what cannot be rendered (inline images).
|
|
91
|
+
|
|
92
|
+
## Test it
|
|
69
93
|
|
|
70
94
|
```sh
|
|
71
|
-
tcut
|
|
95
|
+
tcut test demo.video.ts # runs the script with no delays — just the assertions
|
|
96
|
+
tcut diff a.cast b.cast # catches output changes between two recordings
|
|
72
97
|
```
|
|
73
98
|
|
|
74
|
-
|
|
99
|
+
`expect()` makes a demo a test. `tcut test` runs it fast, and exits non-zero when the screen does not match — so the same script that renders your README video can guard it in CI.
|
|
100
|
+
|
|
101
|
+
## A browser next to the terminal
|
|
102
|
+
|
|
103
|
+
For dev-server demos: the page is recorded on the same clock and composited beside or over the terminal.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
defineVideo({ output: "demo.mp4", browser: { position: "overlay" } }, async (t) => {
|
|
107
|
+
await t.run("bun run dev </dev/null >/tmp/dev.log 2>&1 &");
|
|
108
|
+
await t.browser.goto("http://localhost:5173");
|
|
109
|
+
await t.run("sed -i '' 's/Hello/Hi/' src/App.tsx"); // HMR updates the page
|
|
110
|
+
await t.focus("browser");
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Share it
|
|
75
115
|
|
|
76
116
|
```sh
|
|
77
|
-
tcut publish --setup # once: your S3-compatible bucket (RustFS, MinIO, R2, S3)
|
|
117
|
+
tcut publish --setup # once: your own S3-compatible bucket (RustFS, MinIO, R2, S3)
|
|
78
118
|
tcut publish demo.gif # → https://…/3f9a1c2b7d4e/demo.gif
|
|
79
119
|
```
|
|
80
120
|
|
|
81
|
-
|
|
121
|
+
There is no hosted service; you bring the bucket.
|
|
122
|
+
|
|
123
|
+
## For agents
|
|
82
124
|
|
|
83
125
|
```sh
|
|
84
|
-
npx skills add AmanVarshney01/tcut #
|
|
126
|
+
npx skills add AmanVarshney01/tcut # skills for Claude Code, Cursor, Codex, …
|
|
85
127
|
```
|
|
86
128
|
|
|
87
|
-
Two skills: `tcut` (record terminal videos) and `tcut-remotion` (compose tcut footage into launch
|
|
129
|
+
Two skills: `tcut` (record terminal videos) and `tcut-remotion` (compose tcut footage into a launch video with [Remotion](https://remotion.dev)). Every command has `--json`, exit codes and no prompts; [llms.txt](https://tcut.amanv.dev/llms.txt) is the condensed guide.
|
|
130
|
+
|
|
131
|
+
## Compared with VHS
|
|
132
|
+
|
|
133
|
+
[VHS](https://github.com/charmbracelet/vhs) is the reference point and the inspiration. Where tcut differs:
|
|
134
|
+
|
|
135
|
+
- **Scripts are TypeScript** — loops, imports, shared scenes, autocomplete — instead of a `.tape` DSL.
|
|
136
|
+
- **Waits on the screen** — `run()` returns when your prompt is back; VHS sleeps for a guessed duration.
|
|
137
|
+
- **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.
|
|
138
|
+
- **Demos are tests** — `expect()` asserts on the screen; `tcut test` runs them in CI.
|
|
139
|
+
- **Same emulator as your terminal** — Ghostty's core in WASM, its themes, plus SVG and HTML outputs that need no ffmpeg or browser.
|
|
140
|
+
|
|
141
|
+
## How it works
|
|
142
|
+
|
|
143
|
+
1. **Record.** `Bun.Terminal` runs your shell in a PTY. Every byte is timestamped into a `.cast`.
|
|
144
|
+
2. **Watch.** The same bytes feed a headless [Ghostty](https://ghostty.org) (via [wterm](https://github.com/vercel-labs/wterm)). That is how `run()` knows the prompt is back and `expect()` sees what you see.
|
|
145
|
+
3. **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.
|
|
88
146
|
|
|
89
147
|
## More
|
|
90
148
|
|
|
91
|
-
- [Examples](https://github.com/AmanVarshney01/tcut/tree/main/packages/tcut/examples) — driving an interactive TUI, recording Claude Code
|
|
149
|
+
- [Examples](https://github.com/AmanVarshney01/tcut/tree/main/packages/tcut/examples) — driving an interactive TUI, recording Claude Code and Codex
|
|
92
150
|
- [Reference](https://github.com/AmanVarshney01/tcut/blob/main/packages/tcut/docs/REFERENCE.md) — every CLI flag and script option
|
|
93
|
-
- [llms.txt](https://tcut.amanv.dev/llms.txt) — the same, condensed for coding agents (`--json` gives machine-readable results)
|
|
94
151
|
- [tcut.amanv.dev](https://tcut.amanv.dev)
|
|
95
152
|
|
|
96
153
|
MIT
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { readCast, writeCast } from "./cast";
|
|
|
6
6
|
import { applyOverrides, resolveConfig } from "./config";
|
|
7
7
|
import * as api from "./index";
|
|
8
8
|
import { recordLive } from "./live";
|
|
9
|
+
import { throughShell, userShell } from "./usershell";
|
|
9
10
|
import { ensurePublicBucket, loadPublishConfig, publicUrlFor, publishFiles, savePublishConfig, type PublishConfig, type Published } from "./publish";
|
|
10
11
|
import { diffCasts, type DiffResult } from "./diff";
|
|
11
12
|
import { diagnoseCast, formatDoctorReport, type DoctorReport } from "./doctor";
|
|
@@ -70,6 +71,8 @@ Options (override the script's config):
|
|
|
70
71
|
--gap <dur> concat: still time between parts
|
|
71
72
|
--preset <name> readme | x | youtube | square
|
|
72
73
|
--browser <url> rec: record a browser window too (--browser-position right|left|top|bottom|overlay)
|
|
74
|
+
--raw rec: run the command directly instead of through your shell (no aliases/functions)
|
|
75
|
+
--clean rec: open tcut's clean shell (plain > prompt, no personal config) instead of yours
|
|
73
76
|
--at <seconds> diff: compare the screen at this time instead of the end
|
|
74
77
|
--images <dir> diff: also write a.png / b.png
|
|
75
78
|
--cast <path> where to read/write the .cast
|
|
@@ -130,6 +133,8 @@ const { values, positionals } = parseArgs({
|
|
|
130
133
|
cast: { type: "string" },
|
|
131
134
|
"record-only": { type: "boolean" },
|
|
132
135
|
"no-script": { type: "boolean" },
|
|
136
|
+
raw: { type: "boolean" },
|
|
137
|
+
clean: { type: "boolean" },
|
|
133
138
|
force: { type: "boolean" },
|
|
134
139
|
setup: { type: "boolean" },
|
|
135
140
|
open: { type: "boolean" },
|
|
@@ -471,11 +476,26 @@ async function main(): Promise<void> {
|
|
|
471
476
|
const rawOutputs = overrides.output ?? ["rec.mp4"];
|
|
472
477
|
const outputs = Array.isArray(rawOutputs) ? rawOutputs : [rawOutputs];
|
|
473
478
|
const config = resolveConfig({ ...overrides, output: outputs, cast: overrides.cast });
|
|
474
|
-
|
|
479
|
+
// The shell tcut was typed into: `-- cmd` runs through it (aliases, functions, colours) unless --raw;
|
|
480
|
+
// with no command it IS the session — your prompt and config — unless --clean or an explicit --shell.
|
|
481
|
+
const clean = Boolean(values.clean) || overrides.shell !== undefined;
|
|
482
|
+
const shell = (rest.length > 0 ? !values.raw : !clean) ? userShell() : null;
|
|
483
|
+
let command: string[] | undefined;
|
|
484
|
+
let interactive = false;
|
|
485
|
+
if (rest.length > 0) {
|
|
486
|
+
command = shell ? throughShell(rest, shell) : rest;
|
|
487
|
+
if (shell) log(dim(` via ${shell.name} — your aliases and functions apply (--raw to run the binary directly)`));
|
|
488
|
+
} else if (shell) {
|
|
489
|
+
command = [shell.path, "-il"];
|
|
490
|
+
interactive = true;
|
|
491
|
+
log(dim(` your ${shell.name}, with its config (--clean for a plain shell with a > prompt)`));
|
|
492
|
+
}
|
|
475
493
|
// Size: --cols/--rows if given, else derived from --width/--height, else the terminal tcut runs in.
|
|
476
494
|
const sized = overrides.width !== undefined || overrides.height !== undefined;
|
|
477
495
|
const recording = await recordLive(config, {
|
|
478
496
|
command,
|
|
497
|
+
interactive,
|
|
498
|
+
describe: rest.length > 0 ? rest.join(" ") : shell ? `your ${shell.name}` : undefined,
|
|
479
499
|
log,
|
|
480
500
|
cols: overrides.cols ?? (sized ? config.cols : undefined),
|
|
481
501
|
rows: overrides.rows ?? (sized ? config.rows : undefined),
|
package/src/export/html.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { PIN_CSS } from "../renderer/pin";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { barHeight, embedImage, shadowCss, watermarkCss } from "../renderer/page";
|
|
4
5
|
import { pageAssets } from "../renderer/bundle";
|
|
@@ -62,7 +63,8 @@ ${watermarkCss(config)}
|
|
|
62
63
|
#bar { height: ${barHeight(config)}px; margin-top: -${Math.min(config.padding, 12)}px; display: flex; align-items: center; justify-content: space-between; font: 13px -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; color: ${theme.foreground}; }
|
|
63
64
|
#bar .dots { display: flex; gap: 8px; } #bar .dot { width: 12px; height: 12px; border-radius: 50%; box-sizing: border-box; display: inline-block; }
|
|
64
65
|
#bar .title { flex: 1; text-align: center; opacity: .7; } #bar.right .title { text-align: left; }
|
|
65
|
-
#term.wterm { ${vars}; --term-font-family: ${font.family}; --term-font-size: ${font.size}px; --term-line-height: ${font.lineHeight}; --term-row-height: ${Math.ceil(font.size * font.lineHeight)}px; letter-spacing: ${font.letterSpacing}px; padding: 0; border-radius: 0; box-shadow: none; background: transparent; cursor: pointer; }
|
|
66
|
+
#term.wterm { ${vars}; --term-font-family: ${font.family}; --term-font-size: ${font.size}px; --term-line-height: ${font.lineHeight}; --term-row-height: ${Math.ceil(font.size * font.lineHeight)}px; letter-spacing: ${font.letterSpacing}px; --vt-letter-spacing: ${font.letterSpacing}px; padding: 0; border-radius: 0; box-shadow: none; background: transparent; cursor: pointer; }
|
|
67
|
+
${PIN_CSS}
|
|
66
68
|
#controls { display: flex; gap: 12px; align-items: center; margin-top: 12px; font: 12px -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; color: ${theme.foreground}; opacity: .85; }
|
|
67
69
|
#controls button { background: transparent; color: inherit; border: 1px solid currentColor; border-radius: 6px; width: 34px; height: 26px; cursor: pointer; }
|
|
68
70
|
#controls input[type=range] { flex: 1; accent-color: ${theme.cursor ?? theme.foreground}; }
|
package/src/export/svg.ts
CHANGED
|
@@ -108,20 +108,28 @@ function frameMarkup(frame: GridFrame, config: ResolvedConfig, g: Geometry): str
|
|
|
108
108
|
// Text runs with identical style (and link); OSC 8 links become real <a> elements
|
|
109
109
|
const spans: string[] = [];
|
|
110
110
|
x = 0;
|
|
111
|
-
let run: { x: number; text: string; style: string; link: string | null } | null = null;
|
|
111
|
+
let run: { x: number; text: string; style: string; link: string | null; cols: number[]; single: boolean } | null = null;
|
|
112
112
|
const flushRun = () => {
|
|
113
113
|
if (!run || !run.text.trim()) return;
|
|
114
|
-
const
|
|
114
|
+
const text = run.text.replace(/\s+$/, "");
|
|
115
|
+
// Fallback-font glyphs are not one cell wide; give every glyph its own x when the run has any non-ASCII
|
|
116
|
+
// and each cell is a single code point (an x list addresses code points, so clusters would misalign).
|
|
117
|
+
const pinned = run.single && /[^\x20-\x7e]/.test(text);
|
|
118
|
+
const xAttr = pinned ? run.cols.slice(0, [...text].length).map((c) => num(c * g.cellW)).join(" ") : num(run.x * g.cellW);
|
|
119
|
+
const span = `<tspan x="${xAttr}"${run.style}>${esc(text)}</tspan>`;
|
|
115
120
|
spans.push(run.link ? `<a href="${esc(run.link)}">${span}</a>` : span);
|
|
116
121
|
};
|
|
117
122
|
for (const cell of cells) {
|
|
118
123
|
const blank = cell.text === " " && !(cell.flags & (FLAG.underline | FLAG.strike));
|
|
119
124
|
const style = blank ? "" : styleAttrs(cell, theme.foreground);
|
|
125
|
+
const single = [...cell.text].length === 1;
|
|
120
126
|
if (run && ((run.style === style && run.link === cell.link) || (blank && run.text.length > 0 && run.link === null))) {
|
|
121
127
|
run.text += cell.text;
|
|
128
|
+
run.cols.push(x);
|
|
129
|
+
run.single &&= single;
|
|
122
130
|
} else {
|
|
123
131
|
flushRun();
|
|
124
|
-
run = blank ? null : { x, text: cell.text, style, link: cell.link };
|
|
132
|
+
run = blank ? null : { x, text: cell.text, style, link: cell.link, cols: [x], single };
|
|
125
133
|
}
|
|
126
134
|
x += cell.width;
|
|
127
135
|
}
|
package/src/live.ts
CHANGED
|
@@ -24,6 +24,10 @@ export interface LiveOptions {
|
|
|
24
24
|
/** Keystroke source (default: this process's stdin, switched to raw mode when it is a TTY). */
|
|
25
25
|
stdin?: LiveStdin | null;
|
|
26
26
|
log?: (message: string) => void;
|
|
27
|
+
/** How to name the command in status lines (defaults to the argv). */
|
|
28
|
+
describe?: string;
|
|
29
|
+
/** The command is a shell the user types into (ends on `exit`), not a program that runs and exits. */
|
|
30
|
+
interactive?: boolean;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
/**
|
|
@@ -104,11 +108,13 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
|
|
|
104
108
|
stdin.on("data", onData);
|
|
105
109
|
}
|
|
106
110
|
process.on("SIGWINCH", onResize);
|
|
107
|
-
log(`recording ${setup.cmd.join(" ")} at ${cols}×${rows} — ${opts.command ? "ends when the command exits" : "type exit to stop"}`);
|
|
111
|
+
log(`recording ${opts.describe ?? setup.cmd.join(" ")} at ${cols}×${rows} — ${opts.command && !opts.interactive ? "ends when the command exits" : "type exit to stop"}`);
|
|
108
112
|
|
|
109
113
|
try {
|
|
110
114
|
await Promise.race([exitedPromise, proc.exited]);
|
|
111
|
-
|
|
115
|
+
// Hold the final screen for `endPause`, like a scripted recording does — otherwise a command that exits in
|
|
116
|
+
// 20 ms (`tcut rec -- ls`) becomes a three-frame video. The timeline is virtual: stamp it, don't sleep.
|
|
117
|
+
events.push([Number((stamp() + config.endPause / 1000).toFixed(6)), "m", MARKER.end]);
|
|
112
118
|
} finally {
|
|
113
119
|
await browser?.stop();
|
|
114
120
|
const emitter: NodeJS.EventEmitter = process; // @types/bun's process.off() lacks the signal overload; the generic emitter has it
|