rechrome 1.48.1 → 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 +53 -26
- package/record.ts +59 -27
- package/serve.js +4 -3
- package/serve.ts +4 -3
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 = {
|
|
@@ -90,21 +92,40 @@ function parseRecordArgs(args) {
|
|
|
90
92
|
}
|
|
91
93
|
function buildCaptureChunkCode(a, chunkMs = RECORD_CHUNK_MS) {
|
|
92
94
|
return `async page => {
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
+
})();
|
|
107
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});
|
|
108
129
|
return JSON.stringify({ frames, times });
|
|
109
130
|
}`;
|
|
110
131
|
}
|
|
@@ -175,6 +196,7 @@ class Recording {
|
|
|
175
196
|
pending = [];
|
|
176
197
|
loop;
|
|
177
198
|
summary;
|
|
199
|
+
stopAt = Infinity;
|
|
178
200
|
constructor(opts) {
|
|
179
201
|
this.opts = opts;
|
|
180
202
|
const now = opts.now ?? Date.now;
|
|
@@ -185,18 +207,21 @@ class Recording {
|
|
|
185
207
|
this.pending.push(p);
|
|
186
208
|
});
|
|
187
209
|
this.loop = (async () => {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
this.captured++;
|
|
196
|
-
}
|
|
197
|
-
if (this.pending.length)
|
|
198
|
-
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++;
|
|
199
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));
|
|
200
225
|
} catch (e) {
|
|
201
226
|
this.error = e instanceof Error ? e.message : String(e);
|
|
202
227
|
}
|
|
@@ -216,7 +241,7 @@ class Recording {
|
|
|
216
241
|
if (this.summary)
|
|
217
242
|
return this.summary;
|
|
218
243
|
this.stopping = true;
|
|
219
|
-
const end = (this.opts.now ?? Date.now)();
|
|
244
|
+
const end = this.stopAt = (this.opts.now ?? Date.now)();
|
|
220
245
|
this.summary = (async () => {
|
|
221
246
|
await this.loop;
|
|
222
247
|
let frames = 0, encodeError;
|
|
@@ -270,10 +295,12 @@ export {
|
|
|
270
295
|
RECORD_DEFAULT_SECONDS,
|
|
271
296
|
RECORD_FORMATS,
|
|
272
297
|
RECORD_LIMIT_SECONDS,
|
|
298
|
+
RECORD_MAX_BUFFER_FRAMES,
|
|
273
299
|
RECORD_MAX_CHUNK_FRAMES,
|
|
274
300
|
RECORD_MAX_FPS,
|
|
275
301
|
RECORD_MAX_SECONDS,
|
|
276
302
|
RECORD_OPTIONS,
|
|
303
|
+
RECORD_ORPHAN_MS,
|
|
277
304
|
Recording,
|
|
278
305
|
Retimer,
|
|
279
306
|
buildCaptureChunkCode,
|
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
|
|
|
@@ -106,24 +109,49 @@ export function parseRecordArgs(args: string[]): RecordArgs {
|
|
|
106
109
|
return { mode, seconds, fps, quality, format, filename };
|
|
107
110
|
}
|
|
108
111
|
|
|
109
|
-
/** run-code the cliDaemon executes
|
|
110
|
-
|
|
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 {
|
|
111
120
|
return `async page => {
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
})();
|
|
126
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});
|
|
127
155
|
return JSON.stringify({ frames, times });
|
|
128
156
|
}`;
|
|
129
157
|
}
|
|
@@ -193,9 +221,10 @@ export class Recording {
|
|
|
193
221
|
private readonly pending: Promise<unknown>[] = [];
|
|
194
222
|
private readonly loop: Promise<void>;
|
|
195
223
|
private summary: Promise<RecordingSummary> | undefined;
|
|
224
|
+
private stopAt = Infinity;
|
|
196
225
|
constructor(private readonly opts: {
|
|
197
226
|
fps: number; format: RecordFormat; out: string; maxMs: number;
|
|
198
|
-
capture: () => Promise<CaptureChunk>; sink: FrameSink; now?: () => number;
|
|
227
|
+
capture: (final?: boolean) => Promise<CaptureChunk>; sink: FrameSink; now?: () => number;
|
|
199
228
|
onAutoStop?: (s: RecordingSummary) => void;
|
|
200
229
|
}) {
|
|
201
230
|
const now = opts.now ?? Date.now;
|
|
@@ -204,16 +233,19 @@ export class Recording {
|
|
|
204
233
|
this.loop = (async () => {
|
|
205
234
|
// A failed screenshot (tab closed, session gone) or a dead encoder ends the capture; stop()
|
|
206
235
|
// still closes the file and reports why.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
this.captured++;
|
|
214
|
-
}
|
|
215
|
-
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++;
|
|
216
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));
|
|
217
249
|
} catch (e) { this.error = e instanceof Error ? e.message : String(e); }
|
|
218
250
|
})();
|
|
219
251
|
// Hit --max-seconds or the tab went away: finish the file on our own; stop() then just reports it.
|
|
@@ -226,7 +258,7 @@ export class Recording {
|
|
|
226
258
|
stop(): Promise<RecordingSummary> {
|
|
227
259
|
if (this.summary) return this.summary;
|
|
228
260
|
this.stopping = true;
|
|
229
|
-
const end = (this.opts.now ?? Date.now)();
|
|
261
|
+
const end = this.stopAt = (this.opts.now ?? Date.now)();
|
|
230
262
|
this.summary = (async () => {
|
|
231
263
|
await this.loop;
|
|
232
264
|
let frames = 0, encodeError: string | undefined;
|
package/serve.js
CHANGED
|
@@ -1114,9 +1114,10 @@ export async function serve() {
|
|
|
1114
1114
|
const ffmpegBin = resolveFfmpeg();
|
|
1115
1115
|
const format = resolveFormat(a.format, ffmpegBin !== null);
|
|
1116
1116
|
const out = join(runWorkDir, `.recording-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}.${format}.part`);
|
|
1117
|
-
const
|
|
1118
|
-
const
|
|
1119
|
-
|
|
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 });
|
|
1120
1121
|
if (isError) throw new Error(text.trim().split("\n")[0] || "record: could not screenshot the tab");
|
|
1121
1122
|
// The run-code return (a JSON string) comes back JSON-wrapped by the daemon; unwrap up to twice.
|
|
1122
1123
|
let parsed: unknown; try { parsed = JSON.parse(text).result; } catch { parsed = text.match(/### Result\n([^\n]*)/)?.[1]; }
|
package/serve.ts
CHANGED
|
@@ -1114,9 +1114,10 @@ export async function serve() {
|
|
|
1114
1114
|
const ffmpegBin = resolveFfmpeg();
|
|
1115
1115
|
const format = resolveFormat(a.format, ffmpegBin !== null);
|
|
1116
1116
|
const out = join(runWorkDir, `.recording-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}.${format}.part`);
|
|
1117
|
-
const
|
|
1118
|
-
const
|
|
1119
|
-
|
|
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 });
|
|
1120
1121
|
if (isError) throw new Error(text.trim().split("\n")[0] || "record: could not screenshot the tab");
|
|
1121
1122
|
// The run-code return (a JSON string) comes back JSON-wrapped by the daemon; unwrap up to twice.
|
|
1122
1123
|
let parsed: unknown; try { parsed = JSON.parse(text).result; } catch { parsed = text.match(/### Result\n([^\n]*)/)?.[1]; }
|