Elastic Transcoder(2025年11月サービス終了) から MediaConvert に移行した記録
Elastic Transcoder がついに 2025 年 11 月 13 日サービス終了とのことで移行した時の記録を残しておきます。
現時点で実現している機能
動画ファイル形式の変換とキャプチャの書き出しを自動化しています。具体的には mp4 のファイルを HLS 形式に変換しつつ、動画内の決まった秒数でキャプチャをとって jpg として保存するという機能になります。また変換が完了したらメールで連絡を受け取るという補助的な機能も実装しています。
mp4 の動画ファイルを s3 のバケットにアップロード
↓
Lambda 実行
↓
Elastic Transcoder のパイプラインが走る
↓
HLS 形式に変換されたストリーミング用ファイルを S3 バケットに保存
↓
Elastic Transcoder のパイプラインその2も走る
↓
キャプチャとして動画のある秒数の映像が JPG として S3 バケットに保存
↓
パイプラインの結果が SES でメール送信されてきます。変換に成功したか失敗したかがわかります。
あとはウェブアプリケーション側で動画一覧画面のテンプレートを用意しておくと一覧画面についてはメンテナンス不要になるという仕様です。
移行後の構成
前述の構成で変換部分のサービスをMediaConvertに置き換えます。
現状の確認
まずは既存の Transcoder を使っているシステムの設定から確認をしてみます。
Transcoder から S3 にアクセスする IAM を確認
Transcoder のパイプライン詳細画面の「Permissions」を確認するとどのロールが使用されているのかがわかります。MediaConvert でも同じように IAM ロールが必要になります。
テスト環境で MediaConvert を動かしてみる
MediaConvert 以外にも IAM や S3、Lambda を使っているので順番に設定してまずはテスト環境で動かしてみます。
まずは MediaConvert についてざっくり解説します。
MediaConvert は主にすでに録画された動画などを変換するツールです。(ライブ配信は AWS Elemental MediaLive というサービスが担います)フリーソフトだと ffmpeg などがありますが、これはサーバや手元のクライアント PC にインストールして使う形になります。その点 MediaConvert は AWS がサービスとして提供してくれているため初期設定作業、サーバ管理、バージョン管理などの手間が省け、かつ安価に必要な時だけ利用できると言うメリットがあります。AWS のサービス同士の連携などもやりやすいですね。
MediaConvertの3つの項目
MediaConvert には設定できる項目に主なものが3つあります。
- ジョブ
- プリセット
- ジョブテンプレート
ジョブ
その名の通り一つの仕事です。「a.mp4 を hls に変換する」「b.mp4 をに AV1 に変換する」これらは1つ1つがジョブになります。
プリセット
出力のエンコード設定グループを保存したものです。
ジョブテンプレート
複数のジョブ、プリセットをまとめたものです。
これらを単体または組み合わせて動画変換作業を自動化したりすることができます。
S3 バケットを作成
次に S3 にバケットを作成していきます。今回は
- mp4(加工前)保存用のバケット
- HLS ファイル(加工後)保存用のバケット
- キャプチャ画像用(加工後)のバケット
を作成していきます。
バケット作成
基本設定は 3 つのバケットとも以下の通りでバケット名だけわかりやすい名前をつけてください。
1.S3 コンソールの「バケットを作成」をクリックします。 2.以下のように設定をして「バケットを作成」をクリックします。
バケットタイプ:汎用にチェック
バケット名:任意の名前(ここでは m4test、hlstest、jpegtest とします。)
オブジェクト所有者:ACL 無効にチェックを入れます。
パブリックアクセスをすべてブロックにチェックを入れます。
バケットのバージョニング:無効にする
デフォルトの暗号化:SSE-S3
バケットキー:有効にする(SSE-S3 の場合は無効でも有効でも動作は変わらない)
※ここにない項目はデフォルトで大丈夫です。必要であれば後でわかりやすいようにタグに説明を書いておくのもおすすめです。
これで動画の受け入れ、吐き出し先が構築できました。
IAM ポリシー作成
最低限の IAM ポリシーを JSON で定義します。仮名 test_mediaconvert とします。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::your-input-bucket-name/*",
"arn:aws:s3:::your-output-bucket-name/*"
]
},
{
"Effect": "Allow",
"Action": [
"cloudwatch:PutMetricData",
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"mediaconvert:CreateJob",
"mediaconvert:GetJob",
"mediaconvert:CancelJob"
],
"Resource": "*"
}
]
}
上記のポリシーをざっくり説明すると
- S3 からオブジェクトを読み出す
- S3 に画像を保存する
- S3 からオブジェクトを一覧表示する
- Cloud Watch にログを出力する
- MediaConbert で実行する
これらの権限が付与されています。
IAM ロールを設定
IAM は権限をどの程度許可するのか?ということを設定していきます。
AWS のサービス間で処理をする際や、プログラムから操作をする際にこれらの権限を設定することでセキュアかつ指定したものだけに操作を許可することができます。
仮名 test_mediaconvert_role とします。
ロール作成
このロールは Lambda に割り当てられます。Lambda からスクリプトを実行し、
- s3 からのファイル読み出し、保存
- cloudwatch へのログ保存
- sns での実行結果メール送信
- MediaConvert でのジョブ作成、操作
を行うために必要になります。以下のように設定しました。
- AWS Management Console で IAM サービスを開きます。
- 「ロールの作成」をクリックします。
- 信頼されたエンティティタイプで「AWS サービス」を選択します。
- ユースケースのプルダウンで「MediaConvert」を指定し「次へ」ボタンを押します。
- 許可を追加の画面ではデフォルトで(後で JSON を追加するので許可の追加なしの状態で)「次へ」をクリックします。
- 次の画面では後で見た時にわかりやすいようにロール名と説明を書き「ロールを作成」をクリックします。
- ロールが作成され、一覧画面に戻るので作成したロールを検索窓に入力して選択し編集画面に移動し、許可タブの許可ポリシーがアタッチされていたら一旦削除します。
- 許可タブを開き「許可を追加」をクリックします。「ポリシーをアタッチ」をクリックして前の手順で作成したポリシー(test_mediaconvert)を選択し「許可を追加」をクリックします。
これで権限の設定が完了しました。
SNS 設定
SNS は動画変換プロセスが成功したか失敗したかをメールで通知するために設定します。Transcoder では Lambda 上で生成していましたが、これは管理の面から考えると推奨されないようなのでこの機会にあらかじめコンソールから生成するように修正します。
SNS トピックの設定
SNS のコンソール → トピックの作成で以下を設定していきます。
タイプ:スタンダード
名前:test-topic
表示名:test-topic
その他の項目はデフォルトで作成します。
これでトピックの ARN が表示されると思います。
この項目は後の Lambda 実行のコードで使用されます。
SNS サブスクリプションの作成
サブスクリプションでは MediaConvert の処理が完了したかどうかを受信するメールアドレスを設定します。
AWS SNS コンソールの左メニュー → サブスクリプションを開き
「サブスクリプションの作成」をクリックし以下の設定をします。
プロトコル:EMAIL
エンドポイント:受信したいメールアドレス
その他の項目はデフォルトで「サブスクリプションの作成」をクリックします。
するとエンドポイントに登録したメールアドレスに確認のメール(from:no-reply@sns.amazonaws.com)が届きます。
そのメールを開き「Confirm subscription」というリンクをクリックします。
Subscription confirmed!
と表示されたら SNS の準備は完了です。
MediaConvert を設定
まず Elastic Transcoder と MediaConvert の違いを確認します。
また今回、私の環境では Lambda 上で Python から boto3(AWS sdk for Python)を使用して動的にパイプラインやジョブを生成しています。
Elastic Transcoder と MediaConvert の違い
Elastic Transcoder のパイプラインが MediaConvert のキューになります。
そして大きな違いとして S3 のような入力情報・トリガーが
Elastic Transcoder ではパイプラインだったのが
MediaConvert ではジョブになりました。
詳細は以下の参考ページでわかりやすく図解してくれているのでご参照ください。
プリセットを確認
プリセットとは動画や音声をどのように書き出すかという設定をまとめたものとイメージするとわかりやすいかもしれません。
Elastic Transcoder、MediaConvert ともに AWS があらかじめ用意してくれているプリセットがあります。
・Elastic Transcoder コンソール → Preset
・MediaConvert コンソール → 出力プリセットページのタブでシステムプリセットを選択
これで AWS があらかじめ用意してくれているデフォルトのプリセットが確認できます。
Lambda の python コードを生成
この Lambda のコードで動的に MediaConvert のジョブなどを生成していきます。
現状の Lambda で動かしている Transcoder 用のコードを変換します。
また今回の移行に際して、ファイル形式に CMAF というものを採用しました。
(これまでと同じ HLS も同時に吐き出し、今後トラブルが発生したら戻せる体制も作りました。)
これは HLS と MPEG-DASH というもの両方に対応できるものです。
詳細は以下の記事にまとめてありますのでご参照ください。
では具体的にコードを確認します。
# coding:utf-8
import boto3
from botocore.client import ClientError
import json
import urllib.request, urllib.parse, urllib.error
REGION_NAME = 'example-region-1'
TRANSCODER_ROLE_NAME = 'lambda_auto_transcoder_role_sample'
PIPELINE_NAME = 'test-hls-v4'
OUT_BUCKET_NAME = 'test-hls-output'
THUMBNAIL_BUCKET_NAME = 'test-images'
COMPLETE_TOPIC_NAME = 'transcoder'
print('Loading function')
s3 = boto3.resource('s3')
iam = boto3.resource('iam')
sns = boto3.resource('sns', REGION_NAME)
transcoder = boto3.client('elastictranscoder', REGION_NAME)
def lambda_handler(event, context):
complete_topic_arn = sns.create_topic(Name=COMPLETE_TOPIC_NAME).arn
transcoder_role_arn = iam.Role(TRANSCODER_ROLE_NAME).arn
# Get the object from the event
bucket = event['Records'][0]['s3']['bucket']['name']
key = urllib.parse.unquote_plus(event['Records'][0]['s3']['object']['key'])
print(("bucket={}, key={}".format(bucket, key)))
try:
obj = s3.Object(bucket, key)
except Exception as e:
print(e)
print(("Error getting object {} from bucket {}. Make sure they exist and your bucket is in the same region as this function.".format(key, bucket)))
# Publish a message
sns.Topic(complete_topic_arn).publish(
Subject="Error!",
Message="Failed to get object from S3. bucket={}, key={}, {}".format(bucket, key, e),
)
raise e
# Delete inactive pipelines
pipeline_ids = [pipeline['Id'] for pipeline in transcoder.list_pipelines()['Pipelines'] if pipeline['Name'] == PIPELINE_NAME]
for pipeline_id in pipeline_ids:
try:
response = transcoder.delete_pipeline(Id=pipeline_id)
print(("Delete a transcoder pipeline. pipeline_id={}".format(pipeline_id)))
print(("response={}".format(response)))
except Exception as e:
# Raise nothing
print(("Failed to delete a transcoder pipeline. pipeline_id={}".format(pipeline_id)))
print(e)
# Create a pipeline
try:
response = transcoder.create_pipeline(
Name=PIPELINE_NAME,
InputBucket=bucket,
ContentConfig={'Bucket': OUT_BUCKET_NAME},
Role=transcoder_role_arn,
ThumbnailConfig={
'Bucket': THUMBNAIL_BUCKET_NAME,
'Permissions': [
{
'GranteeType':'Group',
'Grantee': 'AllUsers',
'Access': [
'Read',
]
},
],
},
Notifications={
'Progressing': '',
'Completed': complete_topic_arn,
'Warning': '',
'Error': ''
},
)
pipeline_id = response['Pipeline']['Id']
print(("Create a transcoder pipeline. pipeline_id={}".format(pipeline_id)))
print(("response={}".format(response)))
except Exception as e:
print("Failed to create a transcoder pipeline.")
print(e)
# Publish a message
sns.Topic(complete_topic_arn).publish(
Subject="Error!",
Message="Failed to create a transcoder pipeline. bucket={}, key={}, {}".format(bucket, key, e),
)
raise e
# Create a job
try:
job = transcoder.create_job(
PipelineId=pipeline_id,
Input={
'Key': key,
'FrameRate': 'auto',
'Resolution': 'auto',
'AspectRatio': 'auto',
'Interlaced': 'auto',
'Container': 'auto',
},
Outputs=[
{
'Key': 'hls/{}_d/{}'.format('.'.join(key.split('.')[:-1]),'.'.join(key.split('.')[:-1])) + '_Video',
'ThumbnailPattern': 'hls/{}_d/{}'.format('.'.join(key.split('.')[:-1]),'.'.join(key.split('.')[:-1])) + '{count}_thumbnail',
'PresetId': 'xxxxxxxxxxxxxx-xxxxxx', # System preset: Original
'SegmentDuration': '10',
},
{
'Key': 'hls/{}_d/{}'.format('.'.join(key.split('.')[:-1]),'.'.join(key.split('.')[:-1])) + '_Audio',
'PresetId': '1351620000001-200060', # System preset: HLS 1M
'SegmentDuration': '10',
},
],
Playlists=[
{
'Name': 'hls/{}_d/{}'.format('.'.join(key.split('.')[:-1]),'.'.join(key.split('.')[:-1])),
'Format': 'HLSv4',
'OutputKeys': [
'hls/{}_d/{}'.format('.'.join(key.split('.')[:-1]),'.'.join(key.split('.')[:-1])) + '_Video',
'hls/{}_d/{}'.format('.'.join(key.split('.')[:-1]),'.'.join(key.split('.')[:-1])) + '_Audio',
],
}
],
)
job_id = job['Job']['Id']
print(("Create a transcoder job. job_id={}".format(job_id)))
print(("job={}".format(job)))
except Exception as e:
print(("Failed to create a transcoder job. pipeline_id={}".format(pipeline_id)))
print(e)
# Publish a message
sns.Topic(complete_topic_arn).publish(
Subject="Error!",
Message="Failed to create transcoder job. pipeline_id={}, {}".format(pipeline_id, e),
)
raise e
return "Success"
import boto3
import json
import urllib.parse
import os
import logging
# Lambdaの環境変数から設定を取得、以下コメントのような値が入ります。
REGION_NAME = os.environ.get("AWS_REGION") # example-region-1
MEDIA_CONVERT_ENDPOINT = os.environ.get("MEDIA_CONVERT_ENDPOINT") # https://abcd1234.mediaconvert.example-region-1.amazonaws.com
ROLE_ARN = os.environ.get("MEDIACONVERT_ROLE_ARN") # arn:aws:iam::123456789012:role/YourMediaConvertRole
OUTPUT_BUCKET = os.environ.get("OUTPUT_HLS_BUCKET") # test-hls-output
OUTPUT_THUMBNAIL_BUCKET = os.environ.get("OUTPUT_THUMBNAIL_BUCKET") # test-jpg-output
SNS_TOPIC_ARN = os.environ.get("SNS_TOPIC_ARN") # arn:aws:sns:example-region-1:123456789012:YourSNSTopic
logger = logging.getLogger()
logger.setLevel(os.environ.get("LOG_LEVEL", "INFO"))
def lambda_handler(event, context):
# MediaConvert クライアントの初期化
client = boto3.client('mediaconvert', region_name=REGION_NAME, endpoint_url=MEDIA_CONVERT_ENDPOINT)
s3 = boto3.client('s3')
sns = boto3.client('sns', region_name=REGION_NAME)
# S3 イベントからバケット名とキーを取得
bucket = event['Records'][0]['s3']['bucket']['name']
key = urllib.parse.unquote_plus(event['Records'][0]['s3']['object']['key'])
filename = os.path.basename(key)
basename = os.path.splitext(filename)[0]
logger.info("Received event bucket=%s key=%s", bucket, key)
try:
# 入力ファイルの存在確認
s3.head_object(Bucket=bucket, Key=key)
except Exception as e:
print(e)
sns.publish(
TopicArn=SNS_TOPIC_ARN,
Subject="Error!",
Message=f"Failed to get object from S3. bucket={bucket}, key={key}, error={e}"
)
raise e
# MediaConvert のジョブ設定
job_settings = {
"Role": ROLE_ARN,
"Settings": {
"TimecodeConfig": {"Source": "ZEROBASED"},
"OutputGroups": [
{
"Name": "CMAF (HLS m4s) - Main",
"OutputGroupSettings": {
"Type": "CMAF_GROUP_SETTINGS",
"CmafGroupSettings": {
"Destination": f"s3://{OUTPUT_BUCKET}/test/{basename}/",
"SegmentControl": "SEGMENTED_FILES",
"SegmentLength": 12,
"FragmentLength": 1,
"ManifestCompression": "NONE",
"ClientCache": "ENABLED",
"CodecSpecification": "RFC_6381",
"WriteHlsManifest": "ENABLED",
"WriteDashManifest": "DISABLED",
}
},
"Outputs": [
{
"NameModifier": "_audio_cmaf",
"ContainerSettings": {"Container": "CMFC"},
"AudioDescriptions": [
{
"AudioSourceName": "Audio Selector 1",
"AudioTypeControl": "FOLLOW_INPUT",
"CodecSettings": {
"Codec": "AAC",
"AacSettings": {
"Bitrate": 128000,
"RateControlMode": "CBR",
"CodecProfile": "LC",
"CodingMode": "CODING_MODE_2_0",
"SampleRate": 48000
}
},
"LanguageCodeControl": "FOLLOW_INPUT"
}
]
},
{
"NameModifier": "_v1080p_cmaf",
"ContainerSettings": {"Container": "CMFC"},
"VideoDescription": {
"Width": 1920, "Height": 1080, "Sharpness": 50,
"AntiAlias": "ENABLED", "AfdSignaling": "NONE",
"DropFrameTimecode": "ENABLED", "RespondToAfd": "NONE",
"ColorMetadata": "INSERT",
"CodecSettings": {
"Codec": "H_264",
"H264Settings": {
"RateControlMode": "CBR",
"Bitrate": 5800000,
"CodecProfile": "HIGH",
"CodecLevel": "LEVEL_4_2",
"FramerateControl": "INITIALIZE_FROM_SOURCE",
"GopSize": 60, "GopSizeUnits": "FRAMES",
"GopClosedCadence": 1,
"NumberBFramesBetweenReferenceFrames": 3,
"SceneChangeDetect": "ENABLED"
}
}
}
},
{
"NameModifier": "_v720p_cmaf",
"ContainerSettings": {"Container": "CMFC"},
"VideoDescription": {
"Width": 1280, "Height": 720, "Sharpness": 50,
"AntiAlias": "ENABLED", "AfdSignaling": "NONE",
"DropFrameTimecode": "ENABLED", "RespondToAfd": "NONE",
"ColorMetadata": "INSERT",
"CodecSettings": {
"Codec": "H_264",
"H264Settings": {
"RateControlMode": "CBR",
"Bitrate": 2800000,
"CodecProfile": "HIGH",
"CodecLevel": "LEVEL_4_1",
"FramerateControl": "INITIALIZE_FROM_SOURCE",
"GopSize": 60, "GopSizeUnits": "FRAMES",
"GopClosedCadence": 1,
"NumberBFramesBetweenReferenceFrames": 3,
"SceneChangeDetect": "ENABLED"
}
}
}
},
{
"NameModifier": "_v540p_cmaf",
"ContainerSettings": {"Container": "CMFC"},
"VideoDescription": {
"Width": 960, "Height": 540, "Sharpness": 50,
"AntiAlias": "ENABLED", "AfdSignaling": "NONE",
"DropFrameTimecode": "ENABLED", "RespondToAfd": "NONE",
"ColorMetadata": "INSERT",
"CodecSettings": {
"Codec": "H_264",
"H264Settings": {
"RateControlMode": "CBR",
"Bitrate": 1400000,
"CodecProfile": "MAIN",
"CodecLevel": "LEVEL_4",
"FramerateControl": "INITIALIZE_FROM_SOURCE",
"GopSize": 60, "GopSizeUnits": "FRAMES",
"GopClosedCadence": 1,
"NumberBFramesBetweenReferenceFrames": 3,
"SceneChangeDetect": "ENABLED"
}
}
}
},
{
"NameModifier": "_v360p_cmaf",
"ContainerSettings": {"Container": "CMFC"},
"VideoDescription": {
"Width": 640, "Height": 360, "Sharpness": 50,
"AntiAlias": "ENABLED", "AfdSignaling": "NONE",
"DropFrameTimecode": "ENABLED", "RespondToAfd": "NONE",
"ColorMetadata": "INSERT",
"CodecSettings": {
"Codec": "H_264",
"H264Settings": {
"RateControlMode": "CBR",
"Bitrate": 800000,
"CodecProfile": "MAIN",
"CodecLevel": "LEVEL_3_1",
"FramerateControl": "INITIALIZE_FROM_SOURCE",
"GopSize": 60, "GopSizeUnits": "FRAMES",
"GopClosedCadence": 1,
"NumberBFramesBetweenReferenceFrames": 3,
"SceneChangeDetect": "ENABLED"
}
}
}
}
]
},
{
"Name": "Apple HLS",
"OutputGroupSettings": {
"Type": "HLS_GROUP_SETTINGS",
"HlsGroupSettings": {
"Destination": f"s3://{OUTPUT_BUCKET}/test/{basename}/hls/",
"SegmentControl": "SINGLE_FILE",
"SegmentLength": 12,
"MinSegmentLength": 0,
"DirectoryStructure": "SINGLE_DIRECTORY",
"ManifestCompression": "NONE",
"ManifestDurationFormat": "INTEGER",
"ClientCache": "ENABLED",
"CodecSpecification": "RFC_6381",
"OutputSelection": "MANIFESTS_AND_SEGMENTS",
"StreamInfResolution": "INCLUDE",
"ProgramDateTime": "INCLUDE",
"TimedMetadataId3Period": 10,
"AudioOnlyHeader": "EXCLUDE"
}
},
"Outputs": [
{
"NameModifier": "_audio",
"ContainerSettings": {
"Container": "M3U8",
"M3u8Settings": {
"PcrControl": "PCR_EVERY_PES_PACKET",
"ProgramNumber": 1,
"PatInterval": 0,
"PmtInterval": 0,
"PmtPid": 480,
"PrivateMetadataPid": 503,
"AudioPids": [482]
}
},
"AudioDescriptions": [
{
"AudioSourceName": "Audio Selector 1",
"AudioTypeControl": "FOLLOW_INPUT",
"CodecSettings": {
"Codec": "AAC",
"AacSettings": {
"Bitrate": 128000,
"RateControlMode": "CBR",
"CodecProfile": "LC",
"CodingMode": "CODING_MODE_2_0",
"SampleRate": 48000
}
},
"LanguageCodeControl": "FOLLOW_INPUT"
}
],
"OutputSettings": {
"HlsSettings": {
"AudioGroupId": "program_audio",
"AudioTrackType": "ALTERNATE_AUDIO_AUTO_SELECT_DEFAULT",
"AudioOnlyContainer": "M2TS",
"IFrameOnlyManifest": "EXCLUDE"
}
}
},
{
"NameModifier": "_v1080p",
"ContainerSettings": {
"Container": "M3U8",
"M3u8Settings": {
"AudioFramesPerPes": 4,
"PcrControl": "PCR_EVERY_PES_PACKET",
"ProgramNumber": 1,
"PatInterval": 0,
"PmtInterval": 0,
"PmtPid": 480,
"PrivateMetadataPid": 503,
"VideoPid": 481,
"AudioPids": [482]
}
},
"VideoDescription": {
"Width": 1920,
"Height": 1080,
"Sharpness": 50,
"AntiAlias": "ENABLED",
"AfdSignaling": "NONE",
"DropFrameTimecode": "ENABLED",
"RespondToAfd": "NONE",
"ColorMetadata": "INSERT",
"CodecSettings": {
"Codec": "H_264",
"H264Settings": {
"RateControlMode": "CBR",
"Bitrate": 5800000,
"CodecProfile": "HIGH",
"CodecLevel": "LEVEL_4_2",
"FramerateControl": "INITIALIZE_FROM_SOURCE",
"GopSize": 60,
"GopSizeUnits": "FRAMES",
"GopClosedCadence": 1,
"NumberBFramesBetweenReferenceFrames": 3,
"SceneChangeDetect": "ENABLED"
}
}
},
"OutputSettings": {
"HlsSettings": {
"AudioRenditionSets": "program_audio",
"IFrameOnlyManifest": "INCLUDE"
}
}
},
{
"NameModifier": "_v720p",
"ContainerSettings": {
"Container": "M3U8",
"M3u8Settings": {
"AudioFramesPerPes": 4,
"PcrControl": "PCR_EVERY_PES_PACKET",
"ProgramNumber": 1,
"PatInterval": 0,
"PmtInterval": 0,
"PmtPid": 480,
"PrivateMetadataPid": 503,
"VideoPid": 481,
"AudioPids": [482]
}
},
"VideoDescription": {
"Width": 1280,
"Height": 720,
"Sharpness": 50,
"AntiAlias": "ENABLED",
"AfdSignaling": "NONE",
"DropFrameTimecode": "ENABLED",
"RespondToAfd": "NONE",
"ColorMetadata": "INSERT",
"CodecSettings": {
"Codec": "H_264",
"H264Settings": {
"RateControlMode": "CBR",
"Bitrate": 2800000,
"CodecProfile": "HIGH",
"CodecLevel": "LEVEL_4_1",
"FramerateControl": "INITIALIZE_FROM_SOURCE",
"GopSize": 60,
"GopSizeUnits": "FRAMES",
"GopClosedCadence": 1,
"NumberBFramesBetweenReferenceFrames": 3,
"SceneChangeDetect": "ENABLED"
}
}
},
"OutputSettings": {
"HlsSettings": {
"AudioRenditionSets": "program_audio",
"IFrameOnlyManifest": "EXCLUDE"
}
}
},
{
"NameModifier": "_v540p",
"ContainerSettings": {
"Container": "M3U8",
"M3u8Settings": {
"AudioFramesPerPes": 4,
"PcrControl": "PCR_EVERY_PES_PACKET",
"ProgramNumber": 1,
"PatInterval": 0,
"PmtInterval": 0,
"PmtPid": 480,
"PrivateMetadataPid": 503,
"VideoPid": 481,
"AudioPids": [482]
}
},
"VideoDescription": {
"Width": 960,
"Height": 540,
"Sharpness": 50,
"AntiAlias": "ENABLED",
"AfdSignaling": "NONE",
"DropFrameTimecode": "ENABLED",
"RespondToAfd": "NONE",
"ColorMetadata": "INSERT",
"CodecSettings": {
"Codec": "H_264",
"H264Settings": {
"RateControlMode": "CBR",
"Bitrate": 1400000,
"CodecProfile": "MAIN",
"CodecLevel": "LEVEL_4",
"FramerateControl": "INITIALIZE_FROM_SOURCE",
"GopSize": 60,
"GopSizeUnits": "FRAMES",
"GopClosedCadence": 1,
"NumberBFramesBetweenReferenceFrames": 3,
"SceneChangeDetect": "ENABLED"
}
}
},
"OutputSettings": {
"HlsSettings": {
"AudioRenditionSets": "program_audio",
"IFrameOnlyManifest": "EXCLUDE"
}
}
},
{
"NameModifier": "_v360p",
"ContainerSettings": {
"Container": "M3U8",
"M3u8Settings": {
"AudioFramesPerPes": 4,
"PcrControl": "PCR_EVERY_PES_PACKET",
"ProgramNumber": 1,
"PatInterval": 0,
"PmtInterval": 0,
"PmtPid": 480,
"PrivateMetadataPid": 503,
"VideoPid": 481,
"AudioPids": [482]
}
},
"VideoDescription": {
"Width": 640,
"Height": 360,
"Sharpness": 50,
"AntiAlias": "ENABLED",
"AfdSignaling": "NONE",
"DropFrameTimecode": "ENABLED",
"RespondToAfd": "NONE",
"ColorMetadata": "INSERT",
"CodecSettings": {
"Codec": "H_264",
"H264Settings": {
"RateControlMode": "CBR",
"Bitrate": 800000,
"CodecProfile": "MAIN",
"CodecLevel": "LEVEL_3_1",
"FramerateControl": "INITIALIZE_FROM_SOURCE",
"GopSize": 60,
"GopSizeUnits": "FRAMES",
"GopClosedCadence": 1,
"NumberBFramesBetweenReferenceFrames": 3,
"SceneChangeDetect": "ENABLED"
}
}
},
"OutputSettings": {
"HlsSettings": {
"AudioRenditionSets": "program_audio",
"IFrameOnlyManifest": "EXCLUDE"
}
}
}
]
},
{
"Name": "Thumbnails",
"OutputGroupSettings": {
"Type": "FILE_GROUP_SETTINGS",
"FileGroupSettings": {"Destination": f"s3://{OUTPUT_THUMBNAIL_BUCKET}/test/{basename}/"}
},
"Outputs": [
{
"NameModifier": "_thumbnail",
"ContainerSettings": {"Container": "RAW"},
"VideoDescription": {
"CodecSettings": {
"Codec": "FRAME_CAPTURE",
"FrameCaptureSettings": {
"FramerateNumerator": 1,
"FramerateDenominator": 10,
"MaxCaptures": 10,
"Quality": 80
}
}
}
}
]
}
],
"Inputs": [
{
"AudioSelectors": {
"Audio Selector 1": {
"DefaultSelection": "DEFAULT",
"ProgramSelection": 1
}
},
"VideoSelector": {"ColorSpace": "FOLLOW"},
"TimecodeSource": "ZEROBASED",
"FileInput": f"s3://{bucket}/{key}"
}
]
},
'UserMetadata': {
"pipeline": "this_lambda_function_name"
}
}
try:
logger.debug("Submitting MediaConvert job basename=%s", basename)
response = client.create_job(**job_settings)
job_id = response['Job']['Id']
logger.info("MediaConvert job created job_id=%s", job_id)
except Exception as e:
logger.exception("Failed to create MediaConvert job bucket=%s key=%s", bucket, key)
sns.publish(
TopicArn=SNS_TOPIC_ARN,
Subject='Error!',
Message=f'Failed to create MediaConvert job. Error: {e}'
)
raise e
return 'Success'
調整が必要な部分
media-convert.py でいくつか調整が必要な部分がありますので設定していきます。
MediaConvert のエンドポイント
リージョンごとにエンドポイントがあります。以下のページから確認できます。
値を確認したら MEDIA_CONVERT_ENDPOINT の値に設定します。
確認した値を用いて Lambda のコードに当てはめます。東京リージョンでしたら以下のようになります。
MEDIA_CONVERT_ENDPOINT = 'https://mediaconvert.ap-northeast-1.amazonaws.com'
IAM ロールの設定
MediaConvert が使用する IAM のロールを設定します。ROLE_ARN に前述の「ロール作成」で作成したロールの ARN を AWS コンソールで確認して ROLE_ARN の値として設定します。
ROLE_ARN = 'arn:aws:iam::123456789012:role/YourMediaConvertRole'
SNS の変数設定
SNS トピックの設定をしたことでコンソールから ARN の値が確認できると思います。その値を用いて以下のように Lambda のコードに当てはめます。
SNS_TOPIC_ARN = 'arn:aws:sns:ap-northeast-1:xxxxxxxxxxxxx:test-topic'
S3 バケット名を置き換える
あとは HLS 出力用のバケット名として OUTPUT_BUCKET の値を置き換えます。
Lambda の設定
Lambda コンソールから設定を行います。イベントのきっかけとなる S3 バケットを紐付け、 mp4 ファイルがアップロードされたタイミングで python コードが実行され、HLS ファイルが S3 バケットに吐き出される仕組みです。
- Lambda コンソールにログインします。
- ナビゲーションメニューから「ダッシュボード」→「関数の作成」をクリックします。
- 以下のように設定を進めます。
一から作成にチェック
関数名を入力します。
ランタイムは今回 python の最新版を指定
アーキテクチャは x86_64 を設定
デフォルトの実行ロールの変更は「基本的な Lambda アクセス権限で新しいロールを作成」を選択し新しく Lambda 用のロールを設定します。これによって CloudWatch での監視が可能になります。
ここまでで指定した以外の項目はデフォルトのまま「関数の作成」ボタンをクリックします。
これで関数がひとまず生成されます。 - Lambda コンソール画面の上部にある
「トリガーを追加」
プルダウンで「s3」を選択
バケット名を指定、ここでは「mp4(加工前)保存用のバケット名」を指定
イベントタイプでは「マルチパートアップロードの完了」を選択
他はデフォルト設定のままで最後に再帰呼び出しの承認にチェックを入れて「追加」をクリックします。 - タイムアウト(実行時間)を調整
今回の処理は少し時間がかかるものになるのでタイムアウト(実行時間)を調整します。
Lambda コンソール設定タブ → 一般設定の「編集」をクリックしタイムアウトを「30 秒」にします。 - コードソースの欄に前述の python コードを貼り付けて「deploy」ボタンを押したら Lambda の設定は完了です。
再帰ループ検出について
Lambda では再帰処理を自動で検出し処理を停止してくれる機能がデフォルトで有効になっています。(Lambda コンソール設定タブの再帰検出設定)通常は 16 回処理が呼び出されたら停止するようになっているとのことでした。もし再帰処理が必要な場合はこの機能をオフにする必要がありますが、無限ループになった際は請求も比例して増えることに注意してください。
エラーについて
いくつか遭遇したエラーについてもメモしておきます。主にHLSをVideo.jsで再生する時に関するものです。
タイムアウト
Lambda ではデフォルトの実行時間が 3 秒になっており、今回のような少し時間のかかる処理は全て実行される前にシャットダウンされる場合があります。その際は以下のようなエラー(cloudwatchlog)が出ます。
END RequestId: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
REPORT RequestId: xxxxxxx Duration: 3000.00 ms Billed Duration: 3000 ms Memory Size: 128 MB Max Memory Used: 84 MB Status: timeout
処理が最後まで実行されない場合は前述のタイムアウト調整の設定を変えてみてください。
MediaConvert の設定
必須項目の minSegmentLength という項目の設定が抜けておりエラーになりました。
An error occurred (BadRequestException) when calling the CreateJob operation: /outputGroups/0/outputGroupSettings/hlsGroupSettings: minSegmentLength is a required property
音声が再生されない
safari では音声が再生されるのにその他のブラウザでは音声が再生されないという現象が起きました。原因は HLS 自体 apple で開発されており、safari ではブラウザがネイティブに hls を解釈できるのに対して、chrome など他のブラウザでは MSE(Media Source Extensions は HTML5 の JavaScript API としてブラウザ上の動画や音声ストリーミングを制御するために W3C が策定、アプリケーションレイヤーで動作するものです)で解釈されることで、吐き出す際の設定に間違いがあったことでした。(前述のソースでは修正済み)
JSON の以下の設定が間違っていました。
AudioTrackType
AUDIO_ONLY_VARIANT_STREAM
↓
ALTERNATE_AUDIO_AUTO_SELECT_DEFAULT
この項目はどのオーディオファイルを選択して音声を再生させるかという設定です。AUDIO_ONLY_VARIANT_STREAM は映像と音声を一緒に再生させないという設定でした。ALTERNATE_AUDIO_AUTO_SELECT_DEFAULT は自動選択という設定です。
これらの設定は書き出された後の hls の m3u8 ファイルに EXT-X-STREAM-INF、EXT-X-MEDIA の DEFAULT 属性と AUTOSELECT 属性に記載されます。
AudioOnlyHeader
INCLUDE
↓
EXCLUDE
この項目は音声セグメント先頭の ID3 タグを入れるかどうかという設定になります。ID3 タグがあることで映像と音声のズレが起きにくくすることができ、問題がなければ INCLUDE が推奨されています。
CodecSpecification
コーデックの仕様を指定しています。
RFC6381 と指定するとコーデック名・プロファイル・レベルなどの情報が RFC6381 の規則に従って表記されます。ただ最新の HLS の RFC は RFC 8216 となっていますが、6381 を指定してもプレイリストのタグ、EXT-X-STREAM-INF、EXTINF、EXTM3U など)は RFC 8216 の仕様に従います。
MediaConvert の設定項目として 4281 か 6381 を指定する仕様になっているため今回は 6381 を指定しました。
RFC_4281
↓
RFC_6381
AudioOnlyContainer
この項目を以下の値に設定することで.ts の音声ファイルが書き出されるようになります。
M2TS
EventBridge の設定
MediaConvert の実行結果を SNS で通知する際に EventBridge が使用できます。EventBridge はコンソールから設定していきます。
- AWS マネジメントコンソールで Amazon EventBridge コンソール を開きます。
- ナビゲーションペインで「ルール」を選択し、「ルールを作成」をクリックします。
- 「名前」に任意のルール名(例: MediaConvertJobCompleteRule)を入力します。
- 「イベントバス」はデフォルトのままにします。
- ルールタイプは「イベントパターンを持つルール」を選択し「次へ」をクリックします。
- 次の画面で以下の設定をします。書いていない項目はデフォルトで大丈夫です。
【イベントソース】セクション
イベントソース: 「AWS イベントまたは EventBridge パートナーイベント」
【イベントパターン】セクション
作成のメソッド: パターンフォームを使用する
イベントソース: AWS のサービス
AWS のサービス: 「MediaConvert」
イベントタイプ: 「MediaConvert Job State Change」
イベントタイプの仕様1: 特定の状態
特定の状態: COMPLETE
を設定して「次へ」をクリックします。
7.次の画面で以下の設定をします。
ターゲットタイプ: AWS のサービス
ターゲットを選択: SNS トピック
ターゲットの場所: このアカウントのターゲット
トピック: 前述の設定したトピックを選択
設定が終わったら「次へ」をクリックします。
8.必要であればタグを設定して「次へ」→ 確認して「ルールの作成」で完了です。
MediaConvert での注意点
MediaConvert ではサムネイル画像を書き出すことも可能ですがファイル名の融通が効かない部分があります。
私の解決策として以下の記事にまとめましたのでこちらもご参考になればうれしいです。
以下 MediaConvert → HLS でのできることできないことのパターンを書いていきます。DASHISO では若干融通がきく部分もあるようです。以下公式ドキュメントをもとに解説していきます。
ファイル出力の際のファイル名はデフォルトで以下のようなネーミングになります。
ファイル名.0000001.jpg
このファイル名は指定しないと INPUT で受け取ったファイルのファイル名が流用されます。
編集可能な部分
以下編集できる部分ごとに解説していきます。
このファイル名は一部のみ編集可能です。
ファイル名を指定する
デフォルトでは INPUT でのファイル名が流用されると書きましたが以下のように指定することも可能です。
"FileGroupSettings": {
"Destination": "s3://bucket/test/foo/mythumb"
}
Destination に上記のようにパスを指定すると次のように出力されます。mythumb.0000001.jpg, mythumb.0000002.jpg
接頭詞を追加する
以下のように接頭詞を追加することも可能です。
"Outputs": [
{
"NameModifier": "_test"
}
]
上記のように設定することで
ファイル名_test.0000001.jpg
とすることができます。
編集できない部分
.0000001 この部分は編集ができません。基本的に 7 桁の連番で保存されていきます。
サムネイル画像のファイル名変更
いくつかのパターンを思いつきました。
- Lambda で再度 jpg を読み込みリネームして保存
- PHP 側で複数パターンのパスを読み込めるように編集
PHP のパターンは通信がその都度発生し、サイトの負荷が発生するので Lambda でのリネームを選択しました。コストもかなり低いのでほとんどのケースで Lambda の方が費用対効果が高い気がします。
所感
IAM に関しては移行前より絞れたのでよかった&改めて勉強になりました。少し前であれば以下のようなツールも用意してくださっていたのですが、今回はスクリプトを編集する形で対応しました。
今回の仕組みは動画一覧ページのメンテナンスがなくせるので重宝しています。MediaConvert があってよかった。
またおそらく多くのケースで(toCでユーザーが大量に動画を上げるサービスはわかりませんが)バッチサーバを立てて運用するより、今回のようなサーバレスパターンの方がトータルでコストは削減できそうな気もします。少なくともバッチサーバ管理はなくせますね。
参考ページ
Discussion