task-summary-extractor 8.1.0

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,329 @@
1
+ /**
2
+ * Git Service — wraps git CLI for change detection.
3
+ *
4
+ * Provides structured access to git log, diff, and status information
5
+ * for correlating code changes with call analysis items.
6
+ *
7
+ * All commands use execFileSync for safety (no shell injection).
8
+ * Windows-compatible: uses forward-slash path normalization.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { execFileSync } = require('child_process');
14
+ const path = require('path');
15
+ const fs = require('fs');
16
+
17
+ // ======================== HELPERS ========================
18
+
19
+ /**
20
+ * Execute a git command and return stdout.
21
+ * Returns null on error instead of throwing.
22
+ *
23
+ * @param {string[]} args - Git arguments
24
+ * @param {string} cwd - Working directory
25
+ * @returns {string|null}
26
+ */
27
+ function execGit(args, cwd) {
28
+ try {
29
+ return execFileSync('git', args, {
30
+ cwd,
31
+ encoding: 'utf8',
32
+ maxBuffer: 10 * 1024 * 1024, // 10 MB
33
+ timeout: 30000,
34
+ windowsHide: true,
35
+ }).trim();
36
+ } catch (err) {
37
+ // Common failures: not a repo, detached HEAD, no commits
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /** Normalize path separators to forward slashes for cross-platform comparison */
43
+ function normPath(p) {
44
+ return (p || '').replace(/\\/g, '/');
45
+ }
46
+
47
+ // ======================== AVAILABILITY ========================
48
+
49
+ /**
50
+ * Check if git CLI is available on this system.
51
+ * @returns {boolean}
52
+ */
53
+ function isGitAvailable() {
54
+ try {
55
+ execFileSync('git', ['--version'], {
56
+ encoding: 'utf8',
57
+ timeout: 5000,
58
+ windowsHide: true,
59
+ stdio: ['pipe', 'pipe', 'pipe'],
60
+ });
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Find the git repository root by walking up from startDir.
69
+ *
70
+ * @param {string} startDir - Directory to start searching from
71
+ * @returns {string|null} Absolute path to git root, or null
72
+ */
73
+ function findGitRoot(startDir) {
74
+ const root = execGit(['rev-parse', '--show-toplevel'], startDir);
75
+ return root ? path.resolve(root) : null;
76
+ }
77
+
78
+ /**
79
+ * Check if a directory is inside a git repository.
80
+ * @param {string} dir
81
+ * @returns {boolean}
82
+ */
83
+ function isGitRepo(dir) {
84
+ return findGitRoot(dir) !== null;
85
+ }
86
+
87
+ /**
88
+ * Initialize a new git repository in the given directory.
89
+ *
90
+ * Creates the repo, stages all existing files, and makes an initial commit.
91
+ * If the directory is already a git repo, does nothing and returns the existing root.
92
+ *
93
+ * @param {string} dir - Directory to initialize
94
+ * @returns {{ root: string, created: boolean }} Repo root path and whether it was newly created
95
+ * @throws {Error} If git is not available or init fails
96
+ */
97
+ function initRepo(dir) {
98
+ if (!isGitAvailable()) {
99
+ throw new Error('git is not installed. Install git to use progress tracking.');
100
+ }
101
+
102
+ // Already a repo — return existing root
103
+ const existing = findGitRoot(dir);
104
+ if (existing) return { root: existing, created: false };
105
+
106
+ // Initialize
107
+ const initResult = execGit(['init'], dir);
108
+ if (initResult === null) {
109
+ throw new Error(`Failed to initialize git repository in "${dir}". Check directory permissions.`);
110
+ }
111
+
112
+ // Stage all existing files and create initial commit
113
+ execGit(['add', '-A'], dir);
114
+ execGit(['commit', '-m', 'Initial commit — baseline for progress tracking', '--allow-empty'], dir);
115
+
116
+ const root = findGitRoot(dir);
117
+ if (!root) {
118
+ throw new Error(`git init succeeded but repository root could not be resolved in "${dir}".`);
119
+ }
120
+ return { root, created: true };
121
+ }
122
+
123
+ // ======================== COMMIT LOG ========================
124
+
125
+ /**
126
+ * Get commits since a given ISO timestamp.
127
+ *
128
+ * @param {string} repoPath - Repository root
129
+ * @param {string} sinceISO - ISO 8601 timestamp (e.g. "2026-02-24T16:22:28")
130
+ * @param {number} [maxCount=100] - Max commits to return
131
+ * @returns {Array<{hash: string, author: string, date: string, message: string}>}
132
+ */
133
+ function getCommitsSince(repoPath, sinceISO, maxCount = 100) {
134
+ const SEP = '\x00'; // null byte separator — won't appear in messages
135
+ const format = `%H${SEP}%an${SEP}%aI${SEP}%s`;
136
+
137
+ const output = execGit(
138
+ ['log', `--since=${sinceISO}`, `--format=${format}`, `--max-count=${maxCount}`],
139
+ repoPath,
140
+ );
141
+ if (!output) return [];
142
+
143
+ return output.split('\n').filter(Boolean).map(line => {
144
+ const parts = line.split(SEP);
145
+ if (parts.length < 4) return null;
146
+ return {
147
+ hash: parts[0].slice(0, 12),
148
+ author: parts[1],
149
+ date: parts[2],
150
+ message: parts.slice(3).join(SEP), // message may contain SEP (unlikely)
151
+ };
152
+ }).filter(Boolean);
153
+ }
154
+
155
+ /**
156
+ * Get commit messages with their changed file lists.
157
+ * More expensive than getCommitsSince but gives per-commit file info.
158
+ *
159
+ * @param {string} repoPath
160
+ * @param {string} sinceISO
161
+ * @param {number} [maxCount=50]
162
+ * @returns {Array<{hash: string, author: string, date: string, message: string, files: string[]}>}
163
+ */
164
+ function getCommitsWithFiles(repoPath, sinceISO, maxCount = 50) {
165
+ const output = execGit(
166
+ ['log', `--since=${sinceISO}`, '--name-only', '--format=COMMIT:%H|%an|%aI|%s', `--max-count=${maxCount}`],
167
+ repoPath,
168
+ );
169
+ if (!output) return [];
170
+
171
+ const commits = [];
172
+ let current = null;
173
+
174
+ for (const line of output.split('\n')) {
175
+ const trimmed = line.trim();
176
+ if (!trimmed) continue;
177
+
178
+ if (trimmed.startsWith('COMMIT:')) {
179
+ if (current) commits.push(current);
180
+ const parts = trimmed.slice(7).split('|');
181
+ current = {
182
+ hash: (parts[0] || '').slice(0, 12),
183
+ author: parts[1] || '',
184
+ date: parts[2] || '',
185
+ message: parts.slice(3).join('|'),
186
+ files: [],
187
+ };
188
+ } else if (current) {
189
+ current.files.push(normPath(trimmed));
190
+ }
191
+ }
192
+ if (current) commits.push(current);
193
+
194
+ return commits;
195
+ }
196
+
197
+ // ======================== CHANGED FILES ========================
198
+
199
+ /**
200
+ * Get a deduplicated list of all files changed since a timestamp.
201
+ * Aggregates across all commits — each file appears once with its last status.
202
+ *
203
+ * @param {string} repoPath
204
+ * @param {string} sinceISO
205
+ * @returns {Array<{path: string, status: string, changes: number}>}
206
+ */
207
+ function getChangedFilesSince(repoPath, sinceISO) {
208
+ const output = execGit(
209
+ ['log', `--since=${sinceISO}`, '--name-status', '--format='],
210
+ repoPath,
211
+ );
212
+ if (!output) return [];
213
+
214
+ const fileMap = new Map();
215
+ for (const line of output.split('\n')) {
216
+ const trimmed = line.trim();
217
+ if (!trimmed) continue;
218
+ const match = trimmed.match(/^([AMDRC])\t(.+)$/);
219
+ if (match) {
220
+ const status = match[1];
221
+ const filePath = normPath(match[2]);
222
+ const existing = fileMap.get(filePath);
223
+ if (existing) {
224
+ existing.changes++;
225
+ existing.status = status; // latest status wins
226
+ } else {
227
+ fileMap.set(filePath, { path: filePath, status, changes: 1 });
228
+ }
229
+ }
230
+ }
231
+
232
+ return Array.from(fileMap.values());
233
+ }
234
+
235
+ // ======================== DIFF CONTENT ========================
236
+
237
+ /**
238
+ * Get a summary of changes since a timestamp (insertions/deletions).
239
+ *
240
+ * @param {string} repoPath
241
+ * @param {string} sinceISO
242
+ * @returns {string} Human-readable diff summary
243
+ */
244
+ function getDiffSummary(repoPath, sinceISO) {
245
+ const commits = getCommitsSince(repoPath, sinceISO);
246
+ if (commits.length === 0) return 'No changes';
247
+
248
+ const oldestHash = commits[commits.length - 1].hash;
249
+ // Try parent..HEAD first
250
+ let output = execGit(['diff', '--shortstat', `${oldestHash}~1`, 'HEAD'], repoPath);
251
+ if (!output) {
252
+ output = execGit(['diff', '--shortstat', oldestHash, 'HEAD'], repoPath);
253
+ }
254
+ return output || 'No stat available';
255
+ }
256
+
257
+ /**
258
+ * Get actual diff content, truncated to maxBytes.
259
+ * Used for keyword matching, NOT sent to Gemini directly.
260
+ *
261
+ * @param {string} repoPath
262
+ * @param {string} sinceISO
263
+ * @param {number} [maxBytes=100000]
264
+ * @returns {string} Diff content (may be truncated)
265
+ */
266
+ function getDiffContent(repoPath, sinceISO, maxBytes = 100000) {
267
+ const commits = getCommitsSince(repoPath, sinceISO);
268
+ if (commits.length === 0) return '';
269
+
270
+ const oldestHash = commits[commits.length - 1].hash;
271
+ let output = execGit(
272
+ ['diff', '--no-color', '-U2', `${oldestHash}~1`, 'HEAD'],
273
+ repoPath,
274
+ );
275
+ if (!output) {
276
+ output = execGit(['diff', '--no-color', '-U2', oldestHash, 'HEAD'], repoPath);
277
+ }
278
+ if (!output) return '';
279
+
280
+ return output.length > maxBytes
281
+ ? output.slice(0, maxBytes) + '\n... (truncated at ' + Math.round(maxBytes / 1024) + ' KB)'
282
+ : output;
283
+ }
284
+
285
+ // ======================== WORKING TREE ========================
286
+
287
+ /**
288
+ * Get uncommitted working tree changes (staged + unstaged).
289
+ *
290
+ * @param {string} repoPath
291
+ * @returns {Array<{path: string, status: string}>}
292
+ */
293
+ function getWorkingTreeChanges(repoPath) {
294
+ const output = execGit(['status', '--porcelain', '-u'], repoPath);
295
+ if (!output) return [];
296
+
297
+ return output.split('\n').filter(Boolean).map(line => {
298
+ const status = line.slice(0, 2).trim();
299
+ const filePath = normPath(line.slice(3));
300
+ return { path: filePath, status };
301
+ });
302
+ }
303
+
304
+ /**
305
+ * Get the current branch name.
306
+ *
307
+ * @param {string} repoPath
308
+ * @returns {string|null}
309
+ */
310
+ function getCurrentBranch(repoPath) {
311
+ return execGit(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath);
312
+ }
313
+
314
+ // ======================== EXPORTS ========================
315
+
316
+ module.exports = {
317
+ isGitAvailable,
318
+ findGitRoot,
319
+ isGitRepo,
320
+ initRepo,
321
+ getCommitsSince,
322
+ getCommitsWithFiles,
323
+ getChangedFilesSince,
324
+ getDiffSummary,
325
+ getDiffContent,
326
+ getWorkingTreeChanges,
327
+ getCurrentBranch,
328
+ normPath,
329
+ };
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Video processing — compression, segmentation, ffmpeg/ffprobe wrappers.
3
+ *
4
+ * Improvements:
5
+ * - No process.exit() — throws descriptive errors instead
6
+ * - Lazy binary detection (not at module load time)
7
+ * - Better error messages
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const { execSync, spawnSync } = require('child_process');
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { SPEED, SEG_TIME, PRESET } = require('../config');
16
+ const { fmtDuration } = require('../utils/format');
17
+
18
+ // ======================== BINARY DETECTION ========================
19
+
20
+ let _ffmpeg = null;
21
+ let _ffprobe = null;
22
+
23
+ /** Auto-detect a binary from PATH or common locations. Throws if not found. */
24
+ function findBin(name) {
25
+ // Cross-platform PATH lookup: 'where' on Windows, 'which' on Linux/Mac
26
+ const lookupCmd = process.platform === 'win32' ? 'where' : 'which';
27
+ const suppressStderr = process.platform === 'win32' ? '2>nul' : '2>/dev/null';
28
+ try {
29
+ const found = execSync(`${lookupCmd} ${name} ${suppressStderr}`, { encoding: 'utf8' }).trim().split('\n')[0];
30
+ if (found) return found.trim();
31
+ } catch { /* ignore */ }
32
+
33
+ // Windows-specific fallback location
34
+ if (process.platform === 'win32') {
35
+ const common = `C:\\ffmpeg\\bin\\${name}.exe`;
36
+ if (fs.existsSync(common)) return common;
37
+ }
38
+
39
+ const installHint = process.platform === 'win32'
40
+ ? `Install ffmpeg from https://www.gyan.dev/ffmpeg/builds/ and add to PATH, or place in C:\\ffmpeg\\bin\\`
41
+ : `Install ffmpeg: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)`;
42
+ throw new Error(
43
+ `${name} not found in PATH. ${installHint}`
44
+ );
45
+ }
46
+
47
+ /** Get ffmpeg path (lazy, cached). */
48
+ function getFFmpeg() {
49
+ if (!_ffmpeg) _ffmpeg = findBin('ffmpeg');
50
+ return _ffmpeg;
51
+ }
52
+
53
+ /** Get ffprobe path (lazy, cached). */
54
+ function getFFprobe() {
55
+ if (!_ffprobe) _ffprobe = findBin('ffprobe');
56
+ return _ffprobe;
57
+ }
58
+
59
+ // ======================== PROBING ========================
60
+
61
+ /** Run ffprobe and return a single value from a stream (safe: no shell interpolation) */
62
+ function probe(filePath, streamSelect, entry) {
63
+ try {
64
+ const result = spawnSync(getFFprobe(), [
65
+ '-v', 'error',
66
+ '-select_streams', streamSelect,
67
+ '-show_entries', `stream=${entry}`,
68
+ '-of', 'csv=p=0',
69
+ filePath,
70
+ ], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
71
+ return result.status === 0 ? (result.stdout || '').trim() || null : null;
72
+ } catch { return null; }
73
+ }
74
+
75
+ /** Run ffprobe for format-level entries (safe: no shell interpolation) */
76
+ function probeFormat(filePath, entry) {
77
+ try {
78
+ const result = spawnSync(getFFprobe(), [
79
+ '-v', 'error',
80
+ '-show_entries', `format=${entry}`,
81
+ '-of', 'csv=p=0',
82
+ filePath,
83
+ ], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
84
+ return result.status === 0 ? (result.stdout || '').trim() || null : null;
85
+ } catch { return null; }
86
+ }
87
+
88
+ // ======================== COMPRESSION ========================
89
+
90
+ /**
91
+ * Verify a segment file is a valid MP4 (has moov atom, readable by ffprobe).
92
+ * Returns true if valid, false if corrupt.
93
+ */
94
+ function verifySegment(segPath) {
95
+ try {
96
+ const dur = probeFormat(segPath, 'duration');
97
+ return dur !== null && parseFloat(dur) > 0;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Build the common ffmpeg encoding args (video + audio filters/codecs).
105
+ * Returns { encodingArgs, effectiveDuration }.
106
+ */
107
+ function buildEncodingArgs(inputFile) {
108
+ const width = parseInt(probe(inputFile, 'v:0', 'width') || '0');
109
+ const channels = parseInt(probe(inputFile, 'a:0', 'channels') || '1');
110
+ const sampleRate = probe(inputFile, 'a:0', 'sample_rate') || '16000';
111
+ const duration = probeFormat(inputFile, 'duration');
112
+ const durationSec = duration ? parseFloat(duration) : null;
113
+ const effectiveDuration = durationSec ? durationSec / SPEED : null;
114
+
115
+ let vf = `setpts=PTS/${SPEED}`;
116
+ let crf = 24;
117
+ let tune = ['-tune', 'stillimage'];
118
+ let profile = ['-profile:v', 'main'];
119
+ let audioBr = '64k';
120
+ let x264p = 'aq-mode=3:deblock=-1,-1:psy-rd=1.0,0.0';
121
+
122
+ if (width > 1920) {
123
+ // 4K+ → scale to 1080p
124
+ vf = `scale=1920:1080,unsharp=3:3:0.3,setpts=PTS/${SPEED}`;
125
+ crf = 20;
126
+ tune = [];
127
+ profile = ['-profile:v', 'high'];
128
+ audioBr = '128k';
129
+ } else if (width > 0) {
130
+ // Meeting / screenshare
131
+ vf = `unsharp=3:3:0.3,setpts=PTS/${SPEED}`;
132
+ }
133
+
134
+ if (channels === 2) audioBr = '128k';
135
+
136
+ const encodingArgs = [
137
+ '-vf', vf,
138
+ '-af', `atempo=${SPEED}`,
139
+ '-c:v', 'libx264', '-crf', String(crf), '-preset', PRESET,
140
+ ...tune,
141
+ '-x264-params', x264p,
142
+ ...profile,
143
+ '-pix_fmt', 'yuv420p',
144
+ '-c:a', 'aac', '-b:a', audioBr, '-ar', sampleRate, '-ac', String(channels),
145
+ '-movflags', '+faststart',
146
+ ];
147
+
148
+ return { encodingArgs, effectiveDuration, width, crf, audioBr, duration };
149
+ }
150
+
151
+ /**
152
+ * Compress and segment a video file using ffmpeg.
153
+ * - Short videos (effective duration ≤ SEG_TIME) → single MP4 output (avoids segment muxer issues).
154
+ * - Long videos → segment muxer for splitting.
155
+ * - Post-compression validation: verifies each output has a valid moov atom.
156
+ * Corrupt segments are re-encoded individually with the regular MP4 muxer.
157
+ * Returns sorted array of segment file paths.
158
+ */
159
+ function compressAndSegment(inputFile, outputDir) {
160
+ const { encodingArgs, effectiveDuration, width, crf, audioBr, duration } = buildEncodingArgs(inputFile);
161
+
162
+ fs.mkdirSync(outputDir, { recursive: true });
163
+
164
+ console.log(` Resolution : ${width > 0 ? width + 'p' : 'unknown'}`);
165
+ console.log(` Duration : ${duration ? fmtDuration(parseFloat(duration)) : 'unknown'}${effectiveDuration ? ` (${fmtDuration(effectiveDuration)} at ${SPEED}x)` : ''}`);
166
+ console.log(` CRF ${crf} | ${audioBr} audio | ${SPEED}x speed`);
167
+
168
+ // Decide: single output vs segmented
169
+ const needsSegmentation = effectiveDuration === null || effectiveDuration > SEG_TIME;
170
+
171
+ if (needsSegmentation) {
172
+ console.log(` Compressing (segmented, ${SEG_TIME}s chunks)...`);
173
+ const args = [
174
+ '-y', '-err_detect', 'ignore_err', '-fflags', '+genpts+discardcorrupt',
175
+ '-i', inputFile,
176
+ ...encodingArgs,
177
+ '-f', 'segment', '-segment_time', String(SEG_TIME), '-reset_timestamps', '1',
178
+ '-map', '0:v:0', '-map', '0:a:0',
179
+ path.join(outputDir, 'segment_%02d.mp4'),
180
+ ];
181
+
182
+ const result = spawnSync(getFFmpeg(), args, { stdio: 'inherit' });
183
+ if (result.status !== 0) {
184
+ console.warn(` ⚠ ffmpeg exited with code ${result.status} (output may still be usable)`);
185
+ }
186
+ } else {
187
+ console.log(` Compressing (single output, ${effectiveDuration ? fmtDuration(effectiveDuration) : '?'} effective)...`);
188
+ const outPath = path.join(outputDir, 'segment_00.mp4');
189
+ const args = [
190
+ '-y', '-err_detect', 'ignore_err', '-fflags', '+genpts+discardcorrupt',
191
+ '-i', inputFile,
192
+ ...encodingArgs,
193
+ '-map', '0:v:0', '-map', '0:a:0',
194
+ outPath,
195
+ ];
196
+
197
+ const result = spawnSync(getFFmpeg(), args, { stdio: 'inherit' });
198
+ if (result.status !== 0) {
199
+ console.warn(` ⚠ ffmpeg exited with code ${result.status}`);
200
+ }
201
+ }
202
+
203
+ // Collect segments
204
+ let segments = fs.readdirSync(outputDir)
205
+ .filter(f => f.startsWith('segment_') && f.endsWith('.mp4'))
206
+ .sort()
207
+ .map(f => path.join(outputDir, f));
208
+
209
+ // Post-compression integrity check — verify each segment
210
+ const valid = [];
211
+ const corrupt = [];
212
+ for (const seg of segments) {
213
+ if (verifySegment(seg)) {
214
+ valid.push(seg);
215
+ } else {
216
+ corrupt.push(seg);
217
+ console.warn(` ⚠ Corrupt segment detected: ${path.basename(seg)} (missing moov atom)`);
218
+ }
219
+ }
220
+
221
+ // Attempt to re-encode corrupt segments from original source
222
+ if (corrupt.length > 0 && needsSegmentation) {
223
+ console.log(` Retrying ${corrupt.length} corrupt segment(s) with regular MP4 muxer...`);
224
+ // Fallback: re-compress the entire video as a single file, then re-segment if needed
225
+ const fallbackPath = path.join(outputDir, '_fallback_full.mp4');
226
+ const fbArgs = [
227
+ '-y', '-err_detect', 'ignore_err', '-fflags', '+genpts+discardcorrupt',
228
+ '-i', inputFile,
229
+ ...encodingArgs,
230
+ '-map', '0:v:0', '-map', '0:a:0',
231
+ fallbackPath,
232
+ ];
233
+ const fbResult = spawnSync(getFFmpeg(), fbArgs, { stdio: 'inherit' });
234
+ if (fbResult.status === 0 && verifySegment(fallbackPath)) {
235
+ // Remove all corrupt segments and replace with the fallback
236
+ for (const seg of corrupt) { try { fs.unlinkSync(seg); } catch {} }
237
+ // If this was the only segment, just rename it
238
+ if (segments.length === 1) {
239
+ const dest = path.join(outputDir, 'segment_00.mp4');
240
+ fs.renameSync(fallbackPath, dest);
241
+ segments = [dest];
242
+ console.log(` ✓ Re-encoded successfully as single segment`);
243
+ } else {
244
+ // Re-segment the fallback
245
+ const reSegDir = path.join(outputDir, '_reseg');
246
+ fs.mkdirSync(reSegDir, { recursive: true });
247
+ const rsArgs = [
248
+ '-y', '-i', fallbackPath,
249
+ '-c', 'copy',
250
+ '-f', 'segment', '-segment_time', String(SEG_TIME), '-reset_timestamps', '1',
251
+ '-movflags', '+faststart',
252
+ path.join(reSegDir, 'segment_%02d.mp4'),
253
+ ];
254
+ spawnSync(getFFmpeg(), rsArgs, { stdio: 'inherit' });
255
+ // Move re-segmented files back, overwriting corrupt ones
256
+ const reSegs = fs.readdirSync(reSegDir).filter(f => f.endsWith('.mp4')).sort();
257
+ for (const f of reSegs) {
258
+ fs.renameSync(path.join(reSegDir, f), path.join(outputDir, f));
259
+ }
260
+ try { fs.rmSync(reSegDir, { recursive: true }); } catch {}
261
+ try { fs.unlinkSync(fallbackPath); } catch {}
262
+ // Re-collect
263
+ segments = fs.readdirSync(outputDir)
264
+ .filter(f => f.startsWith('segment_') && f.endsWith('.mp4'))
265
+ .sort()
266
+ .map(f => path.join(outputDir, f));
267
+ console.log(` ✓ Re-segmented from fallback: ${segments.length} segment(s)`);
268
+ }
269
+ } else {
270
+ console.error(` ✗ Fallback re-encode also failed`);
271
+ try { fs.unlinkSync(fallbackPath); } catch {}
272
+ }
273
+ } else if (corrupt.length > 0 && !needsSegmentation) {
274
+ // Single-output mode also failed — try once more without segment muxer flags
275
+ console.log(` Retrying single-output compression...`);
276
+ const retryPath = path.join(outputDir, 'segment_00.mp4');
277
+ try { fs.unlinkSync(retryPath); } catch {}
278
+ const retryArgs = [
279
+ '-y',
280
+ '-i', inputFile,
281
+ ...encodingArgs,
282
+ '-map', '0:v:0', '-map', '0:a:0',
283
+ retryPath,
284
+ ];
285
+ const retryResult = spawnSync(getFFmpeg(), retryArgs, { stdio: 'inherit' });
286
+ if (retryResult.status === 0 && verifySegment(retryPath)) {
287
+ segments = [retryPath];
288
+ console.log(` ✓ Retry succeeded`);
289
+ } else {
290
+ console.error(` ✗ Retry also produced invalid output`);
291
+ }
292
+ }
293
+
294
+ return segments;
295
+ }
296
+
297
+ module.exports = {
298
+ findBin,
299
+ probe,
300
+ probeFormat,
301
+ compressAndSegment,
302
+ verifySegment,
303
+ getFFmpeg,
304
+ getFFprobe,
305
+ };