File size: 5,363 Bytes
2cae2a9
 
 
 
 
 
 
 
 
 
 
 
4cb7ad9
 
 
 
2cae2a9
4cb7ad9
2cae2a9
 
 
 
 
 
 
 
 
 
 
 
4cb7ad9
2cae2a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4cb7ad9
2cae2a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4cb7ad9
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
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
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";

export type SupportedExportFormat = "mp4" | "webm"
export const defaultExportFormat = "mp4"

export type ConcatenateVideoWithAudioOptions = {
  output?: string;
  format?: SupportedExportFormat;
  audioTrack?: string; // base64
  audioFilePath?: string; // path
  videoTracks?: string[]; // base64
  videoFilePaths?: string[]; // path
  videoTracksVolume?: number; // Represents the volume level of the original video track
  audioTrackVolume?: number; // Represents the volume level of the additional audio track
  asBase64?: boolean;
};


export const concatenateVideosWithAudio = async ({
  output,
  format = defaultExportFormat,
  audioTrack = "",
  audioFilePath = "",
  videoTracks = [],
  videoFilePaths = [],
  videoTracksVolume = 0.5, // (1.0 = 100% volume)
  audioTrackVolume = 0.5,
  asBase64 = false,
}: ConcatenateVideoWithAudioOptions): Promise<string> => {

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

    if (audioTrack) {
      audioFilePath = path.join(tempDir, `audio.wav`);
      await writeBase64ToFile(addBase64Header(audioTrack, "wav"), audioFilePath);
    }

    // Decode and concatenate base64 video tracks to temporary file
    let 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))

    // console.log("concatenating videos (without audio)..")
    const tempFilePath = await concatenateVideos({
      videoFilePaths,
    })

    // Check if the concatenated video has audio or not
    const tempMediaInfo = await getMediaInfo(tempFilePath.filepath);
    const hasOriginalAudio = tempMediaInfo.hasAudio;

    const finalOutputFilePath = output || path.join(tempDir, `${uuidv4()}.${format}`);

    // Begin ffmpeg command configuration
    let cmd = ffmpeg();

    // Add silent concatenated video
    cmd = cmd.addInput(tempFilePath.filepath);
 
    // If additional audio is provided, add audio to ffmpeg command
    if (audioFilePath) {
      cmd = cmd.addInput(audioFilePath);
      // If the input video already has audio, we will mix it with additional audio
      if (hasOriginalAudio) {
        const filterComplex = `
          [0:a]volume=${videoTracksVolume}[a0];
          [1:a]volume=${audioTrackVolume}[a1];
          [a0][a1]amix=inputs=2:duration=shortest[a]
        `.trim();

        cmd = cmd.outputOptions([
          '-filter_complex', filterComplex,
          '-map', '0:v',
          '-map', '[a]',
          '-c:v', 'copy',
          '-c:a', 'aac',
        ]);
      } else {
        // If the input video has no audio, just use the additional audio as is
        cmd = cmd.outputOptions([
          '-map', '0:v',
          '-map', '1:a',
          '-c:v', 'copy',
          '-c:a', 'aac',
        ]);
      }
    } else {
      // If no additional audio is provided, simply copy the video stream
      cmd = cmd.outputOptions([
        '-c:v', 'copy',
        hasOriginalAudio ? '-c:a' : '-an', // If original audio exists, copy it; otherwise, indicate no audio
      ]);
    }

    /*
    console.log("DEBUG:", {
      videoTracksVolume,
      audioTrackVolume,
      videoFilePaths,
      tempFilePath,
      hasOriginalAudio,
      // originalAudioVolume,
      audioFilePath,
      // additionalAudioVolume,
      finalOutputFilePath
     })
     */
  
    // Set up event handlers for ffmpeg processing
    const promise = new Promise<string>((resolve, reject) => {
      cmd.on('error', (err) => {
        console.error("    Error during ffmpeg processing:", err.message);
        reject(err);
      }).on('end', async () => {
        // When ffmpeg finishes processing, resolve the promise with file info
        try {
          if (asBase64) {
            try {
              const outputBuffer = await fs.readFile(finalOutputFilePath);
              const outputBase64 = addBase64Header(outputBuffer.toString("base64"), format)
              resolve(outputBase64);
            } catch (error) {
              reject(new Error(`Error reading output video file: ${(error as Error).message}`));
            }
          } else {
            resolve(finalOutputFilePath)
          }
        } catch (err) {
          reject(err);
        }
      }).save(finalOutputFilePath); // Provide the path where to save the file
    });

    // Wait for ffmpeg to complete the process
    const result = await promise;
    return result;
  } catch (error) {
    throw new Error(`Failed to assemble video: ${(error as Error).message}`);
  } finally {
    await removeTemporaryFiles([...videoFilePaths].concat(audioFilePath))
  }
};