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>