termcut 0.1.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/LICENSE +21 -0
- package/README.md +186 -0
- package/package.json +65 -0
- package/scripts/build-assets.ts +45 -0
- package/scripts/build-binaries.ts +41 -0
- package/src/cast.ts +40 -0
- package/src/cli.ts +312 -0
- package/src/config.ts +84 -0
- package/src/duration.ts +21 -0
- package/src/export/frames.ts +142 -0
- package/src/export/html.ts +88 -0
- package/src/export/svg.ts +171 -0
- package/src/index.ts +15 -0
- package/src/keys.ts +58 -0
- package/src/recorder.ts +362 -0
- package/src/render.ts +52 -0
- package/src/renderer/bundle.ts +78 -0
- package/src/renderer/embedded.ts +17 -0
- package/src/renderer/encoder.ts +253 -0
- package/src/renderer/generated/ghostty-vt.wasm +0 -0
- package/src/renderer/generated/page.js +1 -0
- package/src/renderer/generated/player.js +1 -0
- package/src/renderer/generated/terminal.css +195 -0
- package/src/renderer/generated.d.ts +17 -0
- package/src/renderer/page-entry.ts +109 -0
- package/src/renderer/page.ts +114 -0
- package/src/renderer/player-entry.ts +105 -0
- package/src/renderer/webview.ts +140 -0
- package/src/screen.ts +130 -0
- package/src/testing.ts +69 -0
- package/src/themes.ts +138 -0
- package/src/timeline.ts +58 -0
- package/src/types.ts +282 -0
- package/src/video.ts +127 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
export interface Theme {
|
|
2
|
+
name?: string;
|
|
3
|
+
background: string;
|
|
4
|
+
foreground: string;
|
|
5
|
+
cursor?: string;
|
|
6
|
+
cursorAccent?: string;
|
|
7
|
+
selectionBackground?: string;
|
|
8
|
+
black: string;
|
|
9
|
+
red: string;
|
|
10
|
+
green: string;
|
|
11
|
+
yellow: string;
|
|
12
|
+
blue: string;
|
|
13
|
+
magenta: string;
|
|
14
|
+
cyan: string;
|
|
15
|
+
white: string;
|
|
16
|
+
brightBlack: string;
|
|
17
|
+
brightRed: string;
|
|
18
|
+
brightGreen: string;
|
|
19
|
+
brightYellow: string;
|
|
20
|
+
brightBlue: string;
|
|
21
|
+
brightMagenta: string;
|
|
22
|
+
brightCyan: string;
|
|
23
|
+
brightWhite: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ThemeName =
|
|
27
|
+
| "catppuccin-mocha"
|
|
28
|
+
| "dracula"
|
|
29
|
+
| "github-dark"
|
|
30
|
+
| "tokyo-night"
|
|
31
|
+
| "one-dark";
|
|
32
|
+
|
|
33
|
+
export type ShellName = "bash" | "zsh" | "fish" | "sh";
|
|
34
|
+
/** Terminal emulator core: libghostty (full VT, answers queries) or wterm's lite Zig core (faster, fewer features). */
|
|
35
|
+
export type CoreName = "ghostty" | "lite";
|
|
36
|
+
export type WindowBar = "none" | "colorful" | "colorfulRight" | "rings" | "ringsRight";
|
|
37
|
+
|
|
38
|
+
/** Duration as milliseconds or a string like "500ms", "1.5s", "2m". */
|
|
39
|
+
export type Duration = number | string;
|
|
40
|
+
|
|
41
|
+
export interface FontConfig {
|
|
42
|
+
/** CSS font-family list. Default: JetBrains Mono → Menlo → monospace. */
|
|
43
|
+
family?: string;
|
|
44
|
+
/** Pixel size. Default 20. */
|
|
45
|
+
size?: number;
|
|
46
|
+
/** Line height multiplier. Default 1.2. */
|
|
47
|
+
lineHeight?: number;
|
|
48
|
+
/** Extra letter spacing in px. Default 0. */
|
|
49
|
+
letterSpacing?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CursorConfig {
|
|
53
|
+
/** Default true. Blink is driven by the render clock, so it is deterministic. */
|
|
54
|
+
blink?: boolean;
|
|
55
|
+
/** Full blink period in ms (on + off). Default 1000. */
|
|
56
|
+
period?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface VideoConfig {
|
|
60
|
+
/**
|
|
61
|
+
* One or more outputs. Extension picks the encoder: .mp4, .webm, .gif, .webp.
|
|
62
|
+
* A path ending in "/" writes a PNG sequence into that directory.
|
|
63
|
+
*/
|
|
64
|
+
output: string | string[];
|
|
65
|
+
/** Where to save the .cast recording. Default: next to the first output. */
|
|
66
|
+
cast?: string;
|
|
67
|
+
|
|
68
|
+
/** Shell to drive. Named shells get a clean, rc-free, deterministic setup. Default "bash". */
|
|
69
|
+
shell?: ShellName | string[];
|
|
70
|
+
/** Prompt text used for the clean shell setup and for auto-wait in `run()`. Default "> ". */
|
|
71
|
+
prompt?: string;
|
|
72
|
+
/** Override the regex used to detect the prompt on the cursor line. */
|
|
73
|
+
promptPattern?: RegExp;
|
|
74
|
+
cwd?: string;
|
|
75
|
+
env?: Record<string, string>;
|
|
76
|
+
|
|
77
|
+
/** Terminal grid. Default 80 × 24. */
|
|
78
|
+
cols?: number;
|
|
79
|
+
rows?: number;
|
|
80
|
+
|
|
81
|
+
/** Frames per second of the output. Default 60. */
|
|
82
|
+
fps?: number;
|
|
83
|
+
/** Delay between typed characters. Default "50ms". */
|
|
84
|
+
typingSpeed?: Duration;
|
|
85
|
+
/** 0–1, randomises typing delay by ±jitter using a seeded PRNG (reproducible). Default 0. */
|
|
86
|
+
typingJitter?: number;
|
|
87
|
+
/** Seed for the PRNG used by jitter. Default 1. */
|
|
88
|
+
seed?: number;
|
|
89
|
+
/** Speed multiplier applied at render time. 2 = twice as fast. Default 1. */
|
|
90
|
+
playbackSpeed?: number;
|
|
91
|
+
/** Default timeout for `wait()` / `run()` prompt detection. Default "15s". */
|
|
92
|
+
waitTimeout?: Duration;
|
|
93
|
+
/** Extra still time appended after the script ends. Default "1s". */
|
|
94
|
+
endPause?: Duration;
|
|
95
|
+
/** Snap recorded timestamps up to the next 1/fps boundary so identical output gives identical casts. Default false. */
|
|
96
|
+
quantize?: boolean;
|
|
97
|
+
/** Emulator used for the screen model and rendering. Default "ghostty". */
|
|
98
|
+
core?: CoreName;
|
|
99
|
+
/** Reuse the existing cast when the script and record config are unchanged. Default true. */
|
|
100
|
+
cache?: boolean;
|
|
101
|
+
|
|
102
|
+
font?: FontConfig;
|
|
103
|
+
theme?: ThemeName | Theme;
|
|
104
|
+
cursor?: CursorConfig;
|
|
105
|
+
/** Padding inside the window, px. Default 24. */
|
|
106
|
+
padding?: number;
|
|
107
|
+
/** Space around the window, px. Default 0. */
|
|
108
|
+
margin?: number;
|
|
109
|
+
/** Colour behind the window (visible when margin > 0). Default: theme background. */
|
|
110
|
+
marginFill?: string;
|
|
111
|
+
/** Rounded corner radius of the window, px. Default 0 (12 is nice with a margin). */
|
|
112
|
+
borderRadius?: number;
|
|
113
|
+
windowBar?: WindowBar;
|
|
114
|
+
/** Title shown in the window bar. */
|
|
115
|
+
title?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Fully resolved config with every default applied. Serialised into the .cast header. */
|
|
119
|
+
export interface ResolvedConfig {
|
|
120
|
+
output: string[];
|
|
121
|
+
cast: string;
|
|
122
|
+
shell: ShellName | string[];
|
|
123
|
+
prompt: string;
|
|
124
|
+
promptPattern: string;
|
|
125
|
+
cwd: string;
|
|
126
|
+
env: Record<string, string>;
|
|
127
|
+
cols: number;
|
|
128
|
+
rows: number;
|
|
129
|
+
fps: number;
|
|
130
|
+
typingSpeed: number;
|
|
131
|
+
typingJitter: number;
|
|
132
|
+
seed: number;
|
|
133
|
+
playbackSpeed: number;
|
|
134
|
+
waitTimeout: number;
|
|
135
|
+
endPause: number;
|
|
136
|
+
quantize: boolean;
|
|
137
|
+
core: CoreName;
|
|
138
|
+
cache: boolean;
|
|
139
|
+
font: Required<FontConfig>;
|
|
140
|
+
theme: Theme;
|
|
141
|
+
cursor: Required<CursorConfig>;
|
|
142
|
+
padding: number;
|
|
143
|
+
margin: number;
|
|
144
|
+
marginFill: string;
|
|
145
|
+
borderRadius: number;
|
|
146
|
+
windowBar: WindowBar;
|
|
147
|
+
title: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export type KeyName =
|
|
151
|
+
| "enter"
|
|
152
|
+
| "tab"
|
|
153
|
+
| "backspace"
|
|
154
|
+
| "delete"
|
|
155
|
+
| "escape"
|
|
156
|
+
| "space"
|
|
157
|
+
| "up"
|
|
158
|
+
| "down"
|
|
159
|
+
| "left"
|
|
160
|
+
| "right"
|
|
161
|
+
| "home"
|
|
162
|
+
| "end"
|
|
163
|
+
| "pageUp"
|
|
164
|
+
| "pageDown"
|
|
165
|
+
| "insert"
|
|
166
|
+
| `f${1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12}`;
|
|
167
|
+
|
|
168
|
+
export interface WaitOptions {
|
|
169
|
+
/** Match against the cursor line (default) or the whole visible screen. */
|
|
170
|
+
scope?: "line" | "screen";
|
|
171
|
+
timeout?: Duration;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface TypeOptions {
|
|
175
|
+
/** Per-character delay for this call only. */
|
|
176
|
+
speed?: Duration;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface RunOptions extends TypeOptions {
|
|
180
|
+
/** What to wait for after pressing Enter. `true` (default) = the prompt, a RegExp = custom, `false` = don't wait. */
|
|
181
|
+
wait?: boolean | RegExp;
|
|
182
|
+
timeout?: Duration;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The `t` object handed to your script. */
|
|
186
|
+
export interface TerminalSession {
|
|
187
|
+
/** Type text with per-character delay. "\n" is sent as Enter. */
|
|
188
|
+
type(text: string, opts?: TypeOptions): Promise<void>;
|
|
189
|
+
/** Type `command`, press Enter, and wait for the prompt to come back. */
|
|
190
|
+
run(command: string, opts?: RunOptions): Promise<void>;
|
|
191
|
+
/** Send text instantly, as if pasted. */
|
|
192
|
+
paste(text: string): Promise<void>;
|
|
193
|
+
|
|
194
|
+
key(name: KeyName, times?: number): Promise<void>;
|
|
195
|
+
enter(times?: number): Promise<void>;
|
|
196
|
+
tab(times?: number): Promise<void>;
|
|
197
|
+
backspace(times?: number): Promise<void>;
|
|
198
|
+
delete(times?: number): Promise<void>;
|
|
199
|
+
escape(times?: number): Promise<void>;
|
|
200
|
+
space(times?: number): Promise<void>;
|
|
201
|
+
up(times?: number): Promise<void>;
|
|
202
|
+
down(times?: number): Promise<void>;
|
|
203
|
+
left(times?: number): Promise<void>;
|
|
204
|
+
right(times?: number): Promise<void>;
|
|
205
|
+
home(): Promise<void>;
|
|
206
|
+
end(): Promise<void>;
|
|
207
|
+
pageUp(times?: number): Promise<void>;
|
|
208
|
+
pageDown(times?: number): Promise<void>;
|
|
209
|
+
/** Ctrl+<letter>, e.g. `t.ctrl("c")`. */
|
|
210
|
+
ctrl(letter: string, times?: number): Promise<void>;
|
|
211
|
+
/** Alt/Meta+<key>. */
|
|
212
|
+
alt(key: string, times?: number): Promise<void>;
|
|
213
|
+
/** Send raw bytes to the PTY. */
|
|
214
|
+
raw(data: string | Uint8Array): Promise<void>;
|
|
215
|
+
|
|
216
|
+
sleep(duration: Duration): Promise<void>;
|
|
217
|
+
/** Wait until `pattern` matches the cursor line (or screen). Defaults to the prompt pattern. */
|
|
218
|
+
wait(pattern?: RegExp | string, opts?: WaitOptions): Promise<void>;
|
|
219
|
+
/** Assert that `pattern` matches the screen right now (after output settles). Throws with a screen dump otherwise. */
|
|
220
|
+
expect(pattern: RegExp | string, opts?: Pick<WaitOptions, "scope">): Promise<void>;
|
|
221
|
+
|
|
222
|
+
/** Everything inside `fn` happens, but is cut from the video (state changes are kept). */
|
|
223
|
+
hide<T>(fn: () => Promise<T>): Promise<T>;
|
|
224
|
+
/** Save a PNG of the current frame during rendering. */
|
|
225
|
+
screenshot(path: string): Promise<void>;
|
|
226
|
+
/** Insert a named marker (written to the .cast, useful for chapters/tooling). */
|
|
227
|
+
marker(name: string): Promise<void>;
|
|
228
|
+
/** Resize the PTY and the rendered terminal. */
|
|
229
|
+
resize(cols: number, rows: number): Promise<void>;
|
|
230
|
+
/** Shorthand for `run("clear")`. */
|
|
231
|
+
clear(): Promise<void>;
|
|
232
|
+
|
|
233
|
+
/** Current visible screen as text (rows joined by "\n"). */
|
|
234
|
+
screen(): string;
|
|
235
|
+
/** Text of the cursor line. */
|
|
236
|
+
line(): string;
|
|
237
|
+
cursor(): { x: number; y: number };
|
|
238
|
+
readonly cols: number;
|
|
239
|
+
readonly rows: number;
|
|
240
|
+
readonly config: ResolvedConfig;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export type Script = (t: TerminalSession) => Promise<void> | void;
|
|
244
|
+
|
|
245
|
+
export type CastEventType = "o" | "i" | "r" | "m";
|
|
246
|
+
export type CastEvent = [time: number, type: CastEventType, data: string];
|
|
247
|
+
|
|
248
|
+
export interface CastHeader {
|
|
249
|
+
version: 2;
|
|
250
|
+
width: number;
|
|
251
|
+
height: number;
|
|
252
|
+
timestamp?: number;
|
|
253
|
+
duration?: number;
|
|
254
|
+
title?: string;
|
|
255
|
+
env?: Record<string, string>;
|
|
256
|
+
bunVideo?: ResolvedConfig;
|
|
257
|
+
/** SHA-256 of script source + record config; used for cast caching. */
|
|
258
|
+
scriptHash?: string;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export interface Recording {
|
|
262
|
+
header: CastHeader;
|
|
263
|
+
events: CastEvent[];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export interface RenderProgress {
|
|
267
|
+
frame: number;
|
|
268
|
+
total: number;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export interface RenderOptions {
|
|
272
|
+
/** Override resolved config values (theme, font, outputs, …) without re-recording. */
|
|
273
|
+
overrides?: Partial<VideoConfig>;
|
|
274
|
+
onProgress?: (p: RenderProgress) => void;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export interface RecordOptions {
|
|
278
|
+
onEvent?: (e: CastEvent) => void;
|
|
279
|
+
log?: (message: string) => void;
|
|
280
|
+
/** Test mode: no typing delay, `sleep()` is a no-op. Timeouts still apply. */
|
|
281
|
+
fast?: boolean;
|
|
282
|
+
}
|
package/src/video.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readCast, writeCast } from "./cast";
|
|
4
|
+
import { applyOverrides, resolveConfig } from "./config";
|
|
5
|
+
import { record } from "./recorder";
|
|
6
|
+
import { renderOutputs, type RenderResult } from "./render";
|
|
7
|
+
import type { RecordOptions, Recording, RenderOptions, ResolvedConfig, Script, VideoConfig } from "./types";
|
|
8
|
+
|
|
9
|
+
export interface VideoRecordOptions extends RecordOptions {
|
|
10
|
+
/** Re-record even if a cached cast matches. */
|
|
11
|
+
force?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RunOptions extends VideoRecordOptions, RenderOptions {
|
|
15
|
+
/** Skip rendering; only write the .cast. */
|
|
16
|
+
recordOnly?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RunResult extends RenderResult {
|
|
20
|
+
cast: string;
|
|
21
|
+
recording: Recording;
|
|
22
|
+
/** True when the cast was reused from cache instead of re-recorded. */
|
|
23
|
+
cached: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Config keys that affect what gets recorded (render-only keys are excluded from the cache key). */
|
|
27
|
+
const RECORD_KEYS: (keyof ResolvedConfig)[] = [
|
|
28
|
+
"shell", "prompt", "promptPattern", "cwd", "env", "cols", "rows", "fps",
|
|
29
|
+
"typingSpeed", "typingJitter", "seed", "waitTimeout", "endPause", "quantize", "core",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export class Video {
|
|
33
|
+
readonly config: ResolvedConfig;
|
|
34
|
+
readonly script: Script;
|
|
35
|
+
readonly __bunVideo = true as const;
|
|
36
|
+
/** Absolute path of the script file, when loaded by the CLI; enables cast caching. */
|
|
37
|
+
source: string | undefined;
|
|
38
|
+
|
|
39
|
+
constructor(config: VideoConfig, script: Script) {
|
|
40
|
+
this.config = resolveConfig(config);
|
|
41
|
+
this.script = script;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** SHA-256 of the script source + record-relevant config. Undefined when the source path is unknown. */
|
|
45
|
+
async scriptHash(): Promise<string | undefined> {
|
|
46
|
+
if (!this.source) return undefined;
|
|
47
|
+
const file = Bun.file(this.source);
|
|
48
|
+
if (!(await file.exists())) return undefined;
|
|
49
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
50
|
+
hasher.update(await file.arrayBuffer());
|
|
51
|
+
const subset: Record<string, unknown> = {};
|
|
52
|
+
for (const key of RECORD_KEYS) subset[key] = this.config[key];
|
|
53
|
+
hasher.update(JSON.stringify(subset));
|
|
54
|
+
return hasher.digest("hex");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Returns the cached recording if the cast on disk was produced from an identical script + record config. */
|
|
58
|
+
async cachedRecording(): Promise<Recording | undefined> {
|
|
59
|
+
if (!this.config.cache) return undefined;
|
|
60
|
+
const hash = await this.scriptHash();
|
|
61
|
+
if (!hash) return undefined;
|
|
62
|
+
const file = Bun.file(this.config.cast);
|
|
63
|
+
if (!(await file.exists())) return undefined;
|
|
64
|
+
try {
|
|
65
|
+
const rec = await readCast(this.config.cast);
|
|
66
|
+
return rec.header.scriptHash === hash ? rec : undefined;
|
|
67
|
+
} catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Drive the PTY and return the recording (also saved to `config.cast`). Uses the cache unless `force`. */
|
|
73
|
+
async record(opts: VideoRecordOptions = {}): Promise<Recording & { cached?: boolean }> {
|
|
74
|
+
if (!opts.force) {
|
|
75
|
+
const cached = await this.cachedRecording();
|
|
76
|
+
if (cached) {
|
|
77
|
+
opts.log?.(`reusing ${this.config.cast} (script unchanged; pass --force to re-record)`);
|
|
78
|
+
return { ...cached, cached: true };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const recording = await record(this.config, this.script, opts);
|
|
82
|
+
recording.header.scriptHash = await this.scriptHash();
|
|
83
|
+
await mkdir(path.dirname(path.resolve(this.config.cast)), { recursive: true });
|
|
84
|
+
await writeCast(this.config.cast, recording);
|
|
85
|
+
return recording;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Render a recording (defaults to the saved .cast) to the configured outputs. */
|
|
89
|
+
async render(recording?: Recording, opts: RenderOptions = {}): Promise<RenderResult> {
|
|
90
|
+
const rec = recording ?? (await readCast(this.config.cast));
|
|
91
|
+
const config = applyOverrides(this.config, opts.overrides);
|
|
92
|
+
return renderOutputs(rec, config, opts.onProgress);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async run(opts: RunOptions = {}): Promise<RunResult> {
|
|
96
|
+
const recording = await this.record(opts);
|
|
97
|
+
const cached = recording.cached === true;
|
|
98
|
+
if (opts.recordOnly) {
|
|
99
|
+
return { recording, cached, cast: this.config.cast, outputs: [], frames: 0, screenshots: [], durationSeconds: recording.header.duration ?? 0 };
|
|
100
|
+
}
|
|
101
|
+
const result = await this.render(recording, opts);
|
|
102
|
+
return { ...result, recording, cached, cast: this.config.cast };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Define a video. Export it as the default export and run it with `tcut <file>`. */
|
|
107
|
+
export function defineVideo(config: VideoConfig, script: Script): Video {
|
|
108
|
+
return new Video(config, script);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function isVideo(value: unknown): value is Video {
|
|
112
|
+
return typeof value === "object" && value !== null && (value as { __bunVideo?: unknown }).__bunVideo === true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Render an existing .cast file (from tcut or asciinema) with the given settings. */
|
|
116
|
+
export async function renderCast(
|
|
117
|
+
castFile: string,
|
|
118
|
+
overrides: Partial<VideoConfig> & { output?: string | string[] },
|
|
119
|
+
onProgress?: RenderOptions["onProgress"],
|
|
120
|
+
): Promise<RenderResult> {
|
|
121
|
+
const rec = await readCast(castFile);
|
|
122
|
+
const base =
|
|
123
|
+
rec.header.bunVideo ??
|
|
124
|
+
resolveConfig({ output: overrides.output ?? castFile.replace(/\.cast$/, "") + ".mp4", cols: rec.header.width, rows: rec.header.height });
|
|
125
|
+
const config = applyOverrides(base, overrides);
|
|
126
|
+
return renderOutputs(rec, config, onProgress);
|
|
127
|
+
}
|