Infrastructure as Codeで実現するAWS MediaConvertによるHLS形式へ自動変換


XTechグループ Advent Calendar 2020の9日目の担当は、エキサイトのLife&Wellness事業部でエンジニアをしている坂本です。

はじめに

米アップルによって開発されたHLSのシェアが近年拡大されています。HLSとは、HTTP Live Streaming(ストリーミングプロトコル)の略で、アップルが自社iOS向けに開発しました。HTTPベースなので、CDNのキャッシュ技術を利用できます。Androidも対応しますので、個人的に動画配信(VOD)サービスを作りたいのなら、HLS技術採用が最適だと思います。

AWSコンソール上にMediaConvertで簡単に動画形式からhls形式に変換できます。今回は以下のような最低限な構成を作ってみたいと思います。

本稿の範囲で完全にInfrastructure as Codeの紹介が難しいので、自分自身が最も悩んでいたLambdaからMediaConvertのジョブ発行を紹介したいと思います。

事前準備(手動で作成)

S3バケット作成

コンソール上に以下の2つバケットを作成します
① input-excite-mediaconvert-bucket:入稿用のバケット
② converted-excite-mediaconvert-bucket:変換済コンテンツ保管バケット

IAMロール作成

  • まず、MediaConvertでジョブ作成のため、ロール付与が必要です。コンソール上にExciteMediaConvertRoleという名前でロールを作成します。設置場所がMediaConvertなので、作成の際にMediaConvert選択になります。

  • 次に、LambdaからMediaConvertのジョブを実行するために、Lambda用のExciteLambdaMediaConvertRoleという名前で作成します。MediaConvert用のロールからのPassRole設定が必要です。

ロール名 設置場所 設定ポリシー 意味
ExciteMediaConvertRole MediaConvert デフォルト値↓
AmazonS3FullAccess
AmazonAPIGatewayInvokeFullAccess
ジョブ作成時に必要なロール
ExciteLambdaMediaConvertRole Lambda AmazonS3FullAccess
AWSElementalMediaConvertFullAccess
ポリシーにPassRole設定が必要

ExciteLambdaMediaConvertRoleの設定がこのようになります

MediaConvertのジョブ設定をコピーする

AWSコンソール上のMediaConvertからジョブを作成します。ジョブテンプレートを作成して、Lambda上からジョブテンプレートを呼ぶ出す方法もありますが、極力的に手作業を避けたいので、このやり方を使わない。

ジョブ作成の際に最低限で以下の4つ入力が必要です。
- 入力ファイルURL(入稿用バケットのURL)
- 送信先(変換済コンテンツ保管バケットのURL)
- ビットレート:例)「264000」を入力
- 名前修飾子:例)「_hls」を入力

ジョブ作成が完了したら、変換済コンテンツ保管バケットに以下のようなファイルが作成されます。

これでHLS形式に変換が成功しましたので、このジョブの設定をコピーしたいと思います。
- AWSコンソール上の実行したジョブの画面から「JSONのエクスポート」ボタンを押して、JSONファイルをダウンロードしておきます。
- JSON内の「Settings」要素だけ抜き出して、AudioDuration要素削除とバケット情報(OutputGroupSettingsのDestinationとInputsのFileInput)の値を削除(その後Lambda側にこの値を書き換えします)して、「job_setting.json」という名前で保存します。

AudioDuration要素は、任意の設定で動画とオーディオの再生時間の差がきわめて小さい再パッケージのダウンストリームワークフローで出力が消費される場合にのみ指定します。

例)ジョブ設定
{
    "TimecodeConfig": {
      "Source": "ZEROBASED"
    },
    "OutputGroups": [
      {
        "Name": "Apple HLS",
        "Outputs": [
          {
            "ContainerSettings": {
              "Container": "M3U8",
              "M3u8Settings": {
                "AudioFramesPerPes": 4,
                "PcrControl": "PCR_EVERY_PES_PACKET",
                "PmtPid": 480,
                "PrivateMetadataPid": 503,
                "ProgramNumber": 1,
                "PatInterval": 0,
                "PmtInterval": 0,
                "Scte35Source": "NONE",
                "NielsenId3": "NONE",
                "TimedMetadata": "NONE",
                "VideoPid": 481,
                "AudioPids": [
                  482,
                  483,
                  484,
                  485,
                  486,
                  487,
                  488,
                  489,
                  490,
                  491,
                  492
                ]
              }
            },
            "VideoDescription": {
              "ScalingBehavior": "DEFAULT",
              "TimecodeInsertion": "DISABLED",
              "AntiAlias": "ENABLED",
              "Sharpness": 50,
              "CodecSettings": {
                "Codec": "H_264",
                "H264Settings": {
                  "InterlaceMode": "PROGRESSIVE",
                  "NumberReferenceFrames": 3,
                  "Syntax": "DEFAULT",
                  "Softness": 0,
                  "GopClosedCadence": 1,
                  "GopSize": 90,
                  "Slices": 1,
                  "GopBReference": "DISABLED",
                  "SlowPal": "DISABLED",
                  "EntropyEncoding": "CABAC",
                  "Bitrate": 264000,
                  "FramerateControl": "INITIALIZE_FROM_SOURCE",
                  "RateControlMode": "CBR",
                  "CodecProfile": "MAIN",
                  "Telecine": "NONE",
                  "MinIInterval": 0,
                  "AdaptiveQuantization": "AUTO",
                  "CodecLevel": "AUTO",
                  "FieldEncoding": "PAFF",
                  "SceneChangeDetect": "ENABLED",
                  "QualityTuningLevel": "SINGLE_PASS",
                  "FramerateConversionAlgorithm": "DUPLICATE_DROP",
                  "UnregisteredSeiTimecode": "DISABLED",
                  "GopSizeUnits": "FRAMES",
                  "ParControl": "INITIALIZE_FROM_SOURCE",
                  "NumberBFramesBetweenReferenceFrames": 2,
                  "RepeatPps": "DISABLED",
                  "DynamicSubGop": "STATIC"
                }
              },
              "AfdSignaling": "NONE",
              "DropFrameTimecode": "ENABLED",
              "RespondToAfd": "NONE",
              "ColorMetadata": "INSERT"
            },
            "AudioDescriptions": [
              {
                "AudioTypeControl": "FOLLOW_INPUT",
                "CodecSettings": {
                  "Codec": "AAC",
                  "AacSettings": {
                    "AudioDescriptionBroadcasterMix": "NORMAL",
                    "Bitrate": 96000,
                    "RateControlMode": "CBR",
                    "CodecProfile": "LC",
                    "CodingMode": "CODING_MODE_2_0",
                    "RawFormat": "NONE",
                    "SampleRate": 48000,
                    "Specification": "MPEG4"
                  }
                },
                "LanguageCodeControl": "FOLLOW_INPUT"
              }
            ],
            "OutputSettings": {
              "HlsSettings": {
                "AudioGroupId": "program_audio",
                "AudioOnlyContainer": "AUTOMATIC",
                "IFrameOnlyManifest": "EXCLUDE"
              }
            },
            "NameModifier": "_hls"
          }
        ],
        "OutputGroupSettings": {
          "Type": "HLS_GROUP_SETTINGS",
          "HlsGroupSettings": {
            "ManifestDurationFormat": "INTEGER",
            "SegmentLength": 10,
            "TimedMetadataId3Period": 10,
            "CaptionLanguageSetting": "OMIT",
            "Destination": "",
            "TimedMetadataId3Frame": "PRIV",
            "CodecSpecification": "RFC_4281",
            "OutputSelection": "MANIFESTS_AND_SEGMENTS",
            "ProgramDateTimePeriod": 600,
            "MinSegmentLength": 0,
            "MinFinalSegmentLength": 0,
            "DirectoryStructure": "SINGLE_DIRECTORY",
            "ProgramDateTime": "EXCLUDE",
            "SegmentControl": "SEGMENTED_FILES",
            "ManifestCompression": "NONE",
            "ClientCache": "ENABLED",
            "AudioOnlyHeader": "INCLUDE",
            "StreamInfResolution": "INCLUDE"
          }
        }
      }
    ],
    "AdAvailOffset": 0,
    "Inputs": [
      {
        "AudioSelectors": {
          "Audio Selector 1": {
            "Offset": 0,
            "DefaultSelection": "DEFAULT",
            "ProgramSelection": 1
          }
        },
        "VideoSelector": {
          "ColorSpace": "FOLLOW",
          "Rotate": "DEGREE_0",
          "AlphaBehavior": "DISCARD"
        },
        "FilterEnable": "AUTO",
        "PsiControl": "USE_PSI",
        "FilterStrength": 0,
        "DeblockFilter": "DISABLED",
        "DenoiseFilter": "DISABLED",
        "InputScanType": "AUTO",
        "TimecodeSource": "ZEROBASED",
        "FileInput": ""
      }
    ]
  }

Lambdaで変換バッチ作成

AWSコンソール上にLambda関数を作成して、job_setting.jsonも同じところに入れます。

excite-mediaconvert-project/
├── job_setting.json
└── lambda_function.py

Lambdaのアクセス権限にExciteLambdaMediaConvertRoleを付与します。

例)Lambdaの中身
import json
import boto3

region_name = "ap-northeast-1"
endpoint_url = "https://xxxxxxxxx.mediaconvert.ap-northeast-1.amazonaws.com"

convert_client = boto3.client("mediaconvert", region_name=region_name, endpoint_url=endpoint_url)

input_file = "s3://input-excite-mediaconvert-bucket/input.mp4"
output_destination = "s3://converted-excite-mediaconvert-bucket/"
mediaconvert_job_role = "arn:aws:iam::xxxxxxxxx:role/ExciteMediaConvertRole"

def lambda_handler(event, context):
    print("==start mediaconvert job==")

    # ジョブ設定を読み込み
    with open('job_setting.json') as json_data:
        job_settings = json.load(json_data)

    # ジョブ設定の中身を書き換え
    job_settings['Inputs'][0]['FileInput'] = input_file
    job_settings['OutputGroups'][0]['OutputGroupSettings']['HlsGroupSettings']['Destination'] = output_destination

    job = convert_client.create_job(
        Role=mediaconvert_job_role,
        Settings=job_settings
    )

    print(f"job={str(job)}")

これでLambdaを実行すると、上記と同様に出力用のフォルダーにHLS形式のファイルが作成されています。Printしていますので、興味があればジョブ作成のレスポンスも確認できます。

最後に

如何でしょうか。AWS MediaConvertで簡単にストリーミング形式に変換できます。CloudfrontのCookie認証と組み合わせしたら、簡単にプライベート動画配信サービスを実現できると思います。

今回の記事は単純に大きいな動画ファイルから小さいな動画ファイル(約1MB〜2MBが最適)に分割しますので、コンテンツ保護の観点からあまりよろしくないので、実際のプロジェクトでは暗号化など是非ご検討ください。HLSのいいところはシンプルですが、より強固なコンテンツ保護を実現したいのなら、MPEG-DASHが良いかもしれない。

エキサイト株式会社では随時に仲間を募集しております。