codexmeter 1.0.0 → 1.0.2

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/dist/index.html CHANGED
@@ -7,8 +7,8 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
9
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
10
- <script type="module" crossorigin src="/assets/index-DZKogILW.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-DJWqyRDh.css">
10
+ <script type="module" crossorigin src="/assets/index-M87czq50.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-C0qLYGUY.css">
12
12
  </head>
13
13
  <body>
14
14
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codexmeter",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Local telemetry dashboard for Codex CLI usage",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "server",
12
+ "src/utils/animationsDefault.js",
12
13
  "dist",
13
14
  "README.md",
14
15
  "package.json"
@@ -25,7 +26,9 @@
25
26
  "commander": "^13.1.0",
26
27
  "express": "^5.1.0",
27
28
  "fast-glob": "^3.3.3",
28
- "open": "^10.1.0"
29
+ "ffmpeg-static": "^5.2.0",
30
+ "open": "^10.1.0",
31
+ "playwright-core": "^1.58.2"
29
32
  },
30
33
  "devDependencies": {
31
34
  "@tanstack/react-table": "^8.21.3",
@@ -240,7 +240,10 @@ function buildDaily(rawSessions, groupedSessions, tz) {
240
240
  const startDay = toDayKeyInTz(startMs);
241
241
  const endDay = toDayKeyInTz(endMs - 1);
242
242
 
243
- if (startDay === endDay) {
243
+ if (s.has_usage_by_day) {
244
+ addSessionPresence(dayMap, startDay, endDay, startMs, endMs, totalDur, toDayKeyInTz, s);
245
+ addUsageByDay(dayMap, s);
246
+ } else if (startDay === endDay) {
244
247
  addToDay(dayMap, startDay, s, 1.0);
245
248
  } else {
246
249
  let cursor = dayStartMs(startDay);
@@ -314,12 +317,25 @@ function buildHeatmap(rawSessions, groupedSessions, tz) {
314
317
  const dayMap = new Map();
315
318
  for (const s of rawSessions) {
316
319
  if (!s.started_at) continue;
317
- const dk = toDayKeyInTz(s.started_at * 1000);
318
- if (!dayMap.has(dk)) dayMap.set(dk, { tokens: 0, cost: 0, elapsed: 0, sessions: 0 });
319
- const d = dayMap.get(dk);
320
- d.tokens += s.tokens_used;
321
- if (s.cost !== null) d.cost += s.cost;
322
- d.sessions++;
320
+ if (s.has_usage_by_day) {
321
+ for (const usageDay of s.usage_by_day || []) {
322
+ const dayKey = usageDay.day;
323
+ if (!dayMap.has(dayKey)) dayMap.set(dayKey, { tokens: 0, cost: 0, elapsed: 0, sessions: 0 });
324
+ const d = dayMap.get(dayKey);
325
+ d.tokens += usageDay.tokens || 0;
326
+ if (usageDay.cost !== null) d.cost += usageDay.cost;
327
+ }
328
+ const startDay = toDayKeyInTz(s.started_at * 1000);
329
+ if (!dayMap.has(startDay)) dayMap.set(startDay, { tokens: 0, cost: 0, elapsed: 0, sessions: 0 });
330
+ dayMap.get(startDay).sessions++;
331
+ } else {
332
+ const dk = toDayKeyInTz(s.started_at * 1000);
333
+ if (!dayMap.has(dk)) dayMap.set(dk, { tokens: 0, cost: 0, elapsed: 0, sessions: 0 });
334
+ const d = dayMap.get(dk);
335
+ d.tokens += s.tokens_used;
336
+ if (s.cost !== null) d.cost += s.cost;
337
+ d.sessions++;
338
+ }
323
339
  }
324
340
 
325
341
  for (const s of groupedSessions) {
@@ -332,6 +348,62 @@ function buildHeatmap(rawSessions, groupedSessions, tz) {
332
348
  return Object.fromEntries(dayMap);
333
349
  }
334
350
 
351
+ function addSessionPresence(dayMap, startDay, endDay, startMs, endMs, totalDur, toDayKeyInTz, session) {
352
+ if (startDay === endDay) {
353
+ addPresenceToDay(dayMap, startDay, session, 1.0);
354
+ return;
355
+ }
356
+
357
+ let cursor = dayStartMs(startDay);
358
+ while (cursor < endMs) {
359
+ const nextDay = cursor + 86400000;
360
+ const overlapStart = Math.max(cursor, startMs);
361
+ const overlapEnd = Math.min(nextDay, endMs);
362
+ const fraction = (overlapEnd - overlapStart) / totalDur;
363
+ if (fraction > 0) addPresenceToDay(dayMap, toDayKeyInTz(cursor), session, fraction);
364
+ cursor = nextDay;
365
+ }
366
+ }
367
+
368
+ function addPresenceToDay(dayMap, dayKey, session, fraction) {
369
+ if (!dayMap.has(dayKey)) dayMap.set(dayKey, { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0, by_model: {}, by_family: {}, by_repo: {} });
370
+ const d = dayMap.get(dayKey);
371
+ if (fraction > 0.001) d.sessions++;
372
+
373
+ const fKey = session.agent_family;
374
+ if (!d.by_family[fKey]) d.by_family[fKey] = { tokens: 0, cost: 0, sessions: 0 };
375
+ if (fraction > 0.001) d.by_family[fKey].sessions++;
376
+
377
+ const rKey = session.repo_label || 'unknown';
378
+ if (!d.by_repo[rKey]) d.by_repo[rKey] = { tokens: 0, cost: 0, sessions: 0 };
379
+ if (fraction > 0.001) d.by_repo[rKey].sessions++;
380
+ }
381
+
382
+ function addUsageByDay(dayMap, session) {
383
+ for (const usageDay of session.usage_by_day || []) {
384
+ const dayKey = usageDay.day;
385
+ if (!dayMap.has(dayKey)) dayMap.set(dayKey, { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0, by_model: {}, by_family: {}, by_repo: {} });
386
+ const d = dayMap.get(dayKey);
387
+ d.tokens += usageDay.tokens || 0;
388
+ if (usageDay.cost !== null) d.cost += usageDay.cost;
389
+
390
+ const mKey = session.model_name || 'unknown';
391
+ if (!d.by_model[mKey]) d.by_model[mKey] = { tokens: 0, cost: 0, elapsed_seconds: 0 };
392
+ d.by_model[mKey].tokens += usageDay.tokens || 0;
393
+ if (usageDay.cost !== null) d.by_model[mKey].cost += usageDay.cost;
394
+
395
+ const fKey = session.agent_family;
396
+ if (!d.by_family[fKey]) d.by_family[fKey] = { tokens: 0, cost: 0, sessions: 0 };
397
+ d.by_family[fKey].tokens += usageDay.tokens || 0;
398
+ if (usageDay.cost !== null) d.by_family[fKey].cost += usageDay.cost;
399
+
400
+ const rKey = session.repo_label || 'unknown';
401
+ if (!d.by_repo[rKey]) d.by_repo[rKey] = { tokens: 0, cost: 0, sessions: 0 };
402
+ d.by_repo[rKey].tokens += usageDay.tokens || 0;
403
+ if (usageDay.cost !== null) d.by_repo[rKey].cost += usageDay.cost;
404
+ }
405
+ }
406
+
335
407
  function dayStartMs(dayKey) {
336
408
  const [y, m, d] = dayKey.split('-').map(Number);
337
409
  return new Date(y, m - 1, d).getTime();
@@ -32,7 +32,7 @@ export function getCacheAwareRate(modelName) {
32
32
  return INPUT_FRACTION * effectiveInputRate + OUTPUT_FRACTION * entry.output;
33
33
  }
34
34
 
35
- function calculateCostFromUsage(modelName, usage) {
35
+ export function calculateCostFromUsage(modelName, usage) {
36
36
  if (!modelName || !usage) return null;
37
37
  const entry = getPricing()[modelName];
38
38
  if (!entry) return null;
@@ -0,0 +1,80 @@
1
+ function clonePayload(payload) {
2
+ return JSON.parse(JSON.stringify(payload));
3
+ }
4
+
5
+ export function createReplayCaptureState() {
6
+ return {
7
+ ingest_id: null,
8
+ active: false,
9
+ available: false,
10
+ started_at_ms: 0,
11
+ completed_at_ms: 0,
12
+ events: [],
13
+ };
14
+ }
15
+
16
+ export function resetReplayCapture(replay) {
17
+ replay.ingest_id = null;
18
+ replay.active = false;
19
+ replay.available = false;
20
+ replay.started_at_ms = 0;
21
+ replay.completed_at_ms = 0;
22
+ replay.events = [];
23
+ }
24
+
25
+ export function beginReplayCapture(replay, ingestId, bootstrapPayload) {
26
+ resetReplayCapture(replay);
27
+ replay.ingest_id = ingestId;
28
+ replay.active = true;
29
+ replay.started_at_ms = Date.now();
30
+ replay.events.push({
31
+ event: 'bootstrap',
32
+ at_ms: 0,
33
+ payload: clonePayload(bootstrapPayload),
34
+ });
35
+ }
36
+
37
+ export function recordReplayEvent(replay, event, payload) {
38
+ if (!replay?.active) return;
39
+ if (!['progress', 'patch', 'complete'].includes(event)) return;
40
+ replay.events.push({
41
+ event,
42
+ at_ms: Math.max(0, Date.now() - replay.started_at_ms),
43
+ payload: clonePayload(payload),
44
+ });
45
+ if (event === 'complete') {
46
+ replay.active = false;
47
+ replay.available = true;
48
+ replay.completed_at_ms = Date.now();
49
+ }
50
+ }
51
+
52
+ export function failReplayCapture(replay) {
53
+ if (!replay) return;
54
+ replay.active = false;
55
+ replay.available = false;
56
+ }
57
+
58
+ export function getReplaySnapshot(replay) {
59
+ if (!replay?.available || !replay.events.length) return null;
60
+ const [bootstrap, ...rest] = replay.events;
61
+ const durationMs = replay.events[replay.events.length - 1]?.at_ms || 0;
62
+ return {
63
+ ready: true,
64
+ ingest_id: replay.ingest_id,
65
+ started_at_ms: replay.started_at_ms,
66
+ completed_at_ms: replay.completed_at_ms,
67
+ duration_ms: durationMs,
68
+ bootstrap: bootstrap ? {
69
+ event: bootstrap.event,
70
+ at_ms: bootstrap.at_ms,
71
+ payload: clonePayload(bootstrap.payload),
72
+ } : null,
73
+ events: rest.map((event) => ({
74
+ event: event.event,
75
+ at_ms: event.at_ms,
76
+ mode: event.event === 'patch' ? 'patch' : 'progress',
77
+ payload: clonePayload(event.payload),
78
+ })),
79
+ };
80
+ }
@@ -0,0 +1,313 @@
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 ffmpegPath from 'ffmpeg-static';
8
+ import { chromium } from 'playwright-core';
9
+ import { OVERVIEW_INGEST_ANIMATION } from '../src/utils/animationsDefault.js';
10
+
11
+ const EXPORT_WIDTH = OVERVIEW_INGEST_ANIMATION.videoExport?.width ?? 1080;
12
+ const EXPORT_HEIGHT = OVERVIEW_INGEST_ANIMATION.videoExport?.height ?? 864;
13
+ const EXPORT_FPS = OVERVIEW_INGEST_ANIMATION.videoExport?.fps ?? 60;
14
+ const EXPORT_INTRO_DURATION_MS = OVERVIEW_INGEST_ANIMATION.videoExport?.introDurationMs ?? 900;
15
+ const EXPORT_REPLAY_DURATION_MS = OVERVIEW_INGEST_ANIMATION.videoExport?.replayDurationMs ?? 8000;
16
+ 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_CRF = OVERVIEW_INGEST_ANIMATION.videoExport?.crf ?? 20;
22
+ const EXPORT_ENCODER_PRESET = OVERVIEW_INGEST_ANIMATION.videoExport?.encoderPreset ?? 'fast';
23
+
24
+ export function createExportManager({ getReplay, getSettledEnvelope, getBaseUrl }) {
25
+ const jobs = new Map();
26
+
27
+ return {
28
+ async startOverviewVideoJob(clientBaseUrl) {
29
+ const replay = getReplay();
30
+ const settledEnvelope = getSettledEnvelope ? getSettledEnvelope() : null;
31
+ if (!replay?.bootstrap) {
32
+ const err = new Error('No completed ingest replay is available yet.');
33
+ err.statusCode = 409;
34
+ throw err;
35
+ }
36
+
37
+ const jobId = randomUUID();
38
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codexmeter-video-'));
39
+ const framesDir = path.join(tempDir, 'frames');
40
+ const outputPath = path.join(tempDir, 'codexmeter-overview.mp4');
41
+ await fs.mkdir(framesDir, { recursive: true });
42
+
43
+ const job = {
44
+ id: jobId,
45
+ type: 'overview-video',
46
+ status: 'queued',
47
+ phase: 'preparing',
48
+ progress: 0,
49
+ created_at: new Date().toISOString(),
50
+ updated_at: new Date().toISOString(),
51
+ replay_ingest_id: replay.ingest_id,
52
+ replay,
53
+ settled_envelope: settledEnvelope,
54
+ width: EXPORT_WIDTH,
55
+ height: EXPORT_HEIGHT,
56
+ fps: EXPORT_FPS,
57
+ intro_duration_ms: EXPORT_INTRO_DURATION_MS,
58
+ replay_duration_ms: EXPORT_REPLAY_DURATION_MS,
59
+ tail_duration_ms: EXPORT_TAIL_DURATION_MS,
60
+ tail_replay_fraction: EXPORT_TAIL_REPLAY_FRACTION,
61
+ duration_ms: EXPORT_TOTAL_DURATION_MS,
62
+ capture_format: EXPORT_CAPTURE_FORMAT,
63
+ jpeg_quality: EXPORT_JPEG_QUALITY,
64
+ crf: EXPORT_CRF,
65
+ encoder_preset: EXPORT_ENCODER_PRESET,
66
+ temp_dir: tempDir,
67
+ frames_dir: framesDir,
68
+ output_path: outputPath,
69
+ file_name: `codexmeter-overview-${jobId.slice(0, 8)}.mp4`,
70
+ client_base_url: clientBaseUrl || null,
71
+ error: null,
72
+ };
73
+
74
+ jobs.set(jobId, job);
75
+ void runOverviewVideoJob(job, replay, getBaseUrl);
76
+ return sanitizeJob(job);
77
+ },
78
+
79
+ getJob(jobId) {
80
+ return jobs.get(jobId) || null;
81
+ },
82
+
83
+ listJobs() {
84
+ return [...jobs.values()];
85
+ },
86
+
87
+ getRenderPayload(jobId) {
88
+ const job = jobs.get(jobId);
89
+ if (!job) return null;
90
+ return {
91
+ jobId: job.id,
92
+ width: job.width,
93
+ height: job.height,
94
+ fps: job.fps,
95
+ durationMs: job.duration_ms,
96
+ introDurationMs: job.intro_duration_ms,
97
+ replayDurationMs: job.replay_duration_ms,
98
+ tailDurationMs: job.tail_duration_ms,
99
+ tailReplayFraction: job.tail_replay_fraction,
100
+ replay: job.replay,
101
+ settledEnvelope: job.settled_envelope,
102
+ };
103
+ },
104
+
105
+ sanitizeJob,
106
+ };
107
+
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
+ async function runOverviewVideoJob(job, replay, getBaseUrlFn) {
126
+ try {
127
+ updateJob(job, 'rendering', 0.02, 'running');
128
+
129
+ const baseUrl = await getBaseUrlFn(job.client_base_url);
130
+ const browserPath = await detectBrowserExecutable();
131
+ const browser = await chromium.launch({
132
+ headless: true,
133
+ executablePath: browserPath,
134
+ });
135
+
136
+ try {
137
+ const page = await browser.newPage({
138
+ viewport: { width: job.width, height: job.height },
139
+ deviceScaleFactor: 1,
140
+ });
141
+
142
+ const exportUrl = `${baseUrl}/?export=overview-video&job=${encodeURIComponent(job.id)}`;
143
+ await page.goto(exportUrl, { waitUntil: 'networkidle' });
144
+ await page.waitForFunction(
145
+ (expectedJobId) => window.__CODEXMETER_EXPORT__?.ready === true && window.__CODEXMETER_EXPORT__?.jobId === expectedJobId,
146
+ job.id,
147
+ { timeout: 30000 }
148
+ );
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' });
163
+ }
164
+ updateJob(job, 'rendering', 0.05 + ((i + 1) / totalFrames) * 0.8, 'running');
165
+ }
166
+ } finally {
167
+ await browser.close();
168
+ }
169
+
170
+ updateJob(job, 'encoding', 0.9, 'running');
171
+ await encodeFramesToMp4(job.frames_dir, job.output_path, job.fps, {
172
+ captureFormat: job.capture_format,
173
+ crf: job.crf,
174
+ encoderPreset: job.encoder_preset,
175
+ });
176
+ updateJob(job, 'complete', 1, 'complete');
177
+ } catch (err) {
178
+ job.error = err instanceof Error ? err.message : String(err);
179
+ updateJob(job, 'failed', job.progress || 0, 'failed');
180
+ }
181
+ }
182
+ }
183
+
184
+ export function createVideoExportManager() {
185
+ let replayGetter = () => null;
186
+ let settledEnvelopeGetter = () => null;
187
+ let baseUrlResolver = async (clientBaseUrl) => clientBaseUrl || null;
188
+ const manager = createExportManager({
189
+ getReplay: () => replayGetter(),
190
+ getSettledEnvelope: () => settledEnvelopeGetter(),
191
+ getBaseUrl: (clientBaseUrl) => baseUrlResolver(clientBaseUrl),
192
+ });
193
+ manager.setReplayGetter = (fn) => {
194
+ replayGetter = fn;
195
+ };
196
+ manager.setSettledEnvelopeGetter = (fn) => {
197
+ settledEnvelopeGetter = fn;
198
+ };
199
+ manager.setBaseUrlResolver = (fn) => {
200
+ baseUrlResolver = fn;
201
+ };
202
+ return manager;
203
+ }
204
+
205
+ export function startOverviewVideoExport(manager, { replay, settledEnvelope, appBaseUrl }) {
206
+ manager.setReplayGetter(() => replay);
207
+ manager.setSettledEnvelopeGetter(() => settledEnvelope);
208
+ manager.setBaseUrlResolver(async (clientBaseUrl) => clientBaseUrl || appBaseUrl);
209
+ return manager.startOverviewVideoJob(appBaseUrl);
210
+ }
211
+
212
+ export function getVideoExportJob(manager, jobId) {
213
+ return manager.getJob(jobId);
214
+ }
215
+
216
+ export function getActiveVideoExportJob(manager) {
217
+ const jobs = manager.listJobs ? manager.listJobs() : [];
218
+ return jobs
219
+ .filter((job) => job.status === 'queued' || job.status === 'running')
220
+ .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))[0] || null;
221
+ }
222
+
223
+ export function createJobSummary(job) {
224
+ if (!job) return null;
225
+ return {
226
+ id: job.id,
227
+ type: job.type,
228
+ status: job.status,
229
+ phase: job.phase,
230
+ progress: job.progress,
231
+ created_at: job.created_at,
232
+ updated_at: job.updated_at,
233
+ replay_ingest_id: job.replay_ingest_id,
234
+ file_name: job.file_name,
235
+ download_url: job.status === 'complete' ? `/api/export/${job.id}/file` : null,
236
+ error: job.error || null,
237
+ };
238
+ }
239
+
240
+ function updateJob(job, phase, progress, status) {
241
+ job.phase = phase;
242
+ job.progress = Math.max(0, Math.min(progress, 1));
243
+ job.status = status;
244
+ job.updated_at = new Date().toISOString();
245
+ }
246
+
247
+ async function detectBrowserExecutable() {
248
+ if (process.env.CODEXMETER_EXPORT_BROWSER && fsSync.existsSync(process.env.CODEXMETER_EXPORT_BROWSER)) {
249
+ return process.env.CODEXMETER_EXPORT_BROWSER;
250
+ }
251
+
252
+ const candidates = process.platform === 'win32'
253
+ ? [
254
+ process.env['PROGRAMFILES'] ? path.join(process.env['PROGRAMFILES'], 'Google', 'Chrome', 'Application', 'chrome.exe') : null,
255
+ process.env['PROGRAMFILES(X86)'] ? path.join(process.env['PROGRAMFILES(X86)'], 'Google', 'Chrome', 'Application', 'chrome.exe') : null,
256
+ process.env['PROGRAMFILES'] ? path.join(process.env['PROGRAMFILES'], 'Microsoft', 'Edge', 'Application', 'msedge.exe') : null,
257
+ process.env['PROGRAMFILES(X86)'] ? path.join(process.env['PROGRAMFILES(X86)'], 'Microsoft', 'Edge', 'Application', 'msedge.exe') : null,
258
+ ]
259
+ : process.platform === 'darwin'
260
+ ? [
261
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
262
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
263
+ ]
264
+ : [
265
+ '/usr/bin/google-chrome',
266
+ '/usr/bin/chromium',
267
+ '/usr/bin/chromium-browser',
268
+ '/usr/bin/microsoft-edge',
269
+ ];
270
+
271
+ for (const candidate of candidates.filter(Boolean)) {
272
+ if (fsSync.existsSync(candidate)) return candidate;
273
+ }
274
+
275
+ const err = new Error('No supported Chrome/Chromium/Edge executable was found for video export.');
276
+ err.statusCode = 500;
277
+ throw err;
278
+ }
279
+
280
+ async function encodeFramesToMp4(framesDir, outputPath, fps, { captureFormat, crf, encoderPreset }) {
281
+ const extension = captureFormat === 'jpeg' ? 'jpg' : 'png';
282
+ const inputPattern = path.join(framesDir, `%05d.${extension}`);
283
+ if (!ffmpegPath) {
284
+ throw new Error('ffmpeg-static is unavailable');
285
+ }
286
+ await new Promise((resolve, reject) => {
287
+ const proc = spawn(ffmpegPath, [
288
+ '-y',
289
+ '-framerate', String(fps),
290
+ '-i', inputPattern,
291
+ '-c:v', 'libx264',
292
+ '-tune', 'animation',
293
+ '-preset', String(encoderPreset || 'fast'),
294
+ '-crf', String(crf ?? 20),
295
+ '-profile:v', 'high',
296
+ '-pix_fmt', 'yuv420p',
297
+ '-movflags', '+faststart',
298
+ outputPath,
299
+ ], {
300
+ stdio: ['ignore', 'ignore', 'pipe'],
301
+ });
302
+
303
+ let stderr = '';
304
+ proc.stderr.on('data', (chunk) => {
305
+ stderr += chunk.toString();
306
+ });
307
+ proc.on('error', reject);
308
+ proc.on('exit', (code) => {
309
+ if (code === 0) resolve();
310
+ else reject(new Error(stderr || `ffmpeg exited with code ${code}`));
311
+ });
312
+ });
313
+ }
package/server/index.js CHANGED
@@ -1,13 +1,15 @@
1
1
  import express from 'express';
2
2
  import path from 'path';
3
3
  import { fileURLToPath } from 'url';
4
- import { attachLiveSubscriber, createIngestState, detachLiveSubscriber, restartIngest, runIngest } from './ingest.js';
4
+ import { attachLiveSubscriber, createIngestState, detachLiveSubscriber, getLatestReplay, restartIngest, runIngest } from './ingest.js';
5
+ import { createJobSummary, createVideoExportManager, getActiveVideoExportJob, getVideoExportJob, startOverviewVideoExport } from './export-video.js';
5
6
 
6
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
8
 
8
9
  export function createServer(codexHome, opts = {}) {
9
10
  const app = express();
10
11
  const state = createIngestState();
12
+ const exportManager = createVideoExportManager();
11
13
  const distDir = path.join(__dirname, '..', 'dist');
12
14
  const apiOnly = opts.devApiOnly === true;
13
15
  const ingestOpts = { ...opts };
@@ -77,6 +79,71 @@ export function createServer(codexHome, opts = {}) {
77
79
  app.get('/api/heatmap', wrap('heatmap'));
78
80
  app.get('/api/families', wrap('families'));
79
81
 
82
+ app.post('/api/export/overview-video', async (req, res) => {
83
+ const replay = getLatestReplay(state);
84
+ if (!replay) {
85
+ res.status(409).json({ error: 'No completed ingest replay is available yet.' });
86
+ return;
87
+ }
88
+
89
+ const appBaseUrl = req.get('x-codexmeter-client-base') || opts.frontendBaseUrl || `${req.protocol}://${req.get('host')}`;
90
+ const settledEnvelope = state.aggregates ? {
91
+ overview: { data: state.aggregates.overview },
92
+ repos: { data: state.aggregates.repos },
93
+ models: { data: state.aggregates.models },
94
+ families: { data: state.aggregates.families },
95
+ daily: { data: state.aggregates.daily },
96
+ heatmap: { data: state.aggregates.heatmap },
97
+ } : null;
98
+ try {
99
+ const job = await startOverviewVideoExport(exportManager, { replay, settledEnvelope, appBaseUrl });
100
+ res.status(202).json(createJobSummary(job, `${req.protocol}://${req.get('host')}`));
101
+ } catch (err) {
102
+ res.status(err.statusCode || 500).json({ error: err.message || String(err) });
103
+ }
104
+ });
105
+
106
+ app.get('/api/export/active', (req, res) => {
107
+ const job = getActiveVideoExportJob(exportManager);
108
+ if (!job) {
109
+ res.json({ job: null });
110
+ return;
111
+ }
112
+ res.json({ job: createJobSummary(job, `${req.protocol}://${req.get('host')}`) });
113
+ });
114
+
115
+ app.get('/api/export/:jobId/status', (req, res) => {
116
+ const job = getVideoExportJob(exportManager, req.params.jobId);
117
+ if (!job) {
118
+ res.status(404).json({ error: 'Export job not found.' });
119
+ return;
120
+ }
121
+ res.json(createJobSummary(job, `${req.protocol}://${req.get('host')}`));
122
+ });
123
+
124
+ app.get('/api/export/:jobId/render-data', (req, res) => {
125
+ const job = getVideoExportJob(exportManager, req.params.jobId);
126
+ if (!job) {
127
+ res.status(404).json({ error: 'Export job not found.' });
128
+ return;
129
+ }
130
+ const payload = exportManager.getRenderPayload(req.params.jobId);
131
+ if (!payload) {
132
+ res.status(404).json({ error: 'Export render data not found.' });
133
+ return;
134
+ }
135
+ res.json(payload);
136
+ });
137
+
138
+ app.get('/api/export/:jobId/file', (req, res) => {
139
+ const job = getVideoExportJob(exportManager, req.params.jobId);
140
+ if (!job || job.status !== 'complete' || !job.output_path) {
141
+ res.status(404).json({ error: 'Export file not ready.' });
142
+ return;
143
+ }
144
+ res.download(job.output_path, job.file_name || `codexmeter-overview-${job.id}.mp4`);
145
+ });
146
+
80
147
  app.get('/api/sessions', (req, res) => {
81
148
  const q = (req.query.q || '').toLowerCase();
82
149
  let sessions = state.sessions || [];