File size: 7,151 Bytes
3165afb
 
2cae2a9
 
3165afb
 
 
 
 
 
 
2cae2a9
4cb7ad9
 
 
 
2cae2a9
4cb7ad9
2cae2a9
 
 
 
 
 
 
 
 
 
 
 
4cb7ad9
2cae2a9
 
 
 
 
 
 
 
 
 
 
3165afb
2cae2a9
ccd3eba
d66ad20
e9ae5da
3165afb
 
 
2cae2a9
 
 
 
 
 
ccd3eba
 
d66ad20
3165afb
2cae2a9
e9ae5da
ccd3eba
3165afb
2cae2a9
3165afb
2cae2a9
 
 
 
e9ae5da
2cae2a9
 
 
e9ae5da
2cae2a9
 
 
 
 
e9ae5da
ccd3eba
4cb7ad9
2cae2a9
e9ae5da
1083ad0
2cae2a9
ccd3eba
2cae2a9
873dc9a
ccd3eba
2cae2a9
ccd3eba
 
2cae2a9
1628a6d
e9ae5da
1628a6d
ccd3eba
2cae2a9
 
e9ae5da
1628a6d
2cae2a9
 
 
 
 
 
ccd3eba
2cae2a9
 
 
 
 
 
 
e9ae5da
1628a6d
2cae2a9
ccd3eba
2cae2a9
 
 
 
 
 
 
e9ae5da
1628a6d
2cae2a9
ccd3eba
2cae2a9
73a3511
2cae2a9
 
 
1083ad0
d5d9687
1083ad0
2cae2a9
 
 
 
 
 
 
 
 
 
d5d9687
1083ad0
2cae2a9
 
873dc9a
 
 
1628a6d
 
2cae2a9
 
 
 
 
 
3165afb
4cb7ad9
2cae2a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e9ae5da
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
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
import { existsSync } from "node:fs"
import { readFile } from "node:fs/promises"
import path from "node:path"

import { v4 as uuidv4 } from "uuid"
import ffmpeg, { FfmpegCommand } from "fluent-ffmpeg"
import { addBase64Header, extractBase64 } from "@aitube/encoders"
import { getRandomDirectory, removeTemporaryFiles, writeBase64ToFile } from "@aitube/io"

import { getMediaInfo } from "../analyze/getMediaInfo"
import { concatenateVideos } from "./concatenateVideos"

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 = await getRandomDirectory()

    if (audioTrack && audioTrack.length > 0) {
      const analysis = extractBase64(audioTrack)
      // console.log(`concatenateVideosWithAudio: writing down an audio file (${analysis.extension}) from the supplied base64 track`)
      audioFilePath = path.join(tempDir, `audio.${analysis.extension}`)
      
      await writeBase64ToFile(addBase64Header(audioTrack, analysis.extension), audioFilePath)
    }

    // Decode and concatenate base64 video tracks to temporary file
    let i = 0
    for (const track of videoTracks) {
      if (!track) { continue }
      // note: here we assume the input video is in mp4
      
      const analysis = extractBase64(audioTrack)
      const videoFilePath = path.join(tempDir, `video${++i}.${analysis.extension}`)

      // console.log(`concatenateVideosWithAudio: writing down a video file (${analysis.extension}) from the supplied base64 track`)

      await writeBase64ToFile(addBase64Header(track, analysis.extension), videoFilePath)

      videoFilePaths.push(videoFilePath)
    }

    videoFilePaths = videoFilePaths.filter((video) => existsSync(video))

    // console.log("concatenateVideosWithAudio: concatenating videos (without audio)..")
    const tempFilePath = await concatenateVideos({
      videoFilePaths,
    })
    // console.log(`concatenateVideosWithAudio: tempFilePath = ${JSON.stringify(tempFilePath, null, 2)}`)

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

    // console.log(`concatenateVideosWithAudio: hasOriginalAudio = ${hasOriginalAudio}`)

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

    // console.log(`concatenateVideosWithAudio: finalOutputFilePath = ${finalOutputFilePath}`)

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

    
    ffmpegCommand = ffmpegCommand.addInput(tempFilePath.filepath);
 
    ffmpegCommand = ffmpegCommand.outputOptions('-loglevel', 'debug');

    // If additional audio is provided, add audio to ffmpeg command
    if (typeof audioFilePath === "string" && audioFilePath.length > 0) {
      // console.log(`concatenateVideosWithAudio: adding an audio file path: ${audioFilePath}`)

      ffmpegCommand = ffmpegCommand.addInput(audioFilePath);
      // If the input video already has audio, we will mix it with additional audio
      if (hasOriginalAudio) {
        // console.log(`concatenateVideosWithAudio: case 1: additional audio was provided, and we already have audio: we mix`)

        const filterComplex = `
          [0:a]volume=${videoTracksVolume}[a0];
          [1:a]volume=${audioTrackVolume}[a1];
          [a0][a1]amix=inputs=2:duration=shortest[a]
        `.trim();

        ffmpegCommand = ffmpegCommand.outputOptions([
          '-filter_complex', filterComplex,
          '-map', '0:v',
          '-map', '[a]',
          '-c:v', 'copy',
          '-c:a', 'aac',
        ]);
      } else {
        // console.log(`concatenateVideosWithAudio: case 2: additional audio was provided, but we don't already have audio: we overwrite`)

        // If the input video has no audio, just use the additional audio as is
        ffmpegCommand = ffmpegCommand.outputOptions([
          '-map', '0:v',
          '-map', '1:a',
          '-c:v', 'copy',
          '-c:a', 'aac',
        ]);
      }
    } else {
      // console.log(`concatenateVideosWithAudio: case 3: no additional audio provided, we leave the audio as-is`)

      // If no additional audio is provided, simply copy the video stream
      ffmpegCommand = ffmpegCommand.outputOptions([
        '-c:v', 'copy',
        hasOriginalAudio ? '-c:a copy' : '-an', // If original audio exists, copy it; otherwise, indicate no audio
      ]);
    }


    /*
    console.log("concatenateVideosWithAudio: DEBUG:", {
      videoTracksVolume,
      audioTrackVolume,
      videoFilePaths,
      tempFilePath,
      hasOriginalAudio,
      // originalAudioVolume,
      audioFilePath,
      // additionalAudioVolume,
      finalOutputFilePath
     })
     */

    // Set up event handlers for ffmpeg processing
    const promise = new Promise<string>((resolve, reject) => {
      ffmpegCommand.on('start', function(commandLine) {
        console.log('concatenateVideosWithAudio: Spawned Ffmpeg with command: ' + commandLine);
      }).on('error', (err) => {
        console.error("concatenateVideosWithAudio: error during ffmpeg processing");
        console.error(err)
        reject(err);
      }).on('end', async () => {
        // When ffmpeg finishes processing, resolve the promise with file info
        try {
          if (asBase64) {
            try {
              const outputBuffer = await 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 {
    // console.log(`concatenateVideosWithAudio: deleting ${JSON.stringify([...videoFilePaths].concat(audioFilePath), null, 2)}`)
    await removeTemporaryFiles([...videoFilePaths].concat(audioFilePath))
  }
};