codexmeter 1.0.1 → 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.
@@ -0,0 +1,770 @@
1
+ import fs from 'fs/promises';
2
+ import fsSync from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { randomUUID } from 'crypto';
6
+ import { spawn } from 'child_process';
7
+ import process from 'process';
8
+ import { createRequire } from 'module';
9
+ import ffmpegPath from 'ffmpeg-static';
10
+ import { chromium } from 'playwright-core';
11
+ import { OVERVIEW_INGEST_ANIMATION } from '../src/utils/animationsDefault.js';
12
+
13
+ const EXPORT_WIDTH = OVERVIEW_INGEST_ANIMATION.videoExport?.width ?? 1080;
14
+ const EXPORT_HEIGHT = OVERVIEW_INGEST_ANIMATION.videoExport?.height ?? 864;
15
+ const EXPORT_FPS = OVERVIEW_INGEST_ANIMATION.videoExport?.fps ?? 60;
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;
19
+ const EXPORT_REPLAY_DURATION_MS = OVERVIEW_INGEST_ANIMATION.videoExport?.replayDurationMs ?? 8000;
20
+ const EXPORT_TAIL_DURATION_MS = OVERVIEW_INGEST_ANIMATION.videoExport?.tailDurationMs ?? 5000;
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;
25
+ const EXPORT_CRF = OVERVIEW_INGEST_ANIMATION.videoExport?.crf ?? 20;
26
+ const EXPORT_ENCODER_PRESET = OVERVIEW_INGEST_ANIMATION.videoExport?.encoderPreset ?? 'fast';
27
+ let portableBrowserSupportCache = null;
28
+ const require = createRequire(import.meta.url);
29
+
30
+ export function createExportManager({ getReplay, getSettledEnvelope, getBaseUrl }) {
31
+ const jobs = new Map();
32
+
33
+ return {
34
+ async startOverviewVideoJob(clientBaseUrl, opts = {}) {
35
+ const replay = getReplay();
36
+ const settledEnvelope = getSettledEnvelope ? getSettledEnvelope() : null;
37
+ if (!replay?.bootstrap) {
38
+ const err = new Error('No completed ingest replay is available yet.');
39
+ err.statusCode = 409;
40
+ throw err;
41
+ }
42
+
43
+ const jobId = randomUUID();
44
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmeter-video-'));
45
+ const framesDir = path.join(tempDir, 'frames');
46
+ const outputPath = path.join(tempDir, 'codexmeter-overview.mp4');
47
+ await fs.mkdir(framesDir, { recursive: true });
48
+
49
+ const initialBrowserPath = findSupportedBrowserExecutable();
50
+ const willDownloadPortableBrowser = Boolean(opts.installPortableBrowser) && !initialBrowserPath;
51
+ const job = {
52
+ id: jobId,
53
+ type: 'overview-video',
54
+ status: willDownloadPortableBrowser ? 'running' : 'queued',
55
+ phase: willDownloadPortableBrowser ? 'downloading_browser' : 'preparing',
56
+ progress: willDownloadPortableBrowser ? 0.03 : 0,
57
+ created_at: new Date().toISOString(),
58
+ updated_at: new Date().toISOString(),
59
+ replay_ingest_id: replay.ingest_id,
60
+ replay,
61
+ settled_envelope: settledEnvelope,
62
+ width: EXPORT_WIDTH,
63
+ height: EXPORT_HEIGHT,
64
+ fps: EXPORT_FPS,
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,
69
+ replay_duration_ms: EXPORT_REPLAY_DURATION_MS,
70
+ tail_duration_ms: EXPORT_TAIL_DURATION_MS,
71
+ final_hold_duration_ms: EXPORT_FINAL_HOLD_DURATION_MS,
72
+ tail_source_fraction: EXPORT_TAIL_SOURCE_FRACTION,
73
+ duration_ms: EXPORT_TOTAL_DURATION_MS,
74
+ capture_format: OVERVIEW_INGEST_ANIMATION.videoExport?.captureFormat ?? 'png',
75
+ jpeg_quality: OVERVIEW_INGEST_ANIMATION.videoExport?.jpegQuality ?? 92,
76
+ crf: EXPORT_CRF,
77
+ encoder_preset: EXPORT_ENCODER_PRESET,
78
+ temp_dir: tempDir,
79
+ frames_dir: framesDir,
80
+ output_path: outputPath,
81
+ file_name: `codexmeter-overview-${jobId.slice(0, 8)}.mp4`,
82
+ client_base_url: clientBaseUrl || null,
83
+ install_portable_browser: Boolean(opts.installPortableBrowser),
84
+ portable_browser_dir: null,
85
+ portable_browser_executable: null,
86
+ error: null,
87
+ };
88
+
89
+ jobs.set(jobId, job);
90
+ void runOverviewVideoJob(job, replay, getBaseUrl);
91
+ return sanitizeJob(job);
92
+ },
93
+
94
+ getJob(jobId) {
95
+ return jobs.get(jobId) || null;
96
+ },
97
+
98
+ listJobs() {
99
+ return [...jobs.values()];
100
+ },
101
+
102
+ getRenderPayload(jobId) {
103
+ const job = jobs.get(jobId);
104
+ if (!job) return null;
105
+ return {
106
+ jobId: job.id,
107
+ width: job.width,
108
+ height: job.height,
109
+ fps: job.fps,
110
+ supersampleScale: job.supersample_scale,
111
+ frontloadSettledFrameCount: job.frontload_settled_frame_count,
112
+ frontloadSettledDurationMs: job.frontload_settled_duration_ms,
113
+ durationMs: job.duration_ms,
114
+ startHoldDurationMs: job.start_hold_duration_ms,
115
+ replayDurationMs: job.replay_duration_ms,
116
+ replayEasing: OVERVIEW_INGEST_ANIMATION.videoExport?.replayEasing ?? 'cubicInOut',
117
+ tailDurationMs: job.tail_duration_ms,
118
+ tailSourceFraction: job.tail_source_fraction,
119
+ tailEasing: OVERVIEW_INGEST_ANIMATION.videoExport?.tailEasing ?? 'cubicInOut',
120
+ finalHoldDurationMs: job.final_hold_duration_ms,
121
+ replay: job.replay,
122
+ settledEnvelope: job.settled_envelope,
123
+ };
124
+ },
125
+
126
+ sanitizeJob,
127
+ };
128
+
129
+ function sanitizeJob(job) {
130
+ if (!job) return null;
131
+ return {
132
+ id: job.id,
133
+ type: job.type,
134
+ status: job.status,
135
+ phase: job.phase,
136
+ progress: job.progress,
137
+ created_at: job.created_at,
138
+ updated_at: job.updated_at,
139
+ replay_ingest_id: job.replay_ingest_id,
140
+ file_name: job.file_name,
141
+ download_url: job.status === 'complete' ? `/api/export/${job.id}/file` : null,
142
+ error: job.error,
143
+ };
144
+ }
145
+
146
+ async function runOverviewVideoJob(job, replay, getBaseUrlFn) {
147
+ let portableBrowserDir = null;
148
+ try {
149
+ const baseUrl = await getBaseUrlFn(job.client_base_url);
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');
165
+ const browser = await chromium.launch({
166
+ headless: true,
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
+ ],
175
+ });
176
+ let capturedFrameCount = 0;
177
+
178
+ try {
179
+ const context = await browser.newContext({
180
+ viewport: { width: job.width, height: job.height },
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
+ });
222
+ });
223
+
224
+ const exportUrl = `${baseUrl}/?export=overview-video&job=${encodeURIComponent(job.id)}`;
225
+ await page.goto(exportUrl, { waitUntil: 'networkidle' });
226
+ await page.waitForFunction(
227
+ (expectedJobId) => window.__CODEXMETER_EXPORT__?.ready === true && window.__CODEXMETER_EXPORT__?.jobId === expectedJobId,
228
+ job.id,
229
+ { timeout: 30000 }
230
+ );
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');
258
+ }
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();
296
+ } finally {
297
+ await browser.close();
298
+ }
299
+
300
+ updateJob(job, 'encoding', 0.9, 'running');
301
+ await encodeFramesToMp4(job.frames_dir, job.output_path, {
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)),
306
+ crf: job.crf,
307
+ encoderPreset: job.encoder_preset,
308
+ outputWidth: job.width,
309
+ outputHeight: job.height,
310
+ });
311
+ updateJob(job, 'complete', 1, 'complete');
312
+ } catch (err) {
313
+ job.error = err instanceof Error ? err.message : String(err);
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
+ });
371
+ }
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;
421
+ }
422
+
423
+ export function createVideoExportManager() {
424
+ let replayGetter = () => null;
425
+ let settledEnvelopeGetter = () => null;
426
+ let baseUrlResolver = async (clientBaseUrl) => clientBaseUrl || null;
427
+ const manager = createExportManager({
428
+ getReplay: () => replayGetter(),
429
+ getSettledEnvelope: () => settledEnvelopeGetter(),
430
+ getBaseUrl: (clientBaseUrl) => baseUrlResolver(clientBaseUrl),
431
+ });
432
+ manager.setReplayGetter = (fn) => {
433
+ replayGetter = fn;
434
+ };
435
+ manager.setSettledEnvelopeGetter = (fn) => {
436
+ settledEnvelopeGetter = fn;
437
+ };
438
+ manager.setBaseUrlResolver = (fn) => {
439
+ baseUrlResolver = fn;
440
+ };
441
+ return manager;
442
+ }
443
+
444
+ export function startOverviewVideoExport(manager, { replay, settledEnvelope, appBaseUrl, installPortableBrowser = false }) {
445
+ manager.setReplayGetter(() => replay);
446
+ manager.setSettledEnvelopeGetter(() => settledEnvelope);
447
+ manager.setBaseUrlResolver(async (clientBaseUrl) => clientBaseUrl || appBaseUrl);
448
+ return manager.startOverviewVideoJob(appBaseUrl, { installPortableBrowser });
449
+ }
450
+
451
+ export function getVideoExportJob(manager, jobId) {
452
+ return manager.getJob(jobId);
453
+ }
454
+
455
+ export function getActiveVideoExportJob(manager) {
456
+ const jobs = manager.listJobs ? manager.listJobs() : [];
457
+ return jobs
458
+ .filter((job) => job.status === 'queued' || job.status === 'running')
459
+ .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))[0] || null;
460
+ }
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
+
481
+ export function createJobSummary(job) {
482
+ if (!job) return null;
483
+ return {
484
+ id: job.id,
485
+ type: job.type,
486
+ status: job.status,
487
+ phase: job.phase,
488
+ progress: job.progress,
489
+ created_at: job.created_at,
490
+ updated_at: job.updated_at,
491
+ replay_ingest_id: job.replay_ingest_id,
492
+ file_name: job.file_name,
493
+ download_url: job.status === 'complete' ? `/api/export/${job.id}/file` : null,
494
+ error: job.error || null,
495
+ };
496
+ }
497
+
498
+ function updateJob(job, phase, progress, status) {
499
+ job.phase = phase;
500
+ job.progress = Math.max(0, Math.min(progress, 1));
501
+ job.status = status;
502
+ job.updated_at = new Date().toISOString();
503
+ }
504
+
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
+ }
686
+ if (process.env.CODEXMETER_EXPORT_BROWSER && fsSync.existsSync(process.env.CODEXMETER_EXPORT_BROWSER)) {
687
+ return process.env.CODEXMETER_EXPORT_BROWSER;
688
+ }
689
+
690
+ const candidates = process.platform === 'win32'
691
+ ? [
692
+ process.env['PROGRAMFILES'] ? path.join(process.env['PROGRAMFILES'], 'Google', 'Chrome', 'Application', 'chrome.exe') : null,
693
+ process.env['PROGRAMFILES(X86)'] ? path.join(process.env['PROGRAMFILES(X86)'], 'Google', 'Chrome', 'Application', 'chrome.exe') : null,
694
+ process.env['PROGRAMFILES'] ? path.join(process.env['PROGRAMFILES'], 'Microsoft', 'Edge', 'Application', 'msedge.exe') : null,
695
+ process.env['PROGRAMFILES(X86)'] ? path.join(process.env['PROGRAMFILES(X86)'], 'Microsoft', 'Edge', 'Application', 'msedge.exe') : null,
696
+ ]
697
+ : process.platform === 'darwin'
698
+ ? [
699
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
700
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
701
+ ]
702
+ : [
703
+ '/usr/bin/google-chrome',
704
+ '/usr/bin/chromium',
705
+ '/usr/bin/chromium-browser',
706
+ '/usr/bin/microsoft-edge',
707
+ ];
708
+
709
+ for (const candidate of candidates.filter(Boolean)) {
710
+ if (fsSync.existsSync(candidate)) return candidate;
711
+ }
712
+ return null;
713
+ }
714
+
715
+ async function encodeFramesToMp4(framesDir, outputPath, { captureFormat, fps, frameCount, durationMs, crf, encoderPreset, outputWidth, outputHeight }) {
716
+ const extension = captureFormat === 'jpeg' ? 'jpg' : 'png';
717
+ const inputPattern = path.join(framesDir, `%05d.${extension}`);
718
+ if (!ffmpegPath) {
719
+ throw new Error('ffmpeg-static is unavailable');
720
+ }
721
+ const effectiveInputFps = frameCount > 0 && durationMs > 0
722
+ ? Math.max(1, frameCount / (durationMs / 1000))
723
+ : fps;
724
+ await new Promise((resolve, reject) => {
725
+ const proc = spawn(ffmpegPath, [
726
+ '-y',
727
+ '-framerate', String(effectiveInputFps),
728
+ '-i', inputPattern,
729
+ '-c:v', 'libx264',
730
+ '-tune', 'animation',
731
+ '-preset', String(encoderPreset || 'fast'),
732
+ '-crf', String(crf ?? 20),
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),
736
+ '-pix_fmt', 'yuv420p',
737
+ '-movflags', '+faststart',
738
+ outputPath,
739
+ ], {
740
+ stdio: ['ignore', 'ignore', 'pipe'],
741
+ });
742
+
743
+ let stderr = '';
744
+ proc.stderr.on('data', (chunk) => {
745
+ stderr += chunk.toString();
746
+ });
747
+ proc.on('error', reject);
748
+ proc.on('exit', (code) => {
749
+ if (code === 0) resolve();
750
+ else reject(new Error(stderr || `ffmpeg exited with code ${code}`));
751
+ });
752
+ });
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
+ }