rechrome 1.47.0 → 1.48.1

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/record.js ADDED
@@ -0,0 +1,288 @@
1
+ // @bun
2
+ // record.ts
3
+ import { spawnSync } from "child_process";
4
+ var RECORD_DEFAULT_SECONDS = 5;
5
+ var RECORD_MAX_SECONDS = 60;
6
+ var RECORD_DEFAULT_MAX_SECONDS = 600;
7
+ var RECORD_LIMIT_SECONDS = 3600;
8
+ var RECORD_DEFAULT_FPS = 8;
9
+ var RECORD_MAX_FPS = 30;
10
+ var RECORD_DEFAULT_QUALITY = 70;
11
+ var RECORD_CHUNK_MS = 1000;
12
+ var RECORD_MAX_CHUNK_FRAMES = 60;
13
+ var RECORD_FORMATS = ["mp4", "webm", "mjpeg"];
14
+ var FFMPEG_CANDIDATES = [process.env.FFMPEG_BIN, "ffmpeg", "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"].filter((x) => !!x);
15
+ var RECORD_OPTIONS = {
16
+ start: ["--fps", "--quality", "--format", "--max-seconds"],
17
+ stop: ["--filename"],
18
+ timed: ["--seconds", "--fps", "--quality", "--format", "--filename"]
19
+ };
20
+ function intOpt(value, name, min, max) {
21
+ const n = Number(value);
22
+ if (!value || !Number.isInteger(n) || n < min || n > max)
23
+ throw new Error(`record: ${name} must be an integer ${min}-${max}`);
24
+ return n;
25
+ }
26
+ function recordHoldMs(args) {
27
+ const at = args.findIndex((a) => !a.startsWith("-"));
28
+ if (args[at] !== "record")
29
+ return 0;
30
+ try {
31
+ const parsed = parseRecordArgs(args.slice(at));
32
+ return parsed.mode === "timed" ? parsed.seconds * 1000 : 0;
33
+ } catch {
34
+ return 0;
35
+ }
36
+ }
37
+ function withFormatExt(name, format) {
38
+ const m = /^(.+)\.(mp4|webm|mjpeg|mjpg|mkv|mov|avi)$/i.exec(name);
39
+ if (m && m[2].toLowerCase() === format)
40
+ return name;
41
+ return `${m ? m[1] : name}.${format}`;
42
+ }
43
+ function freeName(name, taken) {
44
+ if (!taken(name))
45
+ return name;
46
+ const dot = name.lastIndexOf(".");
47
+ const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
48
+ for (let n = 2;; n++)
49
+ if (!taken(`${stem}-${n}${ext}`))
50
+ return `${stem}-${n}${ext}`;
51
+ }
52
+ var SAFE_NAME = /^[A-Za-z0-9._-]+$/;
53
+ function parseRecordArgs(args) {
54
+ if (args[0] !== "record")
55
+ throw new Error("Expected `record`");
56
+ const sub = args[1] === "start" || args[1] === "stop" ? args[1] : undefined;
57
+ const mode = sub ?? "timed";
58
+ let seconds = RECORD_DEFAULT_SECONDS, maxSeconds = RECORD_DEFAULT_MAX_SECONDS, fps = RECORD_DEFAULT_FPS, quality = RECORD_DEFAULT_QUALITY;
59
+ let filename, format = "mp4";
60
+ for (let i = sub ? 2 : 1;i < args.length; i++) {
61
+ const a = args[i];
62
+ const flag = a.includes("=") ? a.slice(0, a.indexOf("=")) : a;
63
+ if (!RECORD_OPTIONS[mode].includes(flag))
64
+ throw new Error(`record${sub ? " " + sub : ""}: unknown option ${a}`);
65
+ const val = () => a.includes("=") ? a.slice(a.indexOf("=") + 1) : args[++i];
66
+ if (flag === "--seconds")
67
+ seconds = intOpt(val(), "--seconds", 1, RECORD_MAX_SECONDS);
68
+ else if (flag === "--max-seconds")
69
+ maxSeconds = intOpt(val(), "--max-seconds", 1, RECORD_LIMIT_SECONDS);
70
+ else if (flag === "--fps")
71
+ fps = intOpt(val(), "--fps", 1, RECORD_MAX_FPS);
72
+ else if (flag === "--quality")
73
+ quality = intOpt(val(), "--quality", 1, 100);
74
+ else if (flag === "--filename") {
75
+ filename = val();
76
+ if (!filename || !SAFE_NAME.test(filename) || filename.includes(".."))
77
+ throw new Error("record: --filename must be a plain name like clip.mp4 (no path separators)");
78
+ } else if (flag === "--format") {
79
+ const f = val();
80
+ if (!RECORD_FORMATS.includes(f))
81
+ throw new Error(`record: --format must be one of ${RECORD_FORMATS.join("|")}`);
82
+ format = f;
83
+ }
84
+ }
85
+ if (mode === "start")
86
+ return { mode, fps, quality, format, maxSeconds };
87
+ if (mode === "stop")
88
+ return { mode, filename };
89
+ return { mode, seconds, fps, quality, format, filename };
90
+ }
91
+ function buildCaptureChunkCode(a, chunkMs = RECORD_CHUNK_MS) {
92
+ return `async page => {
93
+ const endAt = Date.now() + ${chunkMs};
94
+ const interval = Math.max(1, Math.round(1000 / ${a.fps}));
95
+ const frames = [], times = [];
96
+ while (frames.length < ${RECORD_MAX_CHUNK_FRAMES}) {
97
+ const shotAt = Date.now();
98
+ // The sandbox has no Buffer global, but page.screenshot() returns a Buffer whose own
99
+ // .toString("base64") works without it.
100
+ // CSS pixels, not device pixels: a retina tab would otherwise be a 2x-sized video.
101
+ const b = await page.screenshot({ type: "jpeg", quality: ${a.quality}, scale: "css" });
102
+ frames.push(b.toString("base64"));
103
+ times.push(shotAt);
104
+ if (Date.now() >= endAt) break;
105
+ const rest = interval - (Date.now() - shotAt);
106
+ if (rest > 0) await page.waitForTimeout(rest);
107
+ }
108
+ return JSON.stringify({ frames, times });
109
+ }`;
110
+ }
111
+
112
+ class Retimer {
113
+ startAt;
114
+ fps;
115
+ write;
116
+ last;
117
+ slot = 0;
118
+ constructor(startAt, fps, write) {
119
+ this.startAt = startAt;
120
+ this.fps = fps;
121
+ this.write = write;
122
+ }
123
+ slotOf(t) {
124
+ return Math.floor((t - this.startAt) * this.fps / 1000);
125
+ }
126
+ push(jpeg, t) {
127
+ const s = this.slotOf(t);
128
+ if (this.last)
129
+ while (this.slot < s) {
130
+ this.write(this.last);
131
+ this.slot++;
132
+ }
133
+ else
134
+ this.slot = Math.max(this.slot, s);
135
+ this.last = jpeg;
136
+ }
137
+ finish(t) {
138
+ if (!this.last)
139
+ return this.slot;
140
+ const s = Math.max(this.slotOf(t), this.slot + 1);
141
+ while (this.slot < s) {
142
+ this.write(this.last);
143
+ this.slot++;
144
+ }
145
+ return this.slot;
146
+ }
147
+ }
148
+ var defaultProbe = (bin, args) => spawnSync(bin, args, { stdio: "ignore" });
149
+ function resolveFfmpeg(run = defaultProbe, candidates = FFMPEG_CANDIDATES) {
150
+ for (const bin of candidates) {
151
+ try {
152
+ if (run(bin, ["-version"]).status === 0)
153
+ return bin;
154
+ } catch {}
155
+ }
156
+ return null;
157
+ }
158
+ function resolveFormat(requested, hasFfmpeg) {
159
+ return hasFfmpeg ? requested : "mjpeg";
160
+ }
161
+ function ffmpegStreamArgs(fps, format, out) {
162
+ const base = ["-y", "-loglevel", "error", "-f", "image2pipe", "-framerate", String(fps), "-c:v", "mjpeg", "-i", "-", "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-pix_fmt", "yuv420p"];
163
+ if (format === "webm")
164
+ return [...base, "-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "35", "-deadline", "realtime", "-f", "webm", out];
165
+ return [...base, "-c:v", "libx264", "-preset", "veryfast", "-movflags", "+faststart", "-f", "mp4", out];
166
+ }
167
+
168
+ class Recording {
169
+ opts;
170
+ startAt;
171
+ stopping = false;
172
+ captured = 0;
173
+ error;
174
+ retimer;
175
+ pending = [];
176
+ loop;
177
+ summary;
178
+ constructor(opts) {
179
+ this.opts = opts;
180
+ const now = opts.now ?? Date.now;
181
+ const startAt = this.startAt = now();
182
+ const retimer = this.retimer = new Retimer(startAt, opts.fps, (jpeg) => {
183
+ const p = opts.sink.write(jpeg);
184
+ if (p instanceof Promise)
185
+ this.pending.push(p);
186
+ });
187
+ this.loop = (async () => {
188
+ try {
189
+ while (!this.stopping && now() - startAt < opts.maxMs) {
190
+ const chunk = await opts.capture();
191
+ for (let i = 0;i < chunk.frames.length; i++) {
192
+ if (chunk.times[i] - startAt > opts.maxMs)
193
+ break;
194
+ retimer.push(Buffer.from(chunk.frames[i], "base64"), chunk.times[i]);
195
+ this.captured++;
196
+ }
197
+ if (this.pending.length)
198
+ await Promise.all(this.pending.splice(0));
199
+ }
200
+ } catch (e) {
201
+ this.error = e instanceof Error ? e.message : String(e);
202
+ }
203
+ })();
204
+ this.loop.then(() => {
205
+ if (!this.stopping)
206
+ this.stop().then((s) => opts.onAutoStop?.(s));
207
+ });
208
+ }
209
+ get active() {
210
+ return !this.stopping;
211
+ }
212
+ get ended() {
213
+ return this.loop;
214
+ }
215
+ stop() {
216
+ if (this.summary)
217
+ return this.summary;
218
+ this.stopping = true;
219
+ const end = (this.opts.now ?? Date.now)();
220
+ this.summary = (async () => {
221
+ await this.loop;
222
+ let frames = 0, encodeError;
223
+ const why = (e) => encodeError ??= e instanceof Error ? e.message : String(e);
224
+ try {
225
+ frames = this.retimer.finish(Math.min(end, this.startAt + this.opts.maxMs));
226
+ await Promise.all(this.pending.splice(0));
227
+ } catch (e) {
228
+ why(e);
229
+ }
230
+ try {
231
+ await this.opts.sink.close();
232
+ } catch (e) {
233
+ encodeError = e instanceof Error ? e.message : String(e);
234
+ }
235
+ return { frames, captured: this.captured, seconds: frames / this.opts.fps, fps: this.opts.fps, format: this.opts.format, out: this.opts.out, ...this.error ? { error: this.error } : {}, ...encodeError ? { encodeError } : {} };
236
+ })();
237
+ return this.summary;
238
+ }
239
+ }
240
+ function recordSink(format, ffmpegBin, fps, out) {
241
+ if (format === "mjpeg" || !ffmpegBin) {
242
+ const w = Bun.file(out).writer();
243
+ return { write: (jpeg) => {
244
+ w.write(jpeg);
245
+ }, close: async () => {
246
+ await w.end();
247
+ } };
248
+ }
249
+ const proc = Bun.spawn([ffmpegBin, ...ffmpegStreamArgs(fps, format, out)], { stdin: "pipe", stdout: "ignore", stderr: "pipe" });
250
+ return {
251
+ write: (jpeg) => {
252
+ proc.stdin.write(jpeg);
253
+ return proc.stdin.flush();
254
+ },
255
+ close: async () => {
256
+ await proc.stdin.end();
257
+ const code = await proc.exited;
258
+ if (code !== 0)
259
+ throw new Error(`record: ffmpeg could not encode ${format} (${(await new Response(proc.stderr).text()).split(`
260
+ `).filter(Boolean).slice(-1)[0] ?? "exit " + code})`);
261
+ }
262
+ };
263
+ }
264
+ export {
265
+ FFMPEG_CANDIDATES,
266
+ RECORD_CHUNK_MS,
267
+ RECORD_DEFAULT_FPS,
268
+ RECORD_DEFAULT_MAX_SECONDS,
269
+ RECORD_DEFAULT_QUALITY,
270
+ RECORD_DEFAULT_SECONDS,
271
+ RECORD_FORMATS,
272
+ RECORD_LIMIT_SECONDS,
273
+ RECORD_MAX_CHUNK_FRAMES,
274
+ RECORD_MAX_FPS,
275
+ RECORD_MAX_SECONDS,
276
+ RECORD_OPTIONS,
277
+ Recording,
278
+ Retimer,
279
+ buildCaptureChunkCode,
280
+ ffmpegStreamArgs,
281
+ freeName,
282
+ parseRecordArgs,
283
+ recordHoldMs,
284
+ recordSink,
285
+ resolveFfmpeg,
286
+ resolveFormat,
287
+ withFormatExt
288
+ };
package/record.ts ADDED
@@ -0,0 +1,258 @@
1
+ // rech record: record the attached tab to a video file under .rechrome/output, streamed into ffmpeg.
2
+ //
3
+ // How it works, and why:
4
+ // - The extension relay can't open a new CDP session (newCDPSession fails with
5
+ // "Target.attachToBrowserTarget: Unsupported command"), so Page.startScreencast is out. What works
6
+ // over the relay is page.screenshot(), so frames are JPEG screenshots taken in a loop.
7
+ // - run-code state does not persist between calls (each call gets a fresh vm context), so the capture
8
+ // loop is owned by serve, not the cliDaemon: serve runs short run-code chunks (~1 s of screenshots
9
+ // each) back to back and pipes every frame straight into ffmpeg's stdin as it arrives. Memory stays
10
+ // at one chunk, so `record start` / `record stop` can run as long as --max-seconds allows.
11
+ // - Screenshots arrive at an uneven rate, so the Retimer lays them onto a constant --fps clock by
12
+ // their capture time (repeating the last frame for a slot nothing landed in): the video plays back
13
+ // in real time.
14
+ // No click, no new permission, works on a background tab, no audio. Without ffmpeg the frames go to a
15
+ // raw MJPEG stream instead.
16
+
17
+ import { spawnSync } from "child_process";
18
+
19
+ export const RECORD_DEFAULT_SECONDS = 5;
20
+ export const RECORD_MAX_SECONDS = 60;
21
+ export const RECORD_DEFAULT_MAX_SECONDS = 600;
22
+ export const RECORD_LIMIT_SECONDS = 3600;
23
+ export const RECORD_DEFAULT_FPS = 8;
24
+ export const RECORD_MAX_FPS = 30;
25
+ export const RECORD_DEFAULT_QUALITY = 70;
26
+ export const RECORD_CHUNK_MS = 1000;
27
+ // Per chunk: bounds one run-code reply even if screenshots are very fast.
28
+ export const RECORD_MAX_CHUNK_FRAMES = 60;
29
+ export const RECORD_FORMATS = ["mp4", "webm", "mjpeg"] as const;
30
+ export type RecordFormat = (typeof RECORD_FORMATS)[number];
31
+
32
+ export const FFMPEG_CANDIDATES = [process.env.FFMPEG_BIN, "ffmpeg", "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"].filter((x): x is string => !!x);
33
+
34
+ export type RecordArgs =
35
+ | { mode: "start"; fps: number; quality: number; format: RecordFormat; maxSeconds: number }
36
+ | { mode: "stop"; filename?: string }
37
+ | { mode: "timed"; seconds: number; fps: number; quality: number; format: RecordFormat; filename?: string };
38
+
39
+ // The options each form takes; everything else is refused (here and on scoped listeners).
40
+ export const RECORD_OPTIONS = {
41
+ start: ["--fps", "--quality", "--format", "--max-seconds"],
42
+ stop: ["--filename"],
43
+ timed: ["--seconds", "--fps", "--quality", "--format", "--filename"],
44
+ } as const;
45
+
46
+ function intOpt(value: string | undefined, name: string, min: number, max: number): number {
47
+ const n = Number(value);
48
+ if (!value || !Number.isInteger(n) || n < min || n > max) throw new Error(`record: ${name} must be an integer ${min}-${max}`);
49
+ return n;
50
+ }
51
+
52
+ /** How long a timed `record --seconds N` holds its request, so the client waits that much longer. */
53
+ export function recordHoldMs(args: string[]): number {
54
+ const at = args.findIndex(a => !a.startsWith("-"));
55
+ if (args[at] !== "record") return 0;
56
+ try {
57
+ const parsed = parseRecordArgs(args.slice(at));
58
+ return parsed.mode === "timed" ? parsed.seconds * 1000 : 0;
59
+ } catch { return 0; }
60
+ }
61
+
62
+ /** `name` with the extension of what was actually written: `clip.mp4` saved as mjpeg is `clip.mjpeg`. */
63
+ export function withFormatExt(name: string, format: RecordFormat): string {
64
+ const m = /^(.+)\.(mp4|webm|mjpeg|mjpg|mkv|mov|avi)$/i.exec(name);
65
+ if (m && m[2]!.toLowerCase() === format) return name;
66
+ return `${m ? m[1] : name}.${format}`;
67
+ }
68
+
69
+ /** `name`, or `name-2`, `name-3`… before the extension: the first that is not taken. */
70
+ export function freeName(name: string, taken: (name: string) => boolean): string {
71
+ if (!taken(name)) return name;
72
+ const dot = name.lastIndexOf(".");
73
+ const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
74
+ for (let n = 2; ; n++) if (!taken(`${stem}-${n}${ext}`)) return `${stem}-${n}${ext}`;
75
+ }
76
+
77
+ const SAFE_NAME = /^[A-Za-z0-9._-]+$/;
78
+
79
+ /** Parse `record [start|stop] …` client args. */
80
+ export function parseRecordArgs(args: string[]): RecordArgs {
81
+ if (args[0] !== "record") throw new Error("Expected `record`");
82
+ const sub = args[1] === "start" || args[1] === "stop" ? args[1] : undefined;
83
+ const mode = sub ?? "timed";
84
+ let seconds = RECORD_DEFAULT_SECONDS, maxSeconds = RECORD_DEFAULT_MAX_SECONDS, fps = RECORD_DEFAULT_FPS, quality = RECORD_DEFAULT_QUALITY;
85
+ let filename: string | undefined, format: RecordFormat = "mp4";
86
+ for (let i = sub ? 2 : 1; i < args.length; i++) {
87
+ const a = args[i]!;
88
+ const flag = a.includes("=") ? a.slice(0, a.indexOf("=")) : a;
89
+ if (!(RECORD_OPTIONS[mode] as readonly string[]).includes(flag)) throw new Error(`record${sub ? " " + sub : ""}: unknown option ${a}`);
90
+ const val = (): string | undefined => (a.includes("=") ? a.slice(a.indexOf("=") + 1) : args[++i]);
91
+ if (flag === "--seconds") seconds = intOpt(val(), "--seconds", 1, RECORD_MAX_SECONDS);
92
+ else if (flag === "--max-seconds") maxSeconds = intOpt(val(), "--max-seconds", 1, RECORD_LIMIT_SECONDS);
93
+ else if (flag === "--fps") fps = intOpt(val(), "--fps", 1, RECORD_MAX_FPS);
94
+ else if (flag === "--quality") quality = intOpt(val(), "--quality", 1, 100);
95
+ else if (flag === "--filename") {
96
+ filename = val();
97
+ if (!filename || !SAFE_NAME.test(filename) || filename.includes("..")) throw new Error("record: --filename must be a plain name like clip.mp4 (no path separators)");
98
+ } else if (flag === "--format") {
99
+ const f = val() as RecordFormat;
100
+ if (!RECORD_FORMATS.includes(f)) throw new Error(`record: --format must be one of ${RECORD_FORMATS.join("|")}`);
101
+ format = f;
102
+ }
103
+ }
104
+ if (mode === "start") return { mode, fps, quality, format, maxSeconds };
105
+ if (mode === "stop") return { mode, filename };
106
+ return { mode, seconds, fps, quality, format, filename };
107
+ }
108
+
109
+ /** run-code the cliDaemon executes: screenshot the tab for ~chunkMs, return base64 JPEGs + capture times. */
110
+ export function buildCaptureChunkCode(a: { fps: number; quality: number }, chunkMs = RECORD_CHUNK_MS): string {
111
+ return `async page => {
112
+ const endAt = Date.now() + ${chunkMs};
113
+ const interval = Math.max(1, Math.round(1000 / ${a.fps}));
114
+ const frames = [], times = [];
115
+ while (frames.length < ${RECORD_MAX_CHUNK_FRAMES}) {
116
+ const shotAt = Date.now();
117
+ // The sandbox has no Buffer global, but page.screenshot() returns a Buffer whose own
118
+ // .toString("base64") works without it.
119
+ // CSS pixels, not device pixels: a retina tab would otherwise be a 2x-sized video.
120
+ const b = await page.screenshot({ type: "jpeg", quality: ${a.quality}, scale: "css" });
121
+ frames.push(b.toString("base64"));
122
+ times.push(shotAt);
123
+ if (Date.now() >= endAt) break;
124
+ const rest = interval - (Date.now() - shotAt);
125
+ if (rest > 0) await page.waitForTimeout(rest);
126
+ }
127
+ return JSON.stringify({ frames, times });
128
+ }`;
129
+ }
130
+
131
+ export type CaptureChunk = { frames: string[]; times: number[] };
132
+
133
+ /** Lays frames captured at uneven times onto a constant-fps clock: each output slot shows the
134
+ * newest frame captured before the slot ends, so playback runs in real time. */
135
+ export class Retimer {
136
+ private last: Uint8Array | undefined;
137
+ private slot = 0; // output frames written so far
138
+ constructor(private readonly startAt: number, private readonly fps: number, private readonly write: (jpeg: Uint8Array) => void) {}
139
+ private slotOf(t: number): number { return Math.floor(((t - this.startAt) * this.fps) / 1000); }
140
+ /** A frame captured at time t. */
141
+ push(jpeg: Uint8Array, t: number): void {
142
+ const s = this.slotOf(t);
143
+ if (this.last) while (this.slot < s) { this.write(this.last); this.slot++; }
144
+ else this.slot = Math.max(this.slot, s); // nothing to show before the first frame
145
+ this.last = jpeg;
146
+ }
147
+ /** Close the clock at time t: the last frame fills the slots through t (at least one frame). */
148
+ finish(t: number): number {
149
+ if (!this.last) return this.slot;
150
+ const s = Math.max(this.slotOf(t), this.slot + 1);
151
+ while (this.slot < s) { this.write(this.last); this.slot++; }
152
+ return this.slot;
153
+ }
154
+ }
155
+
156
+ type ProbeRun = (bin: string, args: string[]) => { status: number | null };
157
+ const defaultProbe: ProbeRun = (bin, args) => spawnSync(bin, args, { stdio: "ignore" });
158
+
159
+ /** The first ffmpeg binary that answers `-version`, or null if none is installed. */
160
+ export function resolveFfmpeg(run: ProbeRun = defaultProbe, candidates: string[] = FFMPEG_CANDIDATES): string | null {
161
+ for (const bin of candidates) {
162
+ try { if (run(bin, ["-version"]).status === 0) return bin; } catch {}
163
+ }
164
+ return null;
165
+ }
166
+
167
+ /** The format actually produced: the request, downgraded to mjpeg when ffmpeg is missing. */
168
+ export function resolveFormat(requested: RecordFormat, hasFfmpeg: boolean): RecordFormat {
169
+ return hasFfmpeg ? requested : "mjpeg";
170
+ }
171
+
172
+ /** The ffmpeg argv that reads a JPEG stream on stdin at `fps` and encodes `out`. */
173
+ export function ffmpegStreamArgs(fps: number, format: Exclude<RecordFormat, "mjpeg">, out: string): string[] {
174
+ // yuv420p needs even dimensions; screenshots of an odd-sized viewport would make the encoder fail.
175
+ const base = ["-y", "-loglevel", "error", "-f", "image2pipe", "-framerate", String(fps), "-c:v", "mjpeg", "-i", "-", "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-pix_fmt", "yuv420p"];
176
+ if (format === "webm") return [...base, "-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "35", "-deadline", "realtime", "-f", "webm", out];
177
+ return [...base, "-c:v", "libx264", "-preset", "veryfast", "-movflags", "+faststart", "-f", "mp4", out];
178
+ }
179
+
180
+ // error: why capture ended early (the file is still good). encodeError: the file is not usable.
181
+ export type RecordingSummary = { frames: number; captured: number; seconds: number; fps: number; format: RecordFormat; out: string; error?: string; encodeError?: string };
182
+
183
+ /** Where the retimed frames go: ffmpeg's stdin, or a raw MJPEG file. */
184
+ export type FrameSink = { write(jpeg: Uint8Array): unknown; close(): Promise<void> }; // write may return a promise: backpressure
185
+
186
+ /** One recording: runs capture chunks back to back and streams them through the Retimer into the sink. */
187
+ export class Recording {
188
+ readonly startAt: number;
189
+ private stopping = false;
190
+ private captured = 0;
191
+ private error: string | undefined;
192
+ private readonly retimer: Retimer;
193
+ private readonly pending: Promise<unknown>[] = [];
194
+ private readonly loop: Promise<void>;
195
+ private summary: Promise<RecordingSummary> | undefined;
196
+ constructor(private readonly opts: {
197
+ fps: number; format: RecordFormat; out: string; maxMs: number;
198
+ capture: () => Promise<CaptureChunk>; sink: FrameSink; now?: () => number;
199
+ onAutoStop?: (s: RecordingSummary) => void;
200
+ }) {
201
+ const now = opts.now ?? Date.now;
202
+ const startAt = this.startAt = now();
203
+ const retimer = this.retimer = new Retimer(startAt, opts.fps, jpeg => { const p = opts.sink.write(jpeg); if (p instanceof Promise) this.pending.push(p); });
204
+ this.loop = (async () => {
205
+ // A failed screenshot (tab closed, session gone) or a dead encoder ends the capture; stop()
206
+ // still closes the file and reports why.
207
+ try {
208
+ while (!this.stopping && now() - startAt < opts.maxMs) {
209
+ const chunk = await opts.capture();
210
+ for (let i = 0; i < chunk.frames.length; i++) {
211
+ if (chunk.times[i]! - startAt > opts.maxMs) break;
212
+ retimer.push(Buffer.from(chunk.frames[i]!, "base64"), chunk.times[i]!);
213
+ this.captured++;
214
+ }
215
+ if (this.pending.length) await Promise.all(this.pending.splice(0)); // backpressure from ffmpeg
216
+ }
217
+ } catch (e) { this.error = e instanceof Error ? e.message : String(e); }
218
+ })();
219
+ // Hit --max-seconds or the tab went away: finish the file on our own; stop() then just reports it.
220
+ this.loop.then(() => { if (!this.stopping) this.stop().then(s => opts.onAutoStop?.(s)); });
221
+ }
222
+ get active(): boolean { return !this.stopping; }
223
+ /** Settles when capturing ends: stopped, --max-seconds reached, or the tab went away. */
224
+ get ended(): Promise<void> { return this.loop; }
225
+ /** Stop capturing, flush the sink, and report what was written. Never rejects; safe to call twice. */
226
+ stop(): Promise<RecordingSummary> {
227
+ if (this.summary) return this.summary;
228
+ this.stopping = true;
229
+ const end = (this.opts.now ?? Date.now)();
230
+ this.summary = (async () => {
231
+ await this.loop;
232
+ let frames = 0, encodeError: string | undefined;
233
+ const why = (e: unknown) => (encodeError ??= e instanceof Error ? e.message : String(e));
234
+ try { frames = this.retimer.finish(Math.min(end, this.startAt + this.opts.maxMs)); await Promise.all(this.pending.splice(0)); } catch (e) { why(e); }
235
+ // Always close, so ffmpeg never lingers; its error (with ffmpeg's own last line) wins.
236
+ try { await this.opts.sink.close(); } catch (e) { encodeError = e instanceof Error ? e.message : String(e); }
237
+ return { frames, captured: this.captured, seconds: frames / this.opts.fps, fps: this.opts.fps, format: this.opts.format, out: this.opts.out, ...(this.error ? { error: this.error } : {}), ...(encodeError ? { encodeError } : {}) };
238
+ })();
239
+ return this.summary;
240
+ }
241
+ }
242
+
243
+ /** ffmpeg reading JPEGs on stdin, or (no ffmpeg) a raw MJPEG file. */
244
+ export function recordSink(format: RecordFormat, ffmpegBin: string | null, fps: number, out: string): FrameSink {
245
+ if (format === "mjpeg" || !ffmpegBin) {
246
+ const w = Bun.file(out).writer();
247
+ return { write: jpeg => { w.write(jpeg); }, close: async () => { await w.end(); } };
248
+ }
249
+ const proc = Bun.spawn([ffmpegBin, ...ffmpegStreamArgs(fps, format, out)], { stdin: "pipe", stdout: "ignore", stderr: "pipe" });
250
+ return {
251
+ write: jpeg => { proc.stdin.write(jpeg); return proc.stdin.flush(); },
252
+ close: async () => {
253
+ await proc.stdin.end();
254
+ const code = await proc.exited;
255
+ if (code !== 0) throw new Error(`record: ffmpeg could not encode ${format} (${(await new Response(proc.stderr).text()).split("\n").filter(Boolean).slice(-1)[0] ?? "exit " + code})`);
256
+ },
257
+ };
258
+ }