rechrome 1.48.0 → 1.48.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.48.0",
3
+ "version": "1.48.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/record.js CHANGED
@@ -10,6 +10,8 @@ var RECORD_MAX_FPS = 30;
10
10
  var RECORD_DEFAULT_QUALITY = 70;
11
11
  var RECORD_CHUNK_MS = 1000;
12
12
  var RECORD_MAX_CHUNK_FRAMES = 60;
13
+ var RECORD_MAX_BUFFER_FRAMES = 4 * RECORD_MAX_CHUNK_FRAMES;
14
+ var RECORD_ORPHAN_MS = 15000;
13
15
  var RECORD_FORMATS = ["mp4", "webm", "mjpeg"];
14
16
  var FFMPEG_CANDIDATES = [process.env.FFMPEG_BIN, "ffmpeg", "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"].filter((x) => !!x);
15
17
  var RECORD_OPTIONS = {
@@ -34,6 +36,12 @@ function recordHoldMs(args) {
34
36
  return 0;
35
37
  }
36
38
  }
39
+ function withFormatExt(name, format) {
40
+ const m = /^(.+)\.(mp4|webm|mjpeg|mjpg|mkv|mov|avi)$/i.exec(name);
41
+ if (m && m[2].toLowerCase() === format)
42
+ return name;
43
+ return `${m ? m[1] : name}.${format}`;
44
+ }
37
45
  function freeName(name, taken) {
38
46
  if (!taken(name))
39
47
  return name;
@@ -84,21 +92,40 @@ function parseRecordArgs(args) {
84
92
  }
85
93
  function buildCaptureChunkCode(a, chunkMs = RECORD_CHUNK_MS) {
86
94
  return `async page => {
87
- const endAt = Date.now() + ${chunkMs};
88
- const interval = Math.max(1, Math.round(1000 / ${a.fps}));
89
- const frames = [], times = [];
90
- while (frames.length < ${RECORD_MAX_CHUNK_FRAMES}) {
91
- const shotAt = Date.now();
92
- // The sandbox has no Buffer global, but page.screenshot() returns a Buffer whose own
93
- // .toString("base64") works without it.
94
- // CSS pixels, not device pixels: a retina tab would otherwise be a 2x-sized video.
95
- const b = await page.screenshot({ type: "jpeg", quality: ${a.quality}, scale: "css" });
96
- frames.push(b.toString("base64"));
97
- times.push(shotAt);
98
- if (Date.now() >= endAt) break;
99
- const rest = interval - (Date.now() - shotAt);
100
- if (rest > 0) await page.waitForTimeout(rest);
95
+ const id = ${JSON.stringify(a.id ?? "")}, final = ${a.final === true};
96
+ let r = page.__rechRecord;
97
+ if (final) {
98
+ if (r && r.id === id) { r.done = true; await r.loop; }
99
+ } else if (!r || r.id !== id || (r.done && !r.error)) {
100
+ if (r) r.done = true;
101
+ r = page.__rechRecord = { id, frames: [], times: [], done: false, error: "", seen: Date.now() };
102
+ const rec = r, interval = Math.max(1, Math.round(1000 / ${a.fps}));
103
+ rec.loop = (async () => {
104
+ while (!rec.done) {
105
+ if (Date.now() - rec.seen > ${RECORD_ORPHAN_MS}) break;
106
+ const shotAt = Date.now();
107
+ try {
108
+ // The sandbox has no Buffer global, but page.screenshot() returns a Buffer whose own
109
+ // .toString("base64") works without it.
110
+ // CSS pixels, not device pixels: a retina tab would otherwise be a 2x-sized video.
111
+ const b = await page.screenshot({ type: "jpeg", quality: ${a.quality}, scale: "css" });
112
+ rec.frames.push(b.toString("base64"));
113
+ rec.times.push(shotAt);
114
+ // A stalled reader: keep the newest frames only.
115
+ if (rec.frames.length > ${RECORD_MAX_BUFFER_FRAMES}) { rec.frames.shift(); rec.times.shift(); }
116
+ } catch (e) { rec.error = String((e && e.message) || e); break; }
117
+ const rest = interval - (Date.now() - shotAt);
118
+ if (rest > 0 && !rec.done) await page.waitForTimeout(rest);
119
+ }
120
+ rec.done = true;
121
+ })();
101
122
  }
123
+ if (!r || r.id !== id) return JSON.stringify({ frames: [], times: [] });
124
+ r.seen = Date.now();
125
+ const endAt = Date.now() + ${chunkMs};
126
+ while (!final && !r.done && !r.frames.length && Date.now() < endAt) await page.waitForTimeout(25);
127
+ if (r.error && !r.frames.length) throw new Error(r.error);
128
+ const frames = r.frames.splice(0, ${RECORD_MAX_CHUNK_FRAMES}), times = r.times.splice(0, ${RECORD_MAX_CHUNK_FRAMES});
102
129
  return JSON.stringify({ frames, times });
103
130
  }`;
104
131
  }
@@ -169,6 +196,7 @@ class Recording {
169
196
  pending = [];
170
197
  loop;
171
198
  summary;
199
+ stopAt = Infinity;
172
200
  constructor(opts) {
173
201
  this.opts = opts;
174
202
  const now = opts.now ?? Date.now;
@@ -179,18 +207,21 @@ class Recording {
179
207
  this.pending.push(p);
180
208
  });
181
209
  this.loop = (async () => {
182
- try {
183
- while (!this.stopping && now() - startAt < opts.maxMs) {
184
- const chunk = await opts.capture();
185
- for (let i = 0;i < chunk.frames.length; i++) {
186
- if (chunk.times[i] - startAt > opts.maxMs)
187
- break;
188
- retimer.push(Buffer.from(chunk.frames[i], "base64"), chunk.times[i]);
189
- this.captured++;
190
- }
191
- if (this.pending.length)
192
- await Promise.all(this.pending.splice(0));
210
+ const take = async (chunk) => {
211
+ const until = Math.min(startAt + opts.maxMs, this.stopAt);
212
+ for (let i = 0;i < chunk.frames.length; i++) {
213
+ if (chunk.times[i] >= until)
214
+ break;
215
+ retimer.push(Buffer.from(chunk.frames[i], "base64"), chunk.times[i]);
216
+ this.captured++;
193
217
  }
218
+ if (this.pending.length)
219
+ await Promise.all(this.pending.splice(0));
220
+ };
221
+ try {
222
+ while (!this.stopping && now() - startAt < opts.maxMs)
223
+ await take(await opts.capture());
224
+ await take(await opts.capture(true));
194
225
  } catch (e) {
195
226
  this.error = e instanceof Error ? e.message : String(e);
196
227
  }
@@ -210,7 +241,7 @@ class Recording {
210
241
  if (this.summary)
211
242
  return this.summary;
212
243
  this.stopping = true;
213
- const end = (this.opts.now ?? Date.now)();
244
+ const end = this.stopAt = (this.opts.now ?? Date.now)();
214
245
  this.summary = (async () => {
215
246
  await this.loop;
216
247
  let frames = 0, encodeError;
@@ -264,10 +295,12 @@ export {
264
295
  RECORD_DEFAULT_SECONDS,
265
296
  RECORD_FORMATS,
266
297
  RECORD_LIMIT_SECONDS,
298
+ RECORD_MAX_BUFFER_FRAMES,
267
299
  RECORD_MAX_CHUNK_FRAMES,
268
300
  RECORD_MAX_FPS,
269
301
  RECORD_MAX_SECONDS,
270
302
  RECORD_OPTIONS,
303
+ RECORD_ORPHAN_MS,
271
304
  Recording,
272
305
  Retimer,
273
306
  buildCaptureChunkCode,
@@ -277,5 +310,6 @@ export {
277
310
  recordHoldMs,
278
311
  recordSink,
279
312
  resolveFfmpeg,
280
- resolveFormat
313
+ resolveFormat,
314
+ withFormatExt
281
315
  };
package/record.ts CHANGED
@@ -26,6 +26,9 @@ export const RECORD_DEFAULT_QUALITY = 70;
26
26
  export const RECORD_CHUNK_MS = 1000;
27
27
  // Per chunk: bounds one run-code reply even if screenshots are very fast.
28
28
  export const RECORD_MAX_CHUNK_FRAMES = 60;
29
+ // Frames the browser-side loop holds for the next drain, and how long it runs undrained.
30
+ export const RECORD_MAX_BUFFER_FRAMES = 4 * RECORD_MAX_CHUNK_FRAMES;
31
+ export const RECORD_ORPHAN_MS = 15_000;
29
32
  export const RECORD_FORMATS = ["mp4", "webm", "mjpeg"] as const;
30
33
  export type RecordFormat = (typeof RECORD_FORMATS)[number];
31
34
 
@@ -59,6 +62,13 @@ export function recordHoldMs(args: string[]): number {
59
62
  } catch { return 0; }
60
63
  }
61
64
 
65
+ /** `name` with the extension of what was actually written: `clip.mp4` saved as mjpeg is `clip.mjpeg`. */
66
+ export function withFormatExt(name: string, format: RecordFormat): string {
67
+ const m = /^(.+)\.(mp4|webm|mjpeg|mjpg|mkv|mov|avi)$/i.exec(name);
68
+ if (m && m[2]!.toLowerCase() === format) return name;
69
+ return `${m ? m[1] : name}.${format}`;
70
+ }
71
+
62
72
  /** `name`, or `name-2`, `name-3`… before the extension: the first that is not taken. */
63
73
  export function freeName(name: string, taken: (name: string) => boolean): string {
64
74
  if (!taken(name)) return name;
@@ -99,24 +109,49 @@ export function parseRecordArgs(args: string[]): RecordArgs {
99
109
  return { mode, seconds, fps, quality, format, filename };
100
110
  }
101
111
 
102
- /** run-code the cliDaemon executes: screenshot the tab for ~chunkMs, return base64 JPEGs + capture times. */
103
- export function buildCaptureChunkCode(a: { fps: number; quality: number }, chunkMs = RECORD_CHUNK_MS): string {
112
+ /** run-code the cliDaemon executes. The first call for a recording `id` starts a screenshot loop
113
+ * that keeps running in the browser-side process between calls (stashed on the Page object, the
114
+ * one thing that survives run-code's fresh vm contexts); every call drains the frames shot since
115
+ * the last one, waiting up to chunkMs for at least one. That way the cliDaemon's per-call settle
116
+ * (waitForCompletion: ~1 s fixed, up to 6 s while the page keeps fetching, e.g. a playing video)
117
+ * no longer leaves holes in the capture. `final` drains the rest and ends the loop. With nobody
118
+ * draining for RECORD_ORPHAN_MS (serve went away), the loop ends by itself. */
119
+ export function buildCaptureChunkCode(a: { fps: number; quality: number; id?: string; final?: boolean }, chunkMs = RECORD_CHUNK_MS): string {
104
120
  return `async page => {
105
- const endAt = Date.now() + ${chunkMs};
106
- const interval = Math.max(1, Math.round(1000 / ${a.fps}));
107
- const frames = [], times = [];
108
- while (frames.length < ${RECORD_MAX_CHUNK_FRAMES}) {
109
- const shotAt = Date.now();
110
- // The sandbox has no Buffer global, but page.screenshot() returns a Buffer whose own
111
- // .toString("base64") works without it.
112
- // CSS pixels, not device pixels: a retina tab would otherwise be a 2x-sized video.
113
- const b = await page.screenshot({ type: "jpeg", quality: ${a.quality}, scale: "css" });
114
- frames.push(b.toString("base64"));
115
- times.push(shotAt);
116
- if (Date.now() >= endAt) break;
117
- const rest = interval - (Date.now() - shotAt);
118
- if (rest > 0) await page.waitForTimeout(rest);
121
+ const id = ${JSON.stringify(a.id ?? "")}, final = ${a.final === true};
122
+ let r = page.__rechRecord;
123
+ if (final) {
124
+ if (r && r.id === id) { r.done = true; await r.loop; }
125
+ } else if (!r || r.id !== id || (r.done && !r.error)) {
126
+ if (r) r.done = true;
127
+ r = page.__rechRecord = { id, frames: [], times: [], done: false, error: "", seen: Date.now() };
128
+ const rec = r, interval = Math.max(1, Math.round(1000 / ${a.fps}));
129
+ rec.loop = (async () => {
130
+ while (!rec.done) {
131
+ if (Date.now() - rec.seen > ${RECORD_ORPHAN_MS}) break;
132
+ const shotAt = Date.now();
133
+ try {
134
+ // The sandbox has no Buffer global, but page.screenshot() returns a Buffer whose own
135
+ // .toString("base64") works without it.
136
+ // CSS pixels, not device pixels: a retina tab would otherwise be a 2x-sized video.
137
+ const b = await page.screenshot({ type: "jpeg", quality: ${a.quality}, scale: "css" });
138
+ rec.frames.push(b.toString("base64"));
139
+ rec.times.push(shotAt);
140
+ // A stalled reader: keep the newest frames only.
141
+ if (rec.frames.length > ${RECORD_MAX_BUFFER_FRAMES}) { rec.frames.shift(); rec.times.shift(); }
142
+ } catch (e) { rec.error = String((e && e.message) || e); break; }
143
+ const rest = interval - (Date.now() - shotAt);
144
+ if (rest > 0 && !rec.done) await page.waitForTimeout(rest);
145
+ }
146
+ rec.done = true;
147
+ })();
119
148
  }
149
+ if (!r || r.id !== id) return JSON.stringify({ frames: [], times: [] });
150
+ r.seen = Date.now();
151
+ const endAt = Date.now() + ${chunkMs};
152
+ while (!final && !r.done && !r.frames.length && Date.now() < endAt) await page.waitForTimeout(25);
153
+ if (r.error && !r.frames.length) throw new Error(r.error);
154
+ const frames = r.frames.splice(0, ${RECORD_MAX_CHUNK_FRAMES}), times = r.times.splice(0, ${RECORD_MAX_CHUNK_FRAMES});
120
155
  return JSON.stringify({ frames, times });
121
156
  }`;
122
157
  }
@@ -186,9 +221,10 @@ export class Recording {
186
221
  private readonly pending: Promise<unknown>[] = [];
187
222
  private readonly loop: Promise<void>;
188
223
  private summary: Promise<RecordingSummary> | undefined;
224
+ private stopAt = Infinity;
189
225
  constructor(private readonly opts: {
190
226
  fps: number; format: RecordFormat; out: string; maxMs: number;
191
- capture: () => Promise<CaptureChunk>; sink: FrameSink; now?: () => number;
227
+ capture: (final?: boolean) => Promise<CaptureChunk>; sink: FrameSink; now?: () => number;
192
228
  onAutoStop?: (s: RecordingSummary) => void;
193
229
  }) {
194
230
  const now = opts.now ?? Date.now;
@@ -197,16 +233,19 @@ export class Recording {
197
233
  this.loop = (async () => {
198
234
  // A failed screenshot (tab closed, session gone) or a dead encoder ends the capture; stop()
199
235
  // still closes the file and reports why.
200
- try {
201
- while (!this.stopping && now() - startAt < opts.maxMs) {
202
- const chunk = await opts.capture();
203
- for (let i = 0; i < chunk.frames.length; i++) {
204
- if (chunk.times[i]! - startAt > opts.maxMs) break;
205
- retimer.push(Buffer.from(chunk.frames[i]!, "base64"), chunk.times[i]!);
206
- this.captured++;
207
- }
208
- if (this.pending.length) await Promise.all(this.pending.splice(0)); // backpressure from ffmpeg
236
+ const take = async (chunk: CaptureChunk) => {
237
+ const until = Math.min(startAt + opts.maxMs, this.stopAt);
238
+ for (let i = 0; i < chunk.frames.length; i++) {
239
+ if (chunk.times[i]! >= until) break; // shot at or after the end: not part of the clip
240
+ retimer.push(Buffer.from(chunk.frames[i]!, "base64"), chunk.times[i]!);
241
+ this.captured++;
209
242
  }
243
+ if (this.pending.length) await Promise.all(this.pending.splice(0)); // backpressure from ffmpeg
244
+ };
245
+ try {
246
+ while (!this.stopping && now() - startAt < opts.maxMs) await take(await opts.capture());
247
+ // The frames shot since the last drain; this also ends the browser-side loop.
248
+ await take(await opts.capture(true));
210
249
  } catch (e) { this.error = e instanceof Error ? e.message : String(e); }
211
250
  })();
212
251
  // Hit --max-seconds or the tab went away: finish the file on our own; stop() then just reports it.
@@ -219,7 +258,7 @@ export class Recording {
219
258
  stop(): Promise<RecordingSummary> {
220
259
  if (this.summary) return this.summary;
221
260
  this.stopping = true;
222
- const end = (this.opts.now ?? Date.now)();
261
+ const end = this.stopAt = (this.opts.now ?? Date.now)();
223
262
  this.summary = (async () => {
224
263
  await this.loop;
225
264
  let frames = 0, encodeError: string | undefined;
package/serve.js CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  chromeDataAccessHint,
26
26
  } from "./rechrome.js";
27
27
  import { SecretMasker, runCodeOnSession, parseReadCookieWire, readCookieOnSession, parseReadStorageWire, readStorageOnSession, parseReadSecretWire, readSecretOnSession, parseReadHeadersWire, readHeadersOnSession, readHeadersEvidence, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.js";
28
- import { parseRecordArgs, buildCaptureChunkCode, resolveFfmpeg, resolveFormat, recordSink, Recording, freeName, RECORD_CHUNK_MS, RECORD_MAX_SECONDS, type CaptureChunk, type RecordingSummary } from "./record.js";
28
+ import { parseRecordArgs, buildCaptureChunkCode, resolveFfmpeg, resolveFormat, recordSink, Recording, freeName, withFormatExt, RECORD_CHUNK_MS, RECORD_MAX_SECONDS, type CaptureChunk, type RecordingSummary } from "./record.js";
29
29
 
30
30
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
31
31
  const CERT_RENEW_THRESHOLD_DAYS = 7;
@@ -123,7 +123,9 @@ function deliverRecording(entry: RecordingEntry, s: RecordingSummary, filename:
123
123
  }
124
124
  if (s.encodeError) { try { unlinkSync(s.out); } catch {} throw new Error(s.encodeError); }
125
125
  // Never over an existing file: `--filename clip.mp4` twice keeps both (clip.mp4, clip-2.mp4).
126
- const out = filename ? join(entry.dir, freeName(filename, n => existsSync(join(entry.dir, n)))) : s.out.replace(/\/\.(recording-[^/]+)\.part$/, "/$1");
126
+ // The name says what the file is: no ffmpeg on the host means mjpeg, whatever --filename asked for.
127
+ const wanted = filename ? withFormatExt(filename, s.format) : undefined;
128
+ const out = wanted ? join(entry.dir, freeName(wanted, n => existsSync(join(entry.dir, n)))) : s.out.replace(/\/\.(recording-[^/]+)\.part$/, "/$1");
127
129
  if (out !== s.out) renameSync(s.out, out);
128
130
  log(`record: ${JSON.stringify({ listener: entry.listener, name: relative(entry.dir, out), frames: s.frames, captured: s.captured, fps: s.fps, format: s.format, size: statSync(out).size, kept: entry.keep, ...(s.error ? { ended: s.error.split("\n")[0] } : {}) })}`);
129
131
  if (!entry.keep) {
@@ -131,7 +133,8 @@ function deliverRecording(entry: RecordingEntry, s: RecordingSummary, filename:
131
133
  clearTimeout(pendingDownloads.get(real));
132
134
  pendingDownloads.set(real, setTimeout(() => { pendingDownloads.delete(real); try { unlinkSync(real); } catch {} }, DOWNLOAD_PICKUP_MS));
133
135
  }
134
- const note = s.error ? ` — capture ended early: ${s.error.split("\n")[0]}` : "";
136
+ const note = (filename && wanted !== filename ? ` — saved as ${s.format}, not ${filename}${s.format === "mjpeg" ? " (no ffmpeg on the host)" : ""}` : "")
137
+ + (s.error ? ` — capture ended early: ${s.error.split("\n")[0]}` : "");
135
138
  return { stdout: `record: saved ${relative(entry.dir, out)} (${s.format}, ${s.seconds.toFixed(1)} s at ${s.fps} fps, ${s.captured} screenshots)${note}\n`, files: [(entry.outputPrefix + relative(entry.dir, out)).replaceAll("\\", "/")] };
136
139
  }
137
140
  // playwright-cli's line for a finished download (tools/backend/response.js).
@@ -1111,9 +1114,10 @@ export async function serve() {
1111
1114
  const ffmpegBin = resolveFfmpeg();
1112
1115
  const format = resolveFormat(a.format, ffmpegBin !== null);
1113
1116
  const out = join(runWorkDir, `.recording-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}.${format}.part`);
1114
- const code = buildCaptureChunkCode(a);
1115
- const capture = async (): Promise<CaptureChunk> => {
1116
- const { isError, text } = await runCodeOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, code, json: true, timeoutMs: RECORD_CHUNK_MS + 30_000 });
1117
+ const id = randomBytes(6).toString("hex");
1118
+ const code = buildCaptureChunkCode({ ...a, id }), finalCode = buildCaptureChunkCode({ ...a, id, final: true });
1119
+ const capture = async (final?: boolean): Promise<CaptureChunk> => {
1120
+ const { isError, text } = await runCodeOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, code: final ? finalCode : code, json: true, timeoutMs: RECORD_CHUNK_MS + 30_000 });
1117
1121
  if (isError) throw new Error(text.trim().split("\n")[0] || "record: could not screenshot the tab");
1118
1122
  // The run-code return (a JSON string) comes back JSON-wrapped by the daemon; unwrap up to twice.
1119
1123
  let parsed: unknown; try { parsed = JSON.parse(text).result; } catch { parsed = text.match(/### Result\n([^\n]*)/)?.[1]; }
package/serve.ts CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  chromeDataAccessHint,
26
26
  } from "./rechrome.ts";
27
27
  import { SecretMasker, runCodeOnSession, parseReadCookieWire, readCookieOnSession, parseReadStorageWire, readStorageOnSession, parseReadSecretWire, readSecretOnSession, parseReadHeadersWire, readHeadersOnSession, readHeadersEvidence, parseFillSecretWire, fillSecretOnSession, passwordValuesOnSession, hasSnapshot, maskPasswordLines, withholdSnapshots } from "./fill-secret.ts";
28
- import { parseRecordArgs, buildCaptureChunkCode, resolveFfmpeg, resolveFormat, recordSink, Recording, freeName, RECORD_CHUNK_MS, RECORD_MAX_SECONDS, type CaptureChunk, type RecordingSummary } from "./record.ts";
28
+ import { parseRecordArgs, buildCaptureChunkCode, resolveFfmpeg, resolveFormat, recordSink, Recording, freeName, withFormatExt, RECORD_CHUNK_MS, RECORD_MAX_SECONDS, type CaptureChunk, type RecordingSummary } from "./record.ts";
29
29
 
30
30
  const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
31
31
  const CERT_RENEW_THRESHOLD_DAYS = 7;
@@ -123,7 +123,9 @@ function deliverRecording(entry: RecordingEntry, s: RecordingSummary, filename:
123
123
  }
124
124
  if (s.encodeError) { try { unlinkSync(s.out); } catch {} throw new Error(s.encodeError); }
125
125
  // Never over an existing file: `--filename clip.mp4` twice keeps both (clip.mp4, clip-2.mp4).
126
- const out = filename ? join(entry.dir, freeName(filename, n => existsSync(join(entry.dir, n)))) : s.out.replace(/\/\.(recording-[^/]+)\.part$/, "/$1");
126
+ // The name says what the file is: no ffmpeg on the host means mjpeg, whatever --filename asked for.
127
+ const wanted = filename ? withFormatExt(filename, s.format) : undefined;
128
+ const out = wanted ? join(entry.dir, freeName(wanted, n => existsSync(join(entry.dir, n)))) : s.out.replace(/\/\.(recording-[^/]+)\.part$/, "/$1");
127
129
  if (out !== s.out) renameSync(s.out, out);
128
130
  log(`record: ${JSON.stringify({ listener: entry.listener, name: relative(entry.dir, out), frames: s.frames, captured: s.captured, fps: s.fps, format: s.format, size: statSync(out).size, kept: entry.keep, ...(s.error ? { ended: s.error.split("\n")[0] } : {}) })}`);
129
131
  if (!entry.keep) {
@@ -131,7 +133,8 @@ function deliverRecording(entry: RecordingEntry, s: RecordingSummary, filename:
131
133
  clearTimeout(pendingDownloads.get(real));
132
134
  pendingDownloads.set(real, setTimeout(() => { pendingDownloads.delete(real); try { unlinkSync(real); } catch {} }, DOWNLOAD_PICKUP_MS));
133
135
  }
134
- const note = s.error ? ` — capture ended early: ${s.error.split("\n")[0]}` : "";
136
+ const note = (filename && wanted !== filename ? ` — saved as ${s.format}, not ${filename}${s.format === "mjpeg" ? " (no ffmpeg on the host)" : ""}` : "")
137
+ + (s.error ? ` — capture ended early: ${s.error.split("\n")[0]}` : "");
135
138
  return { stdout: `record: saved ${relative(entry.dir, out)} (${s.format}, ${s.seconds.toFixed(1)} s at ${s.fps} fps, ${s.captured} screenshots)${note}\n`, files: [(entry.outputPrefix + relative(entry.dir, out)).replaceAll("\\", "/")] };
136
139
  }
137
140
  // playwright-cli's line for a finished download (tools/backend/response.ts).
@@ -1111,9 +1114,10 @@ export async function serve() {
1111
1114
  const ffmpegBin = resolveFfmpeg();
1112
1115
  const format = resolveFormat(a.format, ffmpegBin !== null);
1113
1116
  const out = join(runWorkDir, `.recording-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}.${format}.part`);
1114
- const code = buildCaptureChunkCode(a);
1115
- const capture = async (): Promise<CaptureChunk> => {
1116
- const { isError, text } = await runCodeOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, code, json: true, timeoutMs: RECORD_CHUNK_MS + 30_000 });
1117
+ const id = randomBytes(6).toString("hex");
1118
+ const code = buildCaptureChunkCode({ ...a, id }), finalCode = buildCaptureChunkCode({ ...a, id, final: true });
1119
+ const capture = async (final?: boolean): Promise<CaptureChunk> => {
1120
+ const { isError, text } = await runCodeOnSession({ socketRoot: tmpSocketRoot(), session: namespacedSession, cwd: runWorkDir, code: final ? finalCode : code, json: true, timeoutMs: RECORD_CHUNK_MS + 30_000 });
1117
1121
  if (isError) throw new Error(text.trim().split("\n")[0] || "record: could not screenshot the tab");
1118
1122
  // The run-code return (a JSON string) comes back JSON-wrapped by the daemon; unwrap up to twice.
1119
1123
  let parsed: unknown; try { parsed = JSON.parse(text).result; } catch { parsed = text.match(/### Result\n([^\n]*)/)?.[1]; }