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