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/publish.ts
CHANGED
|
@@ -61,19 +61,19 @@ export function publicUrlFor(cfg: PublishConfig, key: string): string {
|
|
|
61
61
|
return `${base}/${key.split("/").map(encodeURIComponent).join("/")}`;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
const MIME
|
|
65
|
-
".mp4"
|
|
66
|
-
".webm"
|
|
67
|
-
".gif"
|
|
68
|
-
".webp"
|
|
69
|
-
".png"
|
|
70
|
-
".jpg"
|
|
71
|
-
".jpeg"
|
|
72
|
-
".svg"
|
|
73
|
-
".html"
|
|
74
|
-
".cast"
|
|
75
|
-
".txt"
|
|
76
|
-
|
|
64
|
+
const MIME = new Map<string, string>([
|
|
65
|
+
[".mp4", "video/mp4"],
|
|
66
|
+
[".webm", "video/webm"],
|
|
67
|
+
[".gif", "image/gif"],
|
|
68
|
+
[".webp", "image/webp"],
|
|
69
|
+
[".png", "image/png"],
|
|
70
|
+
[".jpg", "image/jpeg"],
|
|
71
|
+
[".jpeg", "image/jpeg"],
|
|
72
|
+
[".svg", "image/svg+xml"],
|
|
73
|
+
[".html", "text/html; charset=utf-8"],
|
|
74
|
+
[".cast", "application/x-asciicast"],
|
|
75
|
+
[".txt", "text/plain; charset=utf-8"],
|
|
76
|
+
]);
|
|
77
77
|
|
|
78
78
|
export async function contentHash(file: string): Promise<string> {
|
|
79
79
|
const hasher = new Bun.CryptoHasher("sha256");
|
|
@@ -111,7 +111,7 @@ export interface PublishOptions {
|
|
|
111
111
|
|
|
112
112
|
async function upload(cfg: PublishConfig, file: string, key: string): Promise<void> {
|
|
113
113
|
const s3 = client(cfg);
|
|
114
|
-
const type = MIME
|
|
114
|
+
const type = MIME.get(path.extname(file).toLowerCase()) ?? "application/octet-stream";
|
|
115
115
|
try {
|
|
116
116
|
await s3.write(key, Bun.file(file), { type, acl: "public-read" });
|
|
117
117
|
} catch (err) {
|
|
@@ -151,21 +151,19 @@ function hmac(key: Uint8Array | string, data: string): Uint8Array {
|
|
|
151
151
|
}
|
|
152
152
|
const sha256Hex = (data: string | Uint8Array) => new Bun.CryptoHasher("sha256").update(data).digest("hex");
|
|
153
153
|
|
|
154
|
-
export function signV4(cfg: PublishConfig, method: string, url: URL, body: string, now = new Date())
|
|
154
|
+
export function signV4(cfg: PublishConfig, method: string, url: URL, body: string, now = new Date()) {
|
|
155
155
|
const region = cfg.region ?? "us-east-1";
|
|
156
156
|
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
157
157
|
const date = amzDate.slice(0, 8);
|
|
158
158
|
const payloadHash = sha256Hex(body);
|
|
159
|
-
const headers
|
|
159
|
+
const headers = {
|
|
160
160
|
host: url.host,
|
|
161
161
|
"x-amz-content-sha256": payloadHash,
|
|
162
162
|
"x-amz-date": amzDate,
|
|
163
163
|
};
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
.map((h) => `${h}:${headers[h]!.trim()}\n`)
|
|
168
|
-
.join("");
|
|
164
|
+
const sortedHeaders = Object.entries(headers).sort(([a], [b]) => (a < b ? -1 : 1));
|
|
165
|
+
const signedHeaders = sortedHeaders.map(([h]) => h).join(";");
|
|
166
|
+
const canonicalHeaders = sortedHeaders.map(([h, v]) => `${h}:${v.trim()}\n`).join("");
|
|
169
167
|
const canonicalQuery = [...url.searchParams.entries()]
|
|
170
168
|
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
171
169
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
package/src/recorder.ts
CHANGED
|
@@ -106,15 +106,15 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
|
|
|
106
106
|
};
|
|
107
107
|
|
|
108
108
|
const setup = shellSetup(config);
|
|
109
|
-
const
|
|
110
|
-
|
|
109
|
+
const { PROMPT_COMMAND: _userPromptCommand, ...inheritedEnv } = process.env; // a user PROMPT_COMMAND would repaint over the clean prompt
|
|
110
|
+
const env = {
|
|
111
|
+
...inheritedEnv,
|
|
111
112
|
TERM: "xterm-256color",
|
|
112
113
|
COLORTERM: "truecolor",
|
|
113
114
|
LANG: process.env.LANG ?? "en_US.UTF-8",
|
|
114
115
|
...setup.env,
|
|
115
116
|
...config.env,
|
|
116
117
|
};
|
|
117
|
-
delete env.PROMPT_COMMAND;
|
|
118
118
|
|
|
119
119
|
let exited = false;
|
|
120
120
|
const proc = Bun.spawn(setup.cmd, {
|
|
@@ -160,7 +160,7 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
|
|
|
160
160
|
const raw = async (data: string | Uint8Array): Promise<void> => {
|
|
161
161
|
ensureAlive();
|
|
162
162
|
terminal.write(data);
|
|
163
|
-
push("i",
|
|
163
|
+
push("i", data instanceof Uint8Array ? decoder.decode(data) : data);
|
|
164
164
|
};
|
|
165
165
|
|
|
166
166
|
/** Put text on screen as if the terminal had printed it: into the cast and the screen model, not the PTY. */
|
|
@@ -227,7 +227,7 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
|
|
|
227
227
|
pattern.test(scope === "screen" ? screen.screen() : screen.line());
|
|
228
228
|
|
|
229
229
|
const toRegExp = (pattern: RegExp | string): RegExp =>
|
|
230
|
-
|
|
230
|
+
pattern instanceof RegExp ? pattern : new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
231
231
|
|
|
232
232
|
const waitFor = async (
|
|
233
233
|
description: string,
|
|
@@ -307,6 +307,22 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
|
|
|
307
307
|
}
|
|
308
308
|
};
|
|
309
309
|
|
|
310
|
+
const speedStack: number[] = [];
|
|
311
|
+
const timelapse = async <T>(fn: () => Promise<T>, tlOpts: { speed?: number } = {}): Promise<T> => {
|
|
312
|
+
const speed = tlOpts.speed ?? 8;
|
|
313
|
+
if (!(speed > 0)) throw new Error(`timelapse speed must be greater than 0, got ${speed}`);
|
|
314
|
+
await screen.settle();
|
|
315
|
+
speedStack.push(speed);
|
|
316
|
+
push("m", `${MARKER.speed}${speed}`);
|
|
317
|
+
try {
|
|
318
|
+
return await fn();
|
|
319
|
+
} finally {
|
|
320
|
+
await screen.settle();
|
|
321
|
+
speedStack.pop();
|
|
322
|
+
push("m", `${MARKER.speed}${speedStack[speedStack.length - 1] ?? 1}`);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
310
326
|
const session: TerminalSession = {
|
|
311
327
|
type,
|
|
312
328
|
run,
|
|
@@ -336,6 +352,7 @@ export async function record(config: ResolvedConfig, script: Script, opts: Recor
|
|
|
336
352
|
wait,
|
|
337
353
|
expect,
|
|
338
354
|
hide,
|
|
355
|
+
timelapse,
|
|
339
356
|
screenshot: async (file) => {
|
|
340
357
|
await screen.settle();
|
|
341
358
|
push("m", MARKER.screenshot + file);
|
package/src/render.ts
CHANGED
|
@@ -1,20 +1,31 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { chapterRanges, chapterSlug, cutRecording, findChapters, flattenedConfig, selectChapters } from "./edit";
|
|
4
|
+
import { frameText, replayFrames } from "./export/frames";
|
|
3
5
|
import { writeHtml } from "./export/html";
|
|
4
6
|
import { writeSvg } from "./export/svg";
|
|
5
7
|
import { render as renderRaster, type RenderResult } from "./renderer/webview";
|
|
6
|
-
import type { Recording, RenderProgress, ResolvedConfig } from "./types";
|
|
8
|
+
import type { ClipSelection, Recording, RenderProgress, ResolvedConfig } from "./types";
|
|
7
9
|
|
|
8
10
|
export type { RenderResult };
|
|
9
11
|
|
|
10
|
-
const kind = (output: string): "svg" | "html" | "raster" => {
|
|
12
|
+
const kind = (output: string): "svg" | "html" | "txt" | "raster" => {
|
|
11
13
|
if (output.endsWith("/")) return "raster";
|
|
12
14
|
const ext = path.extname(output).toLowerCase();
|
|
13
15
|
if (ext === ".svg") return "svg";
|
|
14
16
|
if (ext === ".html" || ext === ".htm") return "html";
|
|
17
|
+
if (ext === ".txt") return "txt";
|
|
15
18
|
return "raster";
|
|
16
19
|
};
|
|
17
20
|
|
|
21
|
+
/** The final screen as plain text (what `t.screen()` would return at the end). */
|
|
22
|
+
export async function writeTxt(rec: Recording, config: ResolvedConfig, file: string): Promise<void> {
|
|
23
|
+
const replay = await replayFrames(rec, config);
|
|
24
|
+
const last = replay.frames[replay.frames.length - 1];
|
|
25
|
+
await mkdir(path.dirname(path.resolve(file)), { recursive: true });
|
|
26
|
+
await Bun.write(file, (last ? frameText(last) : []).join("\n") + "\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
/**
|
|
19
30
|
* Fan the configured outputs out to the right backend: `.svg` and `.html` are produced from the headless core
|
|
20
31
|
* (no WebView, no ffmpeg); everything else goes through the WebView + ffmpeg renderer in one pass.
|
|
@@ -26,10 +37,15 @@ export async function renderOutputs(
|
|
|
26
37
|
): Promise<RenderResult> {
|
|
27
38
|
const svg = config.output.filter((o) => kind(o) === "svg");
|
|
28
39
|
const html = config.output.filter((o) => kind(o) === "html");
|
|
40
|
+
const txt = config.output.filter((o) => kind(o) === "txt");
|
|
29
41
|
const raster = config.output.filter((o) => kind(o) === "raster");
|
|
30
42
|
|
|
31
43
|
const result: RenderResult = { outputs: [], frames: 0, screenshots: [], durationSeconds: 0 };
|
|
32
44
|
|
|
45
|
+
for (const file of txt) {
|
|
46
|
+
await writeTxt(rec, config, file);
|
|
47
|
+
result.outputs.push(file);
|
|
48
|
+
}
|
|
33
49
|
for (const file of svg) {
|
|
34
50
|
await mkdir(path.dirname(path.resolve(file)), { recursive: true });
|
|
35
51
|
const r = await writeSvg(rec, config, file);
|
|
@@ -47,6 +63,57 @@ export async function renderOutputs(
|
|
|
47
63
|
result.frames = r.frames;
|
|
48
64
|
result.screenshots.push(...r.screenshots);
|
|
49
65
|
result.durationSeconds = r.durationSeconds;
|
|
66
|
+
if (r.notes) result.notes = r.notes;
|
|
50
67
|
}
|
|
51
68
|
return result;
|
|
52
69
|
}
|
|
70
|
+
|
|
71
|
+
/** "demo.mp4" + "-01-install" → "demo-01-install.mp4"; "frames/" → "frames-01-install/". */
|
|
72
|
+
function suffixOutput(output: string, suffix: string): string {
|
|
73
|
+
if (output.endsWith("/")) return `${output.slice(0, -1)}${suffix}/`;
|
|
74
|
+
const ext = path.extname(output);
|
|
75
|
+
return `${output.slice(0, output.length - ext.length)}${suffix}${ext}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Render a part of the recording: a time window, a set of chapters, or every chapter as its own file.
|
|
80
|
+
* Cutting happens on the cast (see edit.ts), so every output format is supported.
|
|
81
|
+
*/
|
|
82
|
+
export async function renderSelection(
|
|
83
|
+
rec: Recording,
|
|
84
|
+
config: ResolvedConfig,
|
|
85
|
+
clip: ClipSelection | undefined,
|
|
86
|
+
onProgress?: (p: RenderProgress) => void,
|
|
87
|
+
): Promise<RenderResult> {
|
|
88
|
+
if (!clip || (clip.from === undefined && clip.to === undefined && !clip.chapters && !clip.splitChapters)) {
|
|
89
|
+
return renderOutputs(rec, config, onProgress);
|
|
90
|
+
}
|
|
91
|
+
if (clip.splitChapters) {
|
|
92
|
+
let ranges = chapterRanges(rec, config);
|
|
93
|
+
if (clip.chapters) ranges = findChapters(ranges, clip.chapters);
|
|
94
|
+
if (ranges.length === 0) throw new Error("--split-chapters needs chapters: add `t.chapter(name)` calls to the script");
|
|
95
|
+
const total: RenderResult = { outputs: [], frames: 0, screenshots: [], durationSeconds: 0 };
|
|
96
|
+
for (const [i, range] of ranges.entries()) {
|
|
97
|
+
const part = cutRecording(rec, config, range);
|
|
98
|
+
const suffix = `-${chapterSlug(i, range.title)}`;
|
|
99
|
+
const r = await renderOutputs(part, { ...flattenedConfig(config), output: config.output.map((o) => suffixOutput(o, suffix)) }, onProgress);
|
|
100
|
+
total.outputs.push(...r.outputs);
|
|
101
|
+
total.frames += r.frames;
|
|
102
|
+
total.screenshots.push(...r.screenshots);
|
|
103
|
+
total.durationSeconds += r.durationSeconds;
|
|
104
|
+
if (r.notes) total.notes = [...(total.notes ?? []), ...r.notes];
|
|
105
|
+
}
|
|
106
|
+
return total;
|
|
107
|
+
}
|
|
108
|
+
let part = rec;
|
|
109
|
+
let partConfig = config;
|
|
110
|
+
if (clip.chapters) {
|
|
111
|
+
part = selectChapters(rec, config, clip.chapters);
|
|
112
|
+
partConfig = flattenedConfig(config);
|
|
113
|
+
}
|
|
114
|
+
if (clip.from !== undefined || clip.to !== undefined) {
|
|
115
|
+
part = cutRecording(part, partConfig, clip);
|
|
116
|
+
partConfig = flattenedConfig(partConfig);
|
|
117
|
+
}
|
|
118
|
+
return renderOutputs(part, partConfig, onProgress);
|
|
119
|
+
}
|
package/src/renderer/encoder.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface FrameSink {
|
|
|
9
9
|
readonly target: string;
|
|
10
10
|
/** True for outputs that loop (GIF, WebP) — `loopOffset` applies to these. */
|
|
11
11
|
readonly loops?: boolean;
|
|
12
|
+
/** True when this sink receives RGBA frames (transparent output). */
|
|
13
|
+
readonly alpha?: boolean;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
type Format = "mp4" | "webm" | "gif" | "webp" | "png-sequence" | "png" | "jpeg";
|
|
@@ -40,11 +42,15 @@ export function detectFormat(output: string): Format {
|
|
|
40
42
|
/** Still image of the final frame. PNG is written as-is; JPEG is transcoded with Bun.Image (no ffmpeg). */
|
|
41
43
|
class StillSink implements FrameSink {
|
|
42
44
|
private last: Uint8Array | null = null;
|
|
45
|
+
readonly alpha: boolean;
|
|
43
46
|
|
|
44
47
|
constructor(
|
|
45
48
|
readonly target: string,
|
|
46
49
|
private format: "png" | "jpeg",
|
|
47
|
-
|
|
50
|
+
alpha = false,
|
|
51
|
+
) {
|
|
52
|
+
this.alpha = alpha && format === "png";
|
|
53
|
+
}
|
|
48
54
|
|
|
49
55
|
async frame(png: Uint8Array): Promise<void> {
|
|
50
56
|
this.last = png;
|
|
@@ -70,7 +76,7 @@ function ffmpegCandidates(): string[] {
|
|
|
70
76
|
Bun.which("ffmpeg"),
|
|
71
77
|
"/opt/homebrew/opt/ffmpeg-full/bin/ffmpeg",
|
|
72
78
|
"/usr/local/opt/ffmpeg-full/bin/ffmpeg",
|
|
73
|
-
].filter((p): p is string =>
|
|
79
|
+
].filter((p): p is string => p !== undefined && p !== null && p.length > 0);
|
|
74
80
|
return [...new Set(list)];
|
|
75
81
|
}
|
|
76
82
|
|
|
@@ -121,12 +127,12 @@ export async function hasEncoder(...candidates: string[]): Promise<string | null
|
|
|
121
127
|
|
|
122
128
|
type FfmpegFormat = Exclude<Format, "png-sequence" | "png" | "jpeg">;
|
|
123
129
|
|
|
124
|
-
const REQUIRED_ENCODERS
|
|
130
|
+
const REQUIRED_ENCODERS = {
|
|
125
131
|
mp4: { candidates: ["libx264"], hint: "an ffmpeg build with libx264" },
|
|
126
132
|
webm: { candidates: ["libvpx-vp9"], hint: "an ffmpeg build with libvpx" },
|
|
127
133
|
gif: { candidates: ["gif"], hint: "an ffmpeg build with the gif encoder" },
|
|
128
134
|
webp: { candidates: ["libwebp_anim", "libwebp"], hint: "an ffmpeg build with libwebp (Homebrew: `brew install ffmpeg-full`)" },
|
|
129
|
-
};
|
|
135
|
+
} satisfies Record<FfmpegFormat, { candidates: string[]; hint: string }>;
|
|
130
136
|
|
|
131
137
|
async function requireEncoder(format: FfmpegFormat, output: string): Promise<EncoderMatch> {
|
|
132
138
|
const { candidates, hint } = REQUIRED_ENCODERS[format];
|
|
@@ -156,10 +162,26 @@ export function chaptersMetadata(chapters: Chapter[], durationSeconds: number):
|
|
|
156
162
|
return lines.join("\n") + "\n";
|
|
157
163
|
}
|
|
158
164
|
|
|
159
|
-
function ffmpegArgs(format: Format, fps: number, output: string, encoder: string, metadataFile?: string): string[] {
|
|
165
|
+
function ffmpegArgs(format: Format, fps: number, output: string, encoder: string, metadataFile?: string, alpha = false): string[] {
|
|
160
166
|
const input = ["-y", "-loglevel", "error", "-f", "image2pipe", "-framerate", String(fps), "-i", "pipe:0"];
|
|
161
167
|
if (metadataFile && format === "mp4") input.push("-i", metadataFile, "-map_metadata", "1");
|
|
162
168
|
const evenSize = "scale=trunc(iw/2)*2:trunc(ih/2)*2";
|
|
169
|
+
if (alpha && format === "webm") {
|
|
170
|
+
return [...input, "-vf", `${evenSize},format=yuva420p`, "-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-auto-alt-ref", "0", "-b:v", "0", "-crf", "30", "-row-mt", "1", output];
|
|
171
|
+
}
|
|
172
|
+
if (alpha && format === "gif") {
|
|
173
|
+
const gifFps = Math.min(fps, 50);
|
|
174
|
+
return [
|
|
175
|
+
...input,
|
|
176
|
+
"-vf",
|
|
177
|
+
`fps=${gifFps},split[a][b];[a]palettegen=stats_mode=diff:reserve_transparent=1[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle:alpha_threshold=128`,
|
|
178
|
+
"-loop", "0",
|
|
179
|
+
output,
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
if (alpha && format === "webp") {
|
|
183
|
+
return [...input, "-c:v", encoder, "-pix_fmt", "yuva420p", "-lossless", "0", "-q:v", "85", "-loop", "0", "-preset", "text", output];
|
|
184
|
+
}
|
|
163
185
|
switch (format) {
|
|
164
186
|
case "mp4":
|
|
165
187
|
return [
|
|
@@ -191,11 +213,15 @@ function ffmpegArgs(format: Format, fps: number, output: string, encoder: string
|
|
|
191
213
|
}
|
|
192
214
|
}
|
|
193
215
|
|
|
216
|
+
/** Formats whose container/codec can carry an alpha channel. */
|
|
217
|
+
export const ALPHA_FORMATS = new Set<Format>(["webm", "gif", "webp", "png", "png-sequence"]);
|
|
218
|
+
|
|
194
219
|
class FfmpegSink implements FrameSink {
|
|
195
220
|
private proc: Subprocess<"pipe", "ignore", "pipe">;
|
|
196
221
|
private stdin: FileSink;
|
|
197
222
|
private stderr: Promise<string>;
|
|
198
223
|
readonly loops: boolean;
|
|
224
|
+
readonly alpha: boolean;
|
|
199
225
|
|
|
200
226
|
constructor(
|
|
201
227
|
readonly target: string,
|
|
@@ -203,9 +229,11 @@ class FfmpegSink implements FrameSink {
|
|
|
203
229
|
fps: number,
|
|
204
230
|
match: EncoderMatch,
|
|
205
231
|
metadataFile?: string,
|
|
232
|
+
alpha = false,
|
|
206
233
|
) {
|
|
207
234
|
this.loops = format === "gif" || format === "webp";
|
|
208
|
-
this.
|
|
235
|
+
this.alpha = alpha && ALPHA_FORMATS.has(format);
|
|
236
|
+
this.proc = Bun.spawn([match.binary, ...ffmpegArgs(format, fps, target, match.encoder, metadataFile, this.alpha)], { stdin: "pipe", stdout: "ignore", stderr: "pipe" });
|
|
209
237
|
this.stdin = this.proc.stdin;
|
|
210
238
|
this.stderr = new Response(this.proc.stderr).text();
|
|
211
239
|
}
|
|
@@ -229,7 +257,10 @@ class PngSequenceSink implements FrameSink {
|
|
|
229
257
|
private index = 0;
|
|
230
258
|
private pending: Promise<unknown>[] = [];
|
|
231
259
|
|
|
232
|
-
constructor(
|
|
260
|
+
constructor(
|
|
261
|
+
readonly target: string,
|
|
262
|
+
readonly alpha = false,
|
|
263
|
+
) {}
|
|
233
264
|
|
|
234
265
|
async frame(png: Uint8Array): Promise<void> {
|
|
235
266
|
const file = path.join(this.target, `frame-${String(this.index++).padStart(6, "0")}.png`);
|
|
@@ -254,7 +285,14 @@ export async function ensureFfmpeg(): Promise<void> {
|
|
|
254
285
|
);
|
|
255
286
|
}
|
|
256
287
|
|
|
257
|
-
export
|
|
288
|
+
export interface SinkOptions {
|
|
289
|
+
chapters?: Chapter[];
|
|
290
|
+
durationSeconds?: number;
|
|
291
|
+
/** Frames will be RGBA; sinks whose format supports it keep the alpha channel. */
|
|
292
|
+
alpha?: boolean;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function createSinks(outputs: string[], fps: number, opts: SinkOptions = {}): Promise<FrameSink[]> {
|
|
258
296
|
const sinks: FrameSink[] = [];
|
|
259
297
|
let metadataFile: string | undefined;
|
|
260
298
|
if (opts.chapters && opts.chapters.length > 0) {
|
|
@@ -265,18 +303,18 @@ export async function createSinks(outputs: string[], fps: number, opts: { chapte
|
|
|
265
303
|
const format = detectFormat(output);
|
|
266
304
|
if (format === "png-sequence") {
|
|
267
305
|
await mkdir(output, { recursive: true });
|
|
268
|
-
sinks.push(new PngSequenceSink(output));
|
|
306
|
+
sinks.push(new PngSequenceSink(output, opts.alpha === true));
|
|
269
307
|
continue;
|
|
270
308
|
}
|
|
271
309
|
if (format === "png" || format === "jpeg") {
|
|
272
310
|
await mkdir(path.dirname(path.resolve(output)), { recursive: true });
|
|
273
|
-
sinks.push(new StillSink(output, format));
|
|
311
|
+
sinks.push(new StillSink(output, format, opts.alpha === true));
|
|
274
312
|
continue;
|
|
275
313
|
}
|
|
276
314
|
await ensureFfmpeg();
|
|
277
315
|
const match = await requireEncoder(format as FfmpegFormat, output);
|
|
278
316
|
await mkdir(path.dirname(path.resolve(output)), { recursive: true });
|
|
279
|
-
sinks.push(new FfmpegSink(output, format, fps, match, metadataFile));
|
|
317
|
+
sinks.push(new FfmpegSink(output, format, fps, match, metadataFile, opts.alpha === true));
|
|
280
318
|
}
|
|
281
319
|
return sinks;
|
|
282
320
|
}
|