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 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
+ }