(dir: string, inputPaths: string[])
| 349 | } |
| 350 | |
| 351 | async function concat(dir: string, inputPaths: string[]): Promise<FfmpegResult> { |
| 352 | if (inputPaths.length < 2) throw new Error('concat requires at least 2 clips') |
| 353 | const probes = await Promise.all(inputPaths.map(probeFile)) |
| 354 | probes.forEach((p, i) => { |
| 355 | if (!p.hasVideo) { |
| 356 | throw new Error( |
| 357 | `concat input ${i} has no video stream; concat joins video clips (use mix_audio/overlay_audio for audio-only files).` |
| 358 | ) |
| 359 | } |
| 360 | }) |
| 361 | const width = probes[0].width || 1280 |
| 362 | const height = probes[0].height || 720 |
| 363 | const fps = 30 |
| 364 | |
| 365 | // Normalize every clip to identical codec/size/fps/pixfmt, and SYNTHESIZE silent |
| 366 | // audio for clips that have no audio stream. Clips generated without native audio |
| 367 | // (generateAudio:false) otherwise break the concat filtergraph (it referenced a |
| 368 | // non-existent [i:a]), which is the "Error binding filtergraph inputs/outputs" failure. |
| 369 | const normalized: string[] = [] |
| 370 | for (let i = 0; i < inputPaths.length; i++) { |
| 371 | const out = path.join(dir, `norm-${i}.mp4`) |
| 372 | const cmd = ffmpeg().input(inputPaths[i]) |
| 373 | const maps: string[] = ['-map', '0:v:0'] |
| 374 | const extra: string[] = [] |
| 375 | if (probes[i].hasAudio) { |
| 376 | maps.push('-map', '0:a:0') |
| 377 | } else { |
| 378 | cmd |
| 379 | .input('anullsrc=channel_layout=stereo:sample_rate=48000') |
| 380 | .inputOptions(['-f', 'lavfi', '-t', String(probes[i].durationSeconds || 1)]) |
| 381 | maps.push('-map', '1:a:0') |
| 382 | extra.push('-shortest') |
| 383 | } |
| 384 | cmd |
| 385 | .videoFilters( |
| 386 | `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${fps},format=yuv420p` |
| 387 | ) |
| 388 | .outputOptions([ |
| 389 | ...maps, |
| 390 | '-c:v', |
| 391 | 'libx264', |
| 392 | '-preset', |
| 393 | 'medium', |
| 394 | '-crf', |
| 395 | '18', |
| 396 | '-pix_fmt', |
| 397 | 'yuv420p', |
| 398 | '-r', |
| 399 | String(fps), |
| 400 | '-video_track_timescale', |
| 401 | '90000', |
| 402 | '-c:a', |
| 403 | 'aac', |
| 404 | '-b:a', |
| 405 | '192k', |
| 406 | '-ar', |
| 407 | '48000', |
| 408 | '-ac', |
no test coverage detected