tui-cap 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 +213 -0
- package/dist/anim.js +104 -0
- package/dist/animation.js +96 -0
- package/dist/asciinema.js +125 -0
- package/dist/cli.js +648 -0
- package/dist/edits.js +527 -0
- package/dist/fonts.js +87 -0
- package/dist/input.js +136 -0
- package/dist/meta.js +58 -0
- package/dist/palette.js +111 -0
- package/dist/parse.js +659 -0
- package/dist/paths.js +100 -0
- package/dist/record-pty.js +148 -0
- package/dist/record.js +100 -0
- package/dist/server.js +978 -0
- package/dist/svg.js +767 -0
- package/dist/timing.js +112 -0
- package/dist/types.js +2 -0
- package/dist/version.js +232 -0
- package/dist/web/app.js +3312 -0
- package/dist/web/fonts/MonaSansMonoVF-wght.woff2 +0 -0
- package/dist/web/fonts/MonaSansVF-wdth-wght-opsz.woff2 +0 -0
- package/dist/web/fonts/README.md +13 -0
- package/dist/web/index.html +382 -0
- package/dist/web/logo.svg +11 -0
- package/dist/web/styles.css +925 -0
- package/dist/web/timing-model.js +115 -0
- package/dist/web/vendor/mp4-muxer.LICENSE +21 -0
- package/dist/web/vendor/mp4-muxer.js +1885 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cameron Foxly
|
|
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,213 @@
|
|
|
1
|
+
# tui-cap
|
|
2
|
+
|
|
3
|
+
<img width="1623" height="1005" alt="tui-cap GUI screenshot" src="https://github.com/user-attachments/assets/886a413b-8123-4358-b8dd-3c20233b60e5" />
|
|
4
|
+
|
|
5
|
+
**Turn GitHub Copilot CLI (or any terminal program) into a clean, editable,
|
|
6
|
+
brand-correct image.**
|
|
7
|
+
|
|
8
|
+
`tui-cap` records a terminal session and turns it into an **SVG with real, editable
|
|
9
|
+
text** — the exact GitHub colors, ready to drop straight into Figma or Illustrator
|
|
10
|
+
for slides, docs, and marketing shots. It can also export a **PNG** or a **timed
|
|
11
|
+
MP4** of the whole session.
|
|
12
|
+
|
|
13
|
+
Screenshots give you the look but flatten everything to pixels. Copy/paste gives
|
|
14
|
+
you the text but throws the colors away. `tui-cap` keeps **both**.
|
|
15
|
+
|
|
16
|
+
> Currently supported on **macOS**.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## What you need
|
|
21
|
+
|
|
22
|
+
1. **macOS.**
|
|
23
|
+
2. **Node.js 20 or newer.** To check, open Terminal and run:
|
|
24
|
+
```bash
|
|
25
|
+
node --version
|
|
26
|
+
```
|
|
27
|
+
If you see `v20` (or higher), you're set. If the command isn't found or the
|
|
28
|
+
number is lower, install the **LTS** version from
|
|
29
|
+
**[nodejs.org](https://nodejs.org/)** — download the macOS installer and
|
|
30
|
+
double-click it, no terminal needed.
|
|
31
|
+
3. **(Only to record the Copilot CLI)** the `copilot` command, installed and
|
|
32
|
+
signed in. Everything else works without it.
|
|
33
|
+
|
|
34
|
+
That's the entire setup. There's nothing to compile and no other tools to install.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
You don't have to install anything — run it on demand with `npx`:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npx tui-cap --help
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The first run downloads the tool automatically (answer **yes** if prompted). If
|
|
47
|
+
you'll use it often, install it once so the `tui-cap` command is always available:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm install -g tui-cap
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
To update later, run `tui-cap update` — or `npm install -g tui-cap@latest`.
|
|
54
|
+
|
|
55
|
+
> **Stay current automatically.** Every time you open the app, `tui-cap` checks
|
|
56
|
+
> npm for a newer release. When one is available, a banner appears at the top with
|
|
57
|
+
> an **Update now** button — one click upgrades you in place (just restart the app
|
|
58
|
+
> afterward). Prefer the terminal? Run `tui-cap update`.
|
|
59
|
+
|
|
60
|
+
> The rest of this guide writes commands as `tui-cap …`. If you didn't install
|
|
61
|
+
> globally, just put `npx ` in front, e.g. `npx tui-cap record …`.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Quick start — capture the Copilot CLI
|
|
66
|
+
|
|
67
|
+
**1. Record a session.** This launches the Copilot CLI inside a recorder:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
tui-cap record -o my-shot.ans -- copilot
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Use Copilot normally — type a prompt, let it answer. When you're happy, **quit
|
|
74
|
+
Copilot** (e.g. `/exit`, or press `Ctrl+C`) to stop recording.
|
|
75
|
+
|
|
76
|
+
**2. The app opens automatically.** When recording stops, `tui-cap` opens a small
|
|
77
|
+
app in your browser showing your recording.
|
|
78
|
+
|
|
79
|
+
**3. Pick a frame and export.** Scrub the timeline to the moment you want, tweak
|
|
80
|
+
the look if you like (window title, size, spacing), then click:
|
|
81
|
+
|
|
82
|
+
- **Save SVG…** — editable text + colors, perfect for Figma/Illustrator.
|
|
83
|
+
- **Save PNG…** — a flat image at 1×–4× size.
|
|
84
|
+
- **Export MP4…** — a timed video of the whole session.
|
|
85
|
+
|
|
86
|
+
Your recording and exports are saved in a **`captures` folder in your home
|
|
87
|
+
directory** (`~/captures`). That's it.
|
|
88
|
+
|
|
89
|
+
> **Want to record something other than Copilot?** Put any command after `--`,
|
|
90
|
+
> e.g. `tui-cap record -o demo.ans -- git log --oneline`.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Commands
|
|
95
|
+
|
|
96
|
+
Run `tui-cap --help` (or `tui-cap <command> --help`) any time for the full list.
|
|
97
|
+
|
|
98
|
+
| Command | What it does |
|
|
99
|
+
| --- | --- |
|
|
100
|
+
| **`tui-cap record -o <file> -- <command>`** | Record a live terminal session (e.g. `copilot`) to a capture file, then open the app to review and export it. |
|
|
101
|
+
| **`tui-cap gui`** | Open the app to browse your captures, pick frames, tweak the look, and export SVG / PNG / MP4. |
|
|
102
|
+
| **`tui-cap render <file.ans>`** | Turn a capture into an editable **SVG** straight from the command line (no app). |
|
|
103
|
+
| **`tui-cap asciinema <file.cast>`** | Turn an existing [asciinema](https://asciinema.org) recording into an SVG. |
|
|
104
|
+
| **`tui-cap update`** | Update to the latest published version (`npm install -g tui-cap@latest`). |
|
|
105
|
+
| **`tui-cap --version`** | Print the installed version. |
|
|
106
|
+
|
|
107
|
+
### Handy options
|
|
108
|
+
|
|
109
|
+
**`record`**
|
|
110
|
+
- `-o <file>` — name for the capture. Omit it and recordings are auto-numbered
|
|
111
|
+
(`copilot-capture_01.ans`, `_02`, …) in your captures folder.
|
|
112
|
+
- `--no-gui` — don't auto-open the app when recording finishes.
|
|
113
|
+
|
|
114
|
+
**`gui`**
|
|
115
|
+
- `--port <n>` — pick a port (default `4787`; it auto-picks the next free one).
|
|
116
|
+
- `--no-open` — start the server but don't open a browser; just print the URL.
|
|
117
|
+
|
|
118
|
+
**`render`**
|
|
119
|
+
- `-o <file.svg>` — output path (defaults to your captures folder).
|
|
120
|
+
- `--theme dark|light` — color theme (default `dark`).
|
|
121
|
+
- `--title "<text>"` — the window title bar text (default "GitHub Copilot CLI").
|
|
122
|
+
- `--no-chrome` — drop the window frame / title bar.
|
|
123
|
+
- `--list-frames` — list the frames in a capture so you can pick one with
|
|
124
|
+
`--frame <n>`.
|
|
125
|
+
|
|
126
|
+
**`asciinema`**
|
|
127
|
+
- `--at <seconds>` — render the frame at that time (default: the end).
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Using the app (GUI)
|
|
132
|
+
|
|
133
|
+
`tui-cap gui` opens a local app (it runs only on your machine) that turns your
|
|
134
|
+
captures folder into a visual library:
|
|
135
|
+
|
|
136
|
+
- **Browse captures** — every recording, newest first; it refreshes on its own
|
|
137
|
+
when a new recording lands.
|
|
138
|
+
- **Scrub the timeline** — drag through the session and watch the big preview
|
|
139
|
+
update; play it back with the space bar.
|
|
140
|
+
- **Tweak the look** — window title, window style (macOS / Windows), show/hide the
|
|
141
|
+
window frame, font size, and line spacing all update the preview live.
|
|
142
|
+
- **Edit the content (non-destructively)** — recolor or rewrite text right on the
|
|
143
|
+
canvas, or add/remove blank lines for spacing. Your edits follow the text across
|
|
144
|
+
every frame and never change the original recording.
|
|
145
|
+
- **Export** — Save SVG, Save PNG (1×–4×), or Export a timed **MP4** of the
|
|
146
|
+
animation.
|
|
147
|
+
|
|
148
|
+
> **MP4 export needs a modern browser** with a built-in H.264 encoder — **Chrome,
|
|
149
|
+
> Edge, or Safari 16.4+**. (Firefox can't export MP4 yet.) SVG and PNG export work
|
|
150
|
+
> everywhere. The video is made entirely on your machine — nothing is uploaded.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Where your files are saved
|
|
155
|
+
|
|
156
|
+
Everything lands in one **captures folder** so shots don't scatter across whatever
|
|
157
|
+
project you recorded from:
|
|
158
|
+
|
|
159
|
+
- By default that's **`~/captures`** (created automatically).
|
|
160
|
+
- Bare filenames (`-o my-shot.ans`) and outputs go there.
|
|
161
|
+
- Want a specific location? Pass a path with a slash (`-o ./shot.ans` or
|
|
162
|
+
`-o /tmp/shot.ans`) and it's written there instead.
|
|
163
|
+
- Prefer a different default folder? Set the `GHCP_CAPTURE_DIR` environment
|
|
164
|
+
variable.
|
|
165
|
+
|
|
166
|
+
`render` also looks in the captures folder, so `record -o shot.ans -- copilot`
|
|
167
|
+
followed by `render shot.ans` works from any directory.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Tips & troubleshooting
|
|
172
|
+
|
|
173
|
+
- **`command not found: copilot`** — install and sign in to the Copilot CLI first,
|
|
174
|
+
then try the `record` command again.
|
|
175
|
+
- **My recording only shows the shell prompt.** Full-screen tools (like Copilot)
|
|
176
|
+
wipe their screen when they exit. `tui-cap` handles this for you — it keeps the
|
|
177
|
+
live frame by default. Use the app's timeline, or `render … --list-frames` and
|
|
178
|
+
`--frame <n>`, to pick a different moment.
|
|
179
|
+
- **The "Do you trust this folder?" prompt.** Answer it inside the recorded
|
|
180
|
+
session (it's part of Copilot's startup), then continue.
|
|
181
|
+
- **MP4 export is greyed out.** Open the app in Chrome, Edge, or Safari 16.4+ (see
|
|
182
|
+
above).
|
|
183
|
+
- **A capture keystroke cursor / typing effect (advanced).** The optional
|
|
184
|
+
`--backend pty` records keystrokes for a typing-cursor effect. It needs the
|
|
185
|
+
optional native `node-pty` package and is intended for people running from a
|
|
186
|
+
source checkout; the default recorder already captures timing for MP4 export.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## How it works (the short version)
|
|
191
|
+
|
|
192
|
+
The colors never actually leave the terminal — your *clipboard* strips them. The
|
|
193
|
+
escape codes for color, bold, italic, and underline are right there in the bytes
|
|
194
|
+
the program prints. `tui-cap`:
|
|
195
|
+
|
|
196
|
+
1. **Records** the raw output through a real terminal, so the program behaves
|
|
197
|
+
exactly as it would for you.
|
|
198
|
+
2. **Replays** it through the same engine that powers xterm.js, snapshotting the
|
|
199
|
+
screen so it recovers real *frames* (important for full-screen tools that
|
|
200
|
+
repaint constantly).
|
|
201
|
+
3. **Draws** each frame as an SVG where every run of same-styled text is genuine,
|
|
202
|
+
editable `<text>` with an exact hex color.
|
|
203
|
+
|
|
204
|
+
For the full architecture, timing model, Figma details, and contributor docs, see
|
|
205
|
+
**[DEVELOPMENT.md](DEVELOPMENT.md)**.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## License
|
|
210
|
+
|
|
211
|
+
[MIT](LICENSE) © Cameron Foxly
|
|
212
|
+
|
|
213
|
+
Contributions and development setup: see **[DEVELOPMENT.md](DEVELOPMENT.md)**.
|
package/dist/anim.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.DEFAULT_TYPING_CPS = exports.DEFAULT_SPEED = exports.DEFAULT_SCALE = exports.DEFAULT_FPS = exports.ANIM_SCHEMA_VERSION = void 0;
|
|
7
|
+
exports.defaultAnimationSettings = defaultAnimationSettings;
|
|
8
|
+
exports.animPathFor = animPathFor;
|
|
9
|
+
exports.normalizeSettings = normalizeSettings;
|
|
10
|
+
exports.readAnimation = readAnimation;
|
|
11
|
+
exports.writeAnimation = writeAnimation;
|
|
12
|
+
const promises_1 = require("node:fs/promises");
|
|
13
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
14
|
+
const animation_1 = require("./animation");
|
|
15
|
+
exports.ANIM_SCHEMA_VERSION = 1;
|
|
16
|
+
exports.DEFAULT_FPS = 30;
|
|
17
|
+
exports.DEFAULT_SCALE = 2;
|
|
18
|
+
exports.DEFAULT_SPEED = 1;
|
|
19
|
+
exports.DEFAULT_TYPING_CPS = 30;
|
|
20
|
+
const FPS_RANGE = [1, 120];
|
|
21
|
+
const SCALE_RANGE = [1, 4];
|
|
22
|
+
const IDLE_CAP_RANGE = [0, 600_000];
|
|
23
|
+
const SPEED_RANGE = [0.1, 100];
|
|
24
|
+
const OVERRIDE_RANGE = [0, 600_000];
|
|
25
|
+
const TYPING_CPS_RANGE = [10, 60];
|
|
26
|
+
/** Default settings used when a recording has no sidecar yet. */
|
|
27
|
+
function defaultAnimationSettings() {
|
|
28
|
+
return {
|
|
29
|
+
schemaVersion: exports.ANIM_SCHEMA_VERSION,
|
|
30
|
+
fps: exports.DEFAULT_FPS,
|
|
31
|
+
scale: exports.DEFAULT_SCALE,
|
|
32
|
+
idleCapMs: animation_1.DEFAULT_IDLE_CAP_MS,
|
|
33
|
+
speed: exports.DEFAULT_SPEED,
|
|
34
|
+
overrides: {},
|
|
35
|
+
skipped: [],
|
|
36
|
+
smoothTyping: false,
|
|
37
|
+
typingCps: exports.DEFAULT_TYPING_CPS,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Sibling sidecar path for a capture: `foo.ans` -> `foo.anim.json`. */
|
|
41
|
+
function animPathFor(captureFile) {
|
|
42
|
+
const dir = node_path_1.default.dirname(captureFile);
|
|
43
|
+
const base = node_path_1.default.basename(captureFile, node_path_1.default.extname(captureFile));
|
|
44
|
+
return node_path_1.default.join(dir, `${base}.anim.json`);
|
|
45
|
+
}
|
|
46
|
+
function clamp(n, [min, max]) {
|
|
47
|
+
return Math.max(min, Math.min(max, n));
|
|
48
|
+
}
|
|
49
|
+
function num(value, fallback, range) {
|
|
50
|
+
return typeof value === 'number' && Number.isFinite(value) ? clamp(value, range) : fallback;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Coerce arbitrary input (e.g. a request body) into valid settings, clamping
|
|
54
|
+
* every field to a sane range and dropping malformed overrides. Always returns
|
|
55
|
+
* a complete, safe object — never throws.
|
|
56
|
+
*/
|
|
57
|
+
function normalizeSettings(input) {
|
|
58
|
+
const src = (input && typeof input === 'object' ? input : {});
|
|
59
|
+
const base = defaultAnimationSettings();
|
|
60
|
+
const overrides = {};
|
|
61
|
+
if (src.overrides && typeof src.overrides === 'object') {
|
|
62
|
+
for (const [key, value] of Object.entries(src.overrides)) {
|
|
63
|
+
if (!/^\d+$/.test(key))
|
|
64
|
+
continue; // frame index keys only
|
|
65
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
66
|
+
continue;
|
|
67
|
+
overrides[key] = clamp(value, OVERRIDE_RANGE);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const skipped = Array.isArray(src.skipped)
|
|
71
|
+
? Array.from(new Set(src.skipped.filter((n) => typeof n === 'number' && Number.isInteger(n) && n >= 0))).sort((a, b) => a - b)
|
|
72
|
+
: [];
|
|
73
|
+
return {
|
|
74
|
+
schemaVersion: exports.ANIM_SCHEMA_VERSION,
|
|
75
|
+
fps: num(src.fps, base.fps, FPS_RANGE),
|
|
76
|
+
scale: num(src.scale, base.scale, SCALE_RANGE),
|
|
77
|
+
idleCapMs: num(src.idleCapMs, base.idleCapMs, IDLE_CAP_RANGE),
|
|
78
|
+
speed: num(src.speed, base.speed, SPEED_RANGE),
|
|
79
|
+
overrides,
|
|
80
|
+
skipped,
|
|
81
|
+
smoothTyping: typeof src.smoothTyping === 'boolean' ? src.smoothTyping : base.smoothTyping,
|
|
82
|
+
typingCps: num(src.typingCps, base.typingCps, TYPING_CPS_RANGE),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Read a recording's animation settings, or null when there's no sidecar yet.
|
|
87
|
+
* Never throws — a garbled file is treated as absent so the GUI falls back to
|
|
88
|
+
* defaults.
|
|
89
|
+
*/
|
|
90
|
+
async function readAnimation(captureFile) {
|
|
91
|
+
try {
|
|
92
|
+
const raw = await (0, promises_1.readFile)(animPathFor(captureFile), 'utf8');
|
|
93
|
+
return normalizeSettings(JSON.parse(raw));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Write a recording's animation settings sidecar. Returns the sidecar path. */
|
|
100
|
+
async function writeAnimation(captureFile, settings) {
|
|
101
|
+
const out = animPathFor(captureFile);
|
|
102
|
+
await (0, promises_1.writeFile)(out, `${JSON.stringify(normalizeSettings(settings), null, 2)}\n`, 'utf8');
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Pure timeline math for turning captured frame timing into an animation.
|
|
4
|
+
*
|
|
5
|
+
* Kept free of any I/O or DOM dependency so the exact same logic runs on the
|
|
6
|
+
* server (annotating `/frames` with durations) and in the browser (previewing
|
|
7
|
+
* and encoding the export). Everything here is deterministic and unit-tested.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.DEFAULT_TAIL_MS = exports.DEFAULT_IDLE_CAP_MS = void 0;
|
|
11
|
+
exports.frameDurations = frameDurations;
|
|
12
|
+
exports.applyTimeline = applyTimeline;
|
|
13
|
+
exports.cfrCounts = cfrCounts;
|
|
14
|
+
exports.cfrTotalFrames = cfrTotalFrames;
|
|
15
|
+
exports.totalDurationMs = totalDurationMs;
|
|
16
|
+
/**
|
|
17
|
+
* Default cap on how long any single frame is held (ms). A real session often
|
|
18
|
+
* pauses for many seconds on one screen; without a cap that idle time would
|
|
19
|
+
* dominate the animation, so gaps longer than this are clamped. Adjustable per
|
|
20
|
+
* recording and overridable per frame.
|
|
21
|
+
*/
|
|
22
|
+
exports.DEFAULT_IDLE_CAP_MS = 1500;
|
|
23
|
+
/**
|
|
24
|
+
* Default hold (ms) for the final frame when the capture ends the instant it
|
|
25
|
+
* appears (a clean exit), so the animation doesn't end on a 0ms flash.
|
|
26
|
+
*/
|
|
27
|
+
exports.DEFAULT_TAIL_MS = 1200;
|
|
28
|
+
/**
|
|
29
|
+
* Raw per-frame durations derived from each kept frame's appearance time.
|
|
30
|
+
* `startsMs[i]` is when frame *i* first appeared (ms since capture start) and
|
|
31
|
+
* `endMs` is the total capture length. Frame *i* is held until frame *i+1*
|
|
32
|
+
* appears; the last frame is held to `endMs`, floored to `tailMs` so a clean
|
|
33
|
+
* final paint still lingers instead of vanishing instantly.
|
|
34
|
+
*/
|
|
35
|
+
function frameDurations(startsMs, endMs, tailMs = exports.DEFAULT_TAIL_MS) {
|
|
36
|
+
const n = startsMs.length;
|
|
37
|
+
if (n === 0)
|
|
38
|
+
return [];
|
|
39
|
+
const out = new Array(n);
|
|
40
|
+
for (let i = 0; i < n; i++) {
|
|
41
|
+
const next = i + 1 < n ? startsMs[i + 1] : endMs;
|
|
42
|
+
out[i] = Math.max(0, next - startsMs[i]);
|
|
43
|
+
}
|
|
44
|
+
out[n - 1] = Math.max(out[n - 1], Math.max(0, tailMs));
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Apply user timeline tweaks to raw durations. Overrides win outright (no cap,
|
|
49
|
+
* no speed); smooth-typing then retimes flagged typing frames to a constant
|
|
50
|
+
* rate (no cap, no speed); otherwise each duration is capped to `idleCapMs`,
|
|
51
|
+
* divided by `speed`, then floored to `minMs`. Returns a new array the same
|
|
52
|
+
* length as `raw`.
|
|
53
|
+
*/
|
|
54
|
+
function applyTimeline(raw, opts = {}) {
|
|
55
|
+
const cap = opts.idleCapMs ?? exports.DEFAULT_IDLE_CAP_MS;
|
|
56
|
+
const speed = opts.speed && opts.speed > 0 ? opts.speed : 1;
|
|
57
|
+
const min = Math.max(0, opts.minMs ?? 0);
|
|
58
|
+
const overrides = opts.overrides ?? {};
|
|
59
|
+
const smoothTyping = opts.smoothTyping ?? false;
|
|
60
|
+
const cps = opts.typingCps && opts.typingCps > 0 ? opts.typingCps : 30;
|
|
61
|
+
const isTyping = opts.isTyping;
|
|
62
|
+
const typedChars = opts.typedChars;
|
|
63
|
+
return raw.map((d, i) => {
|
|
64
|
+
if (Object.prototype.hasOwnProperty.call(overrides, i) && Number.isFinite(overrides[i])) {
|
|
65
|
+
return Math.max(min, overrides[i]);
|
|
66
|
+
}
|
|
67
|
+
if (smoothTyping && isTyping?.[i] && (typedChars?.[i] ?? 0) > 0) {
|
|
68
|
+
// Absolute hold for a typing frame: one beat per character at `cps`.
|
|
69
|
+
return Math.max(min, (typedChars[i] / cps) * 1000);
|
|
70
|
+
}
|
|
71
|
+
let v = Math.max(0, d);
|
|
72
|
+
if (cap > 0)
|
|
73
|
+
v = Math.min(v, cap);
|
|
74
|
+
v = v / speed;
|
|
75
|
+
if (min > 0)
|
|
76
|
+
v = Math.max(v, min);
|
|
77
|
+
return v;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Map variable per-frame durations to constant-frame-rate repeat counts. At
|
|
82
|
+
* `fps`, frame *i* is shown for `round(durationMs / 1000 * fps)` output frames,
|
|
83
|
+
* at least 1 so every frame stays visible. Returns the per-frame counts.
|
|
84
|
+
*/
|
|
85
|
+
function cfrCounts(durationsMs, fps) {
|
|
86
|
+
const f = fps > 0 ? fps : 1;
|
|
87
|
+
return durationsMs.map((ms) => Math.max(1, Math.round((Math.max(0, ms) / 1000) * f)));
|
|
88
|
+
}
|
|
89
|
+
/** Total output-frame count for a constant-frame-rate render at `fps`. */
|
|
90
|
+
function cfrTotalFrames(durationsMs, fps) {
|
|
91
|
+
return cfrCounts(durationsMs, fps).reduce((a, b) => a + b, 0);
|
|
92
|
+
}
|
|
93
|
+
/** Sum of a duration list (ms) — the animation's total wall-clock length. */
|
|
94
|
+
function totalDurationMs(durationsMs) {
|
|
95
|
+
return durationsMs.reduce((a, b) => a + Math.max(0, b), 0);
|
|
96
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Minimal asciinema v2 cast ingest. The format is a JSON header line followed by
|
|
4
|
+
* one JSON array per event:
|
|
5
|
+
*
|
|
6
|
+
* {"version": 2, "width": 120, "height": 40, "timestamp": 1718000000}
|
|
7
|
+
* [0.123, "o", "raw bytes with \u001b[... escapes"]
|
|
8
|
+
* [0.456, "r", "120x40"]
|
|
9
|
+
* [0.789, "o", "more bytes"]
|
|
10
|
+
*
|
|
11
|
+
* Only "o" (output) and "r" (resize) events affect the rendered frame. We
|
|
12
|
+
* concatenate output up to a chosen time and report the terminal size in effect
|
|
13
|
+
* at that moment, so the cast header solves the PTY-size problem without a
|
|
14
|
+
* sidecar. See https://docs.asciinema.org/manual/asciicast/v2/
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.parseHeader = parseHeader;
|
|
18
|
+
exports.extractFrame = extractFrame;
|
|
19
|
+
exports.extractTiming = extractTiming;
|
|
20
|
+
const timing_1 = require("./timing");
|
|
21
|
+
/** Parse and validate the cast header (first non-empty line). */
|
|
22
|
+
function parseHeader(raw) {
|
|
23
|
+
const line = raw.split('\n').find((l) => l.trim().length > 0);
|
|
24
|
+
if (!line)
|
|
25
|
+
throw new Error('asciinema: empty cast (no header line)');
|
|
26
|
+
let header;
|
|
27
|
+
try {
|
|
28
|
+
header = JSON.parse(line);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error('asciinema: header is not valid JSON');
|
|
32
|
+
}
|
|
33
|
+
const h = header;
|
|
34
|
+
if (h.version !== 2) {
|
|
35
|
+
throw new Error(`asciinema: unsupported version ${h.version ?? '(none)'}; only v2 is supported`);
|
|
36
|
+
}
|
|
37
|
+
if (typeof h.width !== 'number' || typeof h.height !== 'number') {
|
|
38
|
+
throw new Error('asciinema: header missing numeric width/height');
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
version: 2,
|
|
42
|
+
width: h.width,
|
|
43
|
+
height: h.height,
|
|
44
|
+
timestamp: typeof h.timestamp === 'number' ? h.timestamp : undefined,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Concatenate all output events up to and including `atTime` (seconds). When
|
|
49
|
+
* `atTime` is undefined, all output is included. Resize events update the
|
|
50
|
+
* reported dimensions, so the returned cols/rows reflect the size at `atTime`.
|
|
51
|
+
*/
|
|
52
|
+
function extractFrame(raw, atTime) {
|
|
53
|
+
const header = parseHeader(raw);
|
|
54
|
+
let cols = header.width;
|
|
55
|
+
let rows = header.height;
|
|
56
|
+
const out = [];
|
|
57
|
+
const lines = raw.split('\n');
|
|
58
|
+
for (let i = 1; i < lines.length; i++) {
|
|
59
|
+
const line = lines[i].trim();
|
|
60
|
+
if (!line)
|
|
61
|
+
continue;
|
|
62
|
+
let event;
|
|
63
|
+
try {
|
|
64
|
+
event = JSON.parse(line);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
continue; // tolerate malformed/partial trailing lines
|
|
68
|
+
}
|
|
69
|
+
if (!Array.isArray(event) || event.length < 3)
|
|
70
|
+
continue;
|
|
71
|
+
const [time, code, data] = event;
|
|
72
|
+
if (typeof time !== 'number')
|
|
73
|
+
continue;
|
|
74
|
+
if (atTime !== undefined && time > atTime)
|
|
75
|
+
break;
|
|
76
|
+
if (code === 'o') {
|
|
77
|
+
out.push(data);
|
|
78
|
+
}
|
|
79
|
+
else if (code === 'r') {
|
|
80
|
+
const m = /^(\d+)x(\d+)$/.exec(String(data).trim());
|
|
81
|
+
if (m) {
|
|
82
|
+
cols = Number(m[1]);
|
|
83
|
+
rows = Number(m[2]);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { ansi: out.join(''), cols, rows };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Derive timing checkpoints from a cast, aligned to the byte offsets of the
|
|
91
|
+
* stream {@link extractFrame} produces (the concatenated `o`-event output). Each
|
|
92
|
+
* output event contributes a `[msSinceStart, cumulativeByteOffset]` checkpoint,
|
|
93
|
+
* so the animation pipeline can map a frame boundary in that stream back to the
|
|
94
|
+
* moment it played — exactly like the timing sidecar a live `record` writes.
|
|
95
|
+
*/
|
|
96
|
+
function extractTiming(raw) {
|
|
97
|
+
const header = parseHeader(raw);
|
|
98
|
+
const events = [];
|
|
99
|
+
let offset = 0;
|
|
100
|
+
const lines = raw.split('\n');
|
|
101
|
+
for (let i = 1; i < lines.length; i++) {
|
|
102
|
+
const line = lines[i].trim();
|
|
103
|
+
if (!line)
|
|
104
|
+
continue;
|
|
105
|
+
let event;
|
|
106
|
+
try {
|
|
107
|
+
event = JSON.parse(line);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!Array.isArray(event) || event.length < 3)
|
|
113
|
+
continue;
|
|
114
|
+
const [time, code, data] = event;
|
|
115
|
+
if (typeof time !== 'number' || code !== 'o')
|
|
116
|
+
continue;
|
|
117
|
+
offset += Buffer.byteLength(String(data), 'utf8');
|
|
118
|
+
events.push([Math.max(0, Math.round(time * 1000)), offset]);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
schemaVersion: timing_1.TIMING_SCHEMA_VERSION,
|
|
122
|
+
startedAt: header.timestamp ? new Date(header.timestamp * 1000).toISOString() : '',
|
|
123
|
+
events,
|
|
124
|
+
};
|
|
125
|
+
}
|