@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,244 @@
1
+ /**
2
+ * Audio Mixer Engine — Multi-track audio mixing with auto-ducking.
3
+ *
4
+ * Mixes N audio tracks (voiceover + music + SFX) into one.
5
+ * Auto-ducking: automatically lowers music volume when speech is detected.
6
+ * Per-track: volume, fade in/out.
7
+ */
8
+
9
+ import { execFile } from 'child_process';
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import { logger } from '../../lib/logger.js';
13
+
14
+ // ─── Types ──────────────────────────────────────────────────────────
15
+
16
+ export interface AudioTrack {
17
+ /** Path to audio or video file (audio stream will be used) */
18
+ path: string;
19
+ /** Volume: 0.0-2.0 (1.0 = original). Default: 1.0 */
20
+ volume?: number;
21
+ /** Fade in duration in seconds (default: 0) */
22
+ fadeIn?: number;
23
+ /** Fade out duration in seconds (default: 0) */
24
+ fadeOut?: number;
25
+ /** Start time offset in seconds — delay this track. Default: 0 */
26
+ delay?: number;
27
+ /** Track role for auto-ducking: 'voice' tracks trigger ducking on 'music' tracks */
28
+ role?: 'voice' | 'music' | 'sfx';
29
+ }
30
+
31
+ export interface AudioMixConfig {
32
+ /** Audio/video tracks to mix together */
33
+ tracks: AudioTrack[];
34
+ outputPath: string;
35
+ /** Enable auto-ducking: music volume reduces when voice is active. Default: false */
36
+ autoDuck?: boolean;
37
+ /** How much to reduce music volume during speech: 0.0-1.0 (0.2 = reduce to 20%). Default: 0.2 */
38
+ duckLevel?: number;
39
+ /** Output format: 'mp3', 'aac', 'wav'. Default: 'aac' */
40
+ format?: 'mp3' | 'aac' | 'wav';
41
+ /** Duration of output in seconds. If omitted, uses longest track. */
42
+ duration?: number;
43
+ }
44
+
45
+ export interface AudioMixResult {
46
+ outputPath: string;
47
+ trackCount: number;
48
+ ducking: boolean;
49
+ }
50
+
51
+ // ─── Helpers ────────────────────────────────────────────────────────
52
+
53
+ function runFfmpeg(args: string[], timeoutMs = 300_000): Promise<string> {
54
+ return new Promise((resolve, reject) => {
55
+ execFile('ffmpeg', args, { maxBuffer: 100 * 1024 * 1024, timeout: timeoutMs }, (error, stdout, stderr) => {
56
+ if (error) {
57
+ logger.error(`ffmpeg failed: ${stderr}`);
58
+ reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
59
+ return;
60
+ }
61
+ resolve(stdout);
62
+ });
63
+ });
64
+ }
65
+
66
+ function ensureDir(filePath: string): void {
67
+ const dir = path.dirname(filePath);
68
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
69
+ }
70
+
71
+ function assertExists(filePath: string, label = 'File'): void {
72
+ if (!fs.existsSync(filePath)) throw new Error(`${label} not found: ${filePath}`);
73
+ }
74
+
75
+ function fileInfo(filePath: string): string {
76
+ const stats = fs.statSync(filePath);
77
+ return `${(stats.size / 1024 / 1024).toFixed(2)} MB`;
78
+ }
79
+
80
+ function getMediaDuration(filePath: string): Promise<number> {
81
+ return new Promise((resolve, reject) => {
82
+ execFile(
83
+ 'ffprobe',
84
+ ['-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', filePath],
85
+ (error, stdout) => {
86
+ if (error) { reject(new Error(`ffprobe failed: ${error.message}`)); return; }
87
+ const dur = parseFloat(stdout.trim());
88
+ resolve(isNaN(dur) ? 0 : dur);
89
+ }
90
+ );
91
+ });
92
+ }
93
+
94
+ // ─── Main Function ──────────────────────────────────────────────────
95
+
96
+ export async function mixAudioTracks(config: AudioMixConfig): Promise<AudioMixResult> {
97
+ const {
98
+ tracks,
99
+ outputPath,
100
+ autoDuck = false,
101
+ duckLevel = 0.2,
102
+ format = 'aac',
103
+ duration,
104
+ } = config;
105
+
106
+ if (tracks.length < 2) throw new Error('Need at least 2 audio tracks to mix');
107
+ if (tracks.length > 8) throw new Error('Maximum 8 tracks supported');
108
+
109
+ // Validate all files exist
110
+ for (const track of tracks) {
111
+ assertExists(track.path, `Audio track`);
112
+ }
113
+
114
+ ensureDir(outputPath);
115
+
116
+ logger.info(`Mixing ${tracks.length} audio tracks (duck: ${autoDuck}, format: ${format})`);
117
+
118
+ const args: string[] = ['-y'];
119
+ const filterParts: string[] = [];
120
+ const inputLabels: string[] = [];
121
+
122
+ // Add inputs
123
+ for (let i = 0; i < tracks.length; i++) {
124
+ args.push('-i', tracks[i].path);
125
+ }
126
+
127
+ // Build per-track filter chains
128
+ for (let i = 0; i < tracks.length; i++) {
129
+ const track = tracks[i];
130
+ const vol = track.volume ?? 1.0;
131
+ const fadeIn = track.fadeIn ?? 0;
132
+ const fadeOut = track.fadeOut ?? 0;
133
+ const delay = track.delay ?? 0;
134
+
135
+ const subFilters: string[] = [];
136
+
137
+ // Volume adjustment
138
+ if (vol !== 1.0) {
139
+ subFilters.push(`volume=${vol.toFixed(3)}`);
140
+ }
141
+
142
+ // Delay (adelay in milliseconds)
143
+ if (delay > 0) {
144
+ subFilters.push(`adelay=${Math.round(delay * 1000)}|${Math.round(delay * 1000)}`);
145
+ }
146
+
147
+ // Fade in
148
+ if (fadeIn > 0) {
149
+ subFilters.push(`afade=t=in:st=0:d=${fadeIn}`);
150
+ }
151
+
152
+ // Fade out — need duration for this
153
+ if (fadeOut > 0) {
154
+ try {
155
+ const dur = duration ?? await getMediaDuration(track.path);
156
+ if (dur > fadeOut) {
157
+ subFilters.push(`afade=t=out:st=${(dur - fadeOut).toFixed(2)}:d=${fadeOut}`);
158
+ }
159
+ } catch {
160
+ // Skip fade out if we can't get duration
161
+ }
162
+ }
163
+
164
+ const label = `a${i}`;
165
+ if (subFilters.length > 0) {
166
+ filterParts.push(`[${i}:a]${subFilters.join(',')}[${label}]`);
167
+ } else {
168
+ filterParts.push(`[${i}:a]anull[${label}]`);
169
+ }
170
+ inputLabels.push(`[${label}]`);
171
+ }
172
+
173
+ if (autoDuck && tracks.some(t => t.role === 'voice') && tracks.some(t => t.role === 'music')) {
174
+ // Auto-ducking: use sidechaincompress on music tracks triggered by voice
175
+ const voiceIdx = tracks.findIndex(t => t.role === 'voice');
176
+ const musicIdx = tracks.findIndex(t => t.role === 'music');
177
+
178
+ if (voiceIdx !== -1 && musicIdx !== -1) {
179
+ const voiceLabel = `a${voiceIdx}`;
180
+ const musicLabel = `a${musicIdx}`;
181
+ const duckRatio = Math.round(1 / Math.max(0.05, duckLevel));
182
+
183
+ // Sidechain compress: music is ducked when voice is loud
184
+ filterParts.push(
185
+ `[${musicLabel}][${voiceLabel}]sidechaincompress=threshold=0.02:ratio=${duckRatio}:attack=20:release=300:level_sc=1[ducked]`
186
+ );
187
+
188
+ // Build final mix — replace music label with ducked
189
+ const mixLabels = inputLabels.map((label, idx) => {
190
+ if (idx === musicIdx) return '[ducked]';
191
+ if (idx === voiceIdx) return `[${voiceLabel}]`;
192
+ return label;
193
+ });
194
+
195
+ // Use amix to combine all
196
+ filterParts.push(
197
+ `${mixLabels.join('')}amix=inputs=${tracks.length}:duration=longest:dropout_transition=2:normalize=0[out]`
198
+ );
199
+ } else {
200
+ // Fallback: simple amix
201
+ filterParts.push(
202
+ `${inputLabels.join('')}amix=inputs=${tracks.length}:duration=longest:dropout_transition=2:normalize=0[out]`
203
+ );
204
+ }
205
+ } else {
206
+ // Simple amix without ducking
207
+ filterParts.push(
208
+ `${inputLabels.join('')}amix=inputs=${tracks.length}:duration=longest:dropout_transition=2:normalize=0[out]`
209
+ );
210
+ }
211
+
212
+ args.push('-filter_complex', filterParts.join(';'));
213
+ args.push('-map', '[out]');
214
+
215
+ // Duration limit
216
+ if (duration) {
217
+ args.push('-t', String(duration));
218
+ }
219
+
220
+ // Output codec
221
+ switch (format) {
222
+ case 'mp3':
223
+ args.push('-c:a', 'libmp3lame', '-b:a', '192k');
224
+ break;
225
+ case 'wav':
226
+ args.push('-c:a', 'pcm_s16le');
227
+ break;
228
+ case 'aac':
229
+ default:
230
+ args.push('-c:a', 'aac', '-b:a', '192k');
231
+ break;
232
+ }
233
+
234
+ args.push(outputPath);
235
+
236
+ await runFfmpeg(args);
237
+ logger.info(`Audio mixed: ${tracks.length} tracks → ${outputPath} (${fileInfo(outputPath)})`);
238
+
239
+ return {
240
+ outputPath,
241
+ trackCount: tracks.length,
242
+ ducking: autoDuck,
243
+ };
244
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Audio engine — background music, fade, loop, volume control
3
+ * All processing via ffmpeg + ffprobe (no npm dependencies)
4
+ */
5
+
6
+ import { execFile } from 'child_process';
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import { logger } from '../../lib/logger.js';
10
+
11
+ // ─── ffprobe helper ─────────────────────────────────────────────────
12
+
13
+ export function getMediaDuration(filePath: string): Promise<number> {
14
+ return new Promise((resolve, reject) => {
15
+ execFile(
16
+ 'ffprobe',
17
+ ['-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', filePath],
18
+ (error, stdout) => {
19
+ if (error) reject(new Error(`ffprobe failed: ${error.message}`));
20
+ else resolve(parseFloat(stdout.trim()) || 0);
21
+ }
22
+ );
23
+ });
24
+ }
25
+
26
+ // ─── Background Music ───────────────────────────────────────────────
27
+
28
+ export interface AddMusicConfig {
29
+ /** Path to video file */
30
+ videoPath: string;
31
+ /** Path to audio file (mp3, wav, aac, ogg) */
32
+ musicPath: string;
33
+ /** Output path */
34
+ outputPath: string;
35
+ /** Music volume 0.0-1.0 (default: 0.25) */
36
+ musicVolume?: number;
37
+ /** Fade in duration in seconds (default: 2) */
38
+ fadeInDuration?: number;
39
+ /** Fade out duration in seconds (default: 3) */
40
+ fadeOutDuration?: number;
41
+ /** Loop music if shorter than video (default: true) */
42
+ loopMusic?: boolean;
43
+ }
44
+
45
+ export async function addBackgroundMusic(config: AddMusicConfig): Promise<string> {
46
+ const {
47
+ videoPath,
48
+ musicPath,
49
+ outputPath,
50
+ musicVolume = 0.25,
51
+ fadeInDuration = 2,
52
+ fadeOutDuration = 3,
53
+ loopMusic = true,
54
+ } = config;
55
+
56
+ if (!fs.existsSync(videoPath)) throw new Error(`Video not found: ${videoPath}`);
57
+ if (!fs.existsSync(musicPath)) throw new Error(`Music not found: ${musicPath}`);
58
+
59
+ const videoDuration = await getMediaDuration(videoPath);
60
+ const fadeOutStart = Math.max(0, videoDuration - fadeOutDuration);
61
+
62
+ logger.info(`Adding music to video (${videoDuration.toFixed(1)}s, volume: ${musicVolume}, fade: ${fadeInDuration}s/${fadeOutDuration}s)`);
63
+
64
+ // Ensure output directory exists
65
+ const outDir = path.dirname(outputPath);
66
+ if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
67
+
68
+ // Build audio filter chain
69
+ const musicFilter = [
70
+ `afade=t=in:st=0:d=${fadeInDuration}`,
71
+ `afade=t=out:st=${fadeOutStart}:d=${fadeOutDuration}`,
72
+ `volume=${musicVolume}`,
73
+ ].join(',');
74
+
75
+ const args: string[] = ['-y'];
76
+
77
+ // Video input
78
+ args.push('-i', videoPath);
79
+
80
+ // Music input (with optional loop)
81
+ if (loopMusic) args.push('-stream_loop', '-1');
82
+ args.push('-i', musicPath);
83
+
84
+ // Filter: process music, map to output
85
+ args.push('-filter_complex', `[1:a]${musicFilter}[music]`);
86
+ args.push('-map', '0:v', '-map', '[music]');
87
+
88
+ // Encoding
89
+ args.push('-c:v', 'copy'); // Don't re-encode video
90
+ args.push('-c:a', 'aac', '-b:a', '192k');
91
+ args.push('-shortest'); // End when video ends
92
+ args.push('-movflags', '+faststart');
93
+ args.push(outputPath);
94
+
95
+ await runFfmpeg(args);
96
+
97
+ const stats = fs.statSync(outputPath);
98
+ logger.info(`Music added: ${outputPath} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`);
99
+ return outputPath;
100
+ }
101
+
102
+ // ─── ffmpeg runner ──────────────────────────────────────────────────
103
+
104
+ function runFfmpeg(args: string[]): Promise<string> {
105
+ return new Promise((resolve, reject) => {
106
+ execFile('ffmpeg', args, { maxBuffer: 50 * 1024 * 1024 }, (error, stdout, stderr) => {
107
+ if (error) {
108
+ logger.error(`ffmpeg failed: ${stderr}`);
109
+ reject(new Error(`ffmpeg failed: ${stderr || error.message}`));
110
+ return;
111
+ }
112
+ resolve(stdout);
113
+ });
114
+ });
115
+ }