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 +1 -1
- package/record.js +61 -27
- package/record.ts +66 -27
- package/serve.js +10 -6
- package/serve.ts +10 -6
package/package.json
CHANGED
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
103
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
1115
|
-
const
|
|
1116
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
1115
|
-
const
|
|
1116
|
-
|
|
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]; }
|