@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.
- package/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +19 -0
- package/.github/workflows/ci.yml +34 -0
- package/CHANGELOG.md +24 -0
- package/CONTRIBUTING.md +75 -0
- package/LICENSE +21 -0
- package/README.md +198 -0
- package/USAGE.md +144 -0
- package/dist/handlers/capcut.d.ts +6 -0
- package/dist/handlers/capcut.js +229 -0
- package/dist/handlers/capcut.js.map +1 -0
- package/dist/handlers/editing.d.ts +6 -0
- package/dist/handlers/editing.js +242 -0
- package/dist/handlers/editing.js.map +1 -0
- package/dist/handlers/index.d.ts +2 -0
- package/dist/handlers/index.js +33 -0
- package/dist/handlers/index.js.map +1 -0
- package/dist/handlers/post-production.d.ts +5 -0
- package/dist/handlers/post-production.js +109 -0
- package/dist/handlers/post-production.js.map +1 -0
- package/dist/handlers/smart-screenshot.d.ts +5 -0
- package/dist/handlers/smart-screenshot.js +83 -0
- package/dist/handlers/smart-screenshot.js.map +1 -0
- package/dist/handlers/tts.d.ts +5 -0
- package/dist/handlers/tts.js +83 -0
- package/dist/handlers/tts.js.map +1 -0
- package/dist/handlers/video.d.ts +5 -0
- package/dist/handlers/video.js +127 -0
- package/dist/handlers/video.js.map +1 -0
- package/dist/lib/dual-transport.d.ts +42 -0
- package/dist/lib/dual-transport.js +208 -0
- package/dist/lib/dual-transport.js.map +1 -0
- package/dist/lib/logger.d.ts +12 -0
- package/dist/lib/logger.js +42 -0
- package/dist/lib/logger.js.map +1 -0
- package/dist/lib/types.d.ts +16 -0
- package/dist/lib/types.js +15 -0
- package/dist/lib/types.js.map +1 -0
- package/dist/schemas/capcut.d.ts +608 -0
- package/dist/schemas/capcut.js +411 -0
- package/dist/schemas/capcut.js.map +1 -0
- package/dist/schemas/editing.d.ts +822 -0
- package/dist/schemas/editing.js +466 -0
- package/dist/schemas/editing.js.map +1 -0
- package/dist/schemas/index.d.ts +2366 -0
- package/dist/schemas/index.js +15 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/schemas/post-production.d.ts +379 -0
- package/dist/schemas/post-production.js +268 -0
- package/dist/schemas/post-production.js.map +1 -0
- package/dist/schemas/smart-screenshot.d.ts +127 -0
- package/dist/schemas/smart-screenshot.js +122 -0
- package/dist/schemas/smart-screenshot.js.map +1 -0
- package/dist/schemas/tts.d.ts +220 -0
- package/dist/schemas/tts.js +194 -0
- package/dist/schemas/tts.js.map +1 -0
- package/dist/schemas/video.d.ts +236 -0
- package/dist/schemas/video.js +210 -0
- package/dist/schemas/video.js.map +1 -0
- package/dist/server.d.ts +11 -0
- package/dist/server.js +239 -0
- package/dist/server.js.map +1 -0
- package/dist/server.test.d.ts +1 -0
- package/dist/server.test.js +87 -0
- package/dist/server.test.js.map +1 -0
- package/dist/tools/engine/audio-mixer.d.ts +40 -0
- package/dist/tools/engine/audio-mixer.js +169 -0
- package/dist/tools/engine/audio-mixer.js.map +1 -0
- package/dist/tools/engine/audio.d.ts +22 -0
- package/dist/tools/engine/audio.js +73 -0
- package/dist/tools/engine/audio.js.map +1 -0
- package/dist/tools/engine/beat-sync.d.ts +31 -0
- package/dist/tools/engine/beat-sync.js +270 -0
- package/dist/tools/engine/beat-sync.js.map +1 -0
- package/dist/tools/engine/capture.d.ts +12 -0
- package/dist/tools/engine/capture.js +290 -0
- package/dist/tools/engine/capture.js.map +1 -0
- package/dist/tools/engine/chroma-key.d.ts +27 -0
- package/dist/tools/engine/chroma-key.js +154 -0
- package/dist/tools/engine/chroma-key.js.map +1 -0
- package/dist/tools/engine/concat.d.ts +49 -0
- package/dist/tools/engine/concat.js +149 -0
- package/dist/tools/engine/concat.js.map +1 -0
- package/dist/tools/engine/cursor.d.ts +26 -0
- package/dist/tools/engine/cursor.js +185 -0
- package/dist/tools/engine/cursor.js.map +1 -0
- package/dist/tools/engine/easing.d.ts +15 -0
- package/dist/tools/engine/easing.js +100 -0
- package/dist/tools/engine/easing.js.map +1 -0
- package/dist/tools/engine/editing.d.ts +158 -0
- package/dist/tools/engine/editing.js +541 -0
- package/dist/tools/engine/editing.js.map +1 -0
- package/dist/tools/engine/encoder.d.ts +31 -0
- package/dist/tools/engine/encoder.js +154 -0
- package/dist/tools/engine/encoder.js.map +1 -0
- package/dist/tools/engine/index.d.ts +30 -0
- package/dist/tools/engine/index.js +23 -0
- package/dist/tools/engine/index.js.map +1 -0
- package/dist/tools/engine/lut-presets.d.ts +25 -0
- package/dist/tools/engine/lut-presets.js +141 -0
- package/dist/tools/engine/lut-presets.js.map +1 -0
- package/dist/tools/engine/narrated-video.d.ts +63 -0
- package/dist/tools/engine/narrated-video.js +163 -0
- package/dist/tools/engine/narrated-video.js.map +1 -0
- package/dist/tools/engine/scenes.d.ts +17 -0
- package/dist/tools/engine/scenes.js +223 -0
- package/dist/tools/engine/scenes.js.map +1 -0
- package/dist/tools/engine/smart-screenshot.d.ts +80 -0
- package/dist/tools/engine/smart-screenshot.js +744 -0
- package/dist/tools/engine/smart-screenshot.js.map +1 -0
- package/dist/tools/engine/social-format.d.ts +66 -0
- package/dist/tools/engine/social-format.js +107 -0
- package/dist/tools/engine/social-format.js.map +1 -0
- package/dist/tools/engine/template-renderer.d.ts +45 -0
- package/dist/tools/engine/template-renderer.js +233 -0
- package/dist/tools/engine/template-renderer.js.map +1 -0
- package/dist/tools/engine/templates.d.ts +87 -0
- package/dist/tools/engine/templates.js +272 -0
- package/dist/tools/engine/templates.js.map +1 -0
- package/dist/tools/engine/text-animations.d.ts +33 -0
- package/dist/tools/engine/text-animations.js +192 -0
- package/dist/tools/engine/text-animations.js.map +1 -0
- package/dist/tools/engine/text-overlay.d.ts +27 -0
- package/dist/tools/engine/text-overlay.js +84 -0
- package/dist/tools/engine/text-overlay.js.map +1 -0
- package/dist/tools/engine/tts.d.ts +54 -0
- package/dist/tools/engine/tts.js +186 -0
- package/dist/tools/engine/tts.js.map +1 -0
- package/dist/tools/engine/types.d.ts +166 -0
- package/dist/tools/engine/types.js +13 -0
- package/dist/tools/engine/types.js.map +1 -0
- package/dist/tools/engine/voice-effects.d.ts +18 -0
- package/dist/tools/engine/voice-effects.js +215 -0
- package/dist/tools/engine/voice-effects.js.map +1 -0
- package/dist/tools/index.d.ts +32 -0
- package/dist/tools/index.js +23 -0
- package/dist/tools/index.js.map +1 -0
- package/package.json +56 -0
- package/scripts/check-deps.js +39 -0
- package/src/handlers/capcut.ts +245 -0
- package/src/handlers/editing.ts +260 -0
- package/src/handlers/index.ts +34 -0
- package/src/handlers/post-production.ts +136 -0
- package/src/handlers/smart-screenshot.ts +86 -0
- package/src/handlers/tts.ts +103 -0
- package/src/handlers/video.ts +137 -0
- package/src/lib/dual-transport.ts +272 -0
- package/src/lib/logger.ts +59 -0
- package/src/lib/types.ts +25 -0
- package/src/schemas/capcut.ts +418 -0
- package/src/schemas/editing.ts +476 -0
- package/src/schemas/index.ts +15 -0
- package/src/schemas/post-production.ts +273 -0
- package/src/schemas/smart-screenshot.ts +122 -0
- package/src/schemas/tts.ts +197 -0
- package/src/schemas/video.ts +211 -0
- package/src/server.test.ts +99 -0
- package/src/server.ts +289 -0
- package/src/tools/engine/audio-mixer.ts +244 -0
- package/src/tools/engine/audio.ts +115 -0
- package/src/tools/engine/beat-sync.ts +356 -0
- package/src/tools/engine/capture.ts +360 -0
- package/src/tools/engine/chroma-key.ts +202 -0
- package/src/tools/engine/concat.ts +242 -0
- package/src/tools/engine/cursor.ts +222 -0
- package/src/tools/engine/easing.ts +120 -0
- package/src/tools/engine/editing.ts +809 -0
- package/src/tools/engine/encoder.ts +208 -0
- package/src/tools/engine/index.ts +33 -0
- package/src/tools/engine/lut-presets.ts +235 -0
- package/src/tools/engine/narrated-video.ts +267 -0
- package/src/tools/engine/scenes.ts +309 -0
- package/src/tools/engine/smart-screenshot.ts +923 -0
- package/src/tools/engine/social-format.ts +146 -0
- package/src/tools/engine/template-renderer.ts +294 -0
- package/src/tools/engine/templates.ts +370 -0
- package/src/tools/engine/text-animations.ts +282 -0
- package/src/tools/engine/text-overlay.ts +143 -0
- package/src/tools/engine/tts.ts +284 -0
- package/src/tools/engine/types.ts +191 -0
- package/src/tools/engine/voice-effects.ts +258 -0
- package/src/tools/index.ts +67 -0
- package/tsconfig.json +19 -0
- 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
|
+
}
|