codexmeter 1.0.1 → 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/bin/codexmeter.js +1 -0
- package/dist/assets/index-C0qLYGUY.css +1 -0
- package/dist/assets/index-M87czq50.js +113 -0
- package/dist/index.html +2 -2
- package/package.json +5 -2
- package/server/export-replay.js +80 -0
- package/server/export-video.js +313 -0
- package/server/index.js +68 -1
- package/server/ingest.js +125 -41
- package/server/rollout-reader.js +4 -0
- package/src/utils/animationsDefault.js +244 -0
- package/dist/assets/index-DJWqyRDh.css +0 -1
- package/dist/assets/index-DZKogILW.js +0 -113
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-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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
|
-
"
|
|
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",
|
|
@@ -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 || [];
|