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.
@@ -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 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
+ 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
- intro_duration_ms: EXPORT_INTRO_DURATION_MS,
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
- tail_replay_fraction: EXPORT_TAIL_REPLAY_FRACTION,
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: EXPORT_CAPTURE_FORMAT,
63
- jpeg_quality: EXPORT_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
- introDurationMs: job.intro_duration_ms,
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
- tailReplayFraction: job.tail_replay_fraction,
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
- const browserPath = await detectBrowserExecutable();
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 page = await browser.newPage({
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
- 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' });
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, job.fps, {
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, { captureFormat, crf, encoderPreset }) {
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(fps),
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
+ }