close
close

How to convert videos to HLS for web and mobile streaming with AWS Elemental MediaConvert

In today's digital landscape, delivering high-quality video content across a variety of devices and network conditions is more important than ever. Whether you are developing a streaming platform, an online learning portal, a social media app, or any other application that requires video playback, seamless video streaming is essential for an optimal user experience.

By automating video conversion using a cloud-based encoding service, you can easily generate adaptive streams, ensuring the best video quality for your users while reducing infrastructure complexity. Let's explore how you can implement this solution to meet the needs of your application. Our goal is to create a solution that is easy to deploy, requires no maintenance, and supports a growing user base.

A few words about HLS

We focus on HLS (HTTP Live Streaming), one of the most popular video streaming protocols. HLS uses encoding and segmentation: the original video is encoded into multiple versions with different bitrates and resolutions. These encoded versions are then divided into small blocks, typically 2 to 10 seconds long, with each block saved as a single file. Playlists are then created: First, a playlist is generated for each encoded version of the video that contains the URLs of the individual chunks.

Next, a single master playlist is created that references the different versions of the stream (and their corresponding playlists) as well as their resolutions and bitrates. This setup allows client apps to select the optimal stream based on factors such as the client's device resolution, viewport size, and network conditions, ensuring that only the necessary segments are downloaded.

There are many tools available for creating HLS streams from videos. This includes running FFmpeg directly or using cloud-based services to perform the conversion and avoid infrastructure management. Examples of such services include AWS Elemental MediaConvert, Google Cloud Transcoder, Bitmovin and others. In this post we will focus on MediaConvert. Below is a possible workflow for automatically converting uploaded videos to HLS and serving the streams to users. As you go through the workflow, please refer to the attached diagram where each step is labeled.

  1. A user uploads a video to an S3 bucket using the mobile or web client app.

  2. A Lambda function is triggered by the ObjectCreate event in the Video Uploads S3 bucket. This function creates a MediaConvert job with the provided configuration and then exits (it does not wait for the video conversion to complete). The MediaConvert API offers various settings including codec selection, bitrate, quality, audio processing, and more. Multiple versions of the stream can also be generated with different compression settings, e.g. B. 360p, 720p, 1080p etc.

    While encoding configuration selection is not covered in this post, the code example includes a simple HLS packaging job with 1 Mbps bitrate playback. The configuration can be easily expanded to meet the needs of any application. As far as IAM permissions are concerned, this feature requires read access to the source S3 bucket, write access to the destination S3 bucket, and access to the MediaConvert API.

import boto3
import re


output_bucket_name = 'converted-videos-bucket'
mediaconvert_role_arn = 'arn:aws:iam::123456789012:role/MediaConvertRole' # output bucket access

s3_client = boto3.client('s3')
mediaconvert_client = boto3.client('mediaconvert')

hls_main_playlist_suffix = '-hls.m3u8'

# regex used to normalize the object key for the client request token
client_request_token_symbols_to_skip = r'[^a-zA-Z0-9-_]'

def lambda_handler(event, context):
    # get S3 bucket name and object key from the event
    bucket_name = event['Records'][0]['s3']['bucket']['name']
    object_key = event['Records'][0]['s3']['object']['key']  # also used as media id
    
    # normalize the object key for the client request token
    client_request_token_obj_key = re.sub(client_request_token_symbols_to_skip, '_', object_key)
    
    # call MediaConvert to transcode the video
    create_job_response = mediaconvert_client.create_job(
        Role=mediaconvert_role_arn,
        ClientRequestToken=client_request_token_obj_key,
        Settings={
            'Inputs': [
                {
                    'FileInput': f's3://{bucket_name}/{object_key}',
                    'AudioSelectors': {
                        'Audio Selector 1': {
                            'DefaultSelection': 'DEFAULT',
                        },
                    },
                }
            ],
            'OutputGroups': [
                {
                    'Name': 'DefaultOutputGroup',
                    'OutputGroupSettings': {
                        'Type': 'HLS_GROUP_SETTINGS',
                        'HlsGroupSettings': {
                            'Destination': f's3://{output_bucket_name}/{object_key}-hls',
                            'DirectoryStructure': 'SUBDIRECTORY_PER_STREAM',
                            'SegmentLength': 5,
                            'MinSegmentLength': 2,
                            'SegmentsPerSubdirectory': 500,
                            'ProgressiveWriteHlsManifest': 'DISABLED',
                        },
                    },
                    'Outputs': [
                        {
                            'NameModifier': '-h264',
                            'ContainerSettings': {
                                'Container': 'M3U8',
                            },
                            'VideoDescription': {
                               'CodecSettings': {
                                    'Codec': 'H_264',
                                    'H264Settings': {
                                        'RateControlMode': 'VBR',
                                        'Bitrate': 1000000,
                                    },
                               },
                            },
                            'AudioDescriptions': [
                                {
                                    'AudioSourceName': 'Audio Selector 1',
                                    'CodecSettings': {
                                        'Codec': 'AAC',
                                        'AacSettings': {
                                            'Bitrate': 96000,
                                            'CodingMode': 'CODING_MODE_2_0',
                                            'SampleRate': 48000,
                                        },
                                    },
                                },
                            ],
                        },
                    ],
                }
            ],
        },
    )
    
    print('Created a MediaConvert job:', create_job_response)

    return {
        'statusCode': 200,
        'body': 'OK',
    }
  1. MediaConvert processes the video and generates HLS playlists and video segments in the output S3 bucket. The output bucket is connected to a CDN that caches playlists and video segments. In this example we are using Cloudfront, but any S3 compatible CDN can be used.

  2. Another Lambda function is triggered by the ObjectCreate event in the output bucket. There is an object name filter attached to this trigger to ensure that the function only runs when a playlist file is created (segment files are ignored).

Object name filter in Lambda trigger: Execute the function only when the master playlist file is created.Object name filter in Lambda trigger: Execute the function only when the master playlist file is created.

This function adds the playlist URL to the media record in the database. The storage layer is beyond the scope of this post, so the code example simply prints the URL.

import boto3

s3_client = boto3.client('s3')

def lambda_handler(event, context):
    # this function is triggered only when a playlist file
    # with object key that looks like this '-hls.m3u8' 
    # is created in the S3 bucket
    
    # get object key from the event
    object_key = event['Records'][0]['s3']['object']['key']
    
    # extract video id from the object key
    video_id = object_key.replace('-hls.m3u8', '')
    
    print(f'HLS playlist {object_key} created for video {video_id}') 
    # TODO: update the video record in the database

    return {
        'statusCode': 200,
        'body': 'OK',
    }
  1. When users open the video in the client app UI, the client app uses the API to retrieve the media record from the database. This media record contains the master playlist URL.

  2. The video player fetches the master playlist from the CDN and decides which stream to play based on factors such as viewport size, screen resolution, network conditions, etc. It then fetches the stream playlist and video segments from the CDN and starts playing the video.

This solution is very easy to deploy and requires no maintenance. In terms of scalability for many users, it is important to note that by default, MediaConvert jobs are added to a single queue that can handle 100-200 videos at a time (depending on the region). Additional queues can be created (up to 10 per region) and jobs can be assigned priorities when added to queues. There is also the option to request quota increases from AWS.

In conclusion, automating video conversion using cloud-based services like AWS Elemental MediaConvert is an effective way to deliver high-quality streaming content across devices without having to deal with complex infrastructure management. This approach not only simplifies the video encoding process, but also improves scalability and ensures your platform can meet growing demand.

By leveraging tools like S3, Lambda Functions, and CloudFront in conjunction with MediaConvert, you can efficiently generate and deliver adaptive HLS streams, providing users with an optimized viewing experience.