termcut 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,105 @@
1
+ // Browser-side player for `.html` exports. Uses wterm's lite core (inline WASM) so the file is self-contained.
2
+ import { WasmBridge } from "@wterm/core";
3
+ import { WTerm } from "@wterm/dom";
4
+
5
+ interface PlayerData {
6
+ cols: number;
7
+ rows: number;
8
+ duration: number;
9
+ speed: number;
10
+ events: Array<{ vt: number; type: "o" | "r"; data: string }>;
11
+ }
12
+
13
+ const dataEl = document.getElementById("tcut-cast");
14
+ if (!dataEl) throw new Error("tcut player: missing cast data");
15
+ const data = JSON.parse(dataEl.textContent || "{}") as PlayerData;
16
+
17
+ const el = document.getElementById("term")!;
18
+ const playBtn = document.getElementById("play") as HTMLButtonElement;
19
+ const progress = document.getElementById("progress") as HTMLInputElement;
20
+ const loopBox = document.getElementById("loop") as HTMLInputElement;
21
+ const timeLabel = document.getElementById("time")!;
22
+
23
+ const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
24
+
25
+ (async () => {
26
+ const core = await WasmBridge.load();
27
+ const term = new WTerm(el, { cols: data.cols, rows: data.rows, core, autoResize: false, cursorBlink: true, onData: () => {} });
28
+ await term.init();
29
+ el.classList.add("focused");
30
+
31
+ let pointer = 0;
32
+ let elapsed = 0; // seconds on the visible timeline
33
+ let playing = false;
34
+ let last = 0;
35
+ let raf = 0;
36
+
37
+ const reset = () => {
38
+ term.write("\x1bc");
39
+ term.resize(data.cols, data.rows);
40
+ pointer = 0;
41
+ elapsed = 0;
42
+ };
43
+
44
+ const applyUntil = (time: number) => {
45
+ while (pointer < data.events.length && data.events[pointer]!.vt <= time) {
46
+ const e = data.events[pointer++]!;
47
+ if (e.type === "o") term.write(e.data);
48
+ else if (e.type === "r") {
49
+ const [c, r] = e.data.split("x").map(Number);
50
+ if (c! > 0 && r! > 0) term.resize(c!, r!);
51
+ }
52
+ }
53
+ };
54
+
55
+ const updateUi = () => {
56
+ progress.value = String(Math.min(1000, Math.round((elapsed / data.duration) * 1000)));
57
+ timeLabel.textContent = `${fmt(elapsed)} / ${fmt(data.duration)}`;
58
+ playBtn.textContent = playing ? "❚❚" : "▶";
59
+ };
60
+
61
+ const tick = (now: number) => {
62
+ if (!playing) return;
63
+ elapsed += ((now - last) / 1000) * data.speed;
64
+ last = now;
65
+ applyUntil(elapsed);
66
+ if (elapsed >= data.duration) {
67
+ if (loopBox.checked) {
68
+ reset();
69
+ } else {
70
+ playing = false;
71
+ elapsed = data.duration;
72
+ }
73
+ }
74
+ updateUi();
75
+ if (playing) raf = requestAnimationFrame(tick);
76
+ };
77
+
78
+ const play = () => {
79
+ if (playing) return;
80
+ if (elapsed >= data.duration) reset();
81
+ playing = true;
82
+ last = performance.now();
83
+ raf = requestAnimationFrame(tick);
84
+ updateUi();
85
+ };
86
+ const pause = () => {
87
+ playing = false;
88
+ cancelAnimationFrame(raf);
89
+ updateUi();
90
+ };
91
+
92
+ playBtn.addEventListener("click", () => (playing ? pause() : play()));
93
+ progress.addEventListener("input", () => {
94
+ const target = (Number(progress.value) / 1000) * data.duration;
95
+ if (target < elapsed) reset();
96
+ elapsed = target;
97
+ applyUntil(elapsed);
98
+ updateUi();
99
+ });
100
+ el.addEventListener("click", () => (playing ? pause() : play()));
101
+
102
+ applyUntil(0);
103
+ updateUi();
104
+ play();
105
+ })();
@@ -0,0 +1,140 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { MARKER } from "../cast";
4
+ import type { Recording, RenderProgress, ResolvedConfig } from "../types";
5
+ import { buildTimeline, withReinjection, type TimedEvent } from "../timeline";
6
+ import { pageAssets } from "./bundle";
7
+ import { createSinks } from "./encoder";
8
+ import { barHeight, renderHtml, themeOsc } from "./page";
9
+
10
+ export interface RenderResult {
11
+ outputs: string[];
12
+ frames: number;
13
+ screenshots: string[];
14
+ durationSeconds: number;
15
+ }
16
+
17
+ export async function render(
18
+ rec: Recording,
19
+ config: ResolvedConfig,
20
+ onProgress?: (p: RenderProgress) => void,
21
+ ): Promise<RenderResult> {
22
+ if (typeof Bun.WebView !== "function") {
23
+ throw new Error("Bun.WebView is not available in this Bun version. tcut needs Bun >= 1.4.");
24
+ }
25
+
26
+ const assets = await pageAssets();
27
+ const html = renderHtml(config);
28
+ const osc = themeOsc(config.theme);
29
+ const batches = new Map<number, string>();
30
+ let batchId = 0;
31
+
32
+ const server = Bun.serve({
33
+ port: 0,
34
+ hostname: "127.0.0.1",
35
+ fetch(req) {
36
+ const { pathname } = new URL(req.url);
37
+ if (pathname === "/app.js") return new Response(assets.js, { headers: { "content-type": "text/javascript" } });
38
+ if (pathname === "/wterm.css") return new Response(assets.css, { headers: { "content-type": "text/css" } });
39
+ if (pathname === "/ghostty-vt.wasm") return new Response(Bun.file(assets.wasmPath), { headers: { "content-type": "application/wasm" } });
40
+ if (pathname === "/theme") return new Response(osc, { headers: { "content-type": "text/plain; charset=utf-8" } });
41
+ if (pathname.startsWith("/batch/")) {
42
+ const id = Number(pathname.slice("/batch/".length));
43
+ const body = batches.get(id);
44
+ batches.delete(id);
45
+ if (body === undefined) return new Response("[]", { status: 404, headers: { "content-type": "application/json" } });
46
+ return new Response(body, { headers: { "content-type": "application/json; charset=utf-8" } });
47
+ }
48
+ return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
49
+ },
50
+ });
51
+
52
+ const timeline = buildTimeline(rec.events, config.playbackSpeed);
53
+ const lite = config.core === "lite";
54
+ const events = lite ? timeline.events : withReinjection(timeline.events, osc);
55
+ const fps = config.fps;
56
+ const totalFrames = Math.max(1, Math.ceil(timeline.duration * fps) + 1);
57
+ const blinkPeriod = config.cursor.period / 1000;
58
+ const screenshots: string[] = [];
59
+
60
+ const view = new Bun.WebView({ width: 800, height: 600 });
61
+ try {
62
+ await view.navigate(`http://127.0.0.1:${server.port}/`);
63
+ for (let i = 0; i < 100; i++) {
64
+ if ((await view.evaluate("typeof window.__vt === 'object'")) === true) break;
65
+ await Bun.sleep(50);
66
+ }
67
+ const boot = {
68
+ cols: rec.header.width,
69
+ rows: rec.header.height,
70
+ foreground: config.theme.foreground,
71
+ background: config.theme.background,
72
+ wasmUrl: "/ghostty-vt.wasm",
73
+ core: config.core,
74
+ };
75
+ await view.evaluate(`window.__vt.boot(${JSON.stringify(boot)})`);
76
+ if (!lite) await view.evaluate("window.__vt.writeUrl('/theme')");
77
+
78
+ const cell = (await view.evaluate("window.__vt.measure()")) as { w: number; h: number };
79
+ if (!cell || !(cell.w > 0) || !(cell.h > 0)) throw new Error("Could not measure terminal cell size");
80
+
81
+ const termW = Math.ceil(rec.header.width * cell.w);
82
+ const termH = Math.ceil(rec.header.height * cell.h);
83
+ const frameW = termW + config.padding * 2;
84
+ const frameH = termH + config.padding * 2 + barHeight(config);
85
+ const even = (n: number) => (n % 2 === 0 ? n : n + 1);
86
+ const width = even(frameW + config.margin * 2);
87
+ const height = even(frameH + config.margin * 2);
88
+ await view.evaluate(`window.__vt.layout(${frameW}, ${frameH}, ${termW}, ${termH})`);
89
+ await view.resize(width, height);
90
+ await view.evaluate("new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))");
91
+
92
+ const sinks = await createSinks(config.output, fps);
93
+ let pointer = 0;
94
+ let lastPng: Uint8Array | null = null;
95
+ let lastBlink: boolean | null = null;
96
+
97
+ for (let frame = 0; frame < totalFrames; frame++) {
98
+ const time = frame / fps;
99
+ const batch: TimedEvent[] = [];
100
+ while (pointer < events.length && events[pointer]!.vt <= time + 1e-9) {
101
+ batch.push(events[pointer]!);
102
+ pointer++;
103
+ }
104
+
105
+ const blinkOn = !config.cursor.blink || Math.floor((time / blinkPeriod) * 2) % 2 === 0;
106
+ const drawable = batch.filter((e) => e.type === "o" || e.type === "r");
107
+ const shots = batch.filter((e) => e.type === "m" && e.data.startsWith(MARKER.screenshot));
108
+ const dirty = lastPng === null || drawable.length > 0 || blinkOn !== lastBlink;
109
+
110
+ if (drawable.length > 0) {
111
+ const id = ++batchId;
112
+ batches.set(id, JSON.stringify(drawable.map(({ type, data }) => ({ type, data }))));
113
+ await view.evaluate(`window.__vt.applyUrl('/batch/${id}')`);
114
+ }
115
+ if (blinkOn !== lastBlink) {
116
+ await view.evaluate(`window.__vt.cursor(${blinkOn})`);
117
+ lastBlink = blinkOn;
118
+ }
119
+ if (dirty) {
120
+ lastPng = (await view.screenshot({ encoding: "buffer" })) as Uint8Array;
121
+ }
122
+
123
+ for (const shot of shots) {
124
+ const file = shot.data.slice(MARKER.screenshot.length);
125
+ await mkdir(path.dirname(path.resolve(file)), { recursive: true });
126
+ await Bun.write(file, lastPng!);
127
+ screenshots.push(file);
128
+ }
129
+
130
+ for (const sink of sinks) await sink.frame(lastPng!);
131
+ onProgress?.({ frame: frame + 1, total: totalFrames });
132
+ }
133
+
134
+ for (const sink of sinks) await sink.finish();
135
+ return { outputs: sinks.map((s) => s.target), frames: totalFrames, screenshots, durationSeconds: timeline.duration };
136
+ } finally {
137
+ view.close();
138
+ server.stop(true);
139
+ }
140
+ }
package/src/screen.ts ADDED
@@ -0,0 +1,130 @@
1
+ import { WasmBridge, type TerminalCore } from "@wterm/core";
2
+ import { GhosttyCore } from "@wterm/ghostty";
3
+ import { ghosttyWasmUrl } from "./renderer/bundle";
4
+ import type { CoreName } from "./types";
5
+
6
+ let logsSilenced = false;
7
+
8
+ /** libghostty reports unimplemented modes via console.log (e.g. bash's `?1034h`); keep them out of CLI output. */
9
+ function silenceGhosttyLogs(): void {
10
+ if (logsSilenced) return;
11
+ logsSilenced = true;
12
+ const original = console.log;
13
+ console.log = (...args: unknown[]) => {
14
+ if (args[0] === "[ghostty-vt]") return;
15
+ original(...args);
16
+ };
17
+ }
18
+
19
+ export interface ScreenOptions {
20
+ /** Called with terminal responses (e.g. Device Attributes replies) that must be written back to the PTY. */
21
+ onResponse?: (data: string) => void;
22
+ core?: CoreName;
23
+ }
24
+
25
+ export async function loadCore(core: CoreName = "ghostty"): Promise<TerminalCore> {
26
+ if (core === "lite") return WasmBridge.load();
27
+ silenceGhosttyLogs();
28
+ return GhosttyCore.load({ wasmPath: await ghosttyWasmUrl() });
29
+ }
30
+
31
+ /**
32
+ * Headless terminal model backed by libghostty (WASM). The recorder feeds every PTY chunk through it so
33
+ * `wait()` / `expect()` / `run()` look at the actual screen instead of a raw byte stream.
34
+ */
35
+ export class Screen {
36
+ private listeners = new Set<() => void>();
37
+ onResponse: ((data: string) => void) | undefined;
38
+
39
+ private constructor(readonly core: TerminalCore) {}
40
+
41
+ static async create(cols: number, rows: number, opts: ScreenOptions = {}): Promise<Screen> {
42
+ const core = await loadCore(opts.core);
43
+ core.init(cols, rows);
44
+ const screen = new Screen(core);
45
+ screen.onResponse = opts.onResponse;
46
+ return screen;
47
+ }
48
+
49
+ write(data: string | Uint8Array): void {
50
+ if (typeof data === "string") this.core.writeString(data);
51
+ else this.core.writeRaw(data);
52
+ this.drainResponses();
53
+ for (const listener of this.listeners) listener();
54
+ }
55
+
56
+ private drainResponses(): void {
57
+ for (let i = 0; i < 64; i++) {
58
+ const response = this.core.getResponse();
59
+ if (response === null || response === undefined || response.length === 0) return;
60
+ this.onResponse?.(response);
61
+ }
62
+ }
63
+
64
+ /** Writes are synchronous; kept for API symmetry with async models. */
65
+ async settle(): Promise<void> {}
66
+
67
+ /** Resolves the next time a chunk is written. */
68
+ nextFlush(): Promise<void> {
69
+ return new Promise((resolve) => {
70
+ const listener = () => {
71
+ this.listeners.delete(listener);
72
+ resolve();
73
+ };
74
+ this.listeners.add(listener);
75
+ });
76
+ }
77
+
78
+ resize(cols: number, rows: number): void {
79
+ this.core.resize(cols, rows);
80
+ }
81
+
82
+ get cols(): number {
83
+ return this.core.getCols();
84
+ }
85
+
86
+ get rows(): number {
87
+ return this.core.getRows();
88
+ }
89
+
90
+ cursor(): { x: number; y: number } {
91
+ const c = this.core.getCursor();
92
+ return { x: c.col, y: c.row };
93
+ }
94
+
95
+ /** Absolute index of the cursor line, counting every line that ever scrolled off the top. */
96
+ absoluteCursorLine(): number {
97
+ const discarded = this.core.getScrollbackDiscardedCount?.() ?? 0;
98
+ return discarded + this.core.getScrollbackCount() + this.core.getCursor().row;
99
+ }
100
+
101
+ rowText(row: number): string {
102
+ let text = "";
103
+ const cols = this.core.getCols();
104
+ for (let col = 0; col < cols; col++) {
105
+ const cell = this.core.getCell(row, col);
106
+ if (cell.width === 0) continue; // wide-char continuation
107
+ if (cell.chars) text += cell.chars;
108
+ else text += cell.char === 0 ? " " : String.fromCodePoint(cell.char);
109
+ }
110
+ return text.replace(/\s+$/, "");
111
+ }
112
+
113
+ line(): string {
114
+ return this.rowText(this.core.getCursor().row);
115
+ }
116
+
117
+ screen(): string {
118
+ const rows: string[] = [];
119
+ for (let y = 0; y < this.core.getRows(); y++) rows.push(this.rowText(y));
120
+ return rows.join("\n");
121
+ }
122
+
123
+ usingAltScreen(): boolean {
124
+ return this.core.usingAltScreen();
125
+ }
126
+
127
+ dispose(): void {
128
+ this.listeners.clear();
129
+ }
130
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,69 @@
1
+ import path from "node:path";
2
+ import { record } from "./recorder";
3
+ import { isVideo } from "./video";
4
+
5
+ export interface TestResult {
6
+ file: string;
7
+ ok: boolean;
8
+ ms: number;
9
+ error?: string;
10
+ }
11
+
12
+ export interface TestSummary {
13
+ results: TestResult[];
14
+ passed: number;
15
+ failed: number;
16
+ }
17
+
18
+ const PATTERNS = ["**/*.video.ts", "**/*.tcut.ts"];
19
+
20
+ /** Expand files and directories into script paths. Directories are searched for `*.video.ts` / `*.tcut.ts`. */
21
+ export async function discoverScripts(inputs: string[]): Promise<string[]> {
22
+ const files = new Set<string>();
23
+ for (const input of inputs) {
24
+ const abs = path.resolve(input);
25
+ const stat = await Bun.file(abs).stat().catch(() => null);
26
+ if (stat?.isDirectory()) {
27
+ for (const pattern of PATTERNS) {
28
+ for await (const match of new Bun.Glob(pattern).scan({ cwd: abs, absolute: true })) {
29
+ if (!match.includes("/node_modules/")) files.add(match);
30
+ }
31
+ }
32
+ } else if (stat?.isFile()) {
33
+ files.add(abs);
34
+ } else {
35
+ throw new Error(`No such file or directory: ${input}`);
36
+ }
37
+ }
38
+ return [...files].sort();
39
+ }
40
+
41
+ /** Run each script's recording in fast mode (no sleeps, no typing delay), without rendering or writing casts. */
42
+ export async function runScriptTests(inputs: string[], log: (line: string) => void = console.log): Promise<TestSummary> {
43
+ const files = await discoverScripts(inputs);
44
+ if (files.length === 0) throw new Error("No scripts found (looking for *.video.ts or *.tcut.ts)");
45
+ const results: TestResult[] = [];
46
+ log(`TAP version 14\n1..${files.length}`);
47
+ for (const [index, file] of files.entries()) {
48
+ const rel = path.relative(process.cwd(), file);
49
+ const started = performance.now();
50
+ let error: string | undefined;
51
+ try {
52
+ const mod = (await import(file)) as { default?: unknown };
53
+ if (!isVideo(mod.default)) throw new Error("default export is not a defineVideo() result");
54
+ await record(mod.default.config, mod.default.script, { fast: true });
55
+ } catch (err) {
56
+ error = err instanceof Error ? err.message : String(err);
57
+ }
58
+ const ms = Math.round(performance.now() - started);
59
+ results.push({ file: rel, ok: !error, ms, error });
60
+ if (error) {
61
+ log(`not ok ${index + 1} - ${rel} (${ms}ms)\n ---\n${error.split("\n").map((l) => " " + l).join("\n")}\n ...`);
62
+ } else {
63
+ log(`ok ${index + 1} - ${rel} (${ms}ms)`);
64
+ }
65
+ }
66
+ const failed = results.filter((r) => !r.ok).length;
67
+ log(`\n# ${results.length - failed} passed, ${failed} failed`);
68
+ return { results, passed: results.length - failed, failed };
69
+ }
package/src/themes.ts ADDED
@@ -0,0 +1,138 @@
1
+ import type { Theme, ThemeName } from "./types";
2
+
3
+ export const themes: Record<ThemeName, Theme> = {
4
+ "catppuccin-mocha": {
5
+ name: "catppuccin-mocha",
6
+ background: "#1e1e2e",
7
+ foreground: "#cdd6f4",
8
+ cursor: "#f5e0dc",
9
+ cursorAccent: "#1e1e2e",
10
+ selectionBackground: "#585b70",
11
+ black: "#45475a",
12
+ red: "#f38ba8",
13
+ green: "#a6e3a1",
14
+ yellow: "#f9e2af",
15
+ blue: "#89b4fa",
16
+ magenta: "#f5c2e7",
17
+ cyan: "#94e2d5",
18
+ white: "#bac2de",
19
+ brightBlack: "#585b70",
20
+ brightRed: "#f38ba8",
21
+ brightGreen: "#a6e3a1",
22
+ brightYellow: "#f9e2af",
23
+ brightBlue: "#89b4fa",
24
+ brightMagenta: "#f5c2e7",
25
+ brightCyan: "#94e2d5",
26
+ brightWhite: "#a6adc8",
27
+ },
28
+ dracula: {
29
+ name: "dracula",
30
+ background: "#282a36",
31
+ foreground: "#f8f8f2",
32
+ cursor: "#f8f8f2",
33
+ cursorAccent: "#282a36",
34
+ selectionBackground: "#44475a",
35
+ black: "#21222c",
36
+ red: "#ff5555",
37
+ green: "#50fa7b",
38
+ yellow: "#f1fa8c",
39
+ blue: "#bd93f9",
40
+ magenta: "#ff79c6",
41
+ cyan: "#8be9fd",
42
+ white: "#f8f8f2",
43
+ brightBlack: "#6272a4",
44
+ brightRed: "#ff6e6e",
45
+ brightGreen: "#69ff94",
46
+ brightYellow: "#ffffa5",
47
+ brightBlue: "#d6acff",
48
+ brightMagenta: "#ff92df",
49
+ brightCyan: "#a4ffff",
50
+ brightWhite: "#ffffff",
51
+ },
52
+ "github-dark": {
53
+ name: "github-dark",
54
+ background: "#0d1117",
55
+ foreground: "#c9d1d9",
56
+ cursor: "#c9d1d9",
57
+ cursorAccent: "#0d1117",
58
+ selectionBackground: "#264f78",
59
+ black: "#484f58",
60
+ red: "#ff7b72",
61
+ green: "#3fb950",
62
+ yellow: "#d29922",
63
+ blue: "#58a6ff",
64
+ magenta: "#bc8cff",
65
+ cyan: "#39c5cf",
66
+ white: "#b1bac4",
67
+ brightBlack: "#6e7681",
68
+ brightRed: "#ffa198",
69
+ brightGreen: "#56d364",
70
+ brightYellow: "#e3b341",
71
+ brightBlue: "#79c0ff",
72
+ brightMagenta: "#d2a8ff",
73
+ brightCyan: "#56d4dd",
74
+ brightWhite: "#f0f6fc",
75
+ },
76
+ "tokyo-night": {
77
+ name: "tokyo-night",
78
+ background: "#1a1b26",
79
+ foreground: "#c0caf5",
80
+ cursor: "#c0caf5",
81
+ cursorAccent: "#1a1b26",
82
+ selectionBackground: "#33467c",
83
+ black: "#15161e",
84
+ red: "#f7768e",
85
+ green: "#9ece6a",
86
+ yellow: "#e0af68",
87
+ blue: "#7aa2f7",
88
+ magenta: "#bb9af7",
89
+ cyan: "#7dcfff",
90
+ white: "#a9b1d6",
91
+ brightBlack: "#414868",
92
+ brightRed: "#f7768e",
93
+ brightGreen: "#9ece6a",
94
+ brightYellow: "#e0af68",
95
+ brightBlue: "#7aa2f7",
96
+ brightMagenta: "#bb9af7",
97
+ brightCyan: "#7dcfff",
98
+ brightWhite: "#c0caf5",
99
+ },
100
+ "one-dark": {
101
+ name: "one-dark",
102
+ background: "#282c34",
103
+ foreground: "#abb2bf",
104
+ cursor: "#528bff",
105
+ cursorAccent: "#282c34",
106
+ selectionBackground: "#3e4451",
107
+ black: "#282c34",
108
+ red: "#e06c75",
109
+ green: "#98c379",
110
+ yellow: "#e5c07b",
111
+ blue: "#61afef",
112
+ magenta: "#c678dd",
113
+ cyan: "#56b6c2",
114
+ white: "#abb2bf",
115
+ brightBlack: "#5c6370",
116
+ brightRed: "#e06c75",
117
+ brightGreen: "#98c379",
118
+ brightYellow: "#e5c07b",
119
+ brightBlue: "#61afef",
120
+ brightMagenta: "#c678dd",
121
+ brightCyan: "#56b6c2",
122
+ brightWhite: "#ffffff",
123
+ },
124
+ };
125
+
126
+ export const themeNames = Object.keys(themes) as ThemeName[];
127
+
128
+ export function resolveTheme(theme: ThemeName | Theme | undefined): Theme {
129
+ if (!theme) return themes["catppuccin-mocha"];
130
+ if (typeof theme === "string") {
131
+ const found = themes[theme];
132
+ if (!found) {
133
+ throw new Error(`Unknown theme "${theme}". Available: ${themeNames.join(", ")}`);
134
+ }
135
+ return found;
136
+ }
137
+ return theme;
138
+ }
@@ -0,0 +1,58 @@
1
+ import { MARKER } from "./cast";
2
+ import type { CastEvent } from "./types";
3
+
4
+ export interface TimedEvent {
5
+ /** Time on the visible (hide-collapsed, speed-adjusted) timeline, seconds. */
6
+ vt: number;
7
+ type: CastEvent[1];
8
+ data: string;
9
+ }
10
+
11
+ export interface Timeline {
12
+ events: TimedEvent[];
13
+ duration: number;
14
+ }
15
+
16
+ /**
17
+ * Collapse hidden intervals and apply playback speed. Hidden events keep their relative order but all land
18
+ * on the instant the hide started, so the first visible frame after `show` reflects their combined effect.
19
+ * Input (`i`) events are dropped: the PTY already echoed them.
20
+ */
21
+ export function buildTimeline(events: CastEvent[], playbackSpeed: number): Timeline {
22
+ const out: TimedEvent[] = [];
23
+ let hiddenSince: number | null = null;
24
+ let removed = 0;
25
+ let duration = 0;
26
+
27
+ for (const [t, type, data] of events) {
28
+ if (type === "m" && data === MARKER.hide) {
29
+ if (hiddenSince === null) hiddenSince = t;
30
+ continue;
31
+ }
32
+ if (type === "m" && data === MARKER.show) {
33
+ if (hiddenSince !== null) {
34
+ removed += t - hiddenSince;
35
+ hiddenSince = null;
36
+ }
37
+ continue;
38
+ }
39
+ if (type === "i") continue;
40
+ const visible = hiddenSince === null ? t - removed : hiddenSince - removed;
41
+ const vt = visible / playbackSpeed;
42
+ out.push({ vt, type, data });
43
+ if (vt > duration) duration = vt;
44
+ }
45
+ return { events: out, duration };
46
+ }
47
+
48
+ const FULL_RESET = "\x1bc";
49
+
50
+ /** Re-emit `inject` right after any full terminal reset (`ESC c`) so injected state (e.g. palette) survives `reset`. */
51
+ export function withReinjection(events: TimedEvent[], inject: string): TimedEvent[] {
52
+ const out: TimedEvent[] = [];
53
+ for (const e of events) {
54
+ out.push(e);
55
+ if (e.type === "o" && e.data.includes(FULL_RESET)) out.push({ vt: e.vt, type: "o", data: inject });
56
+ }
57
+ return out;
58
+ }