cbrowser 18.69.3 → 18.71.0
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/dist/browser.d.ts +32 -0
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +46 -2
- package/dist/browser.js.map +1 -1
- package/dist/cli.js +1465 -24
- package/dist/cli.js.map +1 -1
- package/dist/cognitive/index.d.ts +10 -0
- package/dist/cognitive/index.d.ts.map +1 -1
- package/dist/cognitive/index.js +8 -0
- package/dist/cognitive/index.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +151 -3
- package/dist/daemon.js.map +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +67 -0
- package/dist/mcp-server.js.map +1 -1
- package/dist/mcp-tools/base/capture-tools.d.ts +189 -0
- package/dist/mcp-tools/base/capture-tools.d.ts.map +1 -0
- package/dist/mcp-tools/base/capture-tools.js +667 -0
- package/dist/mcp-tools/base/capture-tools.js.map +1 -0
- package/dist/mcp-tools/base/gif-tools.d.ts +19 -0
- package/dist/mcp-tools/base/gif-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/gif-tools.js +45 -27
- package/dist/mcp-tools/base/gif-tools.js.map +1 -1
- package/dist/mcp-tools/base/index.d.ts +2 -1
- package/dist/mcp-tools/base/index.d.ts.map +1 -1
- package/dist/mcp-tools/base/index.js +5 -1
- package/dist/mcp-tools/base/index.js.map +1 -1
- package/dist/mcp-tools/tool-categories.d.ts.map +1 -1
- package/dist/mcp-tools/tool-categories.js +11 -0
- package/dist/mcp-tools/tool-categories.js.map +1 -1
- package/dist/recording/auto-capture.d.ts +72 -0
- package/dist/recording/auto-capture.d.ts.map +1 -0
- package/dist/recording/auto-capture.js +155 -0
- package/dist/recording/auto-capture.js.map +1 -0
- package/dist/recording/contact-sheet.d.ts +65 -0
- package/dist/recording/contact-sheet.d.ts.map +1 -0
- package/dist/recording/contact-sheet.js +204 -0
- package/dist/recording/contact-sheet.js.map +1 -0
- package/dist/recording/encoders.d.ts +101 -0
- package/dist/recording/encoders.d.ts.map +1 -0
- package/dist/recording/encoders.js +304 -0
- package/dist/recording/encoders.js.map +1 -0
- package/dist/recording/engine.d.ts +263 -0
- package/dist/recording/engine.d.ts.map +1 -0
- package/dist/recording/engine.js +890 -0
- package/dist/recording/engine.js.map +1 -0
- package/dist/recording/ssim.d.ts +35 -0
- package/dist/recording/ssim.d.ts.map +1 -0
- package/dist/recording/ssim.js +113 -0
- package/dist/recording/ssim.js.map +1 -0
- package/dist/recording/timing.d.ts +124 -0
- package/dist/recording/timing.d.ts.map +1 -0
- package/dist/recording/timing.js +251 -0
- package/dist/recording/timing.js.map +1 -0
- package/dist/recording/types.d.ts +213 -0
- package/dist/recording/types.d.ts.map +1 -0
- package/dist/recording/types.js +171 -0
- package/dist/recording/types.js.map +1 -0
- package/dist/testing/nl-test-suite.d.ts +13 -1
- package/dist/testing/nl-test-suite.d.ts.map +1 -1
- package/dist/testing/nl-test-suite.js +91 -6
- package/dist/testing/nl-test-suite.js.map +1 -1
- package/dist/types.d.ts +25 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CBrowser - Cognitive Browser Automation
|
|
3
|
+
* Copyright 2026 Alexandria Eden alexandria.shai.eden@gmail.com
|
|
4
|
+
* Learn more at https://cbrowser.ai - MIT License
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Frame-capture engine for screen recordings.
|
|
8
|
+
*
|
|
9
|
+
* Chromium capture goes through the CDP screencast (`Page.startScreencast`),
|
|
10
|
+
* which is **event-driven, not clocked**: the browser emits a frame when the
|
|
11
|
+
* page paints, so a static page emits nothing at all. Three consequences run
|
|
12
|
+
* through this whole file:
|
|
13
|
+
*
|
|
14
|
+
* - The clock is wall time. Every frame is stamped at receipt, and playback
|
|
15
|
+
* delays are derived from those stamps. Nothing is ever synthesised to fill
|
|
16
|
+
* a quiet stretch; quiet stretches are recorded as `frame_gaps`.
|
|
17
|
+
* - A recording always brackets its window with a directly-captured frame at
|
|
18
|
+
* the start and (when the stream has gone quiet) at the end, so a completely
|
|
19
|
+
* static page still produces a valid recording rather than an empty one.
|
|
20
|
+
* - Frames stream to disk as they arrive. Nothing accumulates the full frame
|
|
21
|
+
* set in memory, so recording length is bounded by disk, not RAM.
|
|
22
|
+
*
|
|
23
|
+
* Screencast frames arrive full-viewport - CDP takes no clip parameter - so
|
|
24
|
+
* the frames on disk are always the whole viewport. Cropping is a post-hoc
|
|
25
|
+
* concern, which is what keeps a recording re-croppable after the fact.
|
|
26
|
+
*
|
|
27
|
+
* Firefox and WebKit have no screencast; they fall back to a screenshot loop.
|
|
28
|
+
*
|
|
29
|
+
* @since v18.70.0
|
|
30
|
+
*/
|
|
31
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
32
|
+
import { join, relative } from "node:path";
|
|
33
|
+
import { buildContactSheet } from "./contact-sheet.js";
|
|
34
|
+
import { encodeGif, encodeMp4, encodeWebm, encodeWebp, MissingEncoderError } from "./encoders.js";
|
|
35
|
+
import { detectChangePoints, frameSignature, ssim } from "./ssim.js";
|
|
36
|
+
import { clampRect, expandForPlayback, findFrameGaps } from "./timing.js";
|
|
37
|
+
import { MANIFEST_VERSION, parseManifest, } from "./types.js";
|
|
38
|
+
/** Default ceiling on how long any trigger may wait. */
|
|
39
|
+
export const DEFAULT_TRIGGER_TIMEOUT_MS = 30_000;
|
|
40
|
+
/** Human-readable trigger label for the manifest. */
|
|
41
|
+
function describeStartTrigger(trigger, delayMs, waitedMs) {
|
|
42
|
+
const base = trigger.kind === "lifecycle" ? trigger.event : `element:${trigger.selector}`;
|
|
43
|
+
const withDelay = delayMs > 0 ? `${base}+${delayMs}ms` : base;
|
|
44
|
+
return `${withDelay} (waited ${Math.round(waitedMs)}ms)`;
|
|
45
|
+
}
|
|
46
|
+
/** Human-readable stop-reason label for the manifest. */
|
|
47
|
+
function describeStopTrigger(trigger) {
|
|
48
|
+
switch (trigger.kind) {
|
|
49
|
+
case "element-appears": return `element-appears:${trigger.selector}`;
|
|
50
|
+
case "element-disappears": return `element-disappears:${trigger.selector}`;
|
|
51
|
+
case "idle": return `idle:${trigger.idleMs}ms`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const DEFAULT_FPS = 10;
|
|
55
|
+
const DEFAULT_QUALITY = 80;
|
|
56
|
+
const DEFAULT_MAX_FRAMES = 3000;
|
|
57
|
+
/**
|
|
58
|
+
* SSIM below this against the previous frame marks a change point. Screencast
|
|
59
|
+
* JPEG noise on an unchanged region sits well above 0.99, so 0.9 separates real
|
|
60
|
+
* motion from compression jitter without firing on every frame of a video.
|
|
61
|
+
*/
|
|
62
|
+
const CHANGE_THRESHOLD = 0.9;
|
|
63
|
+
/**
|
|
64
|
+
* SSIM below this marks a frame as a change point.
|
|
65
|
+
*
|
|
66
|
+
* Deliberately far stricter than CHANGE_THRESHOLD, which stays the bar for
|
|
67
|
+
* `anomaly` (a big visual jump). change_points answers the question the
|
|
68
|
+
* manifest exists to answer - "when did the page change" - and at 0.9 the
|
|
69
|
+
* answer was NOTHING for a form fill: text typed into an input on a 1280x800
|
|
70
|
+
* page scores ~0.998, and a whole daemon-recorded flow of two fills produced
|
|
71
|
+
* zero change points. Measured on this box: a genuinely still page scores
|
|
72
|
+
* exactly 1.00000, so 0.999 catches real changes without firing on JPEG noise.
|
|
73
|
+
*/
|
|
74
|
+
const CHANGE_POINT_THRESHOLD = 0.999;
|
|
75
|
+
/**
|
|
76
|
+
* SSIM above this means "nothing moved" for the idle stop trigger.
|
|
77
|
+
*
|
|
78
|
+
* Same comparator as CHANGE_THRESHOLD, deliberately different sensitivity:
|
|
79
|
+
* change_points answers "did something SIGNIFICANT happen", idle answers "did
|
|
80
|
+
* anything happen at all". Measured on this box, consecutive frames of a
|
|
81
|
+
* continuously animating page score 0.81-0.97 while a genuinely still page
|
|
82
|
+
* scores exactly 1.00000, so 0.999 separates them with room to spare. Reusing
|
|
83
|
+
* 0.9 here reports a visibly animating page as idle.
|
|
84
|
+
*/
|
|
85
|
+
const IDLE_THRESHOLD = 0.999;
|
|
86
|
+
/**
|
|
87
|
+
* Slack allowed when matching a paint event to its slot on the frame grid.
|
|
88
|
+
* Paint events land a few milliseconds either side of the nominal boundary;
|
|
89
|
+
* without this, every near-miss is deferred to the next slot and the effective
|
|
90
|
+
* rate drifts below the target.
|
|
91
|
+
*/
|
|
92
|
+
const FRAME_DUE_TOLERANCE_MS = 8;
|
|
93
|
+
/**
|
|
94
|
+
* How far past the last frame the recording may end before `stop()` captures a
|
|
95
|
+
* closing frame, as a fraction of the frame interval.
|
|
96
|
+
*/
|
|
97
|
+
const CLOSING_FRAME_THRESHOLD = 0.8;
|
|
98
|
+
/**
|
|
99
|
+
* One recording run. Not reusable: `start()` may be called once, and `stop()`
|
|
100
|
+
* is idempotent, returning the same result on every subsequent call.
|
|
101
|
+
*/
|
|
102
|
+
export class VideoCaptureSession {
|
|
103
|
+
page;
|
|
104
|
+
engine;
|
|
105
|
+
baseDir;
|
|
106
|
+
opts;
|
|
107
|
+
startTrigger;
|
|
108
|
+
startDelayMs = 0;
|
|
109
|
+
stopTrigger;
|
|
110
|
+
target = { kind: "viewport" };
|
|
111
|
+
slug = "";
|
|
112
|
+
outDir = "";
|
|
113
|
+
framesDir = "";
|
|
114
|
+
state = "idle";
|
|
115
|
+
captureMethod = "cdp-screencast";
|
|
116
|
+
cdp = null;
|
|
117
|
+
loopTimer = null;
|
|
118
|
+
durationTimer = null;
|
|
119
|
+
stopping = null;
|
|
120
|
+
startWallMs = 0;
|
|
121
|
+
startedAtIso = "";
|
|
122
|
+
stopWallMs = 0;
|
|
123
|
+
frames = [];
|
|
124
|
+
nextDueMs = 0;
|
|
125
|
+
writeQueue = Promise.resolve();
|
|
126
|
+
consoleEvents = [];
|
|
127
|
+
networkEvents = [];
|
|
128
|
+
consoleListener = null;
|
|
129
|
+
requestListener = null;
|
|
130
|
+
viewport = { width: 0, height: 0, deviceScaleFactor: 1 };
|
|
131
|
+
regionClamped = false;
|
|
132
|
+
outputDims = { width: 0, height: 0 };
|
|
133
|
+
/**
|
|
134
|
+
* Output dimensions as whole pixels.
|
|
135
|
+
*
|
|
136
|
+
* NOT rounded to even: the GIF/WebP encoders now emit exactly these numbers,
|
|
137
|
+
* so a 393px-wide viewport produces a 393px artifact. Only the video path
|
|
138
|
+
* rounds up to even internally (yuv420p needs it), which is the one place a
|
|
139
|
+
* single pixel of difference is unavoidable.
|
|
140
|
+
*/
|
|
141
|
+
static outputDim(value) {
|
|
142
|
+
return Math.max(1, Math.round(value));
|
|
143
|
+
}
|
|
144
|
+
/** Crop applied to frames captured right now; re-derived per frame for element targets. */
|
|
145
|
+
currentCrop = null;
|
|
146
|
+
/** Whether `currentCrop` still reflects a live element measurement. */
|
|
147
|
+
currentTracking = null;
|
|
148
|
+
elementLocator = null;
|
|
149
|
+
trackTimer = null;
|
|
150
|
+
startTriggerLabel = null;
|
|
151
|
+
stopTriggerLabel = null;
|
|
152
|
+
triggerTimedOut = false;
|
|
153
|
+
stopTriggerTimer = null;
|
|
154
|
+
stopTimeoutTimer = null;
|
|
155
|
+
/** Rolling SSIM state for the idle stop trigger. */
|
|
156
|
+
idleSignature = null;
|
|
157
|
+
lastChangeMs = 0;
|
|
158
|
+
idleScanQueue = Promise.resolve();
|
|
159
|
+
result = null;
|
|
160
|
+
finishedResolve;
|
|
161
|
+
/**
|
|
162
|
+
* Resolves when the capture has stopped from ANY source - explicit stop(),
|
|
163
|
+
* the duration timer, or a stop trigger. Callers that started an open-ended
|
|
164
|
+
* capture await this instead of assuming they are the ones who will end it.
|
|
165
|
+
*/
|
|
166
|
+
finished = new Promise((resolve) => {
|
|
167
|
+
this.finishedResolve = resolve;
|
|
168
|
+
});
|
|
169
|
+
/**
|
|
170
|
+
* @param page Page to record. Must already be navigated - the engine never
|
|
171
|
+
* navigates, so the caller controls what is on screen at t=0.
|
|
172
|
+
* @param engine Browser family, which decides the capture method.
|
|
173
|
+
* @param baseDir Directory recordings are created under when no `outDir` is given.
|
|
174
|
+
*/
|
|
175
|
+
constructor(page, engine, baseDir) {
|
|
176
|
+
this.page = page;
|
|
177
|
+
this.engine = engine;
|
|
178
|
+
this.baseDir = baseDir;
|
|
179
|
+
}
|
|
180
|
+
// =======================================================================
|
|
181
|
+
// Lifecycle
|
|
182
|
+
// =======================================================================
|
|
183
|
+
async start(options = {}) {
|
|
184
|
+
if (this.state !== "idle") {
|
|
185
|
+
throw new Error(`Recording already ${this.state}; construct a new VideoCaptureSession per run`);
|
|
186
|
+
}
|
|
187
|
+
const fps = options.fps ?? DEFAULT_FPS;
|
|
188
|
+
if (!Number.isFinite(fps) || fps <= 0) {
|
|
189
|
+
throw new Error(`Invalid fps ${options.fps} - must be a positive number`);
|
|
190
|
+
}
|
|
191
|
+
this.opts = {
|
|
192
|
+
fps,
|
|
193
|
+
durationMs: options.durationMs,
|
|
194
|
+
format: options.format ?? ["gif"],
|
|
195
|
+
quality: options.quality ?? DEFAULT_QUALITY,
|
|
196
|
+
maxFrames: options.maxFrames ?? DEFAULT_MAX_FRAMES,
|
|
197
|
+
contactSheet: options.contactSheet ?? false,
|
|
198
|
+
triggerTimeoutMs: options.triggerTimeoutMs ?? DEFAULT_TRIGGER_TIMEOUT_MS,
|
|
199
|
+
};
|
|
200
|
+
this.startTrigger = options.startTrigger;
|
|
201
|
+
this.startDelayMs = options.startDelayMs ?? 0;
|
|
202
|
+
this.stopTrigger = options.stopTrigger;
|
|
203
|
+
this.target = options.target ?? { kind: "viewport" };
|
|
204
|
+
this.slug = options.slug ?? `recording-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
205
|
+
this.outDir = options.outDir ?? join(this.baseDir, this.slug);
|
|
206
|
+
this.framesDir = join(this.outDir, "frames");
|
|
207
|
+
mkdirSync(this.framesDir, { recursive: true });
|
|
208
|
+
const vp = this.page.viewportSize() ?? { width: 1280, height: 800 };
|
|
209
|
+
const scale = await this.page
|
|
210
|
+
.evaluate(() => window.devicePixelRatio)
|
|
211
|
+
.catch(() => 1);
|
|
212
|
+
this.viewport = { width: vp.width, height: vp.height, deviceScaleFactor: scale || 1 };
|
|
213
|
+
await this.setUpTarget(options);
|
|
214
|
+
// The start trigger is awaited BEFORE the clock starts, so t=0 is the
|
|
215
|
+
// moment the user asked to start from, not the moment the process launched.
|
|
216
|
+
if (this.startTrigger || this.startDelayMs > 0) {
|
|
217
|
+
const fired = await this.awaitStartTrigger(options);
|
|
218
|
+
if (!fired) {
|
|
219
|
+
// The condition never happened, so there is nothing the caller asked
|
|
220
|
+
// for to record. Write an honest empty manifest rather than capturing
|
|
221
|
+
// an arbitrary window and presenting it as the requested one.
|
|
222
|
+
this.state = "recording";
|
|
223
|
+
this.startWallMs = Date.now();
|
|
224
|
+
this.startedAtIso = new Date(this.startWallMs).toISOString();
|
|
225
|
+
this.triggerTimedOut = true;
|
|
226
|
+
this.stopTriggerLabel = "start-trigger-timeout";
|
|
227
|
+
await this.stop();
|
|
228
|
+
return this.status();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
this.attachPageListeners();
|
|
232
|
+
this.startWallMs = Date.now();
|
|
233
|
+
this.startedAtIso = new Date(this.startWallMs).toISOString();
|
|
234
|
+
this.state = "recording";
|
|
235
|
+
if (this.engine === "chromium") {
|
|
236
|
+
await this.startScreencast();
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
this.captureMethod = "screenshot-loop";
|
|
240
|
+
this.startScreenshotLoop();
|
|
241
|
+
}
|
|
242
|
+
// Bracket the window: a page that never paints again still has a frame at
|
|
243
|
+
// t=0, and `stop()` adds the closing frame.
|
|
244
|
+
await this.captureDirectFrame();
|
|
245
|
+
if (this.stopTrigger)
|
|
246
|
+
this.startStopTriggerWatch(options);
|
|
247
|
+
if (this.opts.durationMs !== undefined) {
|
|
248
|
+
this.durationTimer = setTimeout(() => {
|
|
249
|
+
if (!this.stopTriggerLabel)
|
|
250
|
+
this.stopTriggerLabel = "duration";
|
|
251
|
+
void this.stop().catch(() => { });
|
|
252
|
+
}, this.opts.durationMs);
|
|
253
|
+
// Do not hold the event loop open on behalf of the auto-stop timer.
|
|
254
|
+
this.durationTimer.unref?.();
|
|
255
|
+
}
|
|
256
|
+
return this.status();
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Stop capture, score the frames, write the manifest, run the encoders.
|
|
260
|
+
*
|
|
261
|
+
* Idempotent: concurrent and repeated calls all resolve to the same result,
|
|
262
|
+
* so an auto-stop racing an explicit stop cannot double-encode.
|
|
263
|
+
*/
|
|
264
|
+
async stop() {
|
|
265
|
+
if (this.stopping)
|
|
266
|
+
return this.stopping;
|
|
267
|
+
// An explicit stop wins over any pending trigger: whichever call arrives
|
|
268
|
+
// first labels the recording, and the rest collapse onto its result.
|
|
269
|
+
if (this.state === "recording" && !this.stopTriggerLabel)
|
|
270
|
+
this.stopTriggerLabel = "manual";
|
|
271
|
+
if (this.state === "idle") {
|
|
272
|
+
throw new Error("Recording was never started");
|
|
273
|
+
}
|
|
274
|
+
this.stopping = this.doStop();
|
|
275
|
+
return this.stopping;
|
|
276
|
+
}
|
|
277
|
+
status() {
|
|
278
|
+
return {
|
|
279
|
+
state: this.state,
|
|
280
|
+
slug: this.slug,
|
|
281
|
+
outDir: this.outDir,
|
|
282
|
+
frames: this.frames.length,
|
|
283
|
+
elapsedMs: this.state === "stopped" ? this.stopWallMs - this.startWallMs
|
|
284
|
+
: this.state === "recording" ? Date.now() - this.startWallMs
|
|
285
|
+
: 0,
|
|
286
|
+
captureMethod: this.captureMethod,
|
|
287
|
+
...(this.result ? { manifestPath: this.result.manifestPath } : {}),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Resolve the capture target into a crop rect and a locked output size.
|
|
292
|
+
*
|
|
293
|
+
* Output dimensions are fixed here, for the whole capture, because animated
|
|
294
|
+
* GIF/WebP and every video container require a constant frame size. An
|
|
295
|
+
* element that grows or shrinks is re-fitted into these locked dimensions
|
|
296
|
+
* rather than changing them mid-stream.
|
|
297
|
+
*/
|
|
298
|
+
async setUpTarget(options) {
|
|
299
|
+
switch (this.target.kind) {
|
|
300
|
+
case "viewport": {
|
|
301
|
+
this.currentCrop = null;
|
|
302
|
+
this.outputDims = {
|
|
303
|
+
width: VideoCaptureSession.outputDim(this.viewport.width),
|
|
304
|
+
height: VideoCaptureSession.outputDim(this.viewport.height),
|
|
305
|
+
};
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
case "region": {
|
|
309
|
+
// Overflow is trimmed, not rejected: "capture roughly there" is a
|
|
310
|
+
// reasonable ask, and the manifest reports the trim via region_clamped.
|
|
311
|
+
const { rect, clamped } = clampRect(this.target.rect, this.viewport);
|
|
312
|
+
this.target = { kind: "region", rect };
|
|
313
|
+
this.regionClamped = clamped;
|
|
314
|
+
this.currentCrop = rect;
|
|
315
|
+
this.outputDims = {
|
|
316
|
+
width: VideoCaptureSession.outputDim(rect.width),
|
|
317
|
+
height: VideoCaptureSession.outputDim(rect.height),
|
|
318
|
+
};
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
case "element": {
|
|
322
|
+
const { selector, padding } = this.target;
|
|
323
|
+
if (!options.resolveElement) {
|
|
324
|
+
throw new Error(`Cannot capture element "${selector}": no element resolver was supplied to the capture session`);
|
|
325
|
+
}
|
|
326
|
+
this.elementLocator = await options.resolveElement(selector);
|
|
327
|
+
if (!this.elementLocator) {
|
|
328
|
+
throw new Error(`Cannot capture element "${selector}": no element matched that selector`);
|
|
329
|
+
}
|
|
330
|
+
const rect = await this.measureElement();
|
|
331
|
+
if (!rect) {
|
|
332
|
+
throw new Error(`Cannot capture element "${selector}": it resolved but has no visible box (display:none, zero size, or fully off-screen)`);
|
|
333
|
+
}
|
|
334
|
+
this.currentCrop = rect;
|
|
335
|
+
this.currentTracking = "ok";
|
|
336
|
+
this.outputDims = {
|
|
337
|
+
width: VideoCaptureSession.outputDim(rect.width),
|
|
338
|
+
height: VideoCaptureSession.outputDim(rect.height),
|
|
339
|
+
};
|
|
340
|
+
this.startElementTracking();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Measure the tracked element, padded and clamped into the viewport.
|
|
347
|
+
* Returns null when the element has no usable box right now.
|
|
348
|
+
*/
|
|
349
|
+
async measureElement() {
|
|
350
|
+
if (!this.elementLocator || this.target.kind !== "element")
|
|
351
|
+
return null;
|
|
352
|
+
const padding = this.target.padding;
|
|
353
|
+
try {
|
|
354
|
+
const box = await this.elementLocator.boundingBox({ timeout: 1000 });
|
|
355
|
+
if (!box || box.width <= 0 || box.height <= 0)
|
|
356
|
+
return null;
|
|
357
|
+
const padded = {
|
|
358
|
+
x: box.x - padding,
|
|
359
|
+
y: box.y - padding,
|
|
360
|
+
width: box.width + padding * 2,
|
|
361
|
+
height: box.height + padding * 2,
|
|
362
|
+
};
|
|
363
|
+
// clampRect throws when the padded box has left the viewport entirely,
|
|
364
|
+
// which is a tracking outcome rather than a capture failure.
|
|
365
|
+
return clampRect(padded, this.viewport).rect;
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Re-measure the tracked element on the frame cadence so `crop` follows it as
|
|
373
|
+
* it moves. Polling rather than measuring inside the screencast handler: that
|
|
374
|
+
* handler must stay synchronous to ack frames promptly, and an await there
|
|
375
|
+
* would stall the stream.
|
|
376
|
+
*/
|
|
377
|
+
startElementTracking() {
|
|
378
|
+
const intervalMs = 1000 / this.opts.fps;
|
|
379
|
+
let inFlight = false;
|
|
380
|
+
this.trackTimer = setInterval(() => {
|
|
381
|
+
if (this.state !== "recording" || inFlight)
|
|
382
|
+
return;
|
|
383
|
+
inFlight = true;
|
|
384
|
+
void this.measureElement()
|
|
385
|
+
.then((rect) => {
|
|
386
|
+
if (rect) {
|
|
387
|
+
this.currentCrop = rect;
|
|
388
|
+
this.currentTracking = "ok";
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
// Element detached, hidden or scrolled away: keep capturing with
|
|
392
|
+
// the last known rect and mark the frames stale, so the recording
|
|
393
|
+
// shows what happened instead of ending.
|
|
394
|
+
this.currentTracking = "stale";
|
|
395
|
+
}
|
|
396
|
+
})
|
|
397
|
+
.catch(() => { this.currentTracking = "stale"; })
|
|
398
|
+
.finally(() => { inFlight = false; });
|
|
399
|
+
}, intervalMs);
|
|
400
|
+
this.trackTimer.unref?.();
|
|
401
|
+
}
|
|
402
|
+
// =======================================================================
|
|
403
|
+
// Triggers
|
|
404
|
+
// =======================================================================
|
|
405
|
+
/**
|
|
406
|
+
* Wait for the start trigger, then the start delay.
|
|
407
|
+
*
|
|
408
|
+
* Returns false when the trigger timed out. Timing out is a reportable
|
|
409
|
+
* outcome, not an exception: the caller still gets a manifest saying which
|
|
410
|
+
* trigger was asked for and that it never fired.
|
|
411
|
+
*/
|
|
412
|
+
async awaitStartTrigger(options) {
|
|
413
|
+
const began = Date.now();
|
|
414
|
+
const trigger = this.startTrigger;
|
|
415
|
+
if (trigger) {
|
|
416
|
+
const fired = await this.waitForCondition(() => this.startConditionHolds(trigger, options), this.opts.triggerTimeoutMs);
|
|
417
|
+
if (!fired) {
|
|
418
|
+
this.startTriggerLabel = `${trigger.kind === "lifecycle" ? trigger.event : `element:${trigger.selector}`} (timed out after ${this.opts.triggerTimeoutMs}ms)`;
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (this.startDelayMs > 0) {
|
|
423
|
+
await new Promise((r) => setTimeout(r, this.startDelayMs));
|
|
424
|
+
}
|
|
425
|
+
this.startTriggerLabel = trigger
|
|
426
|
+
? describeStartTrigger(trigger, this.startDelayMs, Date.now() - began)
|
|
427
|
+
: `delay:${this.startDelayMs}ms`;
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
/** Whether the start condition currently holds. */
|
|
431
|
+
async startConditionHolds(trigger, options) {
|
|
432
|
+
if (trigger.kind === "lifecycle") {
|
|
433
|
+
try {
|
|
434
|
+
// Already-fired lifecycle events resolve immediately; the short timeout
|
|
435
|
+
// keeps this a poll rather than a second, competing wait.
|
|
436
|
+
await this.page.waitForLoadState(trigger.event, { timeout: 500 });
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const locator = options.resolveElement
|
|
444
|
+
? await options.resolveElement(trigger.selector).catch(() => null)
|
|
445
|
+
: this.page.locator(trigger.selector).first();
|
|
446
|
+
if (!locator)
|
|
447
|
+
return false;
|
|
448
|
+
// Present AND visible: an element in the DOM but not painted is not
|
|
449
|
+
// something a viewer would call "there yet".
|
|
450
|
+
return locator.isVisible({ timeout: 500 }).catch(() => false);
|
|
451
|
+
}
|
|
452
|
+
/** Poll a condition until it holds or the budget runs out. */
|
|
453
|
+
async waitForCondition(holds, timeoutMs) {
|
|
454
|
+
const deadline = Date.now() + timeoutMs;
|
|
455
|
+
const intervalMs = Math.min(100, Math.max(20, 1000 / this.opts.fps));
|
|
456
|
+
while (Date.now() < deadline) {
|
|
457
|
+
if (await holds().catch(() => false))
|
|
458
|
+
return true;
|
|
459
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
460
|
+
}
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Watch for the stop condition, and enforce the trigger timeout.
|
|
465
|
+
*
|
|
466
|
+
* A stop trigger that never fires must not hang the capture: the timeout
|
|
467
|
+
* stops it anyway and flags `trigger_timeout`, keeping every frame captured
|
|
468
|
+
* up to that point.
|
|
469
|
+
*/
|
|
470
|
+
startStopTriggerWatch(options) {
|
|
471
|
+
const trigger = this.stopTrigger;
|
|
472
|
+
if (!trigger)
|
|
473
|
+
return;
|
|
474
|
+
if (trigger.kind === "idle") {
|
|
475
|
+
this.lastChangeMs = Date.now();
|
|
476
|
+
this.watchIdle(trigger.idleMs);
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
const intervalMs = 1000 / this.opts.fps;
|
|
480
|
+
let inFlight = false;
|
|
481
|
+
this.stopTriggerTimer = setInterval(() => {
|
|
482
|
+
if (this.state !== "recording" || inFlight)
|
|
483
|
+
return;
|
|
484
|
+
inFlight = true;
|
|
485
|
+
void this.stopConditionHolds(trigger, options)
|
|
486
|
+
.then((fired) => {
|
|
487
|
+
if (fired && this.state === "recording") {
|
|
488
|
+
this.stopTriggerLabel = describeStopTrigger(trigger);
|
|
489
|
+
void this.stop().catch(() => { });
|
|
490
|
+
}
|
|
491
|
+
})
|
|
492
|
+
.catch(() => { })
|
|
493
|
+
.finally(() => { inFlight = false; });
|
|
494
|
+
}, intervalMs);
|
|
495
|
+
this.stopTriggerTimer.unref?.();
|
|
496
|
+
}
|
|
497
|
+
this.stopTimeoutTimer = setTimeout(() => {
|
|
498
|
+
if (this.state !== "recording")
|
|
499
|
+
return;
|
|
500
|
+
this.triggerTimedOut = true;
|
|
501
|
+
this.stopTriggerLabel = `${describeStopTrigger(trigger)} (timed out after ${this.opts.triggerTimeoutMs}ms)`;
|
|
502
|
+
void this.stop().catch(() => { });
|
|
503
|
+
}, this.opts.triggerTimeoutMs);
|
|
504
|
+
this.stopTimeoutTimer.unref?.();
|
|
505
|
+
}
|
|
506
|
+
/** Whether an element-based stop condition currently holds. */
|
|
507
|
+
async stopConditionHolds(trigger, options) {
|
|
508
|
+
if (trigger.kind === "idle")
|
|
509
|
+
return false;
|
|
510
|
+
const selector = trigger.selector;
|
|
511
|
+
const locator = options.resolveElement
|
|
512
|
+
? await options.resolveElement(selector).catch(() => null)
|
|
513
|
+
: this.page.locator(selector).first();
|
|
514
|
+
if (!locator)
|
|
515
|
+
return trigger.kind === "element-disappears";
|
|
516
|
+
const visible = await locator.isVisible({ timeout: 500 }).catch(() => false);
|
|
517
|
+
return trigger.kind === "element-appears" ? visible : !visible;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Stop once no frame has changed for `idleMs`.
|
|
521
|
+
*
|
|
522
|
+
* Reuses the same SSIM comparator and threshold that produce `change_points`,
|
|
523
|
+
* so "changed" means exactly the same thing live as it does in the manifest.
|
|
524
|
+
*/
|
|
525
|
+
watchIdle(idleMs) {
|
|
526
|
+
let lastScanned = -1;
|
|
527
|
+
this.stopTriggerTimer = setInterval(() => {
|
|
528
|
+
if (this.state !== "recording")
|
|
529
|
+
return;
|
|
530
|
+
const latest = this.frames.length - 1;
|
|
531
|
+
if (latest > lastScanned) {
|
|
532
|
+
const frame = this.frames[latest];
|
|
533
|
+
lastScanned = latest;
|
|
534
|
+
// Signatures are queued so scanning never overlaps itself and never
|
|
535
|
+
// blocks the screencast handler.
|
|
536
|
+
this.idleScanQueue = this.idleScanQueue
|
|
537
|
+
.then(() => this.writeQueue)
|
|
538
|
+
.then(() => frameSignature(frame.path))
|
|
539
|
+
.then((signature) => {
|
|
540
|
+
if (this.idleSignature && ssim(this.idleSignature, signature) < IDLE_THRESHOLD) {
|
|
541
|
+
this.lastChangeMs = Date.now();
|
|
542
|
+
}
|
|
543
|
+
this.idleSignature = signature;
|
|
544
|
+
})
|
|
545
|
+
.catch(() => { });
|
|
546
|
+
}
|
|
547
|
+
if (Date.now() - this.lastChangeMs >= idleMs) {
|
|
548
|
+
this.stopTriggerLabel = `idle:${idleMs}ms`;
|
|
549
|
+
void this.stop().catch(() => { });
|
|
550
|
+
}
|
|
551
|
+
}, Math.min(100, 1000 / this.opts.fps));
|
|
552
|
+
this.stopTriggerTimer.unref?.();
|
|
553
|
+
}
|
|
554
|
+
// =======================================================================
|
|
555
|
+
// Capture
|
|
556
|
+
// =======================================================================
|
|
557
|
+
async startScreencast() {
|
|
558
|
+
this.cdp = await this.page.context().newCDPSession(this.page);
|
|
559
|
+
this.cdp.on("Page.screencastFrame", (event) => {
|
|
560
|
+
const tMs = Date.now() - this.startWallMs;
|
|
561
|
+
// The ack must go out for every frame regardless of whether the frame is
|
|
562
|
+
// kept - Chromium stops emitting until the outstanding frame is acked.
|
|
563
|
+
this.cdp?.send("Page.screencastFrameAck", { sessionId: event.sessionId }).catch(() => {
|
|
564
|
+
/* session detached mid-flight; nothing left to ack */
|
|
565
|
+
});
|
|
566
|
+
if (this.state !== "recording")
|
|
567
|
+
return;
|
|
568
|
+
if (!this.shouldKeep(tMs))
|
|
569
|
+
return;
|
|
570
|
+
this.enqueueFrame(tMs, Buffer.from(event.data, "base64"));
|
|
571
|
+
});
|
|
572
|
+
await this.cdp.send("Page.startScreencast", {
|
|
573
|
+
format: "jpeg",
|
|
574
|
+
quality: this.opts.quality,
|
|
575
|
+
everyNthFrame: 1,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
/** Firefox/WebKit fallback: poll `page.screenshot` at the target rate. */
|
|
579
|
+
startScreenshotLoop() {
|
|
580
|
+
const intervalMs = 1000 / this.opts.fps;
|
|
581
|
+
let inFlight = false;
|
|
582
|
+
this.loopTimer = setInterval(() => {
|
|
583
|
+
if (this.state !== "recording" || inFlight)
|
|
584
|
+
return;
|
|
585
|
+
inFlight = true;
|
|
586
|
+
void this.captureDirectFrame()
|
|
587
|
+
.catch(() => { })
|
|
588
|
+
.finally(() => { inFlight = false; });
|
|
589
|
+
}, intervalMs);
|
|
590
|
+
this.loopTimer.unref?.();
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Capture one frame directly through Playwright rather than the screencast.
|
|
594
|
+
* Used to bracket the recording and to drive the non-Chromium fallback.
|
|
595
|
+
*/
|
|
596
|
+
async captureDirectFrame() {
|
|
597
|
+
try {
|
|
598
|
+
const buffer = await this.page.screenshot({ type: "jpeg", quality: this.opts.quality });
|
|
599
|
+
// Stamped on completion, not on entry: a screencast frame can land while
|
|
600
|
+
// the screenshot is in flight, and an entry-time stamp would order this
|
|
601
|
+
// frame behind a frame that was captured after it.
|
|
602
|
+
this.enqueueFrame(Date.now() - this.startWallMs, buffer);
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
// Page closed or navigating: no frame is better than a fabricated one.
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Rate-limit screencast frames to the requested fps.
|
|
610
|
+
*
|
|
611
|
+
* Frames are matched against a fixed grid of due times rather than against
|
|
612
|
+
* "time since the last kept frame". A since-last rule compounds every paint
|
|
613
|
+
* event's jitter into the next interval and drifts the achieved rate away
|
|
614
|
+
* from the target; the grid holds the average at exactly `fps`.
|
|
615
|
+
*/
|
|
616
|
+
shouldKeep(tMs) {
|
|
617
|
+
if (this.frames.length >= this.opts.maxFrames)
|
|
618
|
+
return false;
|
|
619
|
+
if (tMs + FRAME_DUE_TOLERANCE_MS < this.nextDueMs)
|
|
620
|
+
return false;
|
|
621
|
+
const interval = 1000 / this.opts.fps;
|
|
622
|
+
this.nextDueMs += interval;
|
|
623
|
+
// A stall longer than one interval must not bank credit for a burst of
|
|
624
|
+
// frames once painting resumes; re-base the grid on the current time.
|
|
625
|
+
if (this.nextDueMs < tMs)
|
|
626
|
+
this.nextDueMs = tMs + interval;
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Record a frame and stream its bytes to disk.
|
|
631
|
+
*
|
|
632
|
+
* The index and timestamp are assigned synchronously so frame ordering
|
|
633
|
+
* follows arrival order; only the write is deferred, serialised through a
|
|
634
|
+
* queue so a slow disk cannot reorder frames or unbound memory.
|
|
635
|
+
*/
|
|
636
|
+
enqueueFrame(rawTMs, buffer) {
|
|
637
|
+
// Absolute cap, checked here rather than only in shouldKeep, because the
|
|
638
|
+
// bracket frames captured directly at start and stop bypass shouldKeep -
|
|
639
|
+
// and a --max-frames 5 that delivers 6 is a documented number the tool
|
|
640
|
+
// does not honour.
|
|
641
|
+
if (this.frames.length >= this.opts.maxFrames)
|
|
642
|
+
return;
|
|
643
|
+
const index = this.frames.length;
|
|
644
|
+
// The manifest contract requires non-decreasing timestamps. Timestamps are
|
|
645
|
+
// assigned at capture completion so they already arrive in order; this
|
|
646
|
+
// floor is the backstop that keeps a clock adjustment or an unforeseen
|
|
647
|
+
// interleaving from producing an unparseable manifest.
|
|
648
|
+
const previous = this.frames[index - 1];
|
|
649
|
+
const tMs = previous ? Math.max(rawTMs, previous.tMs) : Math.max(0, rawTMs);
|
|
650
|
+
const name = `${String(index).padStart(4, "0")}.jpg`;
|
|
651
|
+
const path = join(this.framesDir, name);
|
|
652
|
+
this.frames.push({
|
|
653
|
+
index,
|
|
654
|
+
tMs,
|
|
655
|
+
path,
|
|
656
|
+
// Snapshotted, not referenced: currentCrop is re-measured on the tracking
|
|
657
|
+
// timer, and a shared reference would rewrite every earlier frame's crop.
|
|
658
|
+
crop: this.currentCrop
|
|
659
|
+
? { ...this.currentCrop }
|
|
660
|
+
: { x: 0, y: 0, width: this.viewport.width, height: this.viewport.height },
|
|
661
|
+
tracking: this.currentTracking,
|
|
662
|
+
});
|
|
663
|
+
this.writeQueue = this.writeQueue.then(() => {
|
|
664
|
+
writeFileSync(path, buffer);
|
|
665
|
+
}).catch(() => { });
|
|
666
|
+
}
|
|
667
|
+
attachPageListeners() {
|
|
668
|
+
this.consoleListener = (msg) => {
|
|
669
|
+
if (this.state !== "recording")
|
|
670
|
+
return;
|
|
671
|
+
this.consoleEvents.push({
|
|
672
|
+
t_ms: Math.max(0, Date.now() - this.startWallMs),
|
|
673
|
+
type: msg.type(),
|
|
674
|
+
text: msg.text(),
|
|
675
|
+
});
|
|
676
|
+
};
|
|
677
|
+
this.requestListener = (req) => {
|
|
678
|
+
if (this.state !== "recording")
|
|
679
|
+
return;
|
|
680
|
+
this.networkEvents.push({
|
|
681
|
+
t_ms: Math.max(0, Date.now() - this.startWallMs),
|
|
682
|
+
url: req.url(),
|
|
683
|
+
method: req.method(),
|
|
684
|
+
});
|
|
685
|
+
};
|
|
686
|
+
this.page.on("console", this.consoleListener);
|
|
687
|
+
this.page.on("request", this.requestListener);
|
|
688
|
+
}
|
|
689
|
+
detachPageListeners() {
|
|
690
|
+
if (this.consoleListener)
|
|
691
|
+
this.page.off("console", this.consoleListener);
|
|
692
|
+
if (this.requestListener)
|
|
693
|
+
this.page.off("request", this.requestListener);
|
|
694
|
+
this.consoleListener = null;
|
|
695
|
+
this.requestListener = null;
|
|
696
|
+
}
|
|
697
|
+
// =======================================================================
|
|
698
|
+
// Stop / manifest / encode
|
|
699
|
+
// =======================================================================
|
|
700
|
+
async doStop() {
|
|
701
|
+
if (this.durationTimer) {
|
|
702
|
+
clearTimeout(this.durationTimer);
|
|
703
|
+
this.durationTimer = null;
|
|
704
|
+
}
|
|
705
|
+
if (this.loopTimer) {
|
|
706
|
+
clearInterval(this.loopTimer);
|
|
707
|
+
this.loopTimer = null;
|
|
708
|
+
}
|
|
709
|
+
if (this.trackTimer) {
|
|
710
|
+
clearInterval(this.trackTimer);
|
|
711
|
+
this.trackTimer = null;
|
|
712
|
+
}
|
|
713
|
+
if (this.stopTriggerTimer) {
|
|
714
|
+
clearInterval(this.stopTriggerTimer);
|
|
715
|
+
this.stopTriggerTimer = null;
|
|
716
|
+
}
|
|
717
|
+
if (this.stopTimeoutTimer) {
|
|
718
|
+
clearTimeout(this.stopTimeoutTimer);
|
|
719
|
+
this.stopTimeoutTimer = null;
|
|
720
|
+
}
|
|
721
|
+
// Close the window with a real frame when the stream has gone quiet, so the
|
|
722
|
+
// recording's last timestamp reflects the requested duration instead of
|
|
723
|
+
// whenever the page happened to stop painting.
|
|
724
|
+
const nowMs = Date.now() - this.startWallMs;
|
|
725
|
+
const lastMs = this.frames.length > 0 ? this.frames[this.frames.length - 1].tMs : -Infinity;
|
|
726
|
+
if (nowMs - lastMs > (1000 / this.opts.fps) * CLOSING_FRAME_THRESHOLD) {
|
|
727
|
+
await this.captureDirectFrame();
|
|
728
|
+
}
|
|
729
|
+
this.state = "stopped";
|
|
730
|
+
this.stopWallMs = Date.now();
|
|
731
|
+
this.detachPageListeners();
|
|
732
|
+
if (this.cdp) {
|
|
733
|
+
await this.cdp.send("Page.stopScreencast").catch(() => { });
|
|
734
|
+
await this.cdp.detach().catch(() => { });
|
|
735
|
+
this.cdp = null;
|
|
736
|
+
}
|
|
737
|
+
await this.writeQueue;
|
|
738
|
+
const durationMs = Math.max(this.stopWallMs - this.startWallMs, this.frames.length > 0 ? this.frames[this.frames.length - 1].tMs : 0);
|
|
739
|
+
const manifest = await this.buildManifest(durationMs);
|
|
740
|
+
const manifestPath = join(this.outDir, "manifest.json");
|
|
741
|
+
const encodeErrors = await this.runEncoders(manifest, durationMs);
|
|
742
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
743
|
+
this.result = { manifestPath, manifest, encodeErrors };
|
|
744
|
+
this.finishedResolve(this.result);
|
|
745
|
+
return this.result;
|
|
746
|
+
}
|
|
747
|
+
async buildManifest(durationMs) {
|
|
748
|
+
const scores = await this.scoreFrames();
|
|
749
|
+
const changePoints = detectChangePoints(scores, CHANGE_POINT_THRESHOLD);
|
|
750
|
+
// `anomaly` keeps the stricter "something jumped" meaning, so the two
|
|
751
|
+
// fields answer different questions instead of duplicating one.
|
|
752
|
+
const anomalies = new Set(detectChangePoints(scores, CHANGE_THRESHOLD));
|
|
753
|
+
const tMs = this.frames.map((f) => f.tMs);
|
|
754
|
+
const frames = this.frames.map((f, i) => ({
|
|
755
|
+
index: f.index,
|
|
756
|
+
t_ms: f.tMs,
|
|
757
|
+
path: relative(this.outDir, f.path),
|
|
758
|
+
crop: f.crop,
|
|
759
|
+
ssim_prev: scores[i] ?? null,
|
|
760
|
+
anomaly: anomalies.has(i),
|
|
761
|
+
tracking: f.tracking,
|
|
762
|
+
console: this.eventsForFrame(this.consoleEvents, i),
|
|
763
|
+
network: this.eventsForFrame(this.networkEvents, i),
|
|
764
|
+
}));
|
|
765
|
+
const candidate = {
|
|
766
|
+
version: MANIFEST_VERSION,
|
|
767
|
+
slug: this.slug,
|
|
768
|
+
engine: this.engine,
|
|
769
|
+
capture_method: this.captureMethod,
|
|
770
|
+
target_fps: this.opts.fps,
|
|
771
|
+
actual_fps: durationMs > 0 ? frames.length / (durationMs / 1000) : 0,
|
|
772
|
+
viewport: this.viewport,
|
|
773
|
+
target: this.target,
|
|
774
|
+
start_trigger: this.startTriggerLabel,
|
|
775
|
+
stop_trigger: this.stopTriggerLabel,
|
|
776
|
+
trigger_timeout: this.triggerTimedOut,
|
|
777
|
+
started_at: this.startedAtIso,
|
|
778
|
+
duration_ms: durationMs,
|
|
779
|
+
output_dims: this.outputDims,
|
|
780
|
+
frames,
|
|
781
|
+
change_points: changePoints,
|
|
782
|
+
frame_gaps: tMs.length > 0 ? findFrameGaps(tMs, this.opts.fps) : [],
|
|
783
|
+
region_clamped: this.regionClamped,
|
|
784
|
+
artifacts: {},
|
|
785
|
+
};
|
|
786
|
+
return parseManifest(candidate);
|
|
787
|
+
}
|
|
788
|
+
/** SSIM of each frame against its predecessor; index 0 is always null. */
|
|
789
|
+
async scoreFrames() {
|
|
790
|
+
const scores = [];
|
|
791
|
+
let previous = null;
|
|
792
|
+
for (const frame of this.frames) {
|
|
793
|
+
let signature = null;
|
|
794
|
+
try {
|
|
795
|
+
signature = await frameSignature(frame.path);
|
|
796
|
+
}
|
|
797
|
+
catch {
|
|
798
|
+
// An unreadable frame scores null rather than poisoning its neighbours.
|
|
799
|
+
}
|
|
800
|
+
scores.push(previous && signature ? ssim(previous, signature) : null);
|
|
801
|
+
if (signature)
|
|
802
|
+
previous = signature;
|
|
803
|
+
}
|
|
804
|
+
return scores;
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Attach each side-channel event to the frame whose display window contains
|
|
808
|
+
* it: the last frame at or before the event, or frame 0 for anything that
|
|
809
|
+
* fired before the first frame landed.
|
|
810
|
+
*/
|
|
811
|
+
eventsForFrame(events, index) {
|
|
812
|
+
if (this.frames.length === 0)
|
|
813
|
+
return [];
|
|
814
|
+
const start = this.frames[index].tMs;
|
|
815
|
+
const next = this.frames[index + 1];
|
|
816
|
+
const end = next ? next.tMs : Infinity;
|
|
817
|
+
return events.filter((e) => index === 0 ? e.t_ms < end : e.t_ms >= start && e.t_ms < end);
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Run the requested encoders. An encoder failure is recorded and reported but
|
|
821
|
+
* never thrown - the frames and manifest are the durable artifact, and losing
|
|
822
|
+
* a recording because a codec is missing would be the worst possible trade.
|
|
823
|
+
*/
|
|
824
|
+
async runEncoders(manifest, durationMs) {
|
|
825
|
+
const errors = {};
|
|
826
|
+
if (this.frames.length === 0)
|
|
827
|
+
return errors;
|
|
828
|
+
// The contact sheet samples the REAL frames, so it keeps the unexpanded
|
|
829
|
+
// list - expanded repeats would fill the sheet with duplicate tail frames.
|
|
830
|
+
const paths = this.frames.map((f) => f.path);
|
|
831
|
+
// A long quiet tail produces one enormous final delay, and GIF/WebP cap a
|
|
832
|
+
// single delay at 65535ms. Capping alone would silently truncate playback
|
|
833
|
+
// (a 5-minute recording rendered as 68 seconds), so the held frame is
|
|
834
|
+
// repeated instead: identical consecutive frames merge back into one page
|
|
835
|
+
// whose delays sum to the real hold. Frames and crops expand TOGETHER,
|
|
836
|
+
// because encoders index crops per-frame and expanding one alone would
|
|
837
|
+
// misalign every crop after the first repeat.
|
|
838
|
+
const expanded = expandForPlayback(this.frames.map((f) => ({ path: f.path, crop: f.crop })), this.frames.map((f) => f.tMs), Math.max(durationMs, 1));
|
|
839
|
+
const encodePaths = expanded.items.map((i) => i.path);
|
|
840
|
+
const delays = expanded.delays;
|
|
841
|
+
// Frames on disk are full-viewport; the crop is applied here, at encode
|
|
842
|
+
// time, which is what keeps a capture re-croppable after the fact.
|
|
843
|
+
const cropOpts = this.target.kind === "viewport"
|
|
844
|
+
? { width: this.outputDims.width, height: this.outputDims.height }
|
|
845
|
+
: {
|
|
846
|
+
width: this.outputDims.width,
|
|
847
|
+
height: this.outputDims.height,
|
|
848
|
+
crops: expanded.items.map((i) => i.crop),
|
|
849
|
+
};
|
|
850
|
+
if (this.opts.contactSheet) {
|
|
851
|
+
const sheetOpts = typeof this.opts.contactSheet === "object" ? this.opts.contactSheet : {};
|
|
852
|
+
const sheetPath = join(this.outDir, `${this.slug}-contact-sheet.jpg`);
|
|
853
|
+
try {
|
|
854
|
+
const sheet = await buildContactSheet({ framePaths: paths, outPath: sheetPath, ...sheetOpts });
|
|
855
|
+
manifest.artifacts["contact-sheet"] = relative(this.outDir, sheet.path);
|
|
856
|
+
}
|
|
857
|
+
catch (err) {
|
|
858
|
+
errors["contact-sheet"] = `contact sheet failed: ${err.message}`;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
for (const format of this.opts.format) {
|
|
862
|
+
const outPath = join(this.outDir, `${this.slug}.${format}`);
|
|
863
|
+
try {
|
|
864
|
+
switch (format) {
|
|
865
|
+
case "gif":
|
|
866
|
+
await encodeGif(encodePaths, delays, outPath, cropOpts);
|
|
867
|
+
break;
|
|
868
|
+
case "webp":
|
|
869
|
+
await encodeWebp(encodePaths, delays, outPath, cropOpts);
|
|
870
|
+
break;
|
|
871
|
+
case "webm":
|
|
872
|
+
await encodeWebm(encodePaths, delays, outPath, { ...cropOpts, fps: this.opts.fps });
|
|
873
|
+
break;
|
|
874
|
+
case "mp4":
|
|
875
|
+
await encodeMp4(encodePaths, delays, outPath, { ...cropOpts, fps: this.opts.fps });
|
|
876
|
+
break;
|
|
877
|
+
}
|
|
878
|
+
if (existsSync(outPath))
|
|
879
|
+
manifest.artifacts[format] = relative(this.outDir, outPath);
|
|
880
|
+
}
|
|
881
|
+
catch (err) {
|
|
882
|
+
errors[format] = err instanceof MissingEncoderError
|
|
883
|
+
? err.message
|
|
884
|
+
: `${format} encode failed: ${err.message}`;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return errors;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
//# sourceMappingURL=engine.js.map
|