termcut 0.5.1 → 0.6.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.
@@ -97,6 +97,34 @@ const api = {
97
97
  return true;
98
98
  },
99
99
 
100
+ /** Key overlay: show these chips (empty array hides them). */
101
+ keys(labels: string[]): boolean {
102
+ const el = document.getElementById("keys");
103
+ if (!el) return false;
104
+ el.replaceChildren(...labels.map((l) => Object.assign(document.createElement("span"), { textContent: l })));
105
+ return true;
106
+ },
107
+
108
+ /** Zoom: scale the terminal so the px rect (relative to the grid) fills the grid box; null resets. */
109
+ zoom(rect: { x: number; y: number; w: number; h: number } | null): Promise<boolean> {
110
+ const el = document.getElementById("zoom")!;
111
+ const term = document.getElementById("term")!;
112
+ if (!rect) {
113
+ el.style.transform = "";
114
+ } else {
115
+ const W = term.clientWidth;
116
+ const H = term.clientHeight;
117
+ const scale = Math.min(W / rect.w, H / rect.h);
118
+ // Centre the region; clamp so we never show outside the grid.
119
+ let tx = (W - rect.w * scale) / 2 - rect.x * scale;
120
+ let ty = (H - rect.h * scale) / 2 - rect.y * scale;
121
+ tx = Math.min(0, Math.max(W - W * scale, tx));
122
+ ty = Math.min(0, Math.max(H - H * scale, ty));
123
+ el.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`;
124
+ }
125
+ return paint();
126
+ },
127
+
100
128
  /** Overlay layout: which window is in front. */
101
129
  focus(target: "terminal" | "browser"): boolean {
102
130
  document.getElementById("stage")?.classList.toggle("front-browser", target === "browser");
@@ -140,6 +140,18 @@ export function renderHtml(config: ResolvedConfig): string {
140
140
  overflow: hidden;
141
141
  }
142
142
  #term .term-row { overflow: hidden; }
143
+ /* Key overlay: chips for recent key presses, driven by the renderer on the render clock. */
144
+ #keys {
145
+ position: absolute; left: 0; right: 0; ${config.keys?.position === "top" ? "top" : "bottom"}: ${Math.max(10, config.padding - 6)}px;
146
+ display: ${config.keys ? "flex" : "none"}; justify-content: center; gap: 6px; pointer-events: none; z-index: 5;
147
+ }
148
+ #keys span {
149
+ font: 600 ${Math.max(12, Math.round(config.font.size * 0.75))}px ${font.family};
150
+ color: #fff; background: rgba(0,0,0,0.72); border: 1px solid rgba(255,255,255,0.25);
151
+ border-radius: 6px; padding: 3px 8px; letter-spacing: 0.02em; white-space: pre;
152
+ }
153
+ /* Zoom: the terminal grid is scaled inside its frame; the renderer sets the transform per frame. */
154
+ #zoom { transform-origin: 0 0; will-change: transform; }
143
155
  </style>
144
156
  <style id="blink"></style>
145
157
  </head>
@@ -147,7 +159,8 @@ export function renderHtml(config: ResolvedConfig): string {
147
159
  <div id="stage">
148
160
  <div id="frame">
149
161
  ${windowBarHtml(config)}
150
- <div id="term"></div>
162
+ <div id="zoom"><div id="term"></div></div>
163
+ <div id="keys"></div>
151
164
  </div>
152
165
  ${
153
166
  config.browser
@@ -5,7 +5,8 @@ import type { Recording, RenderProgress, ResolvedConfig } from "../types";
5
5
  import { fitFrame, loopOffsetFrames, rotateFrames } from "../loop";
6
6
  import { buildTimeline, withReinjection, type TimedEvent } from "../timeline";
7
7
  import { pageAssets } from "./bundle";
8
- import { createSinks } from "./encoder";
8
+ import { createSinks, type Chapter } from "./encoder";
9
+ import { keyChips } from "../keylabels";
9
10
  import { BROWSER_GAP, barHeight, renderHtml, themeOsc } from "./page";
10
11
 
11
12
  export interface RenderResult {
@@ -13,6 +14,41 @@ export interface RenderResult {
13
14
  frames: number;
14
15
  screenshots: string[];
15
16
  durationSeconds: number;
17
+ chapters?: Chapter[];
18
+ }
19
+
20
+ interface ZoomRect {
21
+ x: number;
22
+ y: number;
23
+ w: number;
24
+ h: number;
25
+ }
26
+
27
+ /** Grid region (inclusive rows/cols, padded by `padding` cells) → px rect relative to the terminal grid. */
28
+ function zoomRect(spec: { rows?: [number, number]; cols?: [number, number]; padding?: number }, cols: number, rows: number, cell: { w: number; h: number }): ZoomRect {
29
+ const pad = spec.padding ?? 1;
30
+ const r0 = Math.max(0, (spec.rows?.[0] ?? 0) - pad);
31
+ const r1 = Math.min(rows - 1, (spec.rows?.[1] ?? rows - 1) + pad);
32
+ const c0 = Math.max(0, (spec.cols?.[0] ?? 0) - pad);
33
+ const c1 = Math.min(cols - 1, (spec.cols?.[1] ?? cols - 1) + pad);
34
+ return { x: c0 * cell.w, y: r0 * cell.h, w: (c1 - c0 + 1) * cell.w, h: (r1 - r0 + 1) * cell.h };
35
+ }
36
+
37
+ const easeInOut = (p: number) => (p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2);
38
+
39
+ /** Where the zoom is at `time`, interpolating from → to over [start, start + duration]. null = no zoom. */
40
+ function currentZoom(from: ZoomRect | null, to: ZoomRect | null, start: number, duration: number, time: number, full?: ZoomRect): ZoomRect | null {
41
+ if (from === null && to === null) return null;
42
+ const p = duration <= 0 ? 1 : Math.min(1, Math.max(0, (time - start) / duration));
43
+ if (p >= 1) return to;
44
+ const e = easeInOut(p);
45
+ const a = from ?? to!; // from null = "unzoomed": treat as the target's containing box expanded; approximate with target
46
+ const b = to ?? from!;
47
+ const lerp = (u: number, v: number) => u + (v - u) * e;
48
+ // Zooming from/to "no zoom" needs the full grid rect; callers pass it via `full` when known.
49
+ const A = from ?? full ?? a;
50
+ const B = to ?? full ?? b;
51
+ return { x: lerp(A.x, B.x), y: lerp(A.y, B.y), w: lerp(A.w, B.w), h: lerp(A.h, B.h) };
16
52
  }
17
53
 
18
54
  export async function render(
@@ -57,9 +93,14 @@ export async function render(
57
93
  },
58
94
  });
59
95
 
60
- const timeline = buildTimeline(rec.events, config.playbackSpeed);
96
+ const timeline = buildTimeline(rec.events, config.playbackSpeed, { keepInput: Boolean(config.keys), maxPause: config.maxPause });
61
97
  const lite = config.core === "lite";
62
98
  const events = lite ? timeline.events : withReinjection(timeline.events, osc);
99
+ // Key overlay chips come from input events; zoom/chapter markers are read as the clock passes them.
100
+ const chips = config.keys ? keyChips(events.filter((e) => e.type === "i"), config.keys.merge / 1000) : [];
101
+ const chapters: Chapter[] = events
102
+ .filter((e) => e.type === "m" && e.data.startsWith(MARKER.chapter))
103
+ .map((e) => ({ title: e.data.slice(MARKER.chapter.length), start: e.vt }));
63
104
  const fps = config.fps;
64
105
  const totalFrames = Math.max(1, Math.ceil(timeline.duration * fps) + 1);
65
106
  const blinkPeriod = config.cursor.period / 1000;
@@ -124,7 +165,15 @@ export async function render(
124
165
  await view.resize(width, height);
125
166
  await view.evaluate("new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))");
126
167
 
127
- const sinks = await createSinks(config.output, fps);
168
+ const sinks = await createSinks(config.output, fps, { chapters, durationSeconds: timeline.duration });
169
+ const cellPx = cell;
170
+ const fullRect: ZoomRect = { x: 0, y: 0, w: termW, h: termH };
171
+ let zoomFrom: ZoomRect | null = null;
172
+ let zoomTo: ZoomRect | null = null;
173
+ let zoomStart = 0;
174
+ let zoomDuration = 0;
175
+ let zoomApplied: string | null = null;
176
+ let lastChips = "";
128
177
  // loopOffset rotates the frame order for looping outputs; those frames are buffered and flushed at the end.
129
178
  const loopSinks = config.loopOffset ? sinks.filter((s) => s.loops) : [];
130
179
  const streamSinks = sinks.filter((s) => !loopSinks.includes(s));
@@ -146,7 +195,7 @@ export async function render(
146
195
  const shots = batch.filter((e) => e.type === "m" && e.data.startsWith(MARKER.screenshot));
147
196
  const browserFrame = hasBrowser ? batch.filter((e) => e.type === "b").at(-1) : undefined;
148
197
  const focusChanged = hasBrowser && batch.some((e) => e.type === "m" && e.data.startsWith(MARKER.focus));
149
- const dirty = lastPng === null || drawable.length > 0 || blinkOn !== lastBlink || browserFrame !== undefined || focusChanged;
198
+ let dirty = lastPng === null || drawable.length > 0 || blinkOn !== lastBlink || browserFrame !== undefined || focusChanged;
150
199
 
151
200
  if (drawable.length > 0) {
152
201
  const id = ++batchId;
@@ -160,10 +209,42 @@ export async function render(
160
209
  if (focus) {
161
210
  await view.evaluate(`window.__vt.focus(${JSON.stringify(focus.data.slice(MARKER.focus.length))})`);
162
211
  }
212
+
213
+ // Zoom markers start an animation on the render clock; interpolate until it lands.
214
+ const zoomMarker = batch.filter((e) => e.type === "m" && e.data.startsWith(MARKER.zoom)).at(-1);
215
+ if (zoomMarker) {
216
+ const spec = JSON.parse(zoomMarker.data.slice(MARKER.zoom.length)) as null | { rows?: [number, number]; cols?: [number, number]; duration?: number; padding?: number };
217
+ zoomFrom = currentZoom(zoomFrom, zoomTo, zoomStart, zoomDuration, time, fullRect);
218
+ zoomTo = spec ? zoomRect(spec, rec.header.width, rec.header.height, cellPx) : null;
219
+ zoomStart = time;
220
+ zoomDuration = (spec?.duration ?? 400) / 1000;
221
+ }
222
+ const zoomNow = currentZoom(zoomFrom, zoomTo, zoomStart, zoomDuration, time, fullRect);
223
+ const zoomKey = zoomNow ? `${zoomNow.x.toFixed(1)},${zoomNow.y.toFixed(1)},${zoomNow.w.toFixed(1)},${zoomNow.h.toFixed(1)}` : "";
224
+ let zoomChanged = false;
225
+ if (zoomKey !== zoomApplied) {
226
+ await view.evaluate(`window.__vt.zoom(${zoomNow ? JSON.stringify(zoomNow) : "null"})`);
227
+ zoomApplied = zoomKey;
228
+ zoomChanged = true;
229
+ }
230
+
231
+ // Key chips visible at this instant.
232
+ let chipsChanged = false;
233
+ if (config.keys) {
234
+ const ttl = config.keys.ttl / 1000;
235
+ const visible = chips.filter((c) => c.at <= time + 1e-9 && time - c.at < ttl).slice(-6).map((c) => c.label);
236
+ const key = visible.join("\u0000");
237
+ if (key !== lastChips) {
238
+ await view.evaluate(`window.__vt.keys(${JSON.stringify(visible)})`);
239
+ lastChips = key;
240
+ chipsChanged = true;
241
+ }
242
+ }
163
243
  if (blinkOn !== lastBlink) {
164
244
  await view.evaluate(`window.__vt.cursor(${blinkOn})`);
165
245
  lastBlink = blinkOn;
166
246
  }
247
+ if (zoomChanged || chipsChanged) dirty = true;
167
248
  if (dirty) {
168
249
  lastPng = (await view.screenshot({ encoding: "buffer" })) as Uint8Array;
169
250
  }
@@ -185,7 +266,7 @@ export async function render(
185
266
  for (const png of rotated) for (const sink of loopSinks) await sink.frame(png);
186
267
  }
187
268
  for (const sink of sinks) await sink.finish();
188
- return { outputs: sinks.map((s) => s.target), frames: totalFrames, screenshots, durationSeconds: timeline.duration };
269
+ return { outputs: sinks.map((s) => s.target), frames: totalFrames, screenshots, durationSeconds: timeline.duration, chapters };
189
270
  } finally {
190
271
  view.close();
191
272
  server.stop(true);
package/src/timeline.ts CHANGED
@@ -2,7 +2,7 @@ import { MARKER } from "./cast";
2
2
  import type { CastEvent } from "./types";
3
3
 
4
4
  export interface TimedEvent {
5
- /** Time on the visible (hide-collapsed, speed-adjusted) timeline, seconds. */
5
+ /** Time on the visible (hide-collapsed, speed-adjusted, idle-compressed) timeline, seconds. */
6
6
  vt: number;
7
7
  type: CastEvent[1];
8
8
  data: string;
@@ -13,16 +13,22 @@ export interface Timeline {
13
13
  duration: number;
14
14
  }
15
15
 
16
+ export interface TimelineOptions {
17
+ /** Keep `i` (input) events; the renderer needs them for the key overlay. Default false. */
18
+ keepInput?: boolean;
19
+ /** Cap any gap between consecutive events to this many seconds (idle compression). */
20
+ maxPause?: number;
21
+ }
22
+
16
23
  /**
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.
24
+ * Collapse hidden intervals, apply playback speed, optionally cap idle gaps. Hidden events keep their relative
25
+ * order but all land on the instant the hide started, so the first visible frame after `show` reflects their
26
+ * combined effect. Input (`i`) events are dropped unless `keepInput`: the PTY already echoed them.
20
27
  */
21
- export function buildTimeline(events: CastEvent[], playbackSpeed: number): Timeline {
28
+ export function buildTimeline(events: CastEvent[], playbackSpeed: number, opts: TimelineOptions = {}): Timeline {
22
29
  const out: TimedEvent[] = [];
23
30
  let hiddenSince: number | null = null;
24
31
  let removed = 0;
25
- let duration = 0;
26
32
 
27
33
  for (const [t, type, data] of events) {
28
34
  if (type === "m" && data === MARKER.hide) {
@@ -36,12 +42,25 @@ export function buildTimeline(events: CastEvent[], playbackSpeed: number): Timel
36
42
  }
37
43
  continue;
38
44
  }
39
- if (type === "i") continue;
45
+ if (type === "i" && !opts.keepInput) continue;
40
46
  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;
47
+ out.push({ vt: visible / playbackSpeed, type, data });
48
+ }
49
+
50
+ if (opts.maxPause !== undefined && opts.maxPause >= 0) {
51
+ // Walk forward; whenever the next event is further away than maxPause, pull everything after it closer.
52
+ let shift = 0;
53
+ let prev: number | null = null;
54
+ for (const e of out) {
55
+ const original = e.vt;
56
+ if (prev !== null && original - prev > opts.maxPause) shift += original - prev - opts.maxPause;
57
+ prev = original;
58
+ e.vt = original - shift;
59
+ }
44
60
  }
61
+
62
+ let duration = 0;
63
+ for (const e of out) if (e.vt > duration) duration = e.vt;
45
64
  return { events: out, duration };
46
65
  }
47
66
 
package/src/types.ts CHANGED
@@ -48,6 +48,26 @@ export interface FontConfig {
48
48
  letterSpacing?: number;
49
49
  }
50
50
 
51
+ export interface KeysConfig {
52
+ position?: "bottom" | "top";
53
+ /** How long a chip stays visible, e.g. "1.2s". */
54
+ ttl?: Duration;
55
+ /** Printable keys pressed within this window merge into one chip. Default "350ms". */
56
+ merge?: Duration;
57
+ }
58
+
59
+ /** A region of the terminal grid to magnify. */
60
+ export interface ZoomRegion {
61
+ /** Inclusive row range, 0-based. Default: all rows. */
62
+ rows?: [number, number];
63
+ /** Inclusive column range, 0-based. Default: all columns. */
64
+ cols?: [number, number];
65
+ /** Animation length on the render clock. Default "400ms". */
66
+ duration?: Duration;
67
+ /** Inner padding around the region, in cells. Default 1. */
68
+ padding?: number;
69
+ }
70
+
51
71
  /** A browser window recorded next to the terminal (Bun.WebView). */
52
72
  export interface BrowserConfig {
53
73
  /** Page to open when recording starts (may also be opened later with `t.browser.goto`). */
@@ -115,6 +135,12 @@ export interface VideoConfig {
115
135
  height?: number;
116
136
  /** Where looping outputs (GIF, WebP) start: a frame number or a percentage like "50%". */
117
137
  loopOffset?: number | string;
138
+ /** Idle compression: at render time, gaps between events longer than this are shortened to this. */
139
+ maxPause?: Duration;
140
+ /** Show recent key presses as chips. `true` = bottom centre, 1.2 s. */
141
+ keys?: boolean | KeysConfig;
142
+ /** A named bundle of defaults applied under explicit settings: readme | x | youtube | square. */
143
+ preset?: "readme" | "x" | "youtube" | "square";
118
144
 
119
145
  /** Frames per second of the output. Default 60. */
120
146
  fps?: number;
@@ -169,6 +195,8 @@ export interface ResolvedConfig {
169
195
  width?: number;
170
196
  height?: number;
171
197
  loopOffset?: number | string;
198
+ maxPause?: number;
199
+ keys?: Required<KeysConfig> & { ttl: number; merge: number };
172
200
  fps: number;
173
201
  typingSpeed: number;
174
202
  typingJitter: number;
@@ -286,6 +314,10 @@ export interface TerminalSession {
286
314
  print(markdown: string): Promise<void>;
287
315
  /** A title card: big heading + rule, then a pause (default "1.5s"). */
288
316
  title(text: string, opts?: { pause?: Duration }): Promise<void>;
317
+ /** Magnify a region of the terminal (animated at render time); `zoom(null)` resets. */
318
+ zoom(region: ZoomRegion | null): Promise<void>;
319
+ /** Named chapter: becomes mp4 chapter metadata and shows up in `--json` output. */
320
+ chapter(name: string): Promise<void>;
289
321
  /** The recorded browser window; throws if `browser` is not configured. */
290
322
  readonly browser: BrowserSession;
291
323
  /** Overlay layout: bring the terminal or the browser window to the front (recorded as a marker). */