termcut 0.6.4 → 0.7.1

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/types.ts CHANGED
@@ -109,6 +109,39 @@ export interface BrowserSession {
109
109
  readonly url: string;
110
110
  }
111
111
 
112
+ /** Drop shadow under the window(s). Drawn by the compositor, so it is in every output that has pixels — and in SVG. */
113
+ export interface ShadowConfig {
114
+ /** Horizontal offset, px. Default 0. */
115
+ x?: number;
116
+ /** Vertical offset, px. Default 18. */
117
+ y?: number;
118
+ /** Blur radius, px. Default 50. */
119
+ blur?: number;
120
+ /** Shadow colour. Default "#000000". */
121
+ color?: string;
122
+ /** 0–1. Default 0.45. */
123
+ opacity?: number;
124
+ }
125
+
126
+ export type WatermarkPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right" | "center";
127
+
128
+ /** A watermark drawn over the whole picture (outside the terminal grid): a line of text or an image file. */
129
+ export interface WatermarkConfig {
130
+ text?: string;
131
+ /** PNG/JPEG/SVG/WebP file. */
132
+ image?: string;
133
+ /** Default "bottom-right". */
134
+ position?: WatermarkPosition;
135
+ /** 0–1. Default 0.6. */
136
+ opacity?: number;
137
+ /** Text size in px (default 14) or image height in px (default 28). */
138
+ size?: number;
139
+ /** Text colour. Default: the theme foreground. */
140
+ color?: string;
141
+ /** Distance from the picture's edge, px. Default 16. */
142
+ margin?: number;
143
+ }
144
+
112
145
  export interface CursorConfig {
113
146
  /** Default true. Blink is driven by the render clock, so it is deterministic. */
114
147
  blink?: boolean;
@@ -182,8 +215,15 @@ export interface VideoConfig {
182
215
  padding?: number;
183
216
  /** Space around the window, px. Default 0. */
184
217
  margin?: number;
185
- /** Colour behind the window (visible when margin > 0). Default: theme background. */
218
+ /**
219
+ * Colour behind the window (visible when margin > 0). Default: theme background. `"transparent"` gives real
220
+ * alpha in PNG, WebP, GIF, WebM, SVG and HTML output (MP4 and JPEG fall back to the theme background).
221
+ */
186
222
  marginFill?: string;
223
+ /** Drop shadow under the window; `true` uses soft defaults. Needs margin — `margin` defaults to 40 when unset. */
224
+ shadow?: boolean | ShadowConfig;
225
+ /** Watermark over the picture: a string is text in the bottom-right corner; an object picks image/position/size. */
226
+ watermark?: string | WatermarkConfig;
187
227
  /** Rounded corner radius of the window, px. Default 0 (12 is nice with a margin). */
188
228
  borderRadius?: number;
189
229
  windowBar?: WindowBar;
@@ -224,6 +264,8 @@ export interface ResolvedConfig {
224
264
  padding: number;
225
265
  margin: number;
226
266
  marginFill: string;
267
+ shadow?: Required<ShadowConfig>;
268
+ watermark?: Required<Pick<WatermarkConfig, "position" | "opacity" | "size" | "color" | "margin">> & Pick<WatermarkConfig, "text" | "image">;
227
269
  borderRadius: number;
228
270
  windowBar: WindowBar;
229
271
  title: string;
@@ -326,8 +368,13 @@ export interface TerminalSession {
326
368
  title(text: string, opts?: { pause?: Duration }): Promise<void>;
327
369
  /** Magnify a region of the terminal (animated at render time); `zoom(null)` resets. */
328
370
  zoom(region: ZoomRegion | null): Promise<void>;
329
- /** Named chapter: becomes mp4 chapter metadata and shows up in `--json` output. */
371
+ /** Named chapter: becomes mp4 chapter metadata, shows up in `--json` output, and is a cut point for `--chapters` / `--split-chapters`. */
330
372
  chapter(name: string): Promise<void>;
373
+ /**
374
+ * Everything inside `fn` plays back `speed`× faster (default 8). Unlike `maxPause`, which only squeezes silence,
375
+ * this squeezes active output too — installs, builds, test runs.
376
+ */
377
+ timelapse<T>(fn: () => Promise<T>, opts?: { speed?: number }): Promise<T>;
331
378
  /** The recorded browser window; throws if `browser` is not configured. */
332
379
  readonly browser: BrowserSession;
333
380
  /** Overlay layout: bring the terminal or the browser window to the front (recorded as a marker). */
@@ -381,9 +428,23 @@ export interface RenderProgress {
381
428
  total: number;
382
429
  }
383
430
 
431
+ /** Which part of the visible timeline to render. */
432
+ export interface ClipSelection {
433
+ /** Start, seconds. */
434
+ from?: number;
435
+ /** End, seconds. */
436
+ to?: number;
437
+ /** Keep only these chapters (titles or 1-based numbers), joined in the order given. */
438
+ chapters?: string[];
439
+ /** Render every chapter to its own file: `demo.mp4` → `demo-01-install.mp4`, … */
440
+ splitChapters?: boolean;
441
+ }
442
+
384
443
  export interface RenderOptions {
385
444
  /** Override resolved config values (theme, font, outputs, …) without re-recording. */
386
445
  overrides?: Partial<VideoConfig>;
446
+ /** Render only part of the recording (by time or by chapter). */
447
+ clip?: ClipSelection;
387
448
  onProgress?: (p: RenderProgress) => void;
388
449
  }
389
450
 
package/src/video.ts CHANGED
@@ -3,8 +3,8 @@ import path from "node:path";
3
3
  import { readCast, writeCast } from "./cast";
4
4
  import { applyOverrides, resolveConfig } from "./config";
5
5
  import { record } from "./recorder";
6
- import { renderOutputs, type RenderResult } from "./render";
7
- import type { CastEvent, RecordOptions, Recording, RenderOptions, ResolvedConfig, Script, VideoConfig } from "./types";
6
+ import { renderSelection, type RenderResult } from "./render";
7
+ import type { CastEvent, ClipSelection, RecordOptions, Recording, RenderOptions, ResolvedConfig, Script, VideoConfig } from "./types";
8
8
 
9
9
  export interface VideoRecordOptions extends RecordOptions {
10
10
  /** Re-record even if a cached cast matches. */
@@ -69,8 +69,7 @@ export class Video {
69
69
  if (!(await file.exists())) return undefined;
70
70
  const hasher = new Bun.CryptoHasher("sha256");
71
71
  hasher.update(await file.arrayBuffer());
72
- const subset: Record<string, unknown> = {};
73
- for (const key of RECORD_KEYS) subset[key] = this.config[key];
72
+ const subset = Object.fromEntries(RECORD_KEYS.map((key) => [key, this.config[key]]));
74
73
  hasher.update(JSON.stringify(subset));
75
74
  return hasher.digest("hex");
76
75
  }
@@ -113,7 +112,7 @@ export class Video {
113
112
  async render(recording?: Recording, opts: RenderOptions = {}): Promise<RenderResult> {
114
113
  const rec = recording ?? (await readCast(this.config.cast));
115
114
  const config = applyOverrides(this.config, opts.overrides);
116
- return renderOutputs(rec, config, opts.onProgress);
115
+ return renderSelection(rec, config, opts.clip, opts.onProgress);
117
116
  }
118
117
 
119
118
  async run(opts: RunOptions = {}): Promise<RunResult> {
@@ -132,8 +131,9 @@ export function defineVideo(config: VideoConfig, script: Script): Video {
132
131
  return new Video(config, script);
133
132
  }
134
133
 
135
- export function isVideo(value: unknown): value is Video {
136
- return typeof value === "object" && value !== null && (value as { __bunVideo?: unknown }).__bunVideo === true;
134
+ /** True for a `defineVideo()` result checked by brand, so a Video built by another copy of tcut still counts. */
135
+ export function isVideo<T>(value: T): value is T & Video {
136
+ return value instanceof Object && "__bunVideo" in value && value.__bunVideo === true;
137
137
  }
138
138
 
139
139
  /** Render an existing .cast file (from tcut or asciinema) with the given settings. */
@@ -141,11 +141,14 @@ export async function renderCast(
141
141
  castFile: string,
142
142
  overrides: Partial<VideoConfig> & { output?: string | string[] },
143
143
  onProgress?: RenderOptions["onProgress"],
144
+ clip?: ClipSelection,
144
145
  ): Promise<RenderResult> {
145
146
  const rec = await readCast(castFile);
146
- const base =
147
- rec.header.bunVideo ??
148
- resolveConfig({ output: overrides.output ?? castFile.replace(/\.cast$/, "") + ".mp4", cols: rec.header.width, rows: rec.header.height });
149
- const config = applyOverrides(base, overrides);
150
- return renderOutputs(rec, config, onProgress);
147
+ const config = applyOverrides(castConfig(rec, castFile, overrides.output), overrides);
148
+ return renderSelection(rec, config, clip, onProgress);
149
+ }
150
+
151
+ /** The config a cast should be rendered with: the one recorded in its header, or sensible defaults for foreign casts. */
152
+ export function castConfig(rec: Recording, castFile: string, output?: string | string[]): ResolvedConfig {
153
+ return rec.header.bunVideo ?? resolveConfig({ output: output ?? castFile.replace(/\.cast$/, "") + ".mp4", cols: rec.header.width, rows: rec.header.height });
151
154
  }