jbilcke-hf's picture
jbilcke-hf HF staff
update dependencies
051e9e4
import { existsSync } from "node:fs"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { UUID } from "@aitube/clap"
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> => {
console.log(`concatenateVideosWithAudio()`)
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, `${UUID()}.${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',
'-shortest'
]);
} 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',
'-shortest'
]);
}
} 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))
}
};