How to stream video from S3 with Express
One of the reasons working with Safari can be annoying is because it only supports streaming video. This means using an S3 video URL as a video source doesn't work right away in Safari, even though it does work in other browsers like Chrome and Firefox.
Doesn't work in Safari:
<video controls muted playsinline>
<source
src="https://s3.amazonaws.com/my_bucket/video.mp4"
type="video/mp4"
/>
</video>
Even if you manage to make video streaming work, Safari is also very fussy with how the video is encoded.
We will solve both of these issues using Express and FFMPEG.
Step 1 - Writing the S3 SDK functions
We'll start by writing our S3 SDK functions to get object streams. Make sure you have your AWS credentials set up on your machine to be able to run this code.
import { GetObjectCommand, GetObjectCommandInput, HeadObjectCommand, HeadObjectCommandInput, S3Client } from '@aws-sdk/client-s3';
import internal, { Readable } from 'stream';
export const s3 = new S3Client({ region: 'us-east-1' });
export function getObject(params: GetObjectCommandInput) {
return s3.send(new GetObjectCommand(params));
}
export async function getObjectReadable(params: GetObjectCommandInput) {
const response = await getObject(params);
return Readable.from(response?.Body as internal.Readable);
}
export async function headObject(params: HeadObjectCommandInput) {
return s3.send(new HeadObjectCommand(params));
}
export async function getObjectFileSize(params: HeadObjectCommandInput) {
const { ContentLength } = await headObject(params);
return ContentLength;
}
The most important function here is getObjectReadable
, which converts our S3 GetObjectCommand into a readable stream.
I do not use a try/catch in these functions because I like to use a try/catch inside the Express route.
Step 2 - Defining the Express route and adding our video streaming code
import { Request, Response } from 'express';
import mime from 'mime-types';
import { bucket } from "../config";
import { getObjectFileSize, getObjectReadable } from "../utils/s3";
// The format for this route is:
// https://yourdomain.com/video/your_key_from_s3
// If your S3 URL is: https://s3.amazonaws.com/my_bucket/video.mp4
// the URL would be: https://yourdomain.com/video/video.mp4
app.get('/video/*', async (req: Request, res: Response, next: Next) => {
try {
// Get the mime type and ensure it's a video
const mimeType = mime.lookup(req.url);
if (!mimeType.startsWith('video/')) throw Error(`Invalid mime type - ${req.url}`);
// Get the key to be used in S3
const key = req.params[0];
// Use the getObjectFileSize function to determine the full size of the video
const videoSize = await getObjectFileSize({
Key: key,
Bucket: bucket,
});
// On initial page load, we don't have a "range" header
if (!req.headers.range) {
// Get the entire stream with no byte range
const stream = await getObjectReadable({
Key: key,
Bucket: bucket,
});
// We need to send the entire video size to the server in the Content-Length header
res.writeHead(200, {
'Content-Length': videoSize,
'Content-Type': mimeType
});
// Pipe the stream from S3 to the response
stream.pipe(res);
} else {
// Using the range header, we are able to determine the start and end chunks, adjusting for the next video chunk
// Credit for this code goes to: https://github.com/caiogondim/video-stream.js
const { range } = req.headers;
const parts = range.replace(/bytes=/, ``).split(`-`);
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : videoSize - 1;
const chunkSize = (end - start) + 1;
// We must send a 206 Partial Content status code to stream video
// We also add a content range, which includes which bytes are being sent
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${videoSize}`,
'Accept-Ranges': `bytes`,
'Content-Length': chunkSize,
'Content-Type': mimeType
});
// The difference between this getObjectReadable call and the call above is that we
// use S3's "Range" parameter to get a byte range from the stream
const stream = await getObjectReadable({
Key: key,
Bucket: bucket,
Range: `bytes=${start}-${end}`
});
// Pipe the stream from S3 to the response
stream.pipe(res);
}
} catch (error) {
// Catch any errors
// If you want, you can be more thorough here and send a 404 response in the event the video file does not exist,
// but I will keep it simple for demonstration purposes
console.error(error);
res.status(500).send({
message: 'Could not get video'
});
}
});
Step 3 - Converting the video into the correct format
If your video isn't working in all browsers, you may need to convert it using FFMPEG.
I used this answer from Stack Overflow to convert my .mp4 file, specifically this line:
ffmpeg -i input -c:v libx264 -profile:v main -vf format=yuv420p -c:a aac -movflags +faststart output.mp4
Then I wrote a node script to automatically convert my .mp4 video already in S3 into the correct format:
import yargs from 'yargs';
import fs from 'fs-extra';
import path from 'path';
import shell from 'shelljs';
import { hideBin } from 'yargs/helpers';
import { putImage } from '../utils/s3';
const extension = 'mp4';
const argv = yargs(hideBin(process.argv)).argv;
const inputURL = argv._[0] as string;
// For this value, it should be the string that begins before your key
// For example, if your video URL is https://s3.amazonaws.com/my_bucket/video.mp4
// This value should be `my_bucket/`
const keyStartAfter = `the immediately proceeding string before the key`;
async function convertVideoURLToStreaming(url: string) {
const tempFilePath = path.join(__dirname, `./convert-video-url-to-streaming/temp-conversion.${extension}`);
const indexOfKeyStart = url.indexOf(keyStartAfter);
const key = url.slice(indexOfKeyStart).replace(keyStartAfter, '');
const newKeySplit = key.split('.');
newKeySplit[newKeySplit.length - 1] = extension;
const newKey = newKeySplit.join('.');
// Run the FFMPEG command
// I found it was easier to just use shelljs for this instead of an FFMPEG
// wrapper such as fluent-ffmpeg
await shell.exec(`ffmpeg -i ${url} -c:v libx264 -profile:v main -vf format=yuv420p -c:a aac -movflags +faststart ${tempFilePath}`);
// Upload to S3
await putImage({
key: newKey,
buffer: fs.readFileSync(tempFilePath),
contentType: `video/${extension}`,
});
// Delete temp file
fs.unlink(tempFilePath);
}
convertVideoURLToStreaming(inputURL);
Now you can run the script wherever it's located in your code, for me it's in the ./scripts directory:
npx tsx ./convert-video-url-to-streaming/index.ts "https://s3.amazonaws.com/my_bucket/video.mp4"
Your video should now work on all browsers using the new Express endpoint:
<video controls muted playsinline poster="thumbnail.jpg">
<source
src="/video/video.mp4"
type="video/mp4"
/>
</video>