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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.48.1",
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 = {
@@ -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 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);
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
- 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));
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: screenshot the tab for ~chunkMs, return base64 JPEGs + capture times. */
110
- 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 {
111
120
  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);
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
- 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
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 code = buildCaptureChunkCode(a);
1118
- const capture = async (): Promise<CaptureChunk> => {
1119
- 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 });
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 code = buildCaptureChunkCode(a);
1118
- const capture = async (): Promise<CaptureChunk> => {
1119
- 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 });
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]; }