I am working on a problem I have been stumped on the past couple days. I am using Node.js with Express (v4.18.2) to eventually create a Firebase deployment that can take in a video URL and output an audio mp3 to the Firebase Firestore. I have made some progress, but am still unsuccessful in some areas.
I cannot save the file locally using fs, but for this example I have shown that it works with FS. I am successfully saving a local .mp3 file.
First a few functions I have:
async function downloadVideo(videoUrl) {
try {
const response = await axios.get(videoUrl, {
responseType: 'stream',
});
if (response.status === 200) {
return response.data;
} else {
throw new Error('Failed to fetch the video');
}
} catch (error) {
throw new Error('Error fetching the video: ' + error.message);
}
}
async function extractAudioFromVideo(videoUrl) {
try {
const videoStream = await downloadVideo(videoUrl);
// Create a PassThrough stream to pipe the video data
const passThrough = new PassThrough();
videoStream.pipe(passThrough);
const outputFile = 'output.mp3';
const outputStream = fs.createWriteStream(outputFile);
return new Promise((resolve, reject) => {
const audioBuffers = [];
passThrough.on('data', chunk => {
audioBuffers.push(chunk)
outputStream.write(chunk); // Write chunks to a local file
});
passThrough.on('error', err => {
reject(err);
});
ffmpeg()
.input(passThrough)
.output('/dev/null') // Null output as a placeholder
.outputOptions('-vn') // Extract audio only
.noVideo()
.audioQuality(0)
.audioCodec('libmp3lame') // Set audio codec
.format('mp3')
.on('end', () => {
const audioBuffer = Buffer.concat(audioBuffers)
if (audioBuffer.length > 0) {
resolve(audioBuffer);
} else {
reject(new Error('Empty audio buffer'));
}
})
.on('error', err => reject(err))
.run();
})
} catch (error) {
throw new Error('Error extracting audio: ' + error.message);
}
}
async function saveAudioToFirebase(audioBuffer, fileName) {
try {
let storage = admin.storage()
let storageRef = storage.bucket(serviceAccount.storage_bucket_content)
const file = storageRef.file(fileName) // Specify the desired file name here
const renamedFileName = fileName.replace(/.[^/.]+$/, '.mp3'); // Change the file extension to .mp3
await file.save(audioBuffer, {
metadata: {
contentType: 'audio/mpeg', // Adjust the content type as needed
},
});
await file.setMetadata({
contentType: 'audio/mpeg'
})
await file.move(renamedFileName); // Rename the file with the .mp3 extension
console.log('Audio saved to Firebase Storage.');
} catch (error) {
console.error('Error saving audio to Firebase Storage:', error);
}
}
What works:
- Downloading the video via Axios
- Saving to Firebase storage (no intializing or pointer issues to Firebase)
- Outputting a local .mp3 file called "output.mp3"
- I am able to log the result of
extractAudioFromVideo
and get a buffer logged in my terminal
What doesn’t work:
- Saving a file to Firebase Storage that is an .mp3. It says ".mp3" in the url and it has a content type of ‘audio/mpeg’ but it is in fact an .mp4. Still has video and plays video in the browser window.
I am willing to use other libraries like tmp if suggested and the solution works.
2
Answers
Preface
Don’t expect answers like this from StackOverflow often. I just enjoyed working on the problem and got carried away.
Note: Below code has been coded free-hand, expect typos. Corrections welcome.
The Problem
Looking at your current approach, the video file is first downloaded into memory (as
audioBuffer
, before audio conversion) and also written out to a file asoutput.mp3
(before audio conversion). This is caused by these lines (rearranged for clarity):Note that the above lines do not make any mention of MP3 file conversion. This is why your uploaded and local file are both videos with an
.mp3
file extension. Below those lines, you feed the output of thepassThrough
stream toffmpeg
and discard the result (by sending it to/dev/null
).Instead of interacting with the file system at all, it should be possible to extract the original video stream, transform the streams content by removing the video content and converting the audio track as needed, then load the resulting audio stream straight into Google Cloud Storage. This is known as an ETL pipeline (for Extract, Transform, Load) and helps minimise the resources needed to host this Cloud Function.
Potential Solution
In this first code block, I’ve combined the
extractAudioFromVideo
andsaveAudioToFirebase
helper methods into onestreamAudioTrackToCloudStorage
helper method. Bringing these components together helps to prevent passing streams around that don’t have appropriate listeners. It also helps with binding the current context to errors thrown during the transform and load steps. This is particularly important because a file that was being uploaded may be incomplete or empty if FFMPEG could not process the incoming stream properly. It is left up to the error handling code to dispose of an incomplete file.The
streamAudioTrackToCloudStorage
method accepts adownloadable
boolean argument that can generate the Firebase Storage Download URL at upload time. This is useful for inserting the file’s record into Cloud Firestore or the Realtime Database if it is for public consumption.Now that we have our transform and load streams, we need to obtain the extract stream.
With the introduction of the native Node Fetch API in Node v18, I’ve dropped
axios
as it wasn’t really being used for anything useful to this step. You can modify the below script to add it back in if you were making use of interceptors for authentication or something similar.In this code block, we define the
storeAudioFromRemoteVideo
helper method which accepts the video URL to be converted along with the final upload path for the converted mp3 file. Unless another GCS bucket is provided as part of the options argument, the file will be uploaded to the default bucket as you specified in the code you shared. The remaining properties of the options argument are passed through tofetch()
as the second argument if you need to specify things likeAuthorization
headers, API keys or request bodies.Usage
Now that the above methods are defined, your Cloud Function code may look like:
Potential Next Steps:
jobDurationMS
values and project cost to execute this function.