File size: 4,572 Bytes
2cae2a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
import { existsSync, promises as fs } from "node:fs"
import os from "node:os"
import path from "node:path"

import { v4 as uuidv4 } from "uuid";
import ffmpeg, { FfmpegCommand } from "fluent-ffmpeg";
import { concatenateVideos } from "./concatenateVideos.mts";
import { writeBase64ToFile } from "../files/writeBase64ToFile.mts";
import { getMediaInfo } from "./getMediaInfo.mts";
import { removeTemporaryFiles } from "../files/removeTmpFiles.mts";
import { addBase64Header } from "../base64/addBase64.mts";

type ConcatenateVideoAndMergeAudioOptions = {
  output?: string;
  audioTracks?: string[]; // base64
  audioFilePaths?: string[]; // path
  videoTracks?: string[]; // base64
  videoFilePaths?: string[]; // path
};

export type ConcatenateVideoAndMergeAudioOutput = {
  filepath: string;
  durationInSec: number;
}

// note: the audio tracks will be fused together, as in "mixed"
// this return a path to the file
export const concatenateVideosAndMergeAudio = async ({
  output,
  audioTracks = [],
  audioFilePaths = [],
  videoTracks = [],
  videoFilePaths = []
}: ConcatenateVideoAndMergeAudioOptions): Promise<ConcatenateVideoAndMergeAudioOutput> => {

  try {
    // Prepare temporary directories
    const tempDir = path.join(os.tmpdir(), uuidv4());
    await fs.mkdir(tempDir);

    let i = 0
    for (const track of audioTracks) {
      if (!track) { continue }
      const audioFilePath = path.join(tempDir, `audio${++i}.wav`);
      await writeBase64ToFile(addBase64Header(track, "wav"), audioFilePath);
      audioFilePaths.push(audioFilePath);
    }
    audioFilePaths = audioFilePaths.filter((audio) => existsSync(audio))


    // Decode and concatenate base64 video tracks to temporary file
    i = 0
    for (const track of videoTracks) {
      if (!track) { continue }
      const videoFilePath = path.join(tempDir, `video${++i}.mp4`);

      await writeBase64ToFile(addBase64Header(track, "mp4"), videoFilePath);

      videoFilePaths.push(videoFilePath);
    }
    videoFilePaths = videoFilePaths.filter((video) => existsSync(video))

    // The final output file path
    const finalOutputFilePath = output ? output : path.join(tempDir, `${uuidv4()}.mp4`);

    /*
    console.log("DEBUG:", {
      tempDir,
      audioFilePath,
      audioTrack: audioTrack.slice(0, 40),
      videoTracks: videoTracks.map(vid => vid.slice(0, 40)),
      videoFilePaths,
      finalOutputFilePath
    })
    */

    // console.log("concatenating videos (without audio)..")
    const tempFilePath = await concatenateVideos({
      videoFilePaths,
    })
    // console.log("concatenated silent shots to: ", tempFilePath)
    
    // console.log("concatenating video + audio..")

    // Add audio to the concatenated video file
    const promise = new Promise<ConcatenateVideoAndMergeAudioOutput>((resolve, reject) => {
      let cmd = ffmpeg().addInput(tempFilePath.filepath).outputOptions("-c:v copy");

      for (const audioFilePath of audioFilePaths) {
        cmd = cmd.addInput(audioFilePath);
      }
    
      if (audioFilePaths.length) {
        // Mix all audio tracks (if there are any) into a single stereo stream
        const mixFilter = audioFilePaths.map((_, index) => `[${index + 1}:a]`).join('') + `amix=inputs=${audioFilePaths.length}:duration=first[outa]`;
        cmd = cmd
          .complexFilter(mixFilter)
          .outputOptions([
            "-map", "0:v:0", // Maps the video stream from the first input (index 0) as the output video stream
            "-map", "[outa]", // Maps the labeled audio output from the complex filter (mixed audio) as the output audio stream
            "-c:a aac", // Specifies the audio codec to be AAC (Advanced Audio Coding)
            "-shortest" // Ensures the output file's duration equals the shortest input stream's duration
          ]);
      } else {
        // If there are no audio tracks, just map the video
        cmd = cmd.outputOptions(["-map", "0:v:0"]);
      }    
    
      cmd = cmd
        .on("error", reject)
        .on('end', async () => {
          try {
            const { durationInSec } = await getMediaInfo(finalOutputFilePath);
            resolve({ filepath: finalOutputFilePath, durationInSec });
          } catch (err) {
            reject(err);
          }
        })
        .saveToFile(finalOutputFilePath);
    });

    const result = await promise;
  
    return result
  } catch (error) {
    throw new Error(`Failed to assemble video: ${(error as Error).message}`);
  } finally {
    await removeTemporaryFiles([...videoFilePaths, ...audioFilePaths])
  }
};