codexmeter 1.0.2 → 1.0.3
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/README.md +2 -1
- package/dist/assets/index-CBnwoOLi.js +117 -0
- package/dist/assets/index-DhDq9dI8.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/aggregator.js +9 -5
- package/server/export-video.js +501 -44
- package/server/index.js +17 -2
- package/server/live-state.js +9 -6
- package/src/utils/animationsDefault.js +42 -6
- package/dist/assets/index-C0qLYGUY.css +0 -1
- package/dist/assets/index-M87czq50.js +0 -113
package/server/export-video.js
CHANGED
|
@@ -4,6 +4,8 @@ import os from 'os';
|
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { randomUUID } from 'crypto';
|
|
6
6
|
import { spawn } from 'child_process';
|
|
7
|
+
import process from 'process';
|
|
8
|
+
import { createRequire } from 'module';
|
|
7
9
|
import ffmpegPath from 'ffmpeg-static';
|
|
8
10
|
import { chromium } from 'playwright-core';
|
|
9
11
|
import { OVERVIEW_INGEST_ANIMATION } from '../src/utils/animationsDefault.js';
|
|
@@ -11,21 +13,25 @@ import { OVERVIEW_INGEST_ANIMATION } from '../src/utils/animationsDefault.js';
|
|
|
11
13
|
const EXPORT_WIDTH = OVERVIEW_INGEST_ANIMATION.videoExport?.width ?? 1080;
|
|
12
14
|
const EXPORT_HEIGHT = OVERVIEW_INGEST_ANIMATION.videoExport?.height ?? 864;
|
|
13
15
|
const EXPORT_FPS = OVERVIEW_INGEST_ANIMATION.videoExport?.fps ?? 60;
|
|
14
|
-
const
|
|
16
|
+
const EXPORT_SUPERSAMPLE_SCALE = Math.max(1, Number(OVERVIEW_INGEST_ANIMATION.videoExport?.supersampleScale ?? 1) || 1);
|
|
17
|
+
const EXPORT_FRONTLOAD_SETTLED_FRAME_COUNT = Math.max(0, Math.round(OVERVIEW_INGEST_ANIMATION.videoExport?.frontloadSettledFrameCount ?? 1));
|
|
18
|
+
const EXPORT_START_HOLD_DURATION_MS = OVERVIEW_INGEST_ANIMATION.videoExport?.startHoldDurationMs ?? 500;
|
|
15
19
|
const EXPORT_REPLAY_DURATION_MS = OVERVIEW_INGEST_ANIMATION.videoExport?.replayDurationMs ?? 8000;
|
|
16
20
|
const EXPORT_TAIL_DURATION_MS = OVERVIEW_INGEST_ANIMATION.videoExport?.tailDurationMs ?? 5000;
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
+
const EXPORT_FINAL_HOLD_DURATION_MS = OVERVIEW_INGEST_ANIMATION.videoExport?.finalHoldDurationMs ?? 3500;
|
|
22
|
+
const EXPORT_TOTAL_DURATION_MS =
|
|
23
|
+
EXPORT_START_HOLD_DURATION_MS + EXPORT_REPLAY_DURATION_MS + EXPORT_TAIL_DURATION_MS + EXPORT_FINAL_HOLD_DURATION_MS;
|
|
24
|
+
const EXPORT_TAIL_SOURCE_FRACTION = OVERVIEW_INGEST_ANIMATION.videoExport?.tailSourceFraction ?? 0.035;
|
|
21
25
|
const EXPORT_CRF = OVERVIEW_INGEST_ANIMATION.videoExport?.crf ?? 20;
|
|
22
26
|
const EXPORT_ENCODER_PRESET = OVERVIEW_INGEST_ANIMATION.videoExport?.encoderPreset ?? 'fast';
|
|
27
|
+
let portableBrowserSupportCache = null;
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
23
29
|
|
|
24
30
|
export function createExportManager({ getReplay, getSettledEnvelope, getBaseUrl }) {
|
|
25
31
|
const jobs = new Map();
|
|
26
32
|
|
|
27
33
|
return {
|
|
28
|
-
async startOverviewVideoJob(clientBaseUrl) {
|
|
34
|
+
async startOverviewVideoJob(clientBaseUrl, opts = {}) {
|
|
29
35
|
const replay = getReplay();
|
|
30
36
|
const settledEnvelope = getSettledEnvelope ? getSettledEnvelope() : null;
|
|
31
37
|
if (!replay?.bootstrap) {
|
|
@@ -40,12 +46,14 @@ export function createExportManager({ getReplay, getSettledEnvelope, getBaseUrl
|
|
|
40
46
|
const outputPath = path.join(tempDir, 'codexmeter-overview.mp4');
|
|
41
47
|
await fs.mkdir(framesDir, { recursive: true });
|
|
42
48
|
|
|
49
|
+
const initialBrowserPath = findSupportedBrowserExecutable();
|
|
50
|
+
const willDownloadPortableBrowser = Boolean(opts.installPortableBrowser) && !initialBrowserPath;
|
|
43
51
|
const job = {
|
|
44
52
|
id: jobId,
|
|
45
53
|
type: 'overview-video',
|
|
46
|
-
status: 'queued',
|
|
47
|
-
phase: 'preparing',
|
|
48
|
-
progress: 0,
|
|
54
|
+
status: willDownloadPortableBrowser ? 'running' : 'queued',
|
|
55
|
+
phase: willDownloadPortableBrowser ? 'downloading_browser' : 'preparing',
|
|
56
|
+
progress: willDownloadPortableBrowser ? 0.03 : 0,
|
|
49
57
|
created_at: new Date().toISOString(),
|
|
50
58
|
updated_at: new Date().toISOString(),
|
|
51
59
|
replay_ingest_id: replay.ingest_id,
|
|
@@ -54,13 +62,17 @@ export function createExportManager({ getReplay, getSettledEnvelope, getBaseUrl
|
|
|
54
62
|
width: EXPORT_WIDTH,
|
|
55
63
|
height: EXPORT_HEIGHT,
|
|
56
64
|
fps: EXPORT_FPS,
|
|
57
|
-
|
|
65
|
+
supersample_scale: EXPORT_SUPERSAMPLE_SCALE,
|
|
66
|
+
frontload_settled_frame_count: EXPORT_FRONTLOAD_SETTLED_FRAME_COUNT,
|
|
67
|
+
frontload_settled_duration_ms: 0,
|
|
68
|
+
start_hold_duration_ms: EXPORT_START_HOLD_DURATION_MS,
|
|
58
69
|
replay_duration_ms: EXPORT_REPLAY_DURATION_MS,
|
|
59
70
|
tail_duration_ms: EXPORT_TAIL_DURATION_MS,
|
|
60
|
-
|
|
71
|
+
final_hold_duration_ms: EXPORT_FINAL_HOLD_DURATION_MS,
|
|
72
|
+
tail_source_fraction: EXPORT_TAIL_SOURCE_FRACTION,
|
|
61
73
|
duration_ms: EXPORT_TOTAL_DURATION_MS,
|
|
62
|
-
capture_format:
|
|
63
|
-
jpeg_quality:
|
|
74
|
+
capture_format: OVERVIEW_INGEST_ANIMATION.videoExport?.captureFormat ?? 'png',
|
|
75
|
+
jpeg_quality: OVERVIEW_INGEST_ANIMATION.videoExport?.jpegQuality ?? 92,
|
|
64
76
|
crf: EXPORT_CRF,
|
|
65
77
|
encoder_preset: EXPORT_ENCODER_PRESET,
|
|
66
78
|
temp_dir: tempDir,
|
|
@@ -68,6 +80,9 @@ export function createExportManager({ getReplay, getSettledEnvelope, getBaseUrl
|
|
|
68
80
|
output_path: outputPath,
|
|
69
81
|
file_name: `codexmeter-overview-${jobId.slice(0, 8)}.mp4`,
|
|
70
82
|
client_base_url: clientBaseUrl || null,
|
|
83
|
+
install_portable_browser: Boolean(opts.installPortableBrowser),
|
|
84
|
+
portable_browser_dir: null,
|
|
85
|
+
portable_browser_executable: null,
|
|
71
86
|
error: null,
|
|
72
87
|
};
|
|
73
88
|
|
|
@@ -92,11 +107,17 @@ export function createExportManager({ getReplay, getSettledEnvelope, getBaseUrl
|
|
|
92
107
|
width: job.width,
|
|
93
108
|
height: job.height,
|
|
94
109
|
fps: job.fps,
|
|
110
|
+
supersampleScale: job.supersample_scale,
|
|
111
|
+
frontloadSettledFrameCount: job.frontload_settled_frame_count,
|
|
112
|
+
frontloadSettledDurationMs: job.frontload_settled_duration_ms,
|
|
95
113
|
durationMs: job.duration_ms,
|
|
96
|
-
|
|
114
|
+
startHoldDurationMs: job.start_hold_duration_ms,
|
|
97
115
|
replayDurationMs: job.replay_duration_ms,
|
|
116
|
+
replayEasing: OVERVIEW_INGEST_ANIMATION.videoExport?.replayEasing ?? 'cubicInOut',
|
|
98
117
|
tailDurationMs: job.tail_duration_ms,
|
|
99
|
-
|
|
118
|
+
tailSourceFraction: job.tail_source_fraction,
|
|
119
|
+
tailEasing: OVERVIEW_INGEST_ANIMATION.videoExport?.tailEasing ?? 'cubicInOut',
|
|
120
|
+
finalHoldDurationMs: job.final_hold_duration_ms,
|
|
100
121
|
replay: job.replay,
|
|
101
122
|
settledEnvelope: job.settled_envelope,
|
|
102
123
|
};
|
|
@@ -123,20 +144,81 @@ export function createExportManager({ getReplay, getSettledEnvelope, getBaseUrl
|
|
|
123
144
|
}
|
|
124
145
|
|
|
125
146
|
async function runOverviewVideoJob(job, replay, getBaseUrlFn) {
|
|
147
|
+
let portableBrowserDir = null;
|
|
126
148
|
try {
|
|
127
|
-
updateJob(job, 'rendering', 0.02, 'running');
|
|
128
|
-
|
|
129
149
|
const baseUrl = await getBaseUrlFn(job.client_base_url);
|
|
130
|
-
|
|
150
|
+
let browserPath = findSupportedBrowserExecutable();
|
|
151
|
+
if (!browserPath && job.install_portable_browser) {
|
|
152
|
+
updateJob(job, 'downloading_browser', 0.03, 'running');
|
|
153
|
+
const portableBrowser = await installSingleUsePortableBrowser(job.temp_dir, (percent) => {
|
|
154
|
+
updateJob(job, 'downloading_browser', 0.03 + (Math.max(0, Math.min(percent, 100)) / 100) * 0.22, 'running');
|
|
155
|
+
});
|
|
156
|
+
portableBrowserDir = portableBrowser.dir;
|
|
157
|
+
job.portable_browser_dir = portableBrowser.dir;
|
|
158
|
+
job.portable_browser_executable = portableBrowser.executablePath;
|
|
159
|
+
browserPath = portableBrowser.executablePath;
|
|
160
|
+
}
|
|
161
|
+
if (!browserPath) {
|
|
162
|
+
await detectBrowserExecutable();
|
|
163
|
+
}
|
|
164
|
+
updateJob(job, 'rendering', Math.max(job.progress || 0, 0.24), 'running');
|
|
131
165
|
const browser = await chromium.launch({
|
|
132
166
|
headless: true,
|
|
133
167
|
executablePath: browserPath,
|
|
168
|
+
args: [
|
|
169
|
+
'--disable-background-timer-throttling',
|
|
170
|
+
'--disable-backgrounding-occluded-windows',
|
|
171
|
+
'--disable-renderer-backgrounding',
|
|
172
|
+
'--disable-frame-rate-limit',
|
|
173
|
+
`--window-size=${job.width},${job.height}`,
|
|
174
|
+
],
|
|
134
175
|
});
|
|
176
|
+
let capturedFrameCount = 0;
|
|
135
177
|
|
|
136
178
|
try {
|
|
137
|
-
const
|
|
179
|
+
const context = await browser.newContext({
|
|
138
180
|
viewport: { width: job.width, height: job.height },
|
|
139
|
-
deviceScaleFactor: 1,
|
|
181
|
+
deviceScaleFactor: job.supersample_scale || 1,
|
|
182
|
+
});
|
|
183
|
+
const page = await context.newPage();
|
|
184
|
+
const cdp = await context.newCDPSession(page);
|
|
185
|
+
let frameIndex = 0;
|
|
186
|
+
let captureError = null;
|
|
187
|
+
let frameWriteChain = Promise.resolve();
|
|
188
|
+
const captureTrace = [];
|
|
189
|
+
const frameExt = job.capture_format === 'jpeg' ? 'jpg' : 'png';
|
|
190
|
+
const targetFrameIntervalMs = 1000 / Math.max(job.fps || 60, 1);
|
|
191
|
+
let firstFrameTimestampMs = null;
|
|
192
|
+
let nextFrameBucketMs = 0;
|
|
193
|
+
cdp.on('Page.screencastFrame', (event) => {
|
|
194
|
+
frameWriteChain = frameWriteChain.then(async () => {
|
|
195
|
+
const screencastTimestampMs = Number.isFinite(event?.metadata?.timestamp)
|
|
196
|
+
? event.metadata.timestamp * 1000
|
|
197
|
+
: Date.now();
|
|
198
|
+
if (firstFrameTimestampMs == null) {
|
|
199
|
+
firstFrameTimestampMs = screencastTimestampMs;
|
|
200
|
+
nextFrameBucketMs = 0;
|
|
201
|
+
}
|
|
202
|
+
const relativeTimestampMs = Math.max(0, screencastTimestampMs - firstFrameTimestampMs);
|
|
203
|
+
const shouldWrite = relativeTimestampMs + 0.5 >= nextFrameBucketMs;
|
|
204
|
+
if (shouldWrite) {
|
|
205
|
+
const framePath = path.join(job.frames_dir, `${String(frameIndex).padStart(5, '0')}.${frameExt}`);
|
|
206
|
+
captureTrace.push({
|
|
207
|
+
frameIndex,
|
|
208
|
+
relativeTimestampMs: Math.round(relativeTimestampMs),
|
|
209
|
+
bucketMs: Math.round(nextFrameBucketMs),
|
|
210
|
+
screencastTimestampMs: Math.round(screencastTimestampMs),
|
|
211
|
+
});
|
|
212
|
+
frameIndex += 1;
|
|
213
|
+
await fs.writeFile(framePath, Buffer.from(event.data, 'base64'));
|
|
214
|
+
while (nextFrameBucketMs <= relativeTimestampMs + 0.5) {
|
|
215
|
+
nextFrameBucketMs += targetFrameIntervalMs;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
await cdp.send('Page.screencastFrameAck', { sessionId: event.sessionId });
|
|
219
|
+
}).catch((err) => {
|
|
220
|
+
captureError = err;
|
|
221
|
+
});
|
|
140
222
|
});
|
|
141
223
|
|
|
142
224
|
const exportUrl = `${baseUrl}/?export=overview-video&job=${encodeURIComponent(job.id)}`;
|
|
@@ -146,39 +228,196 @@ export function createExportManager({ getReplay, getSettledEnvelope, getBaseUrl
|
|
|
146
228
|
job.id,
|
|
147
229
|
{ timeout: 30000 }
|
|
148
230
|
);
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
await
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
231
|
+
await page.evaluate(async () => {
|
|
232
|
+
if (document.fonts?.ready) {
|
|
233
|
+
try {
|
|
234
|
+
await document.fonts.ready;
|
|
235
|
+
} catch {}
|
|
236
|
+
}
|
|
237
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
238
|
+
});
|
|
239
|
+
await cdp.send('Page.startScreencast', {
|
|
240
|
+
format: job.capture_format === 'jpeg' ? 'jpeg' : 'png',
|
|
241
|
+
quality: job.capture_format === 'jpeg' ? job.jpeg_quality : undefined,
|
|
242
|
+
everyNthFrame: 1,
|
|
243
|
+
});
|
|
244
|
+
await page.evaluate(() => window.__CODEXMETER_EXPORT__?.start());
|
|
245
|
+
const startedAt = Date.now();
|
|
246
|
+
while (true) {
|
|
247
|
+
await page.waitForTimeout(200);
|
|
248
|
+
if (captureError) throw captureError;
|
|
249
|
+
const playback = await page.evaluate(() => ({
|
|
250
|
+
currentTimeMs: window.__CODEXMETER_EXPORT__?.currentTimeMs || 0,
|
|
251
|
+
finished: window.__CODEXMETER_EXPORT__?.finished === true,
|
|
252
|
+
}));
|
|
253
|
+
const ratio = Math.min(1, Math.max(0, (playback.currentTimeMs || 0) / Math.max(job.duration_ms, 1)));
|
|
254
|
+
updateJob(job, 'rendering', 0.05 + ratio * 0.8, 'running');
|
|
255
|
+
if (playback.finished) break;
|
|
256
|
+
if (Date.now() - startedAt > Math.max(30000, job.duration_ms * 4)) {
|
|
257
|
+
throw new Error('Export playback timed out before completion');
|
|
163
258
|
}
|
|
164
|
-
updateJob(job, 'rendering', 0.05 + ((i + 1) / totalFrames) * 0.8, 'running');
|
|
165
259
|
}
|
|
260
|
+
await page.waitForTimeout(250);
|
|
261
|
+
await cdp.send('Page.stopScreencast');
|
|
262
|
+
await frameWriteChain;
|
|
263
|
+
if (frameIndex < 2) {
|
|
264
|
+
throw new Error('Screencast capture produced too few frames');
|
|
265
|
+
}
|
|
266
|
+
if (job.frontload_settled_frame_count > 0) {
|
|
267
|
+
await prependSettledFrames(job.frames_dir, {
|
|
268
|
+
frameCount: frameIndex,
|
|
269
|
+
prependCount: job.frontload_settled_frame_count,
|
|
270
|
+
extension: frameExt,
|
|
271
|
+
});
|
|
272
|
+
frameIndex += job.frontload_settled_frame_count;
|
|
273
|
+
}
|
|
274
|
+
capturedFrameCount = frameIndex;
|
|
275
|
+
const exportDebug = await page.evaluate(() => ({
|
|
276
|
+
debugState: window.__CODEXMETER_EXPORT__?.getDebugState?.() || null,
|
|
277
|
+
debugTrace: window.__CODEXMETER_EXPORT__?.getDebugTrace?.() || [],
|
|
278
|
+
}));
|
|
279
|
+
const debugAnalysis = analyzeExportTrace(exportDebug?.debugTrace || [], captureTrace);
|
|
280
|
+
await fs.writeFile(
|
|
281
|
+
path.join(job.temp_dir, 'capture-trace.json'),
|
|
282
|
+
JSON.stringify(captureTrace, null, 2),
|
|
283
|
+
'utf8'
|
|
284
|
+
);
|
|
285
|
+
await fs.writeFile(
|
|
286
|
+
path.join(job.temp_dir, 'simulation-trace.json'),
|
|
287
|
+
JSON.stringify(exportDebug, null, 2),
|
|
288
|
+
'utf8'
|
|
289
|
+
);
|
|
290
|
+
await fs.writeFile(
|
|
291
|
+
path.join(job.temp_dir, 'analysis-trace.json'),
|
|
292
|
+
JSON.stringify(debugAnalysis, null, 2),
|
|
293
|
+
'utf8'
|
|
294
|
+
);
|
|
295
|
+
await context.close();
|
|
166
296
|
} finally {
|
|
167
297
|
await browser.close();
|
|
168
298
|
}
|
|
169
299
|
|
|
170
300
|
updateJob(job, 'encoding', 0.9, 'running');
|
|
171
|
-
await encodeFramesToMp4(job.frames_dir, job.output_path,
|
|
301
|
+
await encodeFramesToMp4(job.frames_dir, job.output_path, {
|
|
172
302
|
captureFormat: job.capture_format,
|
|
303
|
+
fps: job.fps,
|
|
304
|
+
frameCount: capturedFrameCount,
|
|
305
|
+
durationMs: job.duration_ms + Math.round((job.frontload_settled_frame_count * 1000) / Math.max(job.fps || 1, 1)),
|
|
173
306
|
crf: job.crf,
|
|
174
307
|
encoderPreset: job.encoder_preset,
|
|
308
|
+
outputWidth: job.width,
|
|
309
|
+
outputHeight: job.height,
|
|
175
310
|
});
|
|
176
311
|
updateJob(job, 'complete', 1, 'complete');
|
|
177
312
|
} catch (err) {
|
|
178
313
|
job.error = err instanceof Error ? err.message : String(err);
|
|
179
314
|
updateJob(job, 'failed', job.progress || 0, 'failed');
|
|
315
|
+
} finally {
|
|
316
|
+
if (portableBrowserDir) {
|
|
317
|
+
try {
|
|
318
|
+
await fs.rm(portableBrowserDir, { recursive: true, force: true });
|
|
319
|
+
} catch {}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function analyzeExportTrace(debugTrace, captureTrace) {
|
|
326
|
+
const phaseTransitions = [];
|
|
327
|
+
const stalls = [];
|
|
328
|
+
const snaps = [];
|
|
329
|
+
let longestNearFlatMs = 0;
|
|
330
|
+
let currentFlatStart = null;
|
|
331
|
+
|
|
332
|
+
for (let i = 1; i < debugTrace.length; i += 1) {
|
|
333
|
+
const prev = debugTrace[i - 1];
|
|
334
|
+
const curr = debugTrace[i];
|
|
335
|
+
if (curr.phase !== prev.phase) {
|
|
336
|
+
phaseTransitions.push({
|
|
337
|
+
atMs: curr.seekMs,
|
|
338
|
+
from: prev.phase,
|
|
339
|
+
to: curr.phase,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const dt = Math.max(1, curr.seekMs - prev.seekMs);
|
|
344
|
+
const delta = computeSignatureDelta(prev.signature, curr.signature);
|
|
345
|
+
const deltaPerMs = delta / dt;
|
|
346
|
+
|
|
347
|
+
if (deltaPerMs < 0.005) {
|
|
348
|
+
if (currentFlatStart == null) currentFlatStart = prev.seekMs;
|
|
349
|
+
} else if (currentFlatStart != null) {
|
|
350
|
+
const durationMs = prev.seekMs - currentFlatStart;
|
|
351
|
+
if (durationMs >= 180) {
|
|
352
|
+
stalls.push({
|
|
353
|
+
startMs: currentFlatStart,
|
|
354
|
+
endMs: prev.seekMs,
|
|
355
|
+
durationMs,
|
|
356
|
+
phase: prev.phase,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
longestNearFlatMs = Math.max(longestNearFlatMs, durationMs);
|
|
360
|
+
currentFlatStart = null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (deltaPerMs > 1.2) {
|
|
364
|
+
snaps.push({
|
|
365
|
+
atMs: curr.seekMs,
|
|
366
|
+
phase: curr.phase,
|
|
367
|
+
delta,
|
|
368
|
+
deltaPerMs: Number(deltaPerMs.toFixed(4)),
|
|
369
|
+
signature: curr.signature,
|
|
370
|
+
});
|
|
180
371
|
}
|
|
181
372
|
}
|
|
373
|
+
|
|
374
|
+
if (currentFlatStart != null && debugTrace.length) {
|
|
375
|
+
const last = debugTrace[debugTrace.length - 1];
|
|
376
|
+
const durationMs = last.seekMs - currentFlatStart;
|
|
377
|
+
if (durationMs >= 180) {
|
|
378
|
+
stalls.push({
|
|
379
|
+
startMs: currentFlatStart,
|
|
380
|
+
endMs: last.seekMs,
|
|
381
|
+
durationMs,
|
|
382
|
+
phase: last.phase,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
longestNearFlatMs = Math.max(longestNearFlatMs, durationMs);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
frameCount: debugTrace.length,
|
|
390
|
+
captureFrameCount: captureTrace.length,
|
|
391
|
+
phaseTransitions,
|
|
392
|
+
longestNearFlatMs,
|
|
393
|
+
stallCount: stalls.length,
|
|
394
|
+
snapCount: snaps.length,
|
|
395
|
+
topStalls: stalls.sort((a, b) => b.durationMs - a.durationMs).slice(0, 8),
|
|
396
|
+
topSnaps: snaps.sort((a, b) => b.deltaPerMs - a.deltaPerMs).slice(0, 8),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function computeSignatureDelta(prev = {}, curr = {}) {
|
|
401
|
+
const weights = {
|
|
402
|
+
totalTokens: 1 / 1000000,
|
|
403
|
+
totalCost: 25,
|
|
404
|
+
totalSessions: 0.5,
|
|
405
|
+
dailyPoints: 1,
|
|
406
|
+
lastDailyTotal: 1 / 1000000,
|
|
407
|
+
topRepoValue: 1 / 1000000,
|
|
408
|
+
topModelValue: 1 / 1000000,
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
let total = 0;
|
|
412
|
+
for (const [key, weight] of Object.entries(weights)) {
|
|
413
|
+
const left = Number.isFinite(prev[key]) ? prev[key] : 0;
|
|
414
|
+
const right = Number.isFinite(curr[key]) ? curr[key] : 0;
|
|
415
|
+
total += Math.abs(right - left) * weight;
|
|
416
|
+
}
|
|
417
|
+
if ((prev.topRepo || null) !== (curr.topRepo || null)) total += 4;
|
|
418
|
+
if ((prev.topModel || null) !== (curr.topModel || null)) total += 4;
|
|
419
|
+
if ((prev.lastDailyDate || null) !== (curr.lastDailyDate || null)) total += 2;
|
|
420
|
+
return total;
|
|
182
421
|
}
|
|
183
422
|
|
|
184
423
|
export function createVideoExportManager() {
|
|
@@ -202,11 +441,11 @@ export function createVideoExportManager() {
|
|
|
202
441
|
return manager;
|
|
203
442
|
}
|
|
204
443
|
|
|
205
|
-
export function startOverviewVideoExport(manager, { replay, settledEnvelope, appBaseUrl }) {
|
|
444
|
+
export function startOverviewVideoExport(manager, { replay, settledEnvelope, appBaseUrl, installPortableBrowser = false }) {
|
|
206
445
|
manager.setReplayGetter(() => replay);
|
|
207
446
|
manager.setSettledEnvelopeGetter(() => settledEnvelope);
|
|
208
447
|
manager.setBaseUrlResolver(async (clientBaseUrl) => clientBaseUrl || appBaseUrl);
|
|
209
|
-
return manager.startOverviewVideoJob(appBaseUrl);
|
|
448
|
+
return manager.startOverviewVideoJob(appBaseUrl, { installPortableBrowser });
|
|
210
449
|
}
|
|
211
450
|
|
|
212
451
|
export function getVideoExportJob(manager, jobId) {
|
|
@@ -220,6 +459,25 @@ export function getActiveVideoExportJob(manager) {
|
|
|
220
459
|
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))[0] || null;
|
|
221
460
|
}
|
|
222
461
|
|
|
462
|
+
export async function getVideoExportSupport() {
|
|
463
|
+
const browserPath = findSupportedBrowserExecutable();
|
|
464
|
+
if (browserPath) {
|
|
465
|
+
return {
|
|
466
|
+
available: true,
|
|
467
|
+
browser_path: browserPath,
|
|
468
|
+
reason: null,
|
|
469
|
+
portable_download: null,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
const portableDownload = await getPortableBrowserSupport();
|
|
473
|
+
return {
|
|
474
|
+
available: false,
|
|
475
|
+
browser_path: null,
|
|
476
|
+
reason: 'No supported Chrome/Chromium/Edge browser was found for video export.',
|
|
477
|
+
portable_download: portableDownload,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
223
481
|
export function createJobSummary(job) {
|
|
224
482
|
if (!job) return null;
|
|
225
483
|
return {
|
|
@@ -245,6 +503,186 @@ function updateJob(job, phase, progress, status) {
|
|
|
245
503
|
}
|
|
246
504
|
|
|
247
505
|
async function detectBrowserExecutable() {
|
|
506
|
+
const browserPath = findSupportedBrowserExecutable();
|
|
507
|
+
if (browserPath) return browserPath;
|
|
508
|
+
|
|
509
|
+
const err = new Error('No supported Chrome/Chromium/Edge executable was found for video export.');
|
|
510
|
+
err.statusCode = 500;
|
|
511
|
+
throw err;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function getPortableBrowserSupport() {
|
|
515
|
+
if (process.env.CODEXMETER_EXPORT_DEBUG_DISABLE_PORTABLE === '1') {
|
|
516
|
+
return {
|
|
517
|
+
available: false,
|
|
518
|
+
label: null,
|
|
519
|
+
approx_size_mb: null,
|
|
520
|
+
reason: 'Portable browser download disabled by debug flag.',
|
|
521
|
+
platform_id: getPortableBrowserPlatformId(),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
if (portableBrowserSupportCache) return portableBrowserSupportCache;
|
|
525
|
+
const platformId = getPortableBrowserPlatformId();
|
|
526
|
+
if (!platformId) {
|
|
527
|
+
portableBrowserSupportCache = {
|
|
528
|
+
available: false,
|
|
529
|
+
label: null,
|
|
530
|
+
approx_size_mb: null,
|
|
531
|
+
reason: `Portable browser download is not supported on ${process.platform}/${process.arch}.`,
|
|
532
|
+
platform_id: null,
|
|
533
|
+
};
|
|
534
|
+
return portableBrowserSupportCache;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
try {
|
|
538
|
+
const spec = await resolvePortableBrowserSpec();
|
|
539
|
+
portableBrowserSupportCache = {
|
|
540
|
+
available: true,
|
|
541
|
+
label: spec.label,
|
|
542
|
+
approx_size_mb: spec.approxSizeMb,
|
|
543
|
+
reason: null,
|
|
544
|
+
platform_id: spec.platformId,
|
|
545
|
+
};
|
|
546
|
+
return portableBrowserSupportCache;
|
|
547
|
+
} catch (err) {
|
|
548
|
+
portableBrowserSupportCache = {
|
|
549
|
+
available: false,
|
|
550
|
+
label: null,
|
|
551
|
+
approx_size_mb: null,
|
|
552
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
553
|
+
platform_id: platformId,
|
|
554
|
+
};
|
|
555
|
+
return portableBrowserSupportCache;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function resolvePortableBrowserSpec() {
|
|
560
|
+
const platformId = getPortableBrowserPlatformId();
|
|
561
|
+
if (!platformId) {
|
|
562
|
+
throw new Error(`Portable browser download is not supported on ${process.platform}/${process.arch}.`);
|
|
563
|
+
}
|
|
564
|
+
const probeRoot = path.join(os.tmpdir(), 'codexmeter-playwright-probe');
|
|
565
|
+
const cliPath = resolvePlaywrightCliPath();
|
|
566
|
+
const dryRun = await runNodeCommand(cliPath, ['install', 'chromium-headless-shell', '--dry-run'], {
|
|
567
|
+
PLAYWRIGHT_BROWSERS_PATH: probeRoot,
|
|
568
|
+
});
|
|
569
|
+
const output = `${dryRun.stdout}\n${dryRun.stderr}`;
|
|
570
|
+
const urls = [...output.matchAll(/Download url:\s+(\S+)/g)].map((match) => match[1]).filter(Boolean);
|
|
571
|
+
if (!urls.length) {
|
|
572
|
+
throw new Error('Could not resolve portable Chromium download URL.');
|
|
573
|
+
}
|
|
574
|
+
const approxSizeMb = await fetchCombinedContentLengthMb(urls);
|
|
575
|
+
return {
|
|
576
|
+
browser: 'chromium-headless-shell',
|
|
577
|
+
label: 'Portable Chromium',
|
|
578
|
+
platformId,
|
|
579
|
+
downloadUrls: urls,
|
|
580
|
+
approxSizeMb,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function installSingleUsePortableBrowser(jobTempDir, onProgress = null) {
|
|
585
|
+
const browserRoot = path.join(jobTempDir, 'portable-browser');
|
|
586
|
+
const cliPath = resolvePlaywrightCliPath();
|
|
587
|
+
await fs.mkdir(browserRoot, { recursive: true });
|
|
588
|
+
await runNodeCommand(cliPath, ['install', 'chromium-headless-shell'], {
|
|
589
|
+
PLAYWRIGHT_BROWSERS_PATH: browserRoot,
|
|
590
|
+
}, onProgress);
|
|
591
|
+
const executablePath = await findPortableBrowserExecutable(browserRoot);
|
|
592
|
+
if (!executablePath) {
|
|
593
|
+
throw new Error('Portable Chromium was downloaded but no executable was found.');
|
|
594
|
+
}
|
|
595
|
+
return { dir: browserRoot, executablePath };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function getPortableBrowserPlatformId() {
|
|
599
|
+
if (process.platform === 'win32' && process.arch === 'x64') return 'win64';
|
|
600
|
+
if (process.platform === 'darwin' && process.arch === 'arm64') return 'mac-arm64';
|
|
601
|
+
if (process.platform === 'darwin' && process.arch === 'x64') return 'mac-x64';
|
|
602
|
+
if (process.platform === 'linux' && process.arch === 'x64') return 'linux64';
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function fetchCombinedContentLengthMb(urls) {
|
|
607
|
+
try {
|
|
608
|
+
let totalBytes = 0;
|
|
609
|
+
for (const url of urls) {
|
|
610
|
+
const res = await fetch(url, { method: 'HEAD', redirect: 'follow' });
|
|
611
|
+
const length = Number(res.headers.get('content-length') || 0);
|
|
612
|
+
if (!Number.isFinite(length) || length <= 0) return null;
|
|
613
|
+
totalBytes += length;
|
|
614
|
+
}
|
|
615
|
+
return Math.max(1, Math.round(totalBytes / (1024 * 1024)));
|
|
616
|
+
} catch {
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async function runNodeCommand(scriptPath, args, extraEnv = {}, onProgress = null) {
|
|
622
|
+
return await new Promise((resolve, reject) => {
|
|
623
|
+
const proc = spawn(process.execPath, [scriptPath, ...args], {
|
|
624
|
+
cwd: process.cwd(),
|
|
625
|
+
env: { ...process.env, ...extraEnv },
|
|
626
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
627
|
+
});
|
|
628
|
+
let stdout = '';
|
|
629
|
+
let stderr = '';
|
|
630
|
+
const handleChunk = (chunk) => {
|
|
631
|
+
const text = chunk.toString();
|
|
632
|
+
const matches = [...text.matchAll(/(\d{1,3})%/g)];
|
|
633
|
+
if (onProgress && matches.length) {
|
|
634
|
+
const lastMatch = matches[matches.length - 1];
|
|
635
|
+
const percent = Math.max(0, Math.min(100, Number(lastMatch[1]) || 0));
|
|
636
|
+
onProgress(percent);
|
|
637
|
+
}
|
|
638
|
+
return text;
|
|
639
|
+
};
|
|
640
|
+
proc.stdout.on('data', (chunk) => {
|
|
641
|
+
stdout += handleChunk(chunk);
|
|
642
|
+
});
|
|
643
|
+
proc.stderr.on('data', (chunk) => {
|
|
644
|
+
stderr += handleChunk(chunk);
|
|
645
|
+
});
|
|
646
|
+
proc.on('error', reject);
|
|
647
|
+
proc.on('exit', (code) => {
|
|
648
|
+
if (code === 0) resolve({ stdout, stderr });
|
|
649
|
+
else reject(new Error(stderr || stdout || `Node command exited with code ${code}`));
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function findPortableBrowserExecutable(rootDir) {
|
|
655
|
+
const executableNames = process.platform === 'win32'
|
|
656
|
+
? ['chrome-headless-shell.exe', 'chrome.exe']
|
|
657
|
+
: ['chrome-headless-shell', 'Chromium', 'Google Chrome for Testing', 'chrome'];
|
|
658
|
+
const queue = [rootDir];
|
|
659
|
+
while (queue.length) {
|
|
660
|
+
const current = queue.shift();
|
|
661
|
+
const entries = await fs.readdir(current, { withFileTypes: true });
|
|
662
|
+
for (const entry of entries) {
|
|
663
|
+
const fullPath = path.join(current, entry.name);
|
|
664
|
+
if (entry.isDirectory()) {
|
|
665
|
+
queue.push(fullPath);
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (executableNames.includes(entry.name)) {
|
|
669
|
+
return fullPath;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function resolvePlaywrightCliPath() {
|
|
677
|
+
const packageJsonPath = require.resolve('playwright-core/package.json');
|
|
678
|
+
const packageRoot = path.dirname(packageJsonPath);
|
|
679
|
+
return path.join(packageRoot, 'cli.js');
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function findSupportedBrowserExecutable() {
|
|
683
|
+
if (process.env.CODEXMETER_EXPORT_DEBUG_FORCE_UNSUPPORTED === '1') {
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
248
686
|
if (process.env.CODEXMETER_EXPORT_BROWSER && fsSync.existsSync(process.env.CODEXMETER_EXPORT_BROWSER)) {
|
|
249
687
|
return process.env.CODEXMETER_EXPORT_BROWSER;
|
|
250
688
|
}
|
|
@@ -271,28 +709,30 @@ async function detectBrowserExecutable() {
|
|
|
271
709
|
for (const candidate of candidates.filter(Boolean)) {
|
|
272
710
|
if (fsSync.existsSync(candidate)) return candidate;
|
|
273
711
|
}
|
|
274
|
-
|
|
275
|
-
const err = new Error('No supported Chrome/Chromium/Edge executable was found for video export.');
|
|
276
|
-
err.statusCode = 500;
|
|
277
|
-
throw err;
|
|
712
|
+
return null;
|
|
278
713
|
}
|
|
279
714
|
|
|
280
|
-
async function encodeFramesToMp4(framesDir, outputPath, fps,
|
|
715
|
+
async function encodeFramesToMp4(framesDir, outputPath, { captureFormat, fps, frameCount, durationMs, crf, encoderPreset, outputWidth, outputHeight }) {
|
|
281
716
|
const extension = captureFormat === 'jpeg' ? 'jpg' : 'png';
|
|
282
717
|
const inputPattern = path.join(framesDir, `%05d.${extension}`);
|
|
283
718
|
if (!ffmpegPath) {
|
|
284
719
|
throw new Error('ffmpeg-static is unavailable');
|
|
285
720
|
}
|
|
721
|
+
const effectiveInputFps = frameCount > 0 && durationMs > 0
|
|
722
|
+
? Math.max(1, frameCount / (durationMs / 1000))
|
|
723
|
+
: fps;
|
|
286
724
|
await new Promise((resolve, reject) => {
|
|
287
725
|
const proc = spawn(ffmpegPath, [
|
|
288
726
|
'-y',
|
|
289
|
-
'-framerate', String(
|
|
727
|
+
'-framerate', String(effectiveInputFps),
|
|
290
728
|
'-i', inputPattern,
|
|
291
729
|
'-c:v', 'libx264',
|
|
292
730
|
'-tune', 'animation',
|
|
293
731
|
'-preset', String(encoderPreset || 'fast'),
|
|
294
732
|
'-crf', String(crf ?? 20),
|
|
295
733
|
'-profile:v', 'high',
|
|
734
|
+
'-vf', `scale=${Math.max(1, outputWidth || EXPORT_WIDTH)}:${Math.max(1, outputHeight || EXPORT_HEIGHT)}:flags=lanczos`,
|
|
735
|
+
'-r', String(fps),
|
|
296
736
|
'-pix_fmt', 'yuv420p',
|
|
297
737
|
'-movflags', '+faststart',
|
|
298
738
|
outputPath,
|
|
@@ -311,3 +751,20 @@ async function encodeFramesToMp4(framesDir, outputPath, fps, { captureFormat, cr
|
|
|
311
751
|
});
|
|
312
752
|
});
|
|
313
753
|
}
|
|
754
|
+
|
|
755
|
+
async function prependSettledFrames(framesDir, { frameCount, prependCount, extension }) {
|
|
756
|
+
if (!prependCount || frameCount <= 0) return;
|
|
757
|
+
const safePrependCount = Math.max(0, Math.round(prependCount));
|
|
758
|
+
if (!safePrependCount) return;
|
|
759
|
+
const lastFramePath = path.join(framesDir, `${String(frameCount - 1).padStart(5, '0')}.${extension}`);
|
|
760
|
+
const settledFrameBytes = await fs.readFile(lastFramePath);
|
|
761
|
+
for (let index = frameCount - 1; index >= 0; index -= 1) {
|
|
762
|
+
const sourcePath = path.join(framesDir, `${String(index).padStart(5, '0')}.${extension}`);
|
|
763
|
+
const targetPath = path.join(framesDir, `${String(index + safePrependCount).padStart(5, '0')}.${extension}`);
|
|
764
|
+
await fs.rename(sourcePath, targetPath);
|
|
765
|
+
}
|
|
766
|
+
for (let index = 0; index < safePrependCount; index += 1) {
|
|
767
|
+
const targetPath = path.join(framesDir, `${String(index).padStart(5, '0')}.${extension}`);
|
|
768
|
+
await fs.writeFile(targetPath, settledFrameBytes);
|
|
769
|
+
}
|
|
770
|
+
}
|