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.
@@ -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 EXPORT_INTRO_DURATION_MS = OVERVIEW_INGEST_ANIMATION.videoExport?.introDurationMs ?? 900;
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 EXPORT_TOTAL_DURATION_MS = EXPORT_INTRO_DURATION_MS + EXPORT_REPLAY_DURATION_MS + EXPORT_TAIL_DURATION_MS;
18
- const EXPORT_TAIL_REPLAY_FRACTION = OVERVIEW_INGEST_ANIMATION.videoExport?.tailReplayFraction ?? 0.72;
19
- const EXPORT_CAPTURE_FORMAT = OVERVIEW_INGEST_ANIMATION.videoExport?.captureFormat ?? 'png';
20
- const EXPORT_JPEG_QUALITY = OVERVIEW_INGEST_ANIMATION.videoExport?.jpegQuality ?? 92;
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
- intro_duration_ms: EXPORT_INTRO_DURATION_MS,
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
- tail_replay_fraction: EXPORT_TAIL_REPLAY_FRACTION,
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: EXPORT_CAPTURE_FORMAT,
63
- jpeg_quality: EXPORT_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 sanitizeJob(job);
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
- introDurationMs: job.intro_duration_ms,
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
- tailReplayFraction: job.tail_replay_fraction,
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
- sanitizeJob,
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
- const browserPath = await detectBrowserExecutable();
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 page = await browser.newPage({
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
- const totalFrames = Math.max(2, Math.ceil((job.duration_ms / 1000) * job.fps));
151
- const frameStepMs = 1000 / job.fps;
152
-
153
- for (let i = 0; i < totalFrames; i += 1) {
154
- const timeMs = Math.min(job.duration_ms, Math.round(i * frameStepMs));
155
- await page.evaluate((ms) => window.__CODEXMETER_EXPORT__?.seek(ms), timeMs);
156
- await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve())));
157
- const frameExt = job.capture_format === 'jpeg' ? 'jpg' : 'png';
158
- const framePath = path.join(job.frames_dir, `${String(i).padStart(5, '0')}.${frameExt}`);
159
- if (job.capture_format === 'jpeg') {
160
- await page.screenshot({ path: framePath, type: 'jpeg', quality: job.jpeg_quality });
161
- } else {
162
- await page.screenshot({ path: framePath, type: 'png' });
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, job.fps, {
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, { captureFormat, crf, encoderPreset }) {
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(fps),
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
+ }