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 +1 -1
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/export/svg.ts +61 -20
- package/src/recorder.ts +2 -1
- package/src/render.ts +31 -2
- package/src/renderer/webview.ts +1 -1
- package/src/types.ts +8 -1
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
package/src/cli.ts
CHANGED
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
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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 (
|
|
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;
|
package/src/renderer/webview.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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>;
|