termcut 0.8.0 → 1.0.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/README.md CHANGED
@@ -51,7 +51,7 @@ defineVideo({ output: "demo.mp4", browser: { position: "overlay" } }, async (t)
51
51
  });
52
52
  ```
53
53
 
54
- Polish: `shadow: true`, `watermark: "© you"`, `marginFill: "transparent"` (real alpha in PNG/WebP/GIF/WebM/SVG), `title: "auto"` follows the title the program sets, `keys: true` shows key presses, `maxPause: "800ms"` cuts dead air, `t.timelapse(fn, { speed: 8 })` fast-forwards an install, `t.zoom({ rows: [0, 5] })` magnifies output, `t.chapter("Install")` adds mp4 chapters, `preset: "x"` sizes it for X.
54
+ Polish: `shadow: true`, `watermark: "© you"`, `marginFill: "transparent"` (real alpha in PNG/WebP/GIF/WebM/SVG), `title: "auto"` follows the title the program sets, `keys: true` shows key presses, `maxPause: "800ms"` cuts dead air, `t.timelapse(fn, { speed: 8 })` fast-forwards an install, `t.zoom({ rows: [0, 5] })` magnifies output, `t.chapter("Install")` adds mp4 chapters, `t.snapshot("hero.png")` (or `.svg`) saves a still of that exact moment on every render, `preset: "x"` sizes it for X.
55
55
 
56
56
  Faithful to the terminal: arrows switch to the form vim/less asked for, `t.paste()` uses bracketed paste (no autoindent stair-steps), synchronized-output repaints never show torn frames, and OSC 8 hyperlinks — including Markdown links in `t.print()` captions — stay clickable in SVG and HTML output.
57
57
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termcut",
3
- "version": "0.8.0",
3
+ "version": "1.0.0",
4
4
  "description": "Script terminal sessions in TypeScript, render them to reproducible MP4/GIF/WebM/SVG/HTML with Bun.",
5
5
  "license": "MIT",
6
6
  "author": "Aman Varshney",
package/src/cli.ts CHANGED
@@ -370,7 +370,7 @@ export default defineVideo(
370
370
  await t.sleep("400ms");
371
371
  await t.enter();
372
372
  await t.wait(); // prompt is back
373
- await t.screenshot("${name}-notes.png");
373
+ await t.snapshot("${name}-notes.png");
374
374
  await t.sleep("1.5s");
375
375
  },
376
376
  );
package/src/export/svg.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import path from "node:path";
1
3
  import { fitFrame } from "../loop";
2
4
  import { barHeight, embedImage } from "../renderer/page";
3
5
  import type { Recording, ResolvedConfig } from "../types";
@@ -165,11 +167,29 @@ export interface SvgResult {
165
167
  duration: number;
166
168
  }
167
169
 
170
+ /** The shared document: chrome (background, window, bar, watermark) around exporter-supplied style + body. */
171
+ async function svgDocument(config: ResolvedConfig, g: Geometry, title: string, style: string, body: string): Promise<string> {
172
+ const { theme, font } = config;
173
+ return `<?xml version="1.0" encoding="UTF-8"?>
174
+ <svg xmlns="http://www.w3.org/2000/svg" width="${g.width}" height="${g.height}" viewBox="0 0 ${g.width} ${g.height}" font-family="${esc(font.family)}" font-size="${font.size}">
175
+ <style>
176
+ ${style}text{white-space:pre;dominant-baseline:auto}
177
+ </style>
178
+ ${config.marginFill === "transparent" ? "" : `<rect width="100%" height="100%" fill="${config.marginFill}"/>`}
179
+ ${shadowDefs(config)}
180
+ <rect x="${num(g.frameX)}" y="${num(g.frameY)}" width="${num(g.frameW)}" height="${num(g.frameH)}" rx="${config.borderRadius}" fill="${theme.background}"${config.shadow ? ' filter="url(#shadow)"' : ""}/>
181
+ ${windowBar(config, g, title)}
182
+ <clipPath id="term"><rect x="${num(g.termX)}" y="${num(g.termY)}" width="${num(g.termW)}" height="${num(g.termH)}"/></clipPath>
183
+ <g clip-path="url(#term)"><g transform="translate(${num(g.termX)} ${num(g.termY)})">${body}</g></g>
184
+ ${await watermarkMarkup(config, g)}
185
+ </svg>
186
+ `;
187
+ }
188
+
168
189
  /** Animated SVG: a horizontal strip of unique frames moved by a stepped CSS animation. No JS, no fonts embedded. */
169
190
  export async function buildSvg(rec: Recording, config: ResolvedConfig): Promise<SvgResult> {
170
191
  const replay = await replayFrames(rec, config);
171
192
  const g = svgGeometry(config, replay.cols, replay.rows);
172
- const { theme, font } = config;
173
193
  const n = replay.frames.length;
174
194
  const total = replay.duration;
175
195
 
@@ -184,25 +204,46 @@ export async function buildSvg(rec: Recording, config: ResolvedConfig): Promise<
184
204
  .map((f, i) => `<g transform="translate(${num(i * g.termW)} 0)">${frameMarkup(f, config, g)}</g>`)
185
205
  .join("\n");
186
206
 
187
- const svg = `<?xml version="1.0" encoding="UTF-8"?>
188
- <svg xmlns="http://www.w3.org/2000/svg" width="${g.width}" height="${g.height}" viewBox="0 0 ${g.width} ${g.height}" font-family="${esc(font.family)}" font-size="${font.size}">
189
- <style>
190
- .strip{animation:tcut ${num(total)}s steps(1,end) infinite}
191
- @keyframes tcut{${keyframes.join("")}}
192
- text{white-space:pre;dominant-baseline:auto}
193
- </style>
194
- ${config.marginFill === "transparent" ? "" : `<rect width="100%" height="100%" fill="${config.marginFill}"/>`}
195
- ${shadowDefs(config)}
196
- <rect x="${num(g.frameX)}" y="${num(g.frameY)}" width="${num(g.frameW)}" height="${num(g.frameH)}" rx="${config.borderRadius}" fill="${theme.background}"${config.shadow ? ' filter="url(#shadow)"' : ""}/>
197
- ${windowBar(config, g, config.title === "auto" ? (replay.title ?? "") : config.title)}
198
- <clipPath id="term"><rect x="${num(g.termX)}" y="${num(g.termY)}" width="${num(g.termW)}" height="${num(g.termH)}"/></clipPath>
199
- <g clip-path="url(#term)"><g transform="translate(${num(g.termX)} ${num(g.termY)})"><g class="strip" xml:space="preserve">
200
- ${frames}
201
- </g></g></g>
202
- ${await watermarkMarkup(config, g)}
203
- </svg>
204
- `;
205
- return { svg, frames: n, duration: total };
207
+ const style = `.strip{animation:tcut ${num(total)}s steps(1,end) infinite}\n@keyframes tcut{${keyframes.join("")}}\n`;
208
+ const body = `<g class="strip" xml:space="preserve">\n${frames}\n</g>`;
209
+ const title = config.title === "auto" ? (replay.title ?? "") : config.title;
210
+ return { svg: await svgDocument(config, g, title, style, body), frames: n, duration: total };
211
+ }
212
+
213
+ export interface SnapshotMark {
214
+ file: string;
215
+ /** Seconds on the visible timeline. */
216
+ at: number;
217
+ }
218
+
219
+ /** The frame on screen at `at` seconds (frames carry their start time; the last one started before `at` wins). */
220
+ function frameAt(frames: GridFrame[], at: number): GridFrame | undefined {
221
+ let current = frames[0];
222
+ for (const f of frames) {
223
+ if (f.time <= at + 1e-9) current = f;
224
+ else break;
225
+ }
226
+ return current;
227
+ }
228
+
229
+ /** Static (non-animated) SVG stills for `t.snapshot("x.svg")` marks — one replay serves all of them. */
230
+ export async function writeSvgSnapshots(rec: Recording, config: ResolvedConfig, marks: SnapshotMark[]): Promise<string[]> {
231
+ const replay = await replayFrames(rec, config);
232
+ const g = svgGeometry(config, replay.cols, replay.rows);
233
+ const title = config.title === "auto" ? (replay.title ?? "") : config.title;
234
+ const written: string[] = [];
235
+ for (const mark of marks) {
236
+ // The raster pass applies output and marks that share a frame tick together; match that: the mark
237
+ // captures the first tick at or after its instant, so output recorded just before it is included.
238
+ const tick = Math.ceil(mark.at * config.fps - 1e-6) / config.fps;
239
+ const frame = frameAt(replay.frames, tick);
240
+ if (!frame) continue;
241
+ const svg = await svgDocument(config, g, title, "", `<g xml:space="preserve">${frameMarkup(frame, config, g)}</g>`);
242
+ await mkdir(path.dirname(path.resolve(mark.file)), { recursive: true });
243
+ await Bun.write(mark.file, svg);
244
+ written.push(mark.file);
245
+ }
246
+ return written;
206
247
  }
207
248
 
208
249
  export async function writeSvg(rec: Recording, config: ResolvedConfig, file: string): Promise<SvgResult> {
package/src/recorder.ts CHANGED
@@ -385,10 +385,11 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
385
385
  expect,
386
386
  hide,
387
387
  timelapse,
388
- screenshot: async (file) => {
388
+ snapshot: async (file) => {
389
389
  await screen.settle();
390
390
  push("m", MARKER.screenshot + file);
391
391
  },
392
+ screenshot: (file) => session.snapshot(file),
392
393
  marker: async (name) => {
393
394
  push("m", name);
394
395
  },
package/src/render.ts CHANGED
@@ -1,14 +1,38 @@
1
1
  import { mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { MARKER } from "./cast";
3
4
  import { chapterRanges, chapterSlug, cutRecording, findChapters, flattenedConfig, selectChapters } from "./edit";
4
5
  import { frameText, replayFrames } from "./export/frames";
5
6
  import { writeHtml } from "./export/html";
6
- import { writeSvg } from "./export/svg";
7
+ import { writeSvg, writeSvgSnapshots, type SnapshotMark } from "./export/svg";
8
+ import { buildTimeline } from "./timeline";
7
9
  import { render as renderRaster, type RenderResult } from "./renderer/webview";
8
10
  import type { ClipSelection, Recording, RenderProgress, ResolvedConfig } from "./types";
9
11
 
10
12
  export type { RenderResult };
11
13
 
14
+ /**
15
+ * `t.snapshot(file)` marks with their instants on the visible timeline. `.svg` stills are produced headlessly
16
+ * (same clock as the SVG exporter); anything else is a raster still written by the WebView pass.
17
+ */
18
+ interface SnapshotMarks {
19
+ vector: SnapshotMark[];
20
+ /** How many marks need pixels (anything that is not `.svg`). */
21
+ raster: number;
22
+ }
23
+
24
+ function snapshotMarks(rec: Recording, config: ResolvedConfig): SnapshotMarks {
25
+ const vector: SnapshotMark[] = [];
26
+ let raster = 0;
27
+ for (const e of buildTimeline(rec.events, config.playbackSpeed).events) {
28
+ if (e.type !== "m" || !e.data.startsWith(MARKER.screenshot)) continue;
29
+ const file = e.data.slice(MARKER.screenshot.length);
30
+ if (file.toLowerCase().endsWith(".svg")) vector.push({ file, at: e.vt });
31
+ else raster += 1;
32
+ }
33
+ return { vector, raster };
34
+ }
35
+
12
36
  const kind = (output: string): "svg" | "html" | "txt" | "log" | "raster" => {
13
37
  if (output.endsWith("/")) return "raster";
14
38
  const ext = path.extname(output).toLowerCase();
@@ -50,6 +74,7 @@ export async function renderOutputs(
50
74
  const raster = config.output.filter((o) => kind(o) === "raster");
51
75
 
52
76
  const result: RenderResult = { outputs: [], frames: 0, screenshots: [], durationSeconds: 0 };
77
+ const marks = snapshotMarks(rec, config);
53
78
 
54
79
  for (const file of txt) {
55
80
  await writeTxt(rec, config, file);
@@ -70,7 +95,11 @@ export async function renderOutputs(
70
95
  await writeHtml(rec, config, file);
71
96
  result.outputs.push(file);
72
97
  }
73
- if (raster.length > 0) {
98
+ if (marks.vector.length > 0) {
99
+ result.screenshots.push(...(await writeSvgSnapshots(rec, config, marks.vector)));
100
+ }
101
+ // Raster snapshots need pixels: run the WebView pass even when no raster output is configured.
102
+ if (raster.length > 0 || marks.raster > 0) {
74
103
  const r = await renderRaster(rec, { ...config, output: raster }, onProgress);
75
104
  result.outputs.push(...r.outputs);
76
105
  result.frames = r.frames;
@@ -216,7 +216,7 @@ export async function render(
216
216
 
217
217
  const blinkOn = !config.cursor.blink || Math.floor((time / blinkPeriod) * 2) % 2 === 0;
218
218
  const drawable = batch.filter((e) => e.type === "o" || e.type === "r");
219
- const shots = batch.filter((e) => e.type === "m" && e.data.startsWith(MARKER.screenshot));
219
+ const shots = batch.filter((e) => e.type === "m" && e.data.startsWith(MARKER.screenshot) && !e.data.toLowerCase().endsWith(".svg"));
220
220
  const browserFrame = hasBrowser ? batch.filter((e) => e.type === "b").at(-1) : undefined;
221
221
  const focusChanged = hasBrowser && batch.some((e) => e.type === "m" && e.data.startsWith(MARKER.focus));
222
222
  let dirty = lastPng === null || drawable.length > 0 || blinkOn !== lastBlink || browserFrame !== undefined || focusChanged;
package/src/types.ts CHANGED
@@ -353,7 +353,14 @@ export interface TerminalSession {
353
353
 
354
354
  /** Everything inside `fn` happens, but is cut from the video (state changes are kept). */
355
355
  hide<T>(fn: () => Promise<T>): Promise<T>;
356
- /** Save a PNG of the current frame during rendering. */
356
+ /**
357
+ * Save a still of this exact moment when the video renders: `.png` (pixel-perfect raster) or `.svg`
358
+ * (vector, selectable text — produced headlessly, no WebView). Written by `tcut <script>` and
359
+ * `tcut render`, even when no video output is configured, so one script keeps the video and its
360
+ * screenshots in sync.
361
+ */
362
+ snapshot(path: string): Promise<void>;
363
+ /** Alias of `snapshot` (pre-1.0 name). */
357
364
  screenshot(path: string): Promise<void>;
358
365
  /** Insert a named marker (written to the .cast, useful for chapters/tooling). */
359
366
  marker(name: string): Promise<void>;