Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
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)) | |
} | |
}; |