@studiomeyer/mcp-video 1.0.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.
Files changed (184) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +19 -0
  3. package/.github/workflows/ci.yml +34 -0
  4. package/CHANGELOG.md +24 -0
  5. package/CONTRIBUTING.md +75 -0
  6. package/LICENSE +21 -0
  7. package/README.md +198 -0
  8. package/USAGE.md +144 -0
  9. package/dist/handlers/capcut.d.ts +6 -0
  10. package/dist/handlers/capcut.js +229 -0
  11. package/dist/handlers/capcut.js.map +1 -0
  12. package/dist/handlers/editing.d.ts +6 -0
  13. package/dist/handlers/editing.js +242 -0
  14. package/dist/handlers/editing.js.map +1 -0
  15. package/dist/handlers/index.d.ts +2 -0
  16. package/dist/handlers/index.js +33 -0
  17. package/dist/handlers/index.js.map +1 -0
  18. package/dist/handlers/post-production.d.ts +5 -0
  19. package/dist/handlers/post-production.js +109 -0
  20. package/dist/handlers/post-production.js.map +1 -0
  21. package/dist/handlers/smart-screenshot.d.ts +5 -0
  22. package/dist/handlers/smart-screenshot.js +83 -0
  23. package/dist/handlers/smart-screenshot.js.map +1 -0
  24. package/dist/handlers/tts.d.ts +5 -0
  25. package/dist/handlers/tts.js +83 -0
  26. package/dist/handlers/tts.js.map +1 -0
  27. package/dist/handlers/video.d.ts +5 -0
  28. package/dist/handlers/video.js +127 -0
  29. package/dist/handlers/video.js.map +1 -0
  30. package/dist/lib/dual-transport.d.ts +42 -0
  31. package/dist/lib/dual-transport.js +208 -0
  32. package/dist/lib/dual-transport.js.map +1 -0
  33. package/dist/lib/logger.d.ts +12 -0
  34. package/dist/lib/logger.js +42 -0
  35. package/dist/lib/logger.js.map +1 -0
  36. package/dist/lib/types.d.ts +16 -0
  37. package/dist/lib/types.js +15 -0
  38. package/dist/lib/types.js.map +1 -0
  39. package/dist/schemas/capcut.d.ts +608 -0
  40. package/dist/schemas/capcut.js +411 -0
  41. package/dist/schemas/capcut.js.map +1 -0
  42. package/dist/schemas/editing.d.ts +822 -0
  43. package/dist/schemas/editing.js +466 -0
  44. package/dist/schemas/editing.js.map +1 -0
  45. package/dist/schemas/index.d.ts +2366 -0
  46. package/dist/schemas/index.js +15 -0
  47. package/dist/schemas/index.js.map +1 -0
  48. package/dist/schemas/post-production.d.ts +379 -0
  49. package/dist/schemas/post-production.js +268 -0
  50. package/dist/schemas/post-production.js.map +1 -0
  51. package/dist/schemas/smart-screenshot.d.ts +127 -0
  52. package/dist/schemas/smart-screenshot.js +122 -0
  53. package/dist/schemas/smart-screenshot.js.map +1 -0
  54. package/dist/schemas/tts.d.ts +220 -0
  55. package/dist/schemas/tts.js +194 -0
  56. package/dist/schemas/tts.js.map +1 -0
  57. package/dist/schemas/video.d.ts +236 -0
  58. package/dist/schemas/video.js +210 -0
  59. package/dist/schemas/video.js.map +1 -0
  60. package/dist/server.d.ts +11 -0
  61. package/dist/server.js +239 -0
  62. package/dist/server.js.map +1 -0
  63. package/dist/server.test.d.ts +1 -0
  64. package/dist/server.test.js +87 -0
  65. package/dist/server.test.js.map +1 -0
  66. package/dist/tools/engine/audio-mixer.d.ts +40 -0
  67. package/dist/tools/engine/audio-mixer.js +169 -0
  68. package/dist/tools/engine/audio-mixer.js.map +1 -0
  69. package/dist/tools/engine/audio.d.ts +22 -0
  70. package/dist/tools/engine/audio.js +73 -0
  71. package/dist/tools/engine/audio.js.map +1 -0
  72. package/dist/tools/engine/beat-sync.d.ts +31 -0
  73. package/dist/tools/engine/beat-sync.js +270 -0
  74. package/dist/tools/engine/beat-sync.js.map +1 -0
  75. package/dist/tools/engine/capture.d.ts +12 -0
  76. package/dist/tools/engine/capture.js +290 -0
  77. package/dist/tools/engine/capture.js.map +1 -0
  78. package/dist/tools/engine/chroma-key.d.ts +27 -0
  79. package/dist/tools/engine/chroma-key.js +154 -0
  80. package/dist/tools/engine/chroma-key.js.map +1 -0
  81. package/dist/tools/engine/concat.d.ts +49 -0
  82. package/dist/tools/engine/concat.js +149 -0
  83. package/dist/tools/engine/concat.js.map +1 -0
  84. package/dist/tools/engine/cursor.d.ts +26 -0
  85. package/dist/tools/engine/cursor.js +185 -0
  86. package/dist/tools/engine/cursor.js.map +1 -0
  87. package/dist/tools/engine/easing.d.ts +15 -0
  88. package/dist/tools/engine/easing.js +100 -0
  89. package/dist/tools/engine/easing.js.map +1 -0
  90. package/dist/tools/engine/editing.d.ts +158 -0
  91. package/dist/tools/engine/editing.js +541 -0
  92. package/dist/tools/engine/editing.js.map +1 -0
  93. package/dist/tools/engine/encoder.d.ts +31 -0
  94. package/dist/tools/engine/encoder.js +154 -0
  95. package/dist/tools/engine/encoder.js.map +1 -0
  96. package/dist/tools/engine/index.d.ts +30 -0
  97. package/dist/tools/engine/index.js +23 -0
  98. package/dist/tools/engine/index.js.map +1 -0
  99. package/dist/tools/engine/lut-presets.d.ts +25 -0
  100. package/dist/tools/engine/lut-presets.js +141 -0
  101. package/dist/tools/engine/lut-presets.js.map +1 -0
  102. package/dist/tools/engine/narrated-video.d.ts +63 -0
  103. package/dist/tools/engine/narrated-video.js +163 -0
  104. package/dist/tools/engine/narrated-video.js.map +1 -0
  105. package/dist/tools/engine/scenes.d.ts +17 -0
  106. package/dist/tools/engine/scenes.js +223 -0
  107. package/dist/tools/engine/scenes.js.map +1 -0
  108. package/dist/tools/engine/smart-screenshot.d.ts +80 -0
  109. package/dist/tools/engine/smart-screenshot.js +744 -0
  110. package/dist/tools/engine/smart-screenshot.js.map +1 -0
  111. package/dist/tools/engine/social-format.d.ts +66 -0
  112. package/dist/tools/engine/social-format.js +107 -0
  113. package/dist/tools/engine/social-format.js.map +1 -0
  114. package/dist/tools/engine/template-renderer.d.ts +45 -0
  115. package/dist/tools/engine/template-renderer.js +233 -0
  116. package/dist/tools/engine/template-renderer.js.map +1 -0
  117. package/dist/tools/engine/templates.d.ts +87 -0
  118. package/dist/tools/engine/templates.js +272 -0
  119. package/dist/tools/engine/templates.js.map +1 -0
  120. package/dist/tools/engine/text-animations.d.ts +33 -0
  121. package/dist/tools/engine/text-animations.js +192 -0
  122. package/dist/tools/engine/text-animations.js.map +1 -0
  123. package/dist/tools/engine/text-overlay.d.ts +27 -0
  124. package/dist/tools/engine/text-overlay.js +84 -0
  125. package/dist/tools/engine/text-overlay.js.map +1 -0
  126. package/dist/tools/engine/tts.d.ts +54 -0
  127. package/dist/tools/engine/tts.js +186 -0
  128. package/dist/tools/engine/tts.js.map +1 -0
  129. package/dist/tools/engine/types.d.ts +166 -0
  130. package/dist/tools/engine/types.js +13 -0
  131. package/dist/tools/engine/types.js.map +1 -0
  132. package/dist/tools/engine/voice-effects.d.ts +18 -0
  133. package/dist/tools/engine/voice-effects.js +215 -0
  134. package/dist/tools/engine/voice-effects.js.map +1 -0
  135. package/dist/tools/index.d.ts +32 -0
  136. package/dist/tools/index.js +23 -0
  137. package/dist/tools/index.js.map +1 -0
  138. package/package.json +56 -0
  139. package/scripts/check-deps.js +39 -0
  140. package/src/handlers/capcut.ts +245 -0
  141. package/src/handlers/editing.ts +260 -0
  142. package/src/handlers/index.ts +34 -0
  143. package/src/handlers/post-production.ts +136 -0
  144. package/src/handlers/smart-screenshot.ts +86 -0
  145. package/src/handlers/tts.ts +103 -0
  146. package/src/handlers/video.ts +137 -0
  147. package/src/lib/dual-transport.ts +272 -0
  148. package/src/lib/logger.ts +59 -0
  149. package/src/lib/types.ts +25 -0
  150. package/src/schemas/capcut.ts +418 -0
  151. package/src/schemas/editing.ts +476 -0
  152. package/src/schemas/index.ts +15 -0
  153. package/src/schemas/post-production.ts +273 -0
  154. package/src/schemas/smart-screenshot.ts +122 -0
  155. package/src/schemas/tts.ts +197 -0
  156. package/src/schemas/video.ts +211 -0
  157. package/src/server.test.ts +99 -0
  158. package/src/server.ts +289 -0
  159. package/src/tools/engine/audio-mixer.ts +244 -0
  160. package/src/tools/engine/audio.ts +115 -0
  161. package/src/tools/engine/beat-sync.ts +356 -0
  162. package/src/tools/engine/capture.ts +360 -0
  163. package/src/tools/engine/chroma-key.ts +202 -0
  164. package/src/tools/engine/concat.ts +242 -0
  165. package/src/tools/engine/cursor.ts +222 -0
  166. package/src/tools/engine/easing.ts +120 -0
  167. package/src/tools/engine/editing.ts +809 -0
  168. package/src/tools/engine/encoder.ts +208 -0
  169. package/src/tools/engine/index.ts +33 -0
  170. package/src/tools/engine/lut-presets.ts +235 -0
  171. package/src/tools/engine/narrated-video.ts +267 -0
  172. package/src/tools/engine/scenes.ts +309 -0
  173. package/src/tools/engine/smart-screenshot.ts +923 -0
  174. package/src/tools/engine/social-format.ts +146 -0
  175. package/src/tools/engine/template-renderer.ts +294 -0
  176. package/src/tools/engine/templates.ts +370 -0
  177. package/src/tools/engine/text-animations.ts +282 -0
  178. package/src/tools/engine/text-overlay.ts +143 -0
  179. package/src/tools/engine/tts.ts +284 -0
  180. package/src/tools/engine/types.ts +191 -0
  181. package/src/tools/engine/voice-effects.ts +258 -0
  182. package/src/tools/index.ts +67 -0
  183. package/tsconfig.json +19 -0
  184. package/vitest.config.ts +7 -0
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Beat-Sync Engine — Automatic beat detection + video clip cutting to music beats.
3
+ *
4
+ * Uses FFmpeg's `astats` filter for RMS energy analysis to detect beats/onsets.
5
+ * No external dependencies (no Meyda, no Python) — pure FFmpeg.
6
+ *
7
+ * Flow: Analyze audio → find beat positions → cut clips to beats → concatenate
8
+ */
9
+
10
+ import { execFile } from 'child_process';
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import { logger } from '../../lib/logger.js';
14
+
15
+ // ─── Types ──────────────────────────────────────────────────────────
16
+
17
+ export interface BeatSyncConfig {
18
+ /** Audio/music file to analyze for beats */
19
+ audioPath: string;
20
+ /** Video clips to cut and sync to beats (will be used in order, cycling if needed) */
21
+ clips: string[];
22
+ outputPath: string;
23
+ /** Minimum time between beats in seconds (filters out false positives). Default: 0.3 */
24
+ minBeatInterval?: number;
25
+ /** Maximum number of beats to use (limits output length). Default: 50 */
26
+ maxBeats?: number;
27
+ /** Effect to apply on beat transitions: 'cut' (hard cut), 'flash' (white flash), 'zoom' (quick zoom pulse). Default: 'cut' */
28
+ beatEffect?: 'cut' | 'flash' | 'zoom';
29
+ /** Energy threshold for beat detection: 0.0-1.0 (higher = fewer beats detected). Default: 0.6 */
30
+ sensitivity?: number;
31
+ }
32
+
33
+ export interface BeatSyncResult {
34
+ outputPath: string;
35
+ beatsDetected: number;
36
+ beatsUsed: number;
37
+ beatPositions: number[];
38
+ duration: number;
39
+ }
40
+
41
+ // ─── Helpers ────────────────────────────────────────────────────────
42
+
43
+ function runFfmpeg(args: string[], timeoutMs = 600_000): Promise<string> {
44
+ return new Promise((resolve, reject) => {
45
+ execFile('ffmpeg', args, { maxBuffer: 100 * 1024 * 1024, timeout: timeoutMs }, (error, stdout, stderr) => {
46
+ if (error) {
47
+ logger.error(`ffmpeg failed: ${stderr}`);
48
+ reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
49
+ return;
50
+ }
51
+ resolve(stderr); // ffmpeg outputs filter info to stderr
52
+ });
53
+ });
54
+ }
55
+
56
+ function runFfmpegStdout(args: string[], timeoutMs = 300_000): Promise<string> {
57
+ return new Promise((resolve, reject) => {
58
+ execFile('ffmpeg', args, { maxBuffer: 100 * 1024 * 1024, timeout: timeoutMs }, (error, stdout, stderr) => {
59
+ if (error) {
60
+ logger.error(`ffmpeg failed: ${stderr}`);
61
+ reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
62
+ return;
63
+ }
64
+ resolve(stdout);
65
+ });
66
+ });
67
+ }
68
+
69
+ function ensureDir(filePath: string): void {
70
+ const dir = path.dirname(filePath);
71
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
72
+ }
73
+
74
+ function assertExists(filePath: string, label = 'File'): void {
75
+ if (!fs.existsSync(filePath)) throw new Error(`${label} not found: ${filePath}`);
76
+ }
77
+
78
+ function fileInfo(filePath: string): string {
79
+ const stats = fs.statSync(filePath);
80
+ return `${(stats.size / 1024 / 1024).toFixed(2)} MB`;
81
+ }
82
+
83
+ function getMediaDuration(filePath: string): Promise<number> {
84
+ return new Promise((resolve, reject) => {
85
+ execFile(
86
+ 'ffprobe',
87
+ ['-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', filePath],
88
+ (error, stdout) => {
89
+ if (error) { reject(new Error(`ffprobe failed: ${error.message}`)); return; }
90
+ const dur = parseFloat(stdout.trim());
91
+ resolve(isNaN(dur) ? 0 : dur);
92
+ }
93
+ );
94
+ });
95
+ }
96
+
97
+ // ─── Beat Detection ─────────────────────────────────────────────────
98
+
99
+ /**
100
+ * Detect beats using FFmpeg's audio energy analysis.
101
+ *
102
+ * Strategy: Extract RMS energy per short window, find peaks above threshold.
103
+ * Uses `volumedetect` combined with frame-level energy via `astats`.
104
+ */
105
+ async function detectBeats(
106
+ audioPath: string,
107
+ minInterval: number,
108
+ sensitivity: number,
109
+ maxBeats: number,
110
+ ): Promise<number[]> {
111
+ logger.info('Analyzing audio for beat detection...');
112
+
113
+ // Step 1: Get audio duration
114
+ const duration = await getMediaDuration(audioPath);
115
+ if (duration <= 0) throw new Error('Audio file has no duration');
116
+
117
+ // Step 2: Extract per-frame RMS energy using astats
118
+ // Output format: one line per analysis window with RMS_level
119
+ const windowSize = 0.05; // 50ms analysis windows (20 frames/sec)
120
+ const tempFile = `/tmp/beat-analysis-${Date.now()}.txt`;
121
+
122
+ try {
123
+ // Use ebur128 for momentary loudness — outputs per-frame data to stderr
124
+ const stderr = await runFfmpeg([
125
+ '-i', audioPath,
126
+ '-af', `astats=metadata=1:reset=${Math.round(1 / windowSize)},ametadata=print:key=lavfi.astats.Overall.RMS_level:file=${tempFile}`,
127
+ '-f', 'null', '-',
128
+ ]);
129
+
130
+ // Parse the energy data
131
+ if (!fs.existsSync(tempFile)) {
132
+ // Fallback: use simpler approach with volumedetect
133
+ return detectBeatsFallback(audioPath, duration, minInterval, sensitivity, maxBeats);
134
+ }
135
+
136
+ const rawData = fs.readFileSync(tempFile, 'utf-8');
137
+ const lines = rawData.split('\n');
138
+
139
+ const energyPoints: Array<{ time: number; energy: number }> = [];
140
+ let currentTime = -1;
141
+
142
+ for (const line of lines) {
143
+ const trimmed = line.trim();
144
+ if (trimmed.startsWith('frame:')) {
145
+ // Extract pts_time
146
+ const timeMatch = trimmed.match(/pts_time:([\d.]+)/);
147
+ if (timeMatch) {
148
+ currentTime = parseFloat(timeMatch[1]);
149
+ }
150
+ } else if (trimmed.startsWith('lavfi.astats.Overall.RMS_level=')) {
151
+ const val = parseFloat(trimmed.split('=')[1]);
152
+ if (currentTime >= 0 && !isNaN(val) && val > -100) {
153
+ // Convert from dB to linear energy (0-1 scale)
154
+ const linearEnergy = Math.pow(10, val / 20);
155
+ energyPoints.push({ time: currentTime, energy: linearEnergy });
156
+ }
157
+ }
158
+ }
159
+
160
+ // Cleanup temp file
161
+ try { fs.unlinkSync(tempFile); } catch { /* ignore */ }
162
+
163
+ if (energyPoints.length < 10) {
164
+ return detectBeatsFallback(audioPath, duration, minInterval, sensitivity, maxBeats);
165
+ }
166
+
167
+ // Step 3: Find peaks — energy values that are local maxima and above threshold
168
+ const beats = findPeaks(energyPoints, minInterval, sensitivity, maxBeats);
169
+ logger.info(`Beat detection: ${energyPoints.length} energy frames → ${beats.length} beats`);
170
+ return beats;
171
+
172
+ } catch {
173
+ // Cleanup temp file on error
174
+ try { fs.unlinkSync(tempFile); } catch { /* ignore */ }
175
+ return detectBeatsFallback(audioPath, duration, minInterval, sensitivity, maxBeats);
176
+ }
177
+ }
178
+
179
+ /** Find peaks in energy data that represent beats */
180
+ function findPeaks(
181
+ energyPoints: Array<{ time: number; energy: number }>,
182
+ minInterval: number,
183
+ sensitivity: number,
184
+ maxBeats: number,
185
+ ): number[] {
186
+ if (energyPoints.length === 0) return [];
187
+
188
+ // Calculate dynamic threshold based on energy distribution
189
+ const energies = energyPoints.map(p => p.energy).sort((a, b) => a - b);
190
+ const median = energies[Math.floor(energies.length / 2)];
191
+ const max = energies[energies.length - 1];
192
+
193
+ // Threshold: blend between median and max based on sensitivity
194
+ // Higher sensitivity → lower threshold → more beats
195
+ const threshold = median + (max - median) * (1 - sensitivity);
196
+
197
+ const beats: number[] = [];
198
+ let lastBeatTime = -minInterval;
199
+
200
+ for (let i = 1; i < energyPoints.length - 1; i++) {
201
+ const prev = energyPoints[i - 1].energy;
202
+ const curr = energyPoints[i].energy;
203
+ const next = energyPoints[i + 1].energy;
204
+ const time = energyPoints[i].time;
205
+
206
+ // Is this a local maximum above threshold?
207
+ if (curr > prev && curr >= next && curr > threshold) {
208
+ // Respect minimum interval
209
+ if (time - lastBeatTime >= minInterval) {
210
+ beats.push(Math.round(time * 1000) / 1000); // Round to ms
211
+ lastBeatTime = time;
212
+
213
+ if (beats.length >= maxBeats) break;
214
+ }
215
+ }
216
+ }
217
+
218
+ return beats;
219
+ }
220
+
221
+ /** Fallback beat detection: evenly spaced based on estimated BPM */
222
+ async function detectBeatsFallback(
223
+ audioPath: string,
224
+ duration: number,
225
+ minInterval: number,
226
+ _sensitivity: number,
227
+ maxBeats: number,
228
+ ): Promise<number[]> {
229
+ logger.info('Using fallback beat detection (evenly spaced)');
230
+
231
+ // Default to ~120 BPM (0.5s interval) if we can't detect
232
+ const interval = Math.max(minInterval, 0.5);
233
+ const beats: number[] = [];
234
+
235
+ for (let t = interval; t < duration && beats.length < maxBeats; t += interval) {
236
+ beats.push(Math.round(t * 1000) / 1000);
237
+ }
238
+
239
+ return beats;
240
+ }
241
+
242
+ // ─── Main Function ──────────────────────────────────────────────────
243
+
244
+ export async function syncToBeats(config: BeatSyncConfig): Promise<BeatSyncResult> {
245
+ const {
246
+ audioPath,
247
+ clips,
248
+ outputPath,
249
+ minBeatInterval = 0.3,
250
+ maxBeats = 50,
251
+ beatEffect = 'cut',
252
+ sensitivity = 0.6,
253
+ } = config;
254
+
255
+ assertExists(audioPath, 'Audio/music file');
256
+ if (clips.length === 0) throw new Error('Need at least 1 video clip');
257
+ for (const clip of clips) {
258
+ assertExists(clip, 'Video clip');
259
+ }
260
+ ensureDir(outputPath);
261
+
262
+ // Step 1: Detect beats
263
+ const beatPositions = await detectBeats(audioPath, minBeatInterval, sensitivity, maxBeats);
264
+ if (beatPositions.length < 2) {
265
+ throw new Error('Could not detect enough beats in the audio. Try lowering sensitivity.');
266
+ }
267
+
268
+ logger.info(`Detected ${beatPositions.length} beats. Creating beat-synced video...`);
269
+
270
+ // Step 2: Create segment list — each beat transition = new clip segment
271
+ const tempDir = `/tmp/beatsync-${Date.now()}`;
272
+ fs.mkdirSync(tempDir, { recursive: true });
273
+
274
+ const segmentPaths: string[] = [];
275
+ const concatList: string[] = [];
276
+
277
+ try {
278
+ for (let i = 0; i < beatPositions.length - 1; i++) {
279
+ const segDuration = beatPositions[i + 1] - beatPositions[i];
280
+ const clipIdx = i % clips.length;
281
+ const clipPath = clips[clipIdx];
282
+
283
+ // Get clip duration to pick a random start point
284
+ const clipDur = await getMediaDuration(clipPath);
285
+ const maxStart = Math.max(0, clipDur - segDuration);
286
+ const startOffset = maxStart > 0 ? Math.random() * maxStart : 0;
287
+
288
+ const segPath = path.join(tempDir, `seg-${String(i).padStart(4, '0')}.mp4`);
289
+
290
+ // Extract segment from clip
291
+ const segArgs = [
292
+ '-y', '-ss', startOffset.toFixed(3),
293
+ '-i', clipPath,
294
+ '-t', segDuration.toFixed(3),
295
+ '-c:v', 'libx264', '-crf', '20', '-preset', 'fast',
296
+ '-pix_fmt', 'yuv420p', '-an',
297
+ ];
298
+
299
+ // Apply beat effect
300
+ if (beatEffect === 'flash') {
301
+ // White flash at start of each segment (0.05s)
302
+ segArgs.push('-vf', `fade=in:st=0:d=0.05:color=white`);
303
+ } else if (beatEffect === 'zoom') {
304
+ // Quick zoom pulse at start (1.05x → 1.0x over 0.15s)
305
+ segArgs.push('-vf', `zoompan=z='if(lt(on,5),1.05-0.01*on,1)':d=1:s=1920x1080:fps=30`);
306
+ }
307
+
308
+ segArgs.push(segPath);
309
+
310
+ await runFfmpegStdout(segArgs);
311
+ segmentPaths.push(segPath);
312
+ concatList.push(`file '${segPath}'`);
313
+ }
314
+
315
+ // Step 3: Concatenate all segments
316
+ const concatFile = path.join(tempDir, 'concat.txt');
317
+ fs.writeFileSync(concatFile, concatList.join('\n'));
318
+
319
+ const tempOutput = path.join(tempDir, 'video-only.mp4');
320
+ await runFfmpegStdout([
321
+ '-y', '-f', 'concat', '-safe', '0', '-i', concatFile,
322
+ '-c', 'copy', tempOutput,
323
+ ]);
324
+
325
+ // Step 4: Merge with original audio
326
+ const audioDuration = await getMediaDuration(audioPath);
327
+ const videoDuration = beatPositions[beatPositions.length - 1];
328
+
329
+ await runFfmpegStdout([
330
+ '-y',
331
+ '-i', tempOutput,
332
+ '-i', audioPath,
333
+ '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k',
334
+ '-t', Math.min(audioDuration, videoDuration).toFixed(3),
335
+ '-movflags', '+faststart',
336
+ '-shortest',
337
+ outputPath,
338
+ ]);
339
+
340
+ const finalDuration = Math.min(audioDuration, videoDuration);
341
+
342
+ logger.info(`Beat-synced video: ${beatPositions.length - 1} segments, ${finalDuration.toFixed(1)}s → ${outputPath} (${fileInfo(outputPath)})`);
343
+
344
+ return {
345
+ outputPath,
346
+ beatsDetected: beatPositions.length,
347
+ beatsUsed: beatPositions.length - 1,
348
+ beatPositions,
349
+ duration: finalDuration,
350
+ };
351
+
352
+ } finally {
353
+ // Cleanup temp files
354
+ try { fs.rmSync(tempDir, { recursive: true }); } catch { /* ignore */ }
355
+ }
356
+ }