termcut 0.6.4 → 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/README.md +9 -1
- package/package.json +1 -1
- package/scripts/build-themes.ts +10 -6
- package/src/browser.ts +3 -3
- package/src/cast.ts +2 -0
- package/src/cli.ts +135 -27
- package/src/config.ts +35 -2
- package/src/diff.ts +3 -13
- package/src/duration.ts +4 -8
- package/src/edit.ts +188 -0
- package/src/export/frames.ts +18 -1
- package/src/export/html.ts +6 -2
- package/src/export/svg.ts +31 -3
- package/src/index.ts +6 -2
- package/src/keylabels.ts +28 -27
- package/src/keys.ts +18 -18
- package/src/live.ts +18 -7
- package/src/loop.ts +14 -9
- package/src/presets.ts +2 -2
- package/src/publish.ts +19 -21
- package/src/recorder.ts +22 -5
- package/src/render.ts +69 -2
- package/src/renderer/encoder.ts +49 -11
- package/src/renderer/generated/page.js +1 -1
- package/src/renderer/page-entry.ts +18 -6
- package/src/renderer/page.ts +99 -3
- package/src/renderer/png.ts +189 -0
- package/src/renderer/webview.ts +37 -11
- package/src/screen.ts +9 -3
- package/src/scriptgen.ts +31 -30
- package/src/themes.ts +9 -11
- package/src/timeline.ts +21 -5
- package/src/types.ts +63 -2
- package/src/video.ts +15 -12
package/src/edit.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Cast-level editing: cut, concatenate and select chapters. Everything works on the *visible* timeline (hides
|
|
2
|
+
// collapsed, speed and idle compression applied), so the result renders identically in every output format and
|
|
3
|
+
// never goes through ffmpeg — a cut cast is still a cast.
|
|
4
|
+
import { mkdir } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { MARKER } from "./cast";
|
|
7
|
+
import { buildTimeline } from "./timeline";
|
|
8
|
+
import type { CastEvent, Recording, ResolvedConfig } from "./types";
|
|
9
|
+
|
|
10
|
+
/** A window on the visible timeline, seconds. Either end may be open. */
|
|
11
|
+
export interface ClipRange {
|
|
12
|
+
from?: number;
|
|
13
|
+
to?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ChapterRange {
|
|
17
|
+
title: string;
|
|
18
|
+
from: number;
|
|
19
|
+
to: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const round = (t: number): number => Number(t.toFixed(6));
|
|
23
|
+
|
|
24
|
+
/** Config a flattened recording should be rendered with: the timing it was flattened on is now baked in. */
|
|
25
|
+
export function flattenedConfig(config: ResolvedConfig): ResolvedConfig {
|
|
26
|
+
return { ...config, playbackSpeed: 1 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The recording re-timed onto its visible timeline: hidden intervals removed, `playbackSpeed`, `maxPause` and
|
|
31
|
+
* timelapse segments applied, input events kept (the key overlay needs them). Rendering the result with
|
|
32
|
+
* `flattenedConfig(config)` produces the same video as rendering the original with `config`.
|
|
33
|
+
*/
|
|
34
|
+
export function flattenRecording(rec: Recording, config: ResolvedConfig): Recording {
|
|
35
|
+
const timeline = buildTimeline(rec.events, config.playbackSpeed, { keepInput: true, maxPause: config.maxPause });
|
|
36
|
+
const events: CastEvent[] = timeline.events.map((e) => [round(e.vt), e.type, e.data]);
|
|
37
|
+
return {
|
|
38
|
+
header: { ...rec.header, duration: round(timeline.duration), bunVideo: flattenedConfig(config) },
|
|
39
|
+
events,
|
|
40
|
+
...(rec.source && { source: rec.source }),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Duration of a recording: its end marker, or the last event. */
|
|
45
|
+
export function recordingDuration(rec: Recording): number {
|
|
46
|
+
const end = rec.events.find((e) => e[1] === "m" && e[2] === MARKER.end);
|
|
47
|
+
if (end) return end[0];
|
|
48
|
+
return rec.events.length ? rec.events[rec.events.length - 1]![0] : 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const isState = (e: CastEvent): boolean =>
|
|
52
|
+
e[1] === "o" || e[1] === "r" || e[1] === "b" || (e[1] === "m" && (e[2].startsWith(MARKER.zoom) || e[2].startsWith(MARKER.focus)));
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Keep `[from, to]` of the visible timeline. Everything before `from` that shapes the screen (output, resizes,
|
|
56
|
+
* the last zoom/focus/browser frame) is kept at t=0 so the first frame is right; markers such as chapters and
|
|
57
|
+
* screenshots before `from` are dropped. The result is flattened (see `flattenRecording`).
|
|
58
|
+
*/
|
|
59
|
+
export function cutRecording(rec: Recording, config: ResolvedConfig, range: ClipRange): Recording {
|
|
60
|
+
const flat = flattenRecording(rec, config);
|
|
61
|
+
const duration = recordingDuration(flat);
|
|
62
|
+
const from = Math.max(0, range.from ?? 0);
|
|
63
|
+
const to = Math.min(duration, range.to ?? duration);
|
|
64
|
+
if (!(to > from)) throw new Error(`Nothing to keep between ${from}s and ${to}s (the recording is ${duration.toFixed(2)}s long)`);
|
|
65
|
+
|
|
66
|
+
const preroll: CastEvent[] = [];
|
|
67
|
+
let lastZoom: CastEvent | undefined;
|
|
68
|
+
let lastFocus: CastEvent | undefined;
|
|
69
|
+
let lastBrowser: CastEvent | undefined;
|
|
70
|
+
const kept: CastEvent[] = [];
|
|
71
|
+
for (const e of flat.events) {
|
|
72
|
+
const [t, type, data] = e;
|
|
73
|
+
if (t < from - 1e-9) {
|
|
74
|
+
if (!isState(e)) continue;
|
|
75
|
+
if (type === "o" || type === "r") preroll.push([0, type, data]);
|
|
76
|
+
else if (type === "b") lastBrowser = [0, type, data];
|
|
77
|
+
else if (data.startsWith(MARKER.zoom)) lastZoom = [0, type, data];
|
|
78
|
+
else lastFocus = [0, type, data];
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (t > to + 1e-9) continue;
|
|
82
|
+
if (type === "m" && data === MARKER.end) continue;
|
|
83
|
+
kept.push([round(t - from), type, data]);
|
|
84
|
+
}
|
|
85
|
+
const events: CastEvent[] = [...preroll];
|
|
86
|
+
for (const e of [lastBrowser, lastFocus, lastZoom]) if (e) events.push(e);
|
|
87
|
+
events.push(...kept, [round(to - from), "m", MARKER.end]);
|
|
88
|
+
return { ...flat, header: { ...flat.header, duration: round(to - from) }, events };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Chapters on the visible timeline; each runs until the next chapter (or the end). */
|
|
92
|
+
export function chapterRanges(rec: Recording, config: ResolvedConfig): ChapterRange[] {
|
|
93
|
+
const flat = flattenRecording(rec, config);
|
|
94
|
+
const duration = recordingDuration(flat);
|
|
95
|
+
const starts = flat.events.filter((e) => e[1] === "m" && e[2].startsWith(MARKER.chapter)).map((e) => ({ title: e[2].slice(MARKER.chapter.length), from: e[0] }));
|
|
96
|
+
return starts.map((c, i) => ({ ...c, to: starts[i + 1]?.from ?? duration }));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const slug = (s: string): string =>
|
|
100
|
+
s
|
|
101
|
+
.toLowerCase()
|
|
102
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
103
|
+
.replace(/^-+|-+$/g, "");
|
|
104
|
+
|
|
105
|
+
/** File-name friendly chapter label: "02-zoom-in". */
|
|
106
|
+
export function chapterSlug(index: number, title: string): string {
|
|
107
|
+
return `${String(index + 1).padStart(2, "0")}-${slug(title) || "chapter"}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Find chapters by title (case/punctuation-insensitive) or 1-based index; throws listing what exists. */
|
|
111
|
+
export function findChapters(ranges: ChapterRange[], names: string[]): ChapterRange[] {
|
|
112
|
+
if (ranges.length === 0) throw new Error("This recording has no chapters (add `t.chapter(name)` calls to the script)");
|
|
113
|
+
return names.map((name) => {
|
|
114
|
+
const want = slug(name);
|
|
115
|
+
const index = Number(name);
|
|
116
|
+
const found = ranges.find((r) => slug(r.title) === want) ?? (Number.isInteger(index) && index >= 1 ? ranges[index - 1] : undefined);
|
|
117
|
+
if (!found) throw new Error(`Unknown chapter "${name}". Chapters: ${ranges.map((r, i) => `${i + 1}. ${r.title}`).join(", ")}`);
|
|
118
|
+
return found;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface ConcatOptions {
|
|
123
|
+
/** Still time between parts, seconds. Default 0. */
|
|
124
|
+
gap?: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Join recordings end to end. Each part is flattened; the terminal is reset (`ESC c`) at every seam so a part
|
|
129
|
+
* starts on a clean screen, and any zoom/focus from the previous part is cleared. Parts must share a grid size.
|
|
130
|
+
*/
|
|
131
|
+
export function concatRecordings(parts: Array<{ rec: Recording; config: ResolvedConfig }>, opts: ConcatOptions = {}): Recording {
|
|
132
|
+
if (parts.length === 0) throw new Error("concat needs at least one recording");
|
|
133
|
+
const flats = parts.map(({ rec, config }) => flattenRecording(rec, config));
|
|
134
|
+
const first = flats[0]!;
|
|
135
|
+
for (const f of flats) {
|
|
136
|
+
if (f.header.width !== first.header.width || f.header.height !== first.header.height) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`All recordings must have the same size to be joined: got ${flats.map((x) => `${x.header.width}x${x.header.height}`).join(", ")}. Re-record, or render each separately.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const gap = Math.max(0, opts.gap ?? 0);
|
|
143
|
+
const events: CastEvent[] = [];
|
|
144
|
+
let offset = 0;
|
|
145
|
+
flats.forEach((flat, i) => {
|
|
146
|
+
if (i > 0) {
|
|
147
|
+
events.push([round(offset), "o", "\x1bc"]);
|
|
148
|
+
events.push([round(offset), "m", `${MARKER.zoom}null`]);
|
|
149
|
+
if (flat.events.some((e) => e[1] === "b") || flats[i - 1]!.events.some((e) => e[1] === "b")) events.push([round(offset), "m", `${MARKER.focus}terminal`]);
|
|
150
|
+
}
|
|
151
|
+
for (const [t, type, data] of flat.events) {
|
|
152
|
+
if (type === "m" && data === MARKER.end) continue;
|
|
153
|
+
events.push([round(t + offset), type, data]);
|
|
154
|
+
}
|
|
155
|
+
offset += recordingDuration(flat) + (i < flats.length - 1 ? gap : 0);
|
|
156
|
+
});
|
|
157
|
+
events.push([round(offset), "m", MARKER.end]);
|
|
158
|
+
return { header: { ...first.header, duration: round(offset) }, events };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Keep only the named chapters, in the order given (non-adjacent chapters are joined). */
|
|
162
|
+
export function selectChapters(rec: Recording, config: ResolvedConfig, names: string[]): Recording {
|
|
163
|
+
const ranges = findChapters(chapterRanges(rec, config), names);
|
|
164
|
+
const flat = flattenedConfig(config);
|
|
165
|
+
const parts = ranges.map((r) => ({ rec: cutRecording(rec, config, r), config: flat }));
|
|
166
|
+
return parts.length === 1 ? parts[0]!.rec : concatRecordings(parts);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Browser frames (`b` events) are paths relative to the cast they were recorded with. When a cut or joined cast
|
|
171
|
+
* is written somewhere else, copy the frames next to it (`<name>.browser/`) and point the events there.
|
|
172
|
+
*/
|
|
173
|
+
export async function rebaseBrowserFrames(rec: Recording, sourceCast: string | undefined, targetCast: string, prefix = ""): Promise<Recording> {
|
|
174
|
+
const frames = rec.events.filter((e) => e[1] === "b");
|
|
175
|
+
if (frames.length === 0) return rec;
|
|
176
|
+
const fromDir = sourceCast ? path.dirname(path.resolve(sourceCast)) : process.cwd();
|
|
177
|
+
const dirName = `${path.basename(targetCast).replace(/\.cast$/, "")}.browser`;
|
|
178
|
+
const dir = path.join(path.dirname(path.resolve(targetCast)), dirName);
|
|
179
|
+
await mkdir(dir, { recursive: true });
|
|
180
|
+
const moved = new Map<string, string>();
|
|
181
|
+
for (const [, , rel] of frames) {
|
|
182
|
+
if (moved.has(rel)) continue;
|
|
183
|
+
const name = `${prefix}${path.basename(rel)}`;
|
|
184
|
+
await Bun.write(path.join(dir, name), Bun.file(path.join(fromDir, rel)));
|
|
185
|
+
moved.set(rel, `${dirName}/${name}`);
|
|
186
|
+
}
|
|
187
|
+
return { ...rec, events: rec.events.map((e) => (e[1] === "b" ? [e[0], e[1], moved.get(e[2]) ?? e[2]] : e)) };
|
|
188
|
+
}
|
package/src/export/frames.ts
CHANGED
|
@@ -36,6 +36,17 @@ export interface GridFrame {
|
|
|
36
36
|
cursor: { row: number; col: number; visible: boolean };
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** Visible text of a frame: one string per row, trailing spaces and trailing blank rows removed. */
|
|
40
|
+
export function frameText(frame: GridFrame): string[] {
|
|
41
|
+
const out: string[] = [];
|
|
42
|
+
for (let y = 0; y < frame.rows; y++) {
|
|
43
|
+
const cells = frame.rows_.get(y);
|
|
44
|
+
out.push(cells ? cells.map((c) => c.text).join("").replace(/\s+$/, "") : "");
|
|
45
|
+
}
|
|
46
|
+
while (out.length && out[out.length - 1] === "") out.pop();
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
39
50
|
export interface GridReplay {
|
|
40
51
|
frames: GridFrame[];
|
|
41
52
|
duration: number;
|
|
@@ -76,7 +87,13 @@ function toGridCell(cell: CellData, theme: Theme): GridCell {
|
|
|
76
87
|
return { text, width: cell.width === 2 ? 2 : 1, fg, bg, flags: cell.flags & ~FLAG.reverse };
|
|
77
88
|
}
|
|
78
89
|
|
|
79
|
-
|
|
90
|
+
interface ScreenSnapshot {
|
|
91
|
+
rows: Map<number, GridCell[]>;
|
|
92
|
+
/** Content fingerprint: identical screens share a key, so identical frames are deduplicated. */
|
|
93
|
+
key: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function snapshot(core: TerminalCore, theme: Theme): ScreenSnapshot {
|
|
80
97
|
const rows = new Map<number, GridCell[]>();
|
|
81
98
|
const keyParts: string[] = [];
|
|
82
99
|
const cols = core.getCols();
|
package/src/export/html.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { barHeight } from "../renderer/page";
|
|
3
|
+
import { barHeight, embedImage, shadowCss, watermarkCss } from "../renderer/page";
|
|
4
4
|
import { pageAssets } from "../renderer/bundle";
|
|
5
5
|
import { buildTimeline } from "../timeline";
|
|
6
6
|
import type { Recording, ResolvedConfig, Theme } from "../types";
|
|
@@ -29,6 +29,8 @@ export async function buildHtml(rec: Recording, config: ResolvedConfig): Promise
|
|
|
29
29
|
const assets = await pageAssets();
|
|
30
30
|
const { events, duration } = buildTimeline(rec.events, config.playbackSpeed);
|
|
31
31
|
const { theme, font } = config;
|
|
32
|
+
const wm = config.watermark;
|
|
33
|
+
const watermark = wm ? `<div id="watermark">${wm.image ? `<img src="${(await embedImage(wm.image)).dataUri}" alt="">` : escapeHtml(wm.text ?? "")}</div>` : "";
|
|
32
34
|
const data = {
|
|
33
35
|
cols: rec.header.width,
|
|
34
36
|
rows: rec.header.height,
|
|
@@ -54,7 +56,8 @@ export async function buildHtml(rec: Recording, config: ResolvedConfig): Promise
|
|
|
54
56
|
<style>
|
|
55
57
|
${assets.css}
|
|
56
58
|
html, body { margin: 0; background: ${config.marginFill}; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
57
|
-
#frame { display: inline-block; background: ${theme.background}; border-radius: ${config.borderRadius}px; padding: ${config.padding}px; margin: ${config.margin}px; box-shadow: 0 12px 40px rgba(0,0,0,.35); }
|
|
59
|
+
#frame { position: relative; display: inline-block; background: ${theme.background}; border-radius: ${config.borderRadius}px; padding: ${config.padding}px; margin: ${config.margin}px; box-shadow: ${shadowCss(config) ?? "0 12px 40px rgba(0,0,0,.35)"}; }
|
|
60
|
+
${watermarkCss(config)}
|
|
58
61
|
#bar { height: ${barHeight(config)}px; margin-top: -${Math.min(config.padding, 12)}px; display: flex; align-items: center; justify-content: space-between; font: 13px -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; color: ${theme.foreground}; }
|
|
59
62
|
#bar .dots { display: flex; gap: 8px; } #bar .dot { width: 12px; height: 12px; border-radius: 50%; box-sizing: border-box; display: inline-block; }
|
|
60
63
|
#bar .title { flex: 1; text-align: center; opacity: .7; } #bar.right .title { text-align: left; }
|
|
@@ -68,6 +71,7 @@ html, body { margin: 0; background: ${config.marginFill}; min-height: 100vh; dis
|
|
|
68
71
|
<body>
|
|
69
72
|
<div id="frame">
|
|
70
73
|
${windowBar(config)}
|
|
74
|
+
${watermark}
|
|
71
75
|
<div id="term"></div>
|
|
72
76
|
<div id="controls">
|
|
73
77
|
<button id="play" title="Play / pause">▶</button>
|
package/src/export/svg.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { fitFrame } from "../loop";
|
|
2
|
-
import { barHeight } from "../renderer/page";
|
|
2
|
+
import { barHeight, embedImage } from "../renderer/page";
|
|
3
3
|
import type { Recording, ResolvedConfig } from "../types";
|
|
4
4
|
import { FLAG, replayFrames, type GridCell, type GridFrame } from "./frames";
|
|
5
5
|
|
|
@@ -128,6 +128,32 @@ function frameMarkup(frame: GridFrame, config: ResolvedConfig, g: Geometry): str
|
|
|
128
128
|
return parts.join("");
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/** Drop shadow as an SVG filter on the window rect (blur radius ≈ 2 × stdDeviation). */
|
|
132
|
+
function shadowDefs(config: ResolvedConfig): string {
|
|
133
|
+
const s = config.shadow;
|
|
134
|
+
if (!s) return "";
|
|
135
|
+
return `<defs><filter id="shadow" x="-40%" y="-40%" width="180%" height="200%"><feDropShadow dx="${num(s.x)}" dy="${num(s.y)}" stdDeviation="${num(s.blur / 2)}" flood-color="${s.color}" flood-opacity="${num(s.opacity)}"/></filter></defs>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function watermarkMarkup(config: ResolvedConfig, g: Geometry): Promise<string> {
|
|
139
|
+
const w = config.watermark;
|
|
140
|
+
if (!w) return "";
|
|
141
|
+
const m = w.margin;
|
|
142
|
+
const anchor = w.position === "center" ? "middle" : w.position.endsWith("left") ? "start" : "end";
|
|
143
|
+
const x = w.position === "center" ? g.width / 2 : w.position.endsWith("left") ? m : g.width - m;
|
|
144
|
+
if (w.image) {
|
|
145
|
+
const img = await embedImage(w.image);
|
|
146
|
+
const h = w.size;
|
|
147
|
+
const iw = img.height > 0 ? (img.width / img.height) * h : h;
|
|
148
|
+
const ix = anchor === "start" ? x : anchor === "end" ? x - iw : x - iw / 2;
|
|
149
|
+
const iy = w.position === "center" ? g.height / 2 - h / 2 : w.position.startsWith("top") ? m : g.height - m - h;
|
|
150
|
+
return `<image href="${img.dataUri}" x="${num(ix)}" y="${num(iy)}" width="${num(iw)}" height="${num(h)}" opacity="${num(w.opacity)}"/>`;
|
|
151
|
+
}
|
|
152
|
+
const y = w.position === "center" ? g.height / 2 : w.position.startsWith("top") ? m + w.size : g.height - m;
|
|
153
|
+
const baseline = w.position === "center" ? ' dominant-baseline="middle"' : "";
|
|
154
|
+
return `<text x="${num(x)}" y="${num(y)}" text-anchor="${anchor}"${baseline} font-family="-apple-system, Segoe UI, Helvetica, Arial, sans-serif" font-weight="500" font-size="${num(w.size)}" fill="${w.color}" opacity="${num(w.opacity)}">${esc(w.text ?? "")}</text>`;
|
|
155
|
+
}
|
|
156
|
+
|
|
131
157
|
export interface SvgResult {
|
|
132
158
|
svg: string;
|
|
133
159
|
frames: number;
|
|
@@ -160,13 +186,15 @@ export async function buildSvg(rec: Recording, config: ResolvedConfig): Promise<
|
|
|
160
186
|
@keyframes tcut{${keyframes.join("")}}
|
|
161
187
|
text{white-space:pre;dominant-baseline:auto}
|
|
162
188
|
</style>
|
|
163
|
-
|
|
164
|
-
|
|
189
|
+
${config.marginFill === "transparent" ? "" : `<rect width="100%" height="100%" fill="${config.marginFill}"/>`}
|
|
190
|
+
${shadowDefs(config)}
|
|
191
|
+
<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)"' : ""}/>
|
|
165
192
|
${windowBar(config, g)}
|
|
166
193
|
<clipPath id="term"><rect x="${num(g.termX)}" y="${num(g.termY)}" width="${num(g.termW)}" height="${num(g.termH)}"/></clipPath>
|
|
167
194
|
<g clip-path="url(#term)"><g transform="translate(${num(g.termX)} ${num(g.termY)})"><g class="strip" xml:space="preserve">
|
|
168
195
|
${frames}
|
|
169
196
|
</g></g></g>
|
|
197
|
+
${await watermarkMarkup(config, g)}
|
|
170
198
|
</svg>
|
|
171
199
|
`;
|
|
172
200
|
return { svg, frames: n, duration: total };
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
export { defineVideo, Video, renderCast, isVideo } from "./video";
|
|
1
|
+
export { defineVideo, Video, renderCast, castConfig, isVideo } from "./video";
|
|
2
2
|
export type { RunOptions as VideoRunOptions, RunResult, VideoRecordOptions } from "./video";
|
|
3
|
-
export { renderOutputs } from "./render";
|
|
3
|
+
export { renderOutputs, renderSelection, writeTxt } from "./render";
|
|
4
|
+
export { cutRecording, concatRecordings, selectChapters, chapterRanges, findChapters, flattenRecording, flattenedConfig, rebaseBrowserFrames, recordingDuration } from "./edit";
|
|
5
|
+
export type { ClipRange, ChapterRange, ConcatOptions } from "./edit";
|
|
6
|
+
export { decodePng, encodePng, matte } from "./renderer/png";
|
|
7
|
+
export type { RgbaImage } from "./renderer/png";
|
|
4
8
|
export { recordLive } from "./live";
|
|
5
9
|
export type { LiveOptions } from "./live";
|
|
6
10
|
export { buildSvg } from "./export/svg";
|
package/src/keylabels.ts
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
1
|
import { tokenize } from "./scriptgen";
|
|
2
2
|
|
|
3
|
-
const NAMED
|
|
4
|
-
"\r"
|
|
5
|
-
"\n"
|
|
6
|
-
"\t"
|
|
7
|
-
"\x1b[Z"
|
|
8
|
-
"\x7f"
|
|
9
|
-
"\x1b"
|
|
10
|
-
"\x1b[A"
|
|
11
|
-
"\x1b[B"
|
|
12
|
-
"\x1b[C"
|
|
13
|
-
"\x1b[D"
|
|
14
|
-
"\x1bOA"
|
|
15
|
-
"\x1bOB"
|
|
16
|
-
"\x1bOC"
|
|
17
|
-
"\x1bOD"
|
|
18
|
-
"\x1b[1;2A"
|
|
19
|
-
"\x1b[1;2B"
|
|
20
|
-
"\x1b[1;2C"
|
|
21
|
-
"\x1b[1;2D"
|
|
22
|
-
"\x1b[H"
|
|
23
|
-
"\x1b[F"
|
|
24
|
-
"\x1b[3~"
|
|
25
|
-
"\x1b[5~"
|
|
26
|
-
"\x1b[6~"
|
|
27
|
-
|
|
3
|
+
const NAMED = new Map<string, string>([
|
|
4
|
+
["\r", "⏎"],
|
|
5
|
+
["\n", "⏎"],
|
|
6
|
+
["\t", "⇥"],
|
|
7
|
+
["\x1b[Z", "⇧⇥"],
|
|
8
|
+
["\x7f", "⌫"],
|
|
9
|
+
["\x1b", "esc"],
|
|
10
|
+
["\x1b[A", "↑"],
|
|
11
|
+
["\x1b[B", "↓"],
|
|
12
|
+
["\x1b[C", "→"],
|
|
13
|
+
["\x1b[D", "←"],
|
|
14
|
+
["\x1bOA", "↑"],
|
|
15
|
+
["\x1bOB", "↓"],
|
|
16
|
+
["\x1bOC", "→"],
|
|
17
|
+
["\x1bOD", "←"],
|
|
18
|
+
["\x1b[1;2A", "⇧↑"],
|
|
19
|
+
["\x1b[1;2B", "⇧↓"],
|
|
20
|
+
["\x1b[1;2C", "⇧→"],
|
|
21
|
+
["\x1b[1;2D", "⇧←"],
|
|
22
|
+
["\x1b[H", "home"],
|
|
23
|
+
["\x1b[F", "end"],
|
|
24
|
+
["\x1b[3~", "del"],
|
|
25
|
+
["\x1b[5~", "pgup"],
|
|
26
|
+
["\x1b[6~", "pgdn"],
|
|
27
|
+
]);
|
|
28
28
|
|
|
29
29
|
/** Human-readable labels for a raw input chunk: printable runs stay words, control sequences become symbols. */
|
|
30
30
|
export function keyLabels(input: string): string[] {
|
|
31
31
|
const labels: string[] = [];
|
|
32
32
|
for (const token of tokenize(input)) {
|
|
33
33
|
if (token === " ") { labels.push(" "); continue; }
|
|
34
|
-
const named = NAMED
|
|
34
|
+
const named = NAMED.get(token);
|
|
35
35
|
if (named) {
|
|
36
36
|
labels.push(named);
|
|
37
37
|
} else if (token.length === 1 && token.charCodeAt(0) < 32) {
|
|
@@ -39,7 +39,8 @@ export function keyLabels(input: string): string[] {
|
|
|
39
39
|
} else if (token.length === 2 && token[0] === "\x1b") {
|
|
40
40
|
labels.push(`⌥${token[1]}`);
|
|
41
41
|
} else if (token.startsWith("\x1b[<")) {
|
|
42
|
-
|
|
42
|
+
const wheel = token.endsWith("M") && (token.startsWith("\x1b[<64") || token.startsWith("\x1b[<65"));
|
|
43
|
+
labels.push(wheel ? "wheel" : "mouse");
|
|
43
44
|
} else if (token.startsWith("\x1b")) {
|
|
44
45
|
labels.push("esc…");
|
|
45
46
|
} else {
|
package/src/keys.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { KeyName } from "./types";
|
|
|
2
2
|
|
|
3
3
|
const ESC = "\x1b";
|
|
4
4
|
|
|
5
|
-
const keySequences
|
|
5
|
+
const keySequences = {
|
|
6
6
|
enter: "\r",
|
|
7
7
|
tab: "\t",
|
|
8
8
|
backspace: "\x7f",
|
|
@@ -30,7 +30,7 @@ const keySequences: Record<KeyName, string> = {
|
|
|
30
30
|
f10: `${ESC}[21~`,
|
|
31
31
|
f11: `${ESC}[23~`,
|
|
32
32
|
f12: `${ESC}[24~`,
|
|
33
|
-
}
|
|
33
|
+
} satisfies Record<KeyName, string>;
|
|
34
34
|
|
|
35
35
|
export function keySequence(name: KeyName): string {
|
|
36
36
|
const seq = keySequences[name];
|
|
@@ -51,27 +51,27 @@ export function ctrlSequence(key: string): string {
|
|
|
51
51
|
throw new Error(`Cannot send Ctrl+${key}`);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
const shiftedNamed
|
|
55
|
-
tab
|
|
56
|
-
up
|
|
57
|
-
down
|
|
58
|
-
right
|
|
59
|
-
left
|
|
60
|
-
home
|
|
61
|
-
end
|
|
62
|
-
delete
|
|
63
|
-
pageUp
|
|
64
|
-
pageDown
|
|
65
|
-
enter
|
|
66
|
-
space
|
|
67
|
-
|
|
54
|
+
const shiftedNamed = new Map<string, string>([
|
|
55
|
+
["tab", `${ESC}[Z`],
|
|
56
|
+
["up", `${ESC}[1;2A`],
|
|
57
|
+
["down", `${ESC}[1;2B`],
|
|
58
|
+
["right", `${ESC}[1;2C`],
|
|
59
|
+
["left", `${ESC}[1;2D`],
|
|
60
|
+
["home", `${ESC}[1;2H`],
|
|
61
|
+
["end", `${ESC}[1;2F`],
|
|
62
|
+
["delete", `${ESC}[3;2~`],
|
|
63
|
+
["pageUp", `${ESC}[5;2~`],
|
|
64
|
+
["pageDown", `${ESC}[6;2~`],
|
|
65
|
+
["enter", "\r"],
|
|
66
|
+
["space", " "],
|
|
67
|
+
]);
|
|
68
68
|
|
|
69
69
|
/** Shift+<key>: back-tab, shifted navigation keys (xterm modifier 2), or an uppercased character. */
|
|
70
70
|
export function shiftSequence(key: string): string {
|
|
71
|
-
const named = shiftedNamed
|
|
71
|
+
const named = shiftedNamed.get(key);
|
|
72
72
|
if (named) return named;
|
|
73
73
|
if (key.length === 1) return key.toUpperCase();
|
|
74
|
-
throw new Error(`Cannot send Shift+${key}. Known: ${
|
|
74
|
+
throw new Error(`Cannot send Shift+${key}. Known: ${[...shiftedNamed.keys()].join(", ")}, or a single character.`);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/** SGR mouse wheel event (button 64 = up, 65 = down) at a 1-based cell position. */
|
package/src/live.ts
CHANGED
|
@@ -3,6 +3,16 @@ import { MARKER } from "./cast";
|
|
|
3
3
|
import { shellSetup } from "./recorder";
|
|
4
4
|
import type { CastEvent, Recording, ResolvedConfig } from "./types";
|
|
5
5
|
|
|
6
|
+
/** What live recording needs from a keystroke source: `process.stdin`, or any readable stream (a PassThrough in tests). */
|
|
7
|
+
export interface LiveStdin {
|
|
8
|
+
isTTY?: boolean;
|
|
9
|
+
setRawMode?(mode: boolean): unknown;
|
|
10
|
+
resume(): unknown;
|
|
11
|
+
pause(): unknown;
|
|
12
|
+
on(event: "data", listener: (chunk: Buffer | string) => void): unknown;
|
|
13
|
+
off(event: "data", listener: (chunk: Buffer | string) => void): unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
6
16
|
export interface LiveOptions {
|
|
7
17
|
/** Run this command instead of the configured clean shell. */
|
|
8
18
|
command?: string[];
|
|
@@ -12,7 +22,7 @@ export interface LiveOptions {
|
|
|
12
22
|
/** Where to mirror the session (default: this process's stdout). */
|
|
13
23
|
stdout?: { write(data: Uint8Array | string): unknown };
|
|
14
24
|
/** Keystroke source (default: this process's stdin, switched to raw mode when it is a TTY). */
|
|
15
|
-
stdin?:
|
|
25
|
+
stdin?: LiveStdin | null;
|
|
16
26
|
log?: (message: string) => void;
|
|
17
27
|
}
|
|
18
28
|
|
|
@@ -40,7 +50,7 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
|
|
|
40
50
|
|
|
41
51
|
const browser = config.browser ? startBrowserCapture({ ...config, cols, rows }, stamp, log) : null;
|
|
42
52
|
const setup = opts.command ? { cmd: opts.command, env: {} } : shellSetup(config);
|
|
43
|
-
const env
|
|
53
|
+
const env = {
|
|
44
54
|
...process.env,
|
|
45
55
|
TERM: "xterm-256color",
|
|
46
56
|
COLORTERM: "truecolor",
|
|
@@ -74,11 +84,11 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
|
|
|
74
84
|
const terminal = proc.terminal;
|
|
75
85
|
if (!terminal) throw new Error("Bun.spawn did not return a terminal. Is this Bun >= 1.4?");
|
|
76
86
|
|
|
77
|
-
const isTTY = Boolean(stdin
|
|
87
|
+
const isTTY = Boolean(stdin?.isTTY);
|
|
78
88
|
const onData = (chunk: Buffer | string): void => {
|
|
79
89
|
if (exited || terminal.closed) return;
|
|
80
90
|
terminal.write(chunk);
|
|
81
|
-
push("i",
|
|
91
|
+
push("i", chunk instanceof Uint8Array ? chunk.toString("utf8") : chunk);
|
|
82
92
|
};
|
|
83
93
|
const onResize = (): void => {
|
|
84
94
|
const c = process.stdout.columns ?? cols;
|
|
@@ -89,7 +99,7 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
|
|
|
89
99
|
};
|
|
90
100
|
|
|
91
101
|
if (stdin) {
|
|
92
|
-
if (isTTY) stdin.setRawMode(true);
|
|
102
|
+
if (isTTY) stdin.setRawMode?.(true);
|
|
93
103
|
stdin.resume();
|
|
94
104
|
stdin.on("data", onData);
|
|
95
105
|
}
|
|
@@ -101,10 +111,11 @@ export async function recordLive(config: ResolvedConfig, opts: LiveOptions = {})
|
|
|
101
111
|
push("m", MARKER.end);
|
|
102
112
|
} finally {
|
|
103
113
|
await browser?.stop();
|
|
104
|
-
|
|
114
|
+
const emitter: NodeJS.EventEmitter = process; // @types/bun's process.off() lacks the signal overload; the generic emitter has it
|
|
115
|
+
emitter.off("SIGWINCH", onResize);
|
|
105
116
|
if (stdin) {
|
|
106
117
|
stdin.off("data", onData);
|
|
107
|
-
if (isTTY) stdin.setRawMode(false);
|
|
118
|
+
if (isTTY) stdin.setRawMode?.(false);
|
|
108
119
|
stdin.pause();
|
|
109
120
|
}
|
|
110
121
|
try {
|
package/src/loop.ts
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
/** Resolve a `loopOffset` (frame count or "N%") to a frame index in `[0, total)`. */
|
|
2
2
|
export function loopOffsetFrames(total: number, value: number | string | undefined): number {
|
|
3
3
|
if (!value || total <= 1) return 0;
|
|
4
|
-
|
|
5
|
-
if (
|
|
6
|
-
|
|
7
|
-
if (!m) throw new Error(`Invalid loopOffset "${value}" (use a frame count or a percentage like "50%")`);
|
|
8
|
-
frames = m[2] ? Math.round((Number(m[1]) / 100) * total) : Math.round(Number(m[1]));
|
|
9
|
-
} else {
|
|
10
|
-
frames = Math.round(value);
|
|
11
|
-
}
|
|
4
|
+
const m = /^\s*(-?\d+(?:\.\d+)?)\s*(%?)\s*$/.exec(String(value));
|
|
5
|
+
if (!m) throw new Error(`Invalid loopOffset "${value}" (use a frame count or a percentage like "50%")`);
|
|
6
|
+
const frames = m[2] ? Math.round((Number(m[1]) / 100) * total) : Math.round(Number(m[1]));
|
|
12
7
|
return ((frames % total) + total) % total;
|
|
13
8
|
}
|
|
14
9
|
|
|
@@ -18,6 +13,16 @@ export function rotateFrames<T>(frames: T[], offset: number): T[] {
|
|
|
18
13
|
return [...frames.slice(offset), ...frames.slice(0, offset)];
|
|
19
14
|
}
|
|
20
15
|
|
|
16
|
+
/** Where the terminal grid sits inside the frame, and the final (even-sized) video dimensions. */
|
|
17
|
+
export interface FrameFit {
|
|
18
|
+
frameW: number;
|
|
19
|
+
frameH: number;
|
|
20
|
+
padX: number;
|
|
21
|
+
padY: number;
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
21
26
|
/** Place the terminal grid inside a frame of the requested size (or wrap it tightly when no size is requested). */
|
|
22
27
|
export function fitFrame(opts: {
|
|
23
28
|
termW: number;
|
|
@@ -27,7 +32,7 @@ export function fitFrame(opts: {
|
|
|
27
32
|
bar: number;
|
|
28
33
|
width?: number;
|
|
29
34
|
height?: number;
|
|
30
|
-
}):
|
|
35
|
+
}): FrameFit {
|
|
31
36
|
const even = (n: number) => (n % 2 === 0 ? n : n + 1);
|
|
32
37
|
let frameW = opts.termW + opts.padding * 2;
|
|
33
38
|
let frameH = opts.termH + opts.padding * 2 + opts.bar;
|
package/src/presets.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { VideoConfig } from "./types";
|
|
|
3
3
|
export type PresetName = "readme" | "x" | "youtube" | "square";
|
|
4
4
|
|
|
5
5
|
/** Opinionated bundles applied *under* whatever the config sets explicitly. */
|
|
6
|
-
export const presets
|
|
6
|
+
export const presets = {
|
|
7
7
|
// Small, loops well, reads at README width.
|
|
8
8
|
readme: { cols: 80, rows: 20, fps: 30, font: { size: 18 }, padding: 20, margin: 0, borderRadius: 8, windowBar: "none", typingSpeed: "40ms" },
|
|
9
9
|
// 16:9 at the size X/Twitter serves without downscaling.
|
|
@@ -12,7 +12,7 @@ export const presets: Record<PresetName, Partial<VideoConfig>> = {
|
|
|
12
12
|
youtube: { width: 1920, height: 1080, fps: 60, font: { size: 26 }, padding: 32, margin: 40, borderRadius: 14, windowBar: "colorful", typingSpeed: "35ms", typingJitter: 0.3 },
|
|
13
13
|
// 1:1 for feeds.
|
|
14
14
|
square: { width: 1080, height: 1080, fps: 30, font: { size: 22 }, padding: 24, margin: 32, borderRadius: 14, windowBar: "colorful", typingSpeed: "35ms" },
|
|
15
|
-
}
|
|
15
|
+
} satisfies Record<PresetName, Partial<VideoConfig>>;
|
|
16
16
|
|
|
17
17
|
export const presetNames = Object.keys(presets) as PresetName[];
|
|
18
18
|
|