termcut 0.6.3 → 0.7.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/src/publish.ts CHANGED
@@ -61,19 +61,19 @@ export function publicUrlFor(cfg: PublishConfig, key: string): string {
61
61
  return `${base}/${key.split("/").map(encodeURIComponent).join("/")}`;
62
62
  }
63
63
 
64
- const MIME: Record<string, string> = {
65
- ".mp4": "video/mp4",
66
- ".webm": "video/webm",
67
- ".gif": "image/gif",
68
- ".webp": "image/webp",
69
- ".png": "image/png",
70
- ".jpg": "image/jpeg",
71
- ".jpeg": "image/jpeg",
72
- ".svg": "image/svg+xml",
73
- ".html": "text/html; charset=utf-8",
74
- ".cast": "application/x-asciicast",
75
- ".txt": "text/plain; charset=utf-8",
76
- };
64
+ const MIME = new Map<string, string>([
65
+ [".mp4", "video/mp4"],
66
+ [".webm", "video/webm"],
67
+ [".gif", "image/gif"],
68
+ [".webp", "image/webp"],
69
+ [".png", "image/png"],
70
+ [".jpg", "image/jpeg"],
71
+ [".jpeg", "image/jpeg"],
72
+ [".svg", "image/svg+xml"],
73
+ [".html", "text/html; charset=utf-8"],
74
+ [".cast", "application/x-asciicast"],
75
+ [".txt", "text/plain; charset=utf-8"],
76
+ ]);
77
77
 
78
78
  export async function contentHash(file: string): Promise<string> {
79
79
  const hasher = new Bun.CryptoHasher("sha256");
@@ -111,7 +111,7 @@ export interface PublishOptions {
111
111
 
112
112
  async function upload(cfg: PublishConfig, file: string, key: string): Promise<void> {
113
113
  const s3 = client(cfg);
114
- const type = MIME[path.extname(file).toLowerCase()] ?? "application/octet-stream";
114
+ const type = MIME.get(path.extname(file).toLowerCase()) ?? "application/octet-stream";
115
115
  try {
116
116
  await s3.write(key, Bun.file(file), { type, acl: "public-read" });
117
117
  } catch (err) {
@@ -151,21 +151,19 @@ function hmac(key: Uint8Array | string, data: string): Uint8Array {
151
151
  }
152
152
  const sha256Hex = (data: string | Uint8Array) => new Bun.CryptoHasher("sha256").update(data).digest("hex");
153
153
 
154
- export function signV4(cfg: PublishConfig, method: string, url: URL, body: string, now = new Date()): Record<string, string> {
154
+ export function signV4(cfg: PublishConfig, method: string, url: URL, body: string, now = new Date()) {
155
155
  const region = cfg.region ?? "us-east-1";
156
156
  const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
157
157
  const date = amzDate.slice(0, 8);
158
158
  const payloadHash = sha256Hex(body);
159
- const headers: Record<string, string> = {
159
+ const headers = {
160
160
  host: url.host,
161
161
  "x-amz-content-sha256": payloadHash,
162
162
  "x-amz-date": amzDate,
163
163
  };
164
- const signedHeaders = Object.keys(headers).sort().join(";");
165
- const canonicalHeaders = Object.keys(headers)
166
- .sort()
167
- .map((h) => `${h}:${headers[h]!.trim()}\n`)
168
- .join("");
164
+ const sortedHeaders = Object.entries(headers).sort(([a], [b]) => (a < b ? -1 : 1));
165
+ const signedHeaders = sortedHeaders.map(([h]) => h).join(";");
166
+ const canonicalHeaders = sortedHeaders.map(([h, v]) => `${h}:${v.trim()}\n`).join("");
169
167
  const canonicalQuery = [...url.searchParams.entries()]
170
168
  .sort(([a], [b]) => (a < b ? -1 : 1))
171
169
  .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
package/src/recorder.ts CHANGED
@@ -106,15 +106,15 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
106
106
  };
107
107
 
108
108
  const setup = shellSetup(config);
109
- const env: Record<string, string> = {
110
- ...process.env,
109
+ const { PROMPT_COMMAND: _userPromptCommand, ...inheritedEnv } = process.env; // a user PROMPT_COMMAND would repaint over the clean prompt
110
+ const env = {
111
+ ...inheritedEnv,
111
112
  TERM: "xterm-256color",
112
113
  COLORTERM: "truecolor",
113
114
  LANG: process.env.LANG ?? "en_US.UTF-8",
114
115
  ...setup.env,
115
116
  ...config.env,
116
117
  };
117
- delete env.PROMPT_COMMAND;
118
118
 
119
119
  let exited = false;
120
120
  const proc = Bun.spawn(setup.cmd, {
@@ -160,7 +160,7 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
160
160
  const raw = async (data: string | Uint8Array): Promise<void> => {
161
161
  ensureAlive();
162
162
  terminal.write(data);
163
- push("i", typeof data === "string" ? data : decoder.decode(data));
163
+ push("i", data instanceof Uint8Array ? decoder.decode(data) : data);
164
164
  };
165
165
 
166
166
  /** Put text on screen as if the terminal had printed it: into the cast and the screen model, not the PTY. */
@@ -227,7 +227,7 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
227
227
  pattern.test(scope === "screen" ? screen.screen() : screen.line());
228
228
 
229
229
  const toRegExp = (pattern: RegExp | string): RegExp =>
230
- typeof pattern === "string" ? new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) : pattern;
230
+ pattern instanceof RegExp ? pattern : new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
231
231
 
232
232
  const waitFor = async (
233
233
  description: string,
@@ -307,6 +307,22 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
307
307
  }
308
308
  };
309
309
 
310
+ const speedStack: number[] = [];
311
+ const timelapse = async <T>(fn: () => Promise<T>, tlOpts: { speed?: number } = {}): Promise<T> => {
312
+ const speed = tlOpts.speed ?? 8;
313
+ if (!(speed > 0)) throw new Error(`timelapse speed must be greater than 0, got ${speed}`);
314
+ await screen.settle();
315
+ speedStack.push(speed);
316
+ push("m", `${MARKER.speed}${speed}`);
317
+ try {
318
+ return await fn();
319
+ } finally {
320
+ await screen.settle();
321
+ speedStack.pop();
322
+ push("m", `${MARKER.speed}${speedStack[speedStack.length - 1] ?? 1}`);
323
+ }
324
+ };
325
+
310
326
  const session: TerminalSession = {
311
327
  type,
312
328
  run,
@@ -336,6 +352,7 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
336
352
  wait,
337
353
  expect,
338
354
  hide,
355
+ timelapse,
339
356
  screenshot: async (file) => {
340
357
  await screen.settle();
341
358
  push("m", MARKER.screenshot + file);
package/src/render.ts CHANGED
@@ -1,20 +1,31 @@
1
1
  import { mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { chapterRanges, chapterSlug, cutRecording, findChapters, flattenedConfig, selectChapters } from "./edit";
4
+ import { frameText, replayFrames } from "./export/frames";
3
5
  import { writeHtml } from "./export/html";
4
6
  import { writeSvg } from "./export/svg";
5
7
  import { render as renderRaster, type RenderResult } from "./renderer/webview";
6
- import type { Recording, RenderProgress, ResolvedConfig } from "./types";
8
+ import type { ClipSelection, Recording, RenderProgress, ResolvedConfig } from "./types";
7
9
 
8
10
  export type { RenderResult };
9
11
 
10
- const kind = (output: string): "svg" | "html" | "raster" => {
12
+ const kind = (output: string): "svg" | "html" | "txt" | "raster" => {
11
13
  if (output.endsWith("/")) return "raster";
12
14
  const ext = path.extname(output).toLowerCase();
13
15
  if (ext === ".svg") return "svg";
14
16
  if (ext === ".html" || ext === ".htm") return "html";
17
+ if (ext === ".txt") return "txt";
15
18
  return "raster";
16
19
  };
17
20
 
21
+ /** The final screen as plain text (what `t.screen()` would return at the end). */
22
+ export async function writeTxt(rec: Recording, config: ResolvedConfig, file: string): Promise<void> {
23
+ const replay = await replayFrames(rec, config);
24
+ const last = replay.frames[replay.frames.length - 1];
25
+ await mkdir(path.dirname(path.resolve(file)), { recursive: true });
26
+ await Bun.write(file, (last ? frameText(last) : []).join("\n") + "\n");
27
+ }
28
+
18
29
  /**
19
30
  * Fan the configured outputs out to the right backend: `.svg` and `.html` are produced from the headless core
20
31
  * (no WebView, no ffmpeg); everything else goes through the WebView + ffmpeg renderer in one pass.
@@ -26,10 +37,15 @@ export async function renderOutputs(
26
37
  ): Promise<RenderResult> {
27
38
  const svg = config.output.filter((o) => kind(o) === "svg");
28
39
  const html = config.output.filter((o) => kind(o) === "html");
40
+ const txt = config.output.filter((o) => kind(o) === "txt");
29
41
  const raster = config.output.filter((o) => kind(o) === "raster");
30
42
 
31
43
  const result: RenderResult = { outputs: [], frames: 0, screenshots: [], durationSeconds: 0 };
32
44
 
45
+ for (const file of txt) {
46
+ await writeTxt(rec, config, file);
47
+ result.outputs.push(file);
48
+ }
33
49
  for (const file of svg) {
34
50
  await mkdir(path.dirname(path.resolve(file)), { recursive: true });
35
51
  const r = await writeSvg(rec, config, file);
@@ -47,6 +63,57 @@ export async function renderOutputs(
47
63
  result.frames = r.frames;
48
64
  result.screenshots.push(...r.screenshots);
49
65
  result.durationSeconds = r.durationSeconds;
66
+ if (r.notes) result.notes = r.notes;
50
67
  }
51
68
  return result;
52
69
  }
70
+
71
+ /** "demo.mp4" + "-01-install" → "demo-01-install.mp4"; "frames/" → "frames-01-install/". */
72
+ function suffixOutput(output: string, suffix: string): string {
73
+ if (output.endsWith("/")) return `${output.slice(0, -1)}${suffix}/`;
74
+ const ext = path.extname(output);
75
+ return `${output.slice(0, output.length - ext.length)}${suffix}${ext}`;
76
+ }
77
+
78
+ /**
79
+ * Render a part of the recording: a time window, a set of chapters, or every chapter as its own file.
80
+ * Cutting happens on the cast (see edit.ts), so every output format is supported.
81
+ */
82
+ export async function renderSelection(
83
+ rec: Recording,
84
+ config: ResolvedConfig,
85
+ clip: ClipSelection | undefined,
86
+ onProgress?: (p: RenderProgress) => void,
87
+ ): Promise<RenderResult> {
88
+ if (!clip || (clip.from === undefined && clip.to === undefined && !clip.chapters && !clip.splitChapters)) {
89
+ return renderOutputs(rec, config, onProgress);
90
+ }
91
+ if (clip.splitChapters) {
92
+ let ranges = chapterRanges(rec, config);
93
+ if (clip.chapters) ranges = findChapters(ranges, clip.chapters);
94
+ if (ranges.length === 0) throw new Error("--split-chapters needs chapters: add `t.chapter(name)` calls to the script");
95
+ const total: RenderResult = { outputs: [], frames: 0, screenshots: [], durationSeconds: 0 };
96
+ for (const [i, range] of ranges.entries()) {
97
+ const part = cutRecording(rec, config, range);
98
+ const suffix = `-${chapterSlug(i, range.title)}`;
99
+ const r = await renderOutputs(part, { ...flattenedConfig(config), output: config.output.map((o) => suffixOutput(o, suffix)) }, onProgress);
100
+ total.outputs.push(...r.outputs);
101
+ total.frames += r.frames;
102
+ total.screenshots.push(...r.screenshots);
103
+ total.durationSeconds += r.durationSeconds;
104
+ if (r.notes) total.notes = [...(total.notes ?? []), ...r.notes];
105
+ }
106
+ return total;
107
+ }
108
+ let part = rec;
109
+ let partConfig = config;
110
+ if (clip.chapters) {
111
+ part = selectChapters(rec, config, clip.chapters);
112
+ partConfig = flattenedConfig(config);
113
+ }
114
+ if (clip.from !== undefined || clip.to !== undefined) {
115
+ part = cutRecording(part, partConfig, clip);
116
+ partConfig = flattenedConfig(partConfig);
117
+ }
118
+ return renderOutputs(part, partConfig, onProgress);
119
+ }
@@ -9,6 +9,8 @@ export interface FrameSink {
9
9
  readonly target: string;
10
10
  /** True for outputs that loop (GIF, WebP) — `loopOffset` applies to these. */
11
11
  readonly loops?: boolean;
12
+ /** True when this sink receives RGBA frames (transparent output). */
13
+ readonly alpha?: boolean;
12
14
  }
13
15
 
14
16
  type Format = "mp4" | "webm" | "gif" | "webp" | "png-sequence" | "png" | "jpeg";
@@ -40,11 +42,15 @@ export function detectFormat(output: string): Format {
40
42
  /** Still image of the final frame. PNG is written as-is; JPEG is transcoded with Bun.Image (no ffmpeg). */
41
43
  class StillSink implements FrameSink {
42
44
  private last: Uint8Array | null = null;
45
+ readonly alpha: boolean;
43
46
 
44
47
  constructor(
45
48
  readonly target: string,
46
49
  private format: "png" | "jpeg",
47
- ) {}
50
+ alpha = false,
51
+ ) {
52
+ this.alpha = alpha && format === "png";
53
+ }
48
54
 
49
55
  async frame(png: Uint8Array): Promise<void> {
50
56
  this.last = png;
@@ -70,7 +76,7 @@ function ffmpegCandidates(): string[] {
70
76
  Bun.which("ffmpeg"),
71
77
  "/opt/homebrew/opt/ffmpeg-full/bin/ffmpeg",
72
78
  "/usr/local/opt/ffmpeg-full/bin/ffmpeg",
73
- ].filter((p): p is string => typeof p === "string" && p.length > 0);
79
+ ].filter((p): p is string => p !== undefined && p !== null && p.length > 0);
74
80
  return [...new Set(list)];
75
81
  }
76
82
 
@@ -121,12 +127,12 @@ export async function hasEncoder(...candidates: string[]): Promise<string | null
121
127
 
122
128
  type FfmpegFormat = Exclude<Format, "png-sequence" | "png" | "jpeg">;
123
129
 
124
- const REQUIRED_ENCODERS: Record<FfmpegFormat, { candidates: string[]; hint: string }> = {
130
+ const REQUIRED_ENCODERS = {
125
131
  mp4: { candidates: ["libx264"], hint: "an ffmpeg build with libx264" },
126
132
  webm: { candidates: ["libvpx-vp9"], hint: "an ffmpeg build with libvpx" },
127
133
  gif: { candidates: ["gif"], hint: "an ffmpeg build with the gif encoder" },
128
134
  webp: { candidates: ["libwebp_anim", "libwebp"], hint: "an ffmpeg build with libwebp (Homebrew: `brew install ffmpeg-full`)" },
129
- };
135
+ } satisfies Record<FfmpegFormat, { candidates: string[]; hint: string }>;
130
136
 
131
137
  async function requireEncoder(format: FfmpegFormat, output: string): Promise<EncoderMatch> {
132
138
  const { candidates, hint } = REQUIRED_ENCODERS[format];
@@ -156,10 +162,26 @@ export function chaptersMetadata(chapters: Chapter[], durationSeconds: number):
156
162
  return lines.join("\n") + "\n";
157
163
  }
158
164
 
159
- function ffmpegArgs(format: Format, fps: number, output: string, encoder: string, metadataFile?: string): string[] {
165
+ function ffmpegArgs(format: Format, fps: number, output: string, encoder: string, metadataFile?: string, alpha = false): string[] {
160
166
  const input = ["-y", "-loglevel", "error", "-f", "image2pipe", "-framerate", String(fps), "-i", "pipe:0"];
161
167
  if (metadataFile && format === "mp4") input.push("-i", metadataFile, "-map_metadata", "1");
162
168
  const evenSize = "scale=trunc(iw/2)*2:trunc(ih/2)*2";
169
+ if (alpha && format === "webm") {
170
+ return [...input, "-vf", `${evenSize},format=yuva420p`, "-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-auto-alt-ref", "0", "-b:v", "0", "-crf", "30", "-row-mt", "1", output];
171
+ }
172
+ if (alpha && format === "gif") {
173
+ const gifFps = Math.min(fps, 50);
174
+ return [
175
+ ...input,
176
+ "-vf",
177
+ `fps=${gifFps},split[a][b];[a]palettegen=stats_mode=diff:reserve_transparent=1[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle:alpha_threshold=128`,
178
+ "-loop", "0",
179
+ output,
180
+ ];
181
+ }
182
+ if (alpha && format === "webp") {
183
+ return [...input, "-c:v", encoder, "-pix_fmt", "yuva420p", "-lossless", "0", "-q:v", "85", "-loop", "0", "-preset", "text", output];
184
+ }
163
185
  switch (format) {
164
186
  case "mp4":
165
187
  return [
@@ -191,11 +213,15 @@ function ffmpegArgs(format: Format, fps: number, output: string, encoder: string
191
213
  }
192
214
  }
193
215
 
216
+ /** Formats whose container/codec can carry an alpha channel. */
217
+ export const ALPHA_FORMATS = new Set<Format>(["webm", "gif", "webp", "png", "png-sequence"]);
218
+
194
219
  class FfmpegSink implements FrameSink {
195
220
  private proc: Subprocess<"pipe", "ignore", "pipe">;
196
221
  private stdin: FileSink;
197
222
  private stderr: Promise<string>;
198
223
  readonly loops: boolean;
224
+ readonly alpha: boolean;
199
225
 
200
226
  constructor(
201
227
  readonly target: string,
@@ -203,9 +229,11 @@ class FfmpegSink implements FrameSink {
203
229
  fps: number,
204
230
  match: EncoderMatch,
205
231
  metadataFile?: string,
232
+ alpha = false,
206
233
  ) {
207
234
  this.loops = format === "gif" || format === "webp";
208
- this.proc = Bun.spawn([match.binary, ...ffmpegArgs(format, fps, target, match.encoder, metadataFile)], { stdin: "pipe", stdout: "ignore", stderr: "pipe" });
235
+ this.alpha = alpha && ALPHA_FORMATS.has(format);
236
+ this.proc = Bun.spawn([match.binary, ...ffmpegArgs(format, fps, target, match.encoder, metadataFile, this.alpha)], { stdin: "pipe", stdout: "ignore", stderr: "pipe" });
209
237
  this.stdin = this.proc.stdin;
210
238
  this.stderr = new Response(this.proc.stderr).text();
211
239
  }
@@ -229,7 +257,10 @@ class PngSequenceSink implements FrameSink {
229
257
  private index = 0;
230
258
  private pending: Promise<unknown>[] = [];
231
259
 
232
- constructor(readonly target: string) {}
260
+ constructor(
261
+ readonly target: string,
262
+ readonly alpha = false,
263
+ ) {}
233
264
 
234
265
  async frame(png: Uint8Array): Promise<void> {
235
266
  const file = path.join(this.target, `frame-${String(this.index++).padStart(6, "0")}.png`);
@@ -254,7 +285,14 @@ export async function ensureFfmpeg(): Promise<void> {
254
285
  );
255
286
  }
256
287
 
257
- export async function createSinks(outputs: string[], fps: number, opts: { chapters?: Chapter[]; durationSeconds?: number } = {}): Promise<FrameSink[]> {
288
+ export interface SinkOptions {
289
+ chapters?: Chapter[];
290
+ durationSeconds?: number;
291
+ /** Frames will be RGBA; sinks whose format supports it keep the alpha channel. */
292
+ alpha?: boolean;
293
+ }
294
+
295
+ export async function createSinks(outputs: string[], fps: number, opts: SinkOptions = {}): Promise<FrameSink[]> {
258
296
  const sinks: FrameSink[] = [];
259
297
  let metadataFile: string | undefined;
260
298
  if (opts.chapters && opts.chapters.length > 0) {
@@ -265,18 +303,18 @@ export async function createSinks(outputs: string[], fps: number, opts: { chapte
265
303
  const format = detectFormat(output);
266
304
  if (format === "png-sequence") {
267
305
  await mkdir(output, { recursive: true });
268
- sinks.push(new PngSequenceSink(output));
306
+ sinks.push(new PngSequenceSink(output, opts.alpha === true));
269
307
  continue;
270
308
  }
271
309
  if (format === "png" || format === "jpeg") {
272
310
  await mkdir(path.dirname(path.resolve(output)), { recursive: true });
273
- sinks.push(new StillSink(output, format));
311
+ sinks.push(new StillSink(output, format, opts.alpha === true));
274
312
  continue;
275
313
  }
276
314
  await ensureFfmpeg();
277
315
  const match = await requireEncoder(format as FfmpegFormat, output);
278
316
  await mkdir(path.dirname(path.resolve(output)), { recursive: true });
279
- sinks.push(new FfmpegSink(output, format, fps, match, metadataFile));
317
+ sinks.push(new FfmpegSink(output, format, fps, match, metadataFile, opts.alpha === true));
280
318
  }
281
319
  return sinks;
282
320
  }