argusqa-os 9.6.6 → 9.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Argus PDF Exporter
3
+ *
4
+ * Exports the Argus HTML report as a branded A4 PDF using puppeteer.
5
+ * puppeteer is an optional peer dependency — install when needed:
6
+ * npm install puppeteer
7
+ *
8
+ * Usage (programmatic):
9
+ * import { exportReportToPdf } from './pdf-exporter.js';
10
+ * const pdfPath = await exportReportToPdf('./reports/report.html', './reports/report.pdf');
11
+ *
12
+ * Usage (CLI):
13
+ * node src/utils/pdf-exporter.js ./reports/report.html ./reports/report.pdf
14
+ */
15
+
16
+ import fs from 'fs';
17
+ import path from 'path';
18
+ import { fileURLToPath, pathToFileURL } from 'url';
19
+
20
+ const __filename = fileURLToPath(import.meta.url);
21
+
22
+ /**
23
+ * Load puppeteer dynamically so the import failure is a clear runtime error,
24
+ * not a module load error on startup.
25
+ *
26
+ * @returns {Promise<object>} puppeteer default export
27
+ * @throws {Error} with install instructions if not installed
28
+ */
29
+ async function loadPuppeteer() {
30
+ try {
31
+ return (await import('puppeteer')).default;
32
+ } catch {
33
+ throw new Error(
34
+ 'PDF export requires puppeteer:\n' +
35
+ ' npm install puppeteer\n' +
36
+ 'Then retry.'
37
+ );
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Export an Argus HTML report to a branded A4 PDF.
43
+ *
44
+ * @param {string} htmlPath - Absolute or relative path to the source HTML report
45
+ * @param {string} outputPath - Destination PDF file path (written to disk)
46
+ * @param {{ format?: string, landscape?: boolean, scale?: number }} [options]
47
+ * @returns {Promise<string>} Resolved outputPath
48
+ */
49
+ export async function exportReportToPdf(htmlPath, outputPath, options = {}) {
50
+ const {
51
+ format = 'A4',
52
+ landscape = false,
53
+ scale = 1,
54
+ } = options;
55
+
56
+ const resolvedHtml = path.resolve(htmlPath);
57
+ if (!fs.existsSync(resolvedHtml)) {
58
+ throw new Error(`HTML report not found: ${resolvedHtml}`);
59
+ }
60
+
61
+ const resolvedOut = path.resolve(outputPath);
62
+ fs.mkdirSync(path.dirname(resolvedOut), { recursive: true });
63
+
64
+ const puppeteer = await loadPuppeteer();
65
+ const browser = await puppeteer.launch({ headless: 'new' });
66
+
67
+ try {
68
+ const page = await browser.newPage();
69
+ await page.setViewport({ width: 1280, height: 900 });
70
+ await page.goto(pathToFileURL(resolvedHtml).href, { waitUntil: 'networkidle0', timeout: 30_000 });
71
+
72
+ await page.pdf({
73
+ path: resolvedOut,
74
+ format,
75
+ landscape,
76
+ scale,
77
+ printBackground: true,
78
+ margin: { top: '18mm', right: '14mm', bottom: '18mm', left: '14mm' },
79
+ });
80
+ } finally {
81
+ await browser.close();
82
+ }
83
+
84
+ return resolvedOut;
85
+ }
86
+
87
+ /**
88
+ * Navigate to a live URL and export to PDF.
89
+ *
90
+ * @param {string} pageUrl - URL to navigate to before printing
91
+ * @param {string} outputPath - Destination PDF file path
92
+ * @param {{ format?: string, landscape?: boolean, scale?: number, waitUntil?: string }} [options]
93
+ * @returns {Promise<string>}
94
+ */
95
+ export async function exportPageToPdf(pageUrl, outputPath, options = {}) {
96
+ const {
97
+ format = 'A4',
98
+ landscape = false,
99
+ scale = 1,
100
+ waitUntil = 'networkidle0',
101
+ } = options;
102
+
103
+ const resolvedOut = path.resolve(outputPath);
104
+ fs.mkdirSync(path.dirname(resolvedOut), { recursive: true });
105
+
106
+ const puppeteer = await loadPuppeteer();
107
+ const browser = await puppeteer.launch({ headless: 'new' });
108
+
109
+ try {
110
+ const page = await browser.newPage();
111
+ await page.setViewport({ width: 1280, height: 900 });
112
+ await page.goto(pageUrl, { waitUntil, timeout: 30_000 });
113
+
114
+ await page.pdf({
115
+ path: resolvedOut,
116
+ format,
117
+ landscape,
118
+ scale,
119
+ printBackground: true,
120
+ margin: { top: '18mm', right: '14mm', bottom: '18mm', left: '14mm' },
121
+ });
122
+ } finally {
123
+ await browser.close();
124
+ }
125
+
126
+ return resolvedOut;
127
+ }
128
+
129
+ // ── CLI entry ─────────────────────────────────────────────────────────────────
130
+
131
+ if (process.argv[1] === __filename) {
132
+ const [,, htmlArg, outArg] = process.argv;
133
+
134
+ if (!htmlArg || !outArg) {
135
+ process.stderr.write('Usage: node src/utils/pdf-exporter.js <report.html> <output.pdf>\n');
136
+ process.exit(1);
137
+ }
138
+
139
+ try {
140
+ const out = await exportReportToPdf(htmlArg, outArg);
141
+ process.stdout.write(`✓ PDF written: ${out}\n`);
142
+ } catch (err) {
143
+ process.stderr.write(`✗ ${err.message}\n`);
144
+ process.exit(1);
145
+ }
146
+ }
@@ -7,8 +7,16 @@
7
7
  *
8
8
  * Pure functions + one async fetch — no Chrome, no MCP, no AI verdict.
9
9
  * AI verdict logic ships separately in the private argus-pro repo.
10
+ *
11
+ * This module is imported by the MCP server — nothing here may write to
12
+ * stdout (reserved for JSON-RPC). CI annotations are emitted by the CLI
13
+ * entry point (src/cli/pr-validate.js), which owns stdout.
10
14
  */
11
15
 
16
+ import { childLogger } from './logger.js';
17
+
18
+ const logger = childLogger('pr-diff-analyzer');
19
+
12
20
  /**
13
21
  * Parse a GitHub PR URL into its owner/repo/prNumber components.
14
22
  *
@@ -59,7 +67,7 @@ export async function fetchPrFiles(prUrl, githubToken) {
59
67
  }
60
68
 
61
69
  if (allFiles.length >= 300) {
62
- console.log('::warning::PR has 300+ changed files — Argus analyzed the first 300. Routes affected by later files may be missed.');
70
+ logger.warn('[ARGUS] PR has 300+ changed files — Argus analyzed the first 300. Routes affected by later files may be missed.');
63
71
  }
64
72
 
65
73
  return allFiles;
@@ -81,8 +89,9 @@ const EXCLUDED_PATTERNS = [
81
89
  /**
82
90
  * Patterns that indicate an infrastructure-level file whose change can affect
83
91
  * every route — framework configs, root layouts, global stylesheets, package.json.
92
+ * Exported for reuse by root-cause-linker.js (MIT).
84
93
  */
85
- const INFRA_PATTERNS = [
94
+ export const INFRA_PATTERNS = [
86
95
  /next\.config\./i,
87
96
  /vite\.config\./i,
88
97
  /tailwind\.config\./i,
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Root Cause Linking — recent git changes → suspect files per finding.
3
+ *
4
+ * Pure git-diff heuristic: no external API, no AI call. Reads the last N
5
+ * commits from the local repo (`git log --name-only`), maps changed files to
6
+ * route paths with the same slug heuristic as pr-diff-analyzer, and annotates
7
+ * each NEW finding (isNew: true) with the files/commits most likely to have
8
+ * caused it:
9
+ *
10
+ * finding.rootCause = {
11
+ * files: ['src/pages/checkout/Form.jsx', ...], // ≤ MAX_FILES
12
+ * commits: [{ hash: 'abc1234', subject: '...' }], // ≤ MAX_COMMITS
13
+ * global: false, // true when only infra-level files matched
14
+ * }
15
+ *
16
+ * Only new findings are annotated — a finding that predates the recent commits
17
+ * cannot have been caused by them. Source repo defaults to ARGUS_SOURCE_DIR or
18
+ * the current working directory. Disable with ARGUS_ROOT_CAUSE=0.
19
+ */
20
+
21
+ import { execSync } from 'child_process';
22
+ import { INFRA_PATTERNS } from './pr-diff-analyzer.js';
23
+ import { childLogger } from './logger.js';
24
+
25
+ const logger = childLogger('root-cause-linker');
26
+
27
+ /** Commits inspected by default. */
28
+ export const DEFAULT_COMMITS = 10;
29
+ /** Max suspect files attached to one finding. */
30
+ const MAX_FILES = 5;
31
+ /** Max commits attached to one finding. */
32
+ const MAX_COMMITS = 3;
33
+
34
+ /**
35
+ * Read the last N commits with their changed files from a local git repo.
36
+ * Returns [] when the directory is not a repo, git is unavailable, or the
37
+ * command fails — root cause linking is always best-effort.
38
+ *
39
+ * @param {string} [repoDir] - Defaults to ARGUS_SOURCE_DIR, then cwd
40
+ * @param {object} [opts]
41
+ * @param {number} [opts.commits]
42
+ * @returns {Array<{ hash: string, subject: string, files: string[] }>}
43
+ */
44
+ export function getRecentChanges(repoDir = process.env.ARGUS_SOURCE_DIR || process.cwd(), { commits = DEFAULT_COMMITS } = {}) {
45
+ let out;
46
+ try {
47
+ out = execSync(`git log --name-only --pretty=format:%h%x09%s -n ${Math.max(1, commits | 0)}`, {
48
+ cwd: repoDir,
49
+ stdio: ['pipe', 'pipe', 'pipe'],
50
+ timeout: 5000,
51
+ }).toString();
52
+ } catch {
53
+ return [];
54
+ }
55
+
56
+ const changes = [];
57
+ let current = null;
58
+ for (const line of out.split('\n')) {
59
+ // Detect commit lines on the RAW line — an empty commit subject leaves a
60
+ // trailing tab ("abc1234\t") that trimEnd() would strip, misclassifying
61
+ // the hash as a file path. File lines never contain raw tabs: git quotes
62
+ // paths with special characters (core.quotePath octal escapes).
63
+ if (line.includes('\t')) {
64
+ const [hash, ...subjectParts] = line.trimEnd().split('\t');
65
+ current = { hash, subject: subjectParts.join('\t'), files: [] };
66
+ changes.push(current);
67
+ continue;
68
+ }
69
+ const trimmed = line.trimEnd();
70
+ if (!trimmed) continue;
71
+ if (current) {
72
+ // Git emits paths with forward slashes on every platform
73
+ current.files.push(trimmed);
74
+ }
75
+ }
76
+ return changes;
77
+ }
78
+
79
+ /**
80
+ * Match changed file paths against one route path using the slug heuristic
81
+ * from pr-diff-analyzer: a file matches when any of its path/name slugs equals
82
+ * a route path segment. Infra-level files (configs, root layouts, global CSS)
83
+ * match every route and are reported via `global`.
84
+ *
85
+ * @param {string[]} files
86
+ * @param {string} routePath - e.g. "/checkout/review"
87
+ * @returns {{ files: string[], global: boolean }}
88
+ */
89
+ export function matchFilesToRoutePath(files, routePath) {
90
+ const segments = String(routePath ?? '')
91
+ .toLowerCase()
92
+ .split('/')
93
+ .map(s => s.replace(/[^a-z0-9]/g, ''))
94
+ .filter(s => s.length > 1);
95
+
96
+ const matched = [];
97
+ let global = false;
98
+
99
+ for (const file of (files ?? [])) {
100
+ if (INFRA_PATTERNS.some(re => re.test(file))) {
101
+ global = true;
102
+ continue;
103
+ }
104
+ if (segments.length === 0) continue; // home route "/" — only infra files apply
105
+ const slugs = file
106
+ .toLowerCase()
107
+ .replace(/\.[^./\\]+$/, '')
108
+ .split(/[/\\._-]+/)
109
+ .filter(s => s.length > 1);
110
+ if (segments.some(seg => slugs.includes(seg))) matched.push(file);
111
+ }
112
+
113
+ return { files: matched, global };
114
+ }
115
+
116
+ /**
117
+ * Annotate NEW findings in the report with `rootCause` (mutates in place).
118
+ *
119
+ * For each route, slug-matches every recent commit's files against the route's
120
+ * URL pathname. Direct file matches win; when only infra-level files changed,
121
+ * the annotation carries `global: true` with those files as suspects.
122
+ *
123
+ * @param {object} report - { routes: [{ url, errors }] }; findings need isNew
124
+ * @param {Array} changes - From getRecentChanges()
125
+ * @returns {{ linkedCount: number }}
126
+ */
127
+ export function linkRootCauses(report, changes) {
128
+ let linkedCount = 0;
129
+ if (!Array.isArray(changes) || changes.length === 0) return { linkedCount };
130
+
131
+ for (const routeResult of (report.routes ?? [])) {
132
+ let routePath;
133
+ try {
134
+ routePath = new URL(routeResult.url).pathname;
135
+ } catch {
136
+ continue;
137
+ }
138
+
139
+ // Collect suspect files + commits for this route across the recent commits
140
+ const directFiles = [];
141
+ const infraFiles = [];
142
+ const directCommits = [];
143
+ const infraCommits = [];
144
+ for (const commit of changes) {
145
+ const { files, global } = matchFilesToRoutePath(commit.files, routePath);
146
+ if (files.length > 0) {
147
+ directFiles.push(...files);
148
+ directCommits.push({ hash: commit.hash, subject: commit.subject });
149
+ } else if (global) {
150
+ infraFiles.push(...commit.files.filter(f => INFRA_PATTERNS.some(re => re.test(f))));
151
+ infraCommits.push({ hash: commit.hash, subject: commit.subject });
152
+ }
153
+ }
154
+
155
+ const useDirect = directFiles.length > 0;
156
+ const suspectFiles = [...new Set(useDirect ? directFiles : infraFiles)].slice(0, MAX_FILES);
157
+ const suspectCommits = (useDirect ? directCommits : infraCommits).slice(0, MAX_COMMITS);
158
+ if (suspectFiles.length === 0) continue;
159
+
160
+ for (const finding of (routeResult.errors ?? [])) {
161
+ if (!finding.isNew) continue; // pre-existing findings predate these commits
162
+ finding.rootCause = {
163
+ files: suspectFiles,
164
+ commits: suspectCommits,
165
+ global: !useDirect,
166
+ };
167
+ linkedCount++;
168
+ }
169
+ }
170
+
171
+ if (linkedCount > 0) {
172
+ logger.info(`[ARGUS] Root cause linking: ${linkedCount} new finding(s) linked to recent commits`);
173
+ }
174
+ return { linkedCount };
175
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Argus Screen Recorder
3
+ *
4
+ * Records an audit session as a series of JPEG frames by periodically
5
+ * calling browser.screenshot(). Frames are saved to reports/recordings/<id>/.
6
+ *
7
+ * For genuine CDP screencast (Page.startScreencast) instead of polling,
8
+ * install the optional `ws` package:
9
+ * npm install ws
10
+ * then use CdpScreenRecorder (exported below) which connects directly to
11
+ * Chrome's WebSocket debugger URL.
12
+ *
13
+ * Usage (polling recorder — no extra deps):
14
+ * import { PollingRecorder } from './screen-recorder.js';
15
+ * const rec = new PollingRecorder(browser, { intervalMs: 500 });
16
+ * rec.start();
17
+ * // ... run audit ...
18
+ * const outputDir = await rec.stop('./reports/recordings/my-run');
19
+ *
20
+ * Usage (CDP screencast — requires ws):
21
+ * import { CdpScreenRecorder } from './screen-recorder.js';
22
+ * const rec = await CdpScreenRecorder.create(9222);
23
+ * await rec.start();
24
+ * // ...
25
+ * const outputDir = await rec.stop('./reports/recordings/my-run');
26
+ */
27
+
28
+ import { spawn } from 'child_process';
29
+ import fs from 'fs';
30
+ import path from 'path';
31
+ import http from 'http';
32
+
33
+ // ── Polling Recorder (no extra dependencies) ──────────────────────────────────
34
+
35
+ export class PollingRecorder {
36
+ /**
37
+ * @param {import('../adapters/browser.js').CdpBrowserAdapter} browser
38
+ * @param {{ intervalMs?: number, quality?: number }} [options]
39
+ */
40
+ constructor(browser, options = {}) {
41
+ this._browser = browser;
42
+ this._intervalMs = options.intervalMs ?? 500;
43
+ this._quality = options.quality ?? 70;
44
+ this._frames = [];
45
+ this._timer = null;
46
+ this._startedAt = null;
47
+ }
48
+
49
+ /** Begin periodic screenshot capture. */
50
+ start() {
51
+ if (this._timer) return;
52
+ this._startedAt = Date.now();
53
+ this._frames = [];
54
+ this._timer = setInterval(async () => {
55
+ try {
56
+ const raw = await this._browser.screenshot({ quality: this._quality });
57
+ // screenshot() returns an MCP result — extract base64 data if wrapped
58
+ const b64 = typeof raw === 'object' ? (raw.result ?? raw.data ?? raw) : raw;
59
+ if (b64) this._frames.push({ ts: Date.now(), data: b64 });
60
+ } catch { /* ignore individual capture errors */ }
61
+ }, this._intervalMs);
62
+ }
63
+
64
+ /**
65
+ * Stop recording and save frames to outputDir.
66
+ *
67
+ * @param {string} outputDir - Directory to write frame-NNN.jpg files into
68
+ * @returns {Promise<{ outputDir: string, frameCount: number, durationMs: number }>}
69
+ */
70
+ async stop(outputDir) {
71
+ if (this._timer) {
72
+ clearInterval(this._timer);
73
+ this._timer = null;
74
+ }
75
+
76
+ const durationMs = Date.now() - (this._startedAt ?? Date.now());
77
+ const frames = this._frames.slice();
78
+ this._frames = [];
79
+
80
+ const resolved = path.resolve(outputDir);
81
+ fs.mkdirSync(resolved, { recursive: true });
82
+
83
+ for (let i = 0; i < frames.length; i++) {
84
+ const name = `frame-${String(i).padStart(4, '0')}.jpg`;
85
+ const filePath = path.join(resolved, name);
86
+ const b64 = frames[i].data;
87
+ // b64 may be a data URL prefix — strip it
88
+ const raw = typeof b64 === 'string'
89
+ ? b64.replace(/^data:image\/\w+;base64,/, '')
90
+ : b64;
91
+ try {
92
+ fs.writeFileSync(filePath, Buffer.from(raw, 'base64'));
93
+ } catch { /* skip unwritable frames */ }
94
+ }
95
+
96
+ // Write metadata + ffmpeg hint
97
+ const meta = {
98
+ frameCount: frames.length,
99
+ durationMs,
100
+ intervalMs: this._intervalMs,
101
+ capturedAt: new Date(this._startedAt).toISOString(),
102
+ ffmpegHint: `ffmpeg -framerate ${Math.round(1000 / this._intervalMs)} -i frame-%04d.jpg -c:v libx264 -pix_fmt yuv420p recording.mp4`,
103
+ };
104
+ fs.writeFileSync(path.join(resolved, 'meta.json'), JSON.stringify(meta, null, 2));
105
+
106
+ // Run ffmpeg automatically if available
107
+ await _tryFfmpeg(resolved, meta.intervalMs).catch(() => {});
108
+
109
+ return { outputDir: resolved, frameCount: frames.length, durationMs };
110
+ }
111
+ }
112
+
113
+ // ── CDP Screencast Recorder (requires ws package) ─────────────────────────────
114
+
115
+ export class CdpScreenRecorder {
116
+ constructor(ws, sessionId) {
117
+ this._ws = ws;
118
+ this._sessionId = sessionId;
119
+ this._frames = [];
120
+ this._startedAt = null;
121
+ this._msgId = 1;
122
+ }
123
+
124
+ /**
125
+ * Create a CdpScreenRecorder connected to the first Chrome tab.
126
+ * Requires the `ws` package: npm install ws
127
+ *
128
+ * @param {number} [port=9222]
129
+ * @returns {Promise<CdpScreenRecorder>}
130
+ */
131
+ static async create(port = 9222) {
132
+ let WebSocket;
133
+ try {
134
+ WebSocket = (await import('ws')).default;
135
+ } catch {
136
+ throw new Error(
137
+ 'CDP screencast requires the ws package:\n' +
138
+ ' npm install ws\n' +
139
+ 'Or use PollingRecorder instead (no extra deps).'
140
+ );
141
+ }
142
+
143
+ const debuggerUrl = await _fetchDebuggerUrl(port);
144
+ const ws = new WebSocket(debuggerUrl);
145
+
146
+ await new Promise((resolve, reject) => {
147
+ ws.once('open', resolve);
148
+ ws.once('error', reject);
149
+ });
150
+
151
+ const rec = new CdpScreenRecorder(ws, null);
152
+ ws.on('message', data => {
153
+ try {
154
+ const msg = JSON.parse(data.toString());
155
+ if (msg.method === 'Page.screencastFrame') {
156
+ rec._frames.push({ ts: Date.now(), data: msg.params.data });
157
+ // Acknowledge the frame to keep Chrome sending
158
+ rec._send('Page.screencastFrameAck', { sessionId: msg.params.metadata.sessionId });
159
+ }
160
+ } catch { /* ignore parse errors */ }
161
+ });
162
+
163
+ return rec;
164
+ }
165
+
166
+ _send(method, params = {}) {
167
+ this._ws.send(JSON.stringify({ id: this._msgId++, method, params }));
168
+ }
169
+
170
+ /** Start CDP screencast. */
171
+ async start() {
172
+ this._startedAt = Date.now();
173
+ this._frames = [];
174
+ this._send('Page.startScreencast', {
175
+ format: 'jpeg',
176
+ quality: 70,
177
+ maxWidth: 1280,
178
+ maxHeight: 900,
179
+ everyNthFrame: 1,
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Stop screencast and save frames to outputDir.
185
+ *
186
+ * @param {string} outputDir
187
+ * @returns {Promise<{ outputDir: string, frameCount: number, durationMs: number }>}
188
+ */
189
+ async stop(outputDir) {
190
+ this._send('Page.stopScreencast');
191
+ await new Promise(r => setTimeout(r, 200)); // drain any buffered frames
192
+ this._ws.close();
193
+
194
+ const durationMs = Date.now() - (this._startedAt ?? Date.now());
195
+ const frames = this._frames.slice();
196
+ const resolved = path.resolve(outputDir);
197
+
198
+ fs.mkdirSync(resolved, { recursive: true });
199
+
200
+ for (let i = 0; i < frames.length; i++) {
201
+ const name = `frame-${String(i).padStart(4, '0')}.jpg`;
202
+ fs.writeFileSync(path.join(resolved, name), Buffer.from(frames[i].data, 'base64'));
203
+ }
204
+
205
+ const meta = {
206
+ frameCount: frames.length,
207
+ durationMs,
208
+ capturedAt: new Date(this._startedAt).toISOString(),
209
+ ffmpegHint: `ffmpeg -framerate 2 -i frame-%04d.jpg -c:v libx264 -pix_fmt yuv420p recording.mp4`,
210
+ };
211
+ fs.writeFileSync(path.join(resolved, 'meta.json'), JSON.stringify(meta, null, 2));
212
+
213
+ await _tryFfmpeg(resolved, 500).catch(() => {});
214
+
215
+ return { outputDir: resolved, frameCount: frames.length, durationMs };
216
+ }
217
+ }
218
+
219
+ // ── Helpers ───────────────────────────────────────────────────────────────────
220
+
221
+ function _fetchDebuggerUrl(port) {
222
+ return new Promise((resolve, reject) => {
223
+ http.get(`http://localhost:${port}/json/list`, res => {
224
+ let body = '';
225
+ res.on('data', d => { body += d; });
226
+ res.on('end', () => {
227
+ try {
228
+ const pages = JSON.parse(body);
229
+ const target = pages.find(p => p.type === 'page') ?? pages[0];
230
+ if (!target?.webSocketDebuggerUrl) reject(new Error('No debuggable Chrome page found'));
231
+ else resolve(target.webSocketDebuggerUrl);
232
+ } catch (e) { reject(e); }
233
+ });
234
+ }).on('error', reject);
235
+ });
236
+ }
237
+
238
+ function _tryFfmpeg(outputDir, intervalMs) {
239
+ return new Promise((resolve, reject) => {
240
+ const fps = Math.max(1, Math.round(1000 / intervalMs));
241
+ const proc = spawn('ffmpeg', [
242
+ '-y', '-framerate', String(fps),
243
+ '-i', 'frame-%04d.jpg',
244
+ '-c:v', 'libx264', '-pix_fmt', 'yuv420p',
245
+ 'recording.mp4',
246
+ ], { cwd: outputDir, stdio: 'ignore' });
247
+ proc.on('close', code => (code === 0 ? resolve() : reject(new Error(`ffmpeg exited ${code}`))));
248
+ proc.on('error', reject);
249
+ });
250
+ }