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:
<span class="hljs-tag"><<span class="hljs-name">video</span> <span class="hljs-attr">controls</span> <span class="hljs-attr">muted</span> <span class="hljs-attr">playsinline</span>></span>
<span class="hljs-tag"><<span class="hljs-name">source</span>
<span class="hljs-attr">src</span>=<span class="hljs-string">"https://s3.amazonaws.com/my_bucket/video.mp4"</span>
<span class="hljs-attr">type</span>=<span class="hljs-string">"video/mp4"</span>
/></span>
<span class="hljs-tag"></<span class="hljs-name">video</span>></span>
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.
<span class="hljs-keyword">import</span> { <span class="hljs-title class_">GetObjectCommand</span>, <span class="hljs-title class_">GetObjectCommandInput</span>, <span class="hljs-title class_">HeadObjectCommand</span>, <span class="hljs-title class_">HeadObjectCommandInput</span>, S3Client } <span class="hljs-keyword">from</span> <span class="hljs-string">'@aws-sdk/client-s3'</span>;
<span class="hljs-keyword">import</span> internal, { <span class="hljs-title class_">Readable</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'stream'</span>;
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> s3 = <span class="hljs-keyword">new</span> <span class="hljs-title function_">S3Client</span>({ <span class="hljs-attr">region</span>: <span class="hljs-string">'us-east-1'</span> });
<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">getObject</span>(<span class="hljs-params"><span class="hljs-attr">params</span>: <span class="hljs-title class_">GetObjectCommandInput</span></span>) {
<span class="hljs-keyword">return</span> s3.<span class="hljs-title function_">send</span>(<span class="hljs-keyword">new</span> <span class="hljs-title class_">GetObjectCommand</span>(params));
}
<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">getObjectReadable</span>(<span class="hljs-params"><span class="hljs-attr">params</span>: <span class="hljs-title class_">GetObjectCommandInput</span></span>) {
<span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> <span class="hljs-title function_">getObject</span>(params);
<span class="hljs-keyword">return</span> <span class="hljs-title class_">Readable</span>.<span class="hljs-title function_">from</span>(response?.<span class="hljs-property">Body</span> <span class="hljs-keyword">as</span> internal.<span class="hljs-property">Readable</span>);
}
<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">headObject</span>(<span class="hljs-params"><span class="hljs-attr">params</span>: <span class="hljs-title class_">HeadObjectCommandInput</span></span>) {
<span class="hljs-keyword">return</span> s3.<span class="hljs-title function_">send</span>(<span class="hljs-keyword">new</span> <span class="hljs-title class_">HeadObjectCommand</span>(params));
}
<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">getObjectFileSize</span>(<span class="hljs-params"><span class="hljs-attr">params</span>: <span class="hljs-title class_">HeadObjectCommandInput</span></span>) {
<span class="hljs-keyword">const</span> { <span class="hljs-title class_">ContentLength</span> } = <span class="hljs-keyword">await</span> <span class="hljs-title function_">headObject</span>(params);
<span class="hljs-keyword">return</span> <span class="hljs-title class_">ContentLength</span>;
}
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
<span class="hljs-keyword">import</span> { <span class="hljs-title class_">Request</span>, <span class="hljs-title class_">Response</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'express'</span>;
<span class="hljs-keyword">import</span> mime <span class="hljs-keyword">from</span> <span class="hljs-string">'mime-types'</span>;
<span class="hljs-keyword">import</span> { bucket } <span class="hljs-keyword">from</span> <span class="hljs-string">"../config"</span>;
<span class="hljs-keyword">import</span> { getObjectFileSize, getObjectReadable } <span class="hljs-keyword">from</span> <span class="hljs-string">"../utils/s3"</span>;
<span class="hljs-comment">// The format for this route is:</span>
<span class="hljs-comment">// https://yourdomain.com/video/your_key_from_s3</span>
<span class="hljs-comment">// If your S3 URL is: https://s3.amazonaws.com/my_bucket/video.mp4</span>
<span class="hljs-comment">// the URL would be: https://yourdomain.com/video/video.mp4</span>
app.<span class="hljs-title function_">get</span>(<span class="hljs-string">'/video/*'</span>, <span class="hljs-title function_">async</span> (<span class="hljs-attr">req</span>: <span class="hljs-title class_">Request</span>, <span class="hljs-attr">res</span>: <span class="hljs-title class_">Response</span>, <span class="hljs-attr">next</span>: <span class="hljs-title class_">Next</span>) => {
<span class="hljs-keyword">try</span> {
<span class="hljs-comment">// Get the mime type and ensure it's a video</span>
<span class="hljs-keyword">const</span> mimeType = mime.<span class="hljs-title function_">lookup</span>(req.<span class="hljs-property">url</span>);
<span class="hljs-keyword">if</span> (!mimeType.<span class="hljs-title function_">startsWith</span>(<span class="hljs-string">'video/'</span>)) <span class="hljs-keyword">throw</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">`Invalid mime type - <span class="hljs-subst">${req.url}</span>`</span>);
<span class="hljs-comment">// Get the key to be used in S3</span>
<span class="hljs-keyword">const</span> key = req.<span class="hljs-property">params</span>[<span class="hljs-number">0</span>];
<span class="hljs-comment">// Use the getObjectFileSize function to determine the full size of the video</span>
<span class="hljs-keyword">const</span> videoSize = <span class="hljs-keyword">await</span> <span class="hljs-title function_">getObjectFileSize</span>({
<span class="hljs-title class_">Key</span>: key,
<span class="hljs-title class_">Bucket</span>: bucket,
});
<span class="hljs-comment">// On initial page load, we don't have a "range" header</span>
<span class="hljs-keyword">if</span> (!req.<span class="hljs-property">headers</span>.<span class="hljs-property">range</span>) {
<span class="hljs-comment">// Get the entire stream with no byte range</span>
<span class="hljs-keyword">const</span> stream = <span class="hljs-keyword">await</span> <span class="hljs-title function_">getObjectReadable</span>({
<span class="hljs-title class_">Key</span>: key,
<span class="hljs-title class_">Bucket</span>: bucket,
});
<span class="hljs-comment">// We need to send the entire video size to the server in the Content-Length header</span>
res.<span class="hljs-title function_">writeHead</span>(<span class="hljs-number">200</span>, {
<span class="hljs-string">'Content-Length'</span>: videoSize,
<span class="hljs-string">'Content-Type'</span>: mimeType
});
<span class="hljs-comment">// Pipe the stream from S3 to the response</span>
stream.<span class="hljs-title function_">pipe</span>(res);
} <span class="hljs-keyword">else</span> {
<span class="hljs-comment">// Using the range header, we are able to determine the start and end chunks, adjusting for the next video chunk</span>
<span class="hljs-comment">// Credit for this code goes to: https://github.com/caiogondim/video-stream.js</span>
<span class="hljs-keyword">const</span> { range } = req.<span class="hljs-property">headers</span>;
<span class="hljs-keyword">const</span> parts = range.<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/bytes=/</span>, <span class="hljs-string">''</span>).<span class="hljs-title function_">split</span>(<span class="hljs-string">`-`</span>);
<span class="hljs-keyword">const</span> start = <span class="hljs-built_in">parseInt</span>(parts[<span class="hljs-number">0</span>], <span class="hljs-number">10</span>);
<span class="hljs-keyword">const</span> end = parts[<span class="hljs-number">1</span>] ? <span class="hljs-built_in">parseInt</span>(parts[<span class="hljs-number">1</span>], <span class="hljs-number">10</span>) : videoSize - <span class="hljs-number">1</span>;
<span class="hljs-keyword">const</span> chunkSize = (end - start) + <span class="hljs-number">1</span>;
<span class="hljs-comment">// We must send a 206 Partial Content status code to stream video</span>
<span class="hljs-comment">// We also add a content range, which includes which bytes are being sent</span>
res.<span class="hljs-title function_">writeHead</span>(<span class="hljs-number">206</span>, {
<span class="hljs-string">'Content-Range'</span>: <span class="hljs-string">`bytes <span class="hljs-subst">${start}</span>-<span class="hljs-subst">${end}</span>/<span class="hljs-subst">${videoSize}</span>`</span>,
<span class="hljs-string">'Accept-Ranges'</span>: <span class="hljs-string">`bytes`</span>,
<span class="hljs-string">'Content-Length'</span>: chunkSize,
<span class="hljs-string">'Content-Type'</span>: mimeType
});
<span class="hljs-comment">// The difference between this getObjectReadable call and the call above is that we</span>
<span class="hljs-comment">// use S3's "Range" parameter to get a byte range from the stream</span>
<span class="hljs-keyword">const</span> stream = <span class="hljs-keyword">await</span> <span class="hljs-title function_">getObjectReadable</span>({
<span class="hljs-title class_">Key</span>: key,
<span class="hljs-title class_">Bucket</span>: bucket,
<span class="hljs-title class_">Range</span>: <span class="hljs-string">`bytes=<span class="hljs-subst">${start}</span>-<span class="hljs-subst">${end}</span>`</span>
});
<span class="hljs-comment">// Pipe the stream from S3 to the response</span>
stream.<span class="hljs-title function_">pipe</span>(res);
}
} <span class="hljs-keyword">catch</span> (error) {
<span class="hljs-comment">// Catch any errors</span>
<span class="hljs-comment">// If you want, you can be more thorough here and send a 404 response in the event the video file does not exist,</span>
<span class="hljs-comment">// but I will keep it simple for demonstration purposes</span>
<span class="hljs-variable language_">console</span>.<span class="hljs-title function_">error</span>(error);
res.<span class="hljs-title function_">status</span>(<span class="hljs-number">500</span>).<span class="hljs-title function_">send</span>({
<span class="hljs-attr">message</span>: <span class="hljs-string">'Could not get video'</span>
});
}
});
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:
<span class="hljs-keyword">import</span> yargs <span class="hljs-keyword">from</span> <span class="hljs-string">'yargs'</span>;
<span class="hljs-keyword">import</span> fs <span class="hljs-keyword">from</span> <span class="hljs-string">'fs-extra'</span>;
<span class="hljs-keyword">import</span> path <span class="hljs-keyword">from</span> <span class="hljs-string">'path'</span>;
<span class="hljs-keyword">import</span> shell <span class="hljs-keyword">from</span> <span class="hljs-string">'shelljs'</span>;
<span class="hljs-keyword">import</span> { hideBin } <span class="hljs-keyword">from</span> <span class="hljs-string">'yargs/helpers'</span>;
<span class="hljs-keyword">import</span> { putImage } <span class="hljs-keyword">from</span> <span class="hljs-string">'../utils/s3'</span>;
<span class="hljs-keyword">const</span> extension = <span class="hljs-string">'mp4'</span>;
<span class="hljs-keyword">const</span> argv = <span class="hljs-title function_">yargs</span>(<span class="hljs-title function_">hideBin</span>(process.<span class="hljs-property">argv</span>)).<span class="hljs-property">argv</span>;
<span class="hljs-keyword">const</span> inputURL = argv.<span class="hljs-property">_</span>[<span class="hljs-number">0</span>] <span class="hljs-keyword">as</span> <span class="hljs-built_in">string</span>;
<span class="hljs-comment">// For this value, it should be the string that begins before your key</span>
<span class="hljs-comment">// For example, if your video URL is https://s3.amazonaws.com/my_bucket/video.mp4</span>
<span class="hljs-comment">// This value should be `my_bucket/`</span>
<span class="hljs-keyword">const</span> keyStartAfter = <span class="hljs-string">`the immediately proceeding string before the key`</span>;
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">convertVideoURLToStreaming</span>(<span class="hljs-params"><span class="hljs-attr">url</span>: <span class="hljs-built_in">string</span></span>) {
<span class="hljs-keyword">const</span> tempFilePath = path.<span class="hljs-title function_">join</span>(__dirname, <span class="hljs-string">`./convert-video-url-to-streaming/temp-conversion.<span class="hljs-subst">${extension}</span>`</span>);
<span class="hljs-keyword">const</span> indexOfKeyStart = url.<span class="hljs-title function_">indexOf</span>(keyStartAfter);
<span class="hljs-keyword">const</span> key = url.<span class="hljs-title function_">slice</span>(indexOfKeyStart).<span class="hljs-title function_">replace</span>(keyStartAfter, <span class="hljs-string">''</span>);
<span class="hljs-keyword">const</span> newKeySplit = key.<span class="hljs-title function_">split</span>(<span class="hljs-string">'.'</span>);
newKeySplit[newKeySplit.<span class="hljs-property">length</span> - <span class="hljs-number">1</span>] = extension;
<span class="hljs-keyword">const</span> newKey = newKeySplit.<span class="hljs-title function_">join</span>(<span class="hljs-string">'.'</span>);
<span class="hljs-comment">// Run the FFMPEG command</span>
<span class="hljs-comment">// I found it was easier to just use shelljs for this instead of an FFMPEG</span>
<span class="hljs-comment">// wrapper such as fluent-ffmpeg</span>
<span class="hljs-keyword">await</span> shell.<span class="hljs-title function_">exec</span>(<span class="hljs-string">`ffmpeg -i <span class="hljs-subst">${url}</span> -c:v libx264 -profile:v main -vf format=yuv420p -c:a aac -movflags +faststart <span class="hljs-subst">${tempFilePath}</span>`</span>);
<span class="hljs-comment">// Upload to S3</span>
<span class="hljs-keyword">await</span> <span class="hljs-title function_">putImage</span>({
<span class="hljs-attr">key</span>: newKey,
<span class="hljs-attr">buffer</span>: fs.<span class="hljs-title function_">readFileSync</span>(tempFilePath),
<span class="hljs-attr">contentType</span>: <span class="hljs-string">`video/<span class="hljs-subst">${extension}</span>`</span>,
});
<span class="hljs-comment">// Delete temp file</span>
fs.<span class="hljs-title function_">unlink</span>(tempFilePath);
}
<span class="hljs-title function_">convertVideoURLToStreaming</span>(inputURL);
Now you can run the script wherever it's located in your code:
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:
<span class="hljs-tag"><<span class="hljs-name">video</span> <span class="hljs-attr">controls</span> <span class="hljs-attr">muted</span> <span class="hljs-attr">playsinline</span> <span class="hljs-attr">poster</span>=<span class="hljs-string">"thumbnail.jpg"</span>></span>
<span class="hljs-tag"><<span class="hljs-name">source</span>
<span class="hljs-attr">src</span>=<span class="hljs-string">"/video/video.mp4"</span>
<span class="hljs-attr">type</span>=<span class="hljs-string">"video/mp4"</span>
/></span>
<span class="hljs-tag"></<span class="hljs-name">video</span>></span>