Kinesis Video Streamsにffmpegで生成した動画を入力すると発生する問題とその対応
この記事はUMITRON Advent Calendar 2023 12日目の記事です。
背景
Amazon Kinesis Video Streams(以下KVS)を使うと動画配信システムを比較的簡単に実装できます。特に公式が提供しているKVS用のgstreamerプラグインを使うとビルドしてgstreamerのコマンドを実行するだけで入力ができます。
今回gstreamerのプラグインを使わず、ffmpegで動画ストリームを作成して標準出力に出力し、pipeでデータを受け取ってKVSに送信する、という実装を作って見たところ、ぱっと見動くけれど、HLSで視聴するとローディングが頻繁に発生する現象が起きてしまいました。この問題を解決するのが結構分かりづらかったので、知見を共有します。
本編
実際のffmpegのコマンドの例は以下
ffmpeg -i /dev/video1 -c:v h264_omx -f matroska -r 30 -g 60 pipe:1
この時のHLSのplaylistが以下(tokenなどは置き換えてあります)
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:2
#EXT-X-MEDIA-SEQUENCE:80
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXT-X-MAP:URI="getMP4InitFragment.mp4?SessionToken=***&TrackNumber=1&SequenceNumber=80"
#EXTINF:1.034,
getMP4MediaFragment.mp4?FragmentNumber=***&SessionToken=***&TrackNumber=1
#EXT-X-DISCONTINUITY
#EXTINF:1.034,
getMP4MediaFragment.mp4?FragmentNumber=***&SessionToken=***&TrackNumber=1
#EXT-X-DISCONTINUITY
#EXTINF:1.038,
getMP4MediaFragment.mp4?FragmentNumber=***&SessionToken=***&TrackNumber=1
#EXT-X-DISCONTINUITY
#EXTINF:1.038,
getMP4MediaFragment.mp4?FragmentNumber=***&SessionToken=***&TrackNumber=1
#EXT-X-DISCONTINUITY
#EXTINF:0.24,
getMP4MediaFragment.mp4?FragmentNumber=***&SessionToken=***&TrackNumber=1
#EXT-X-DISCONTINUITY
#EXTINF:1.037,
getMP4MediaFragment.mp4?FragmentNumber=***&SessionToken=***&TrackNumber=1
#EXT-X-DISCONTINUITY
#EXTINF:1.034,
getMP4MediaFragment.mp4?FragmentNumber=***&SessionToken=***&TrackNumber=1
#EXT-X-DISCONTINUITY
#EXTINF:1.035,
getMP4MediaFragment.mp4?FragmentNumber=***&SessionToken=***&TrackNumber=1
#EXT-X-DISCONTINUITY
#EXTINF:1.035,
getMP4MediaFragment.mp4?FragmentNumber=***&SessionToken=***&TrackNumber=1
#EXT-X-DISCONTINUITY
#EXTINF:1.035,
getMP4MediaFragment.mp4?FragmentNumber=***&SessionToken=***&TrackNumber=1
注目ポイントは
-
#EXT-X-TARGETDURATION:2
、つまり1 segmentの長さが2秒設定 - 実際のsegmentの長さは
#EXTINF:1.034,
のように1秒ほど。その上たまに#EXTINF:0.24,
のように極端に小さいsegmentもある
大抵のHLS実装はおそらく EXT-X-TARGETDURATION
をベースにポーリングするでしょうから、それを元にsegmentを取りに行っても実際のsegmentの長さが1秒ほどだったり0.2秒だったりするのでローディングが発生してしまう、という状況のようでした。
これらの実装をしている時に参考にしたのは以下のページで
以下のような記述があり、
For example, a media stream with a key-frame interval of 30 and a frame rate of 15 fps will receive a key-frame every 2-seconds which with KVS video producers will generally result in 2-second fragment lengths.
key-frame interval / frame rate がsegmentの長さになる、とのことだったので、これに倣って上記のffmpegのコマンドは -r 30
(frame rateが30), -g 60
(key-frame intervalが60)のオプションをつけて、2秒ごとのsegmentになることを狙っていました。
ですが先述の通り期待通りには動きませんでした。そこで動画の入力のAPIであるPutMediaのドキュメントを確認したところ
Each MKV cluster maps to a Kinesis video stream fragment. Whatever cluster duration you choose becomes the fragment duration.
という記述がありました。またここでは省きますが以下のページにより詳細が記述されていました。
つまり、MatroskaフォーマットのClusterという単位をコントロールできれば解決するようです。
実際に生成した動画データのClusterを mkvinfo
というコマンドで確認してみると
$ ffmpeg -t 10 -i /dev/video1 -c:v h264_omx -f matroska -r 30 -g 60 pipe:1 > test.mkv
$ mkvinfo -a test.mkv | grep 'Cluster timestamp:'
| + Cluster timestamp: 00:00:00.000000000
| + Cluster timestamp: 00:00:01.033000000
| + Cluster timestamp: 00:00:02.100000000
| + Cluster timestamp: 00:00:02.267000000
| + Cluster timestamp: 00:00:03.300000000
| + Cluster timestamp: 00:00:04.333000000
| + Cluster timestamp: 00:00:04.600000000
| + Cluster timestamp: 00:00:05.667000000
| + Cluster timestamp: 00:00:06.700000000
| + Cluster timestamp: 00:00:06.967000000
| + Cluster timestamp: 00:00:08.000000000
| + Cluster timestamp: 00:00:09.067000000
確かにClusterが約1秒ごとに区切られており、たまに0.2秒ほどのClusterもある、という状況でした。
このClusterをコントロールするオプションがffmpegにないか確認したところ以下のとおりで、サイズと時間の上限は決められるけれど、直接長さを指定するなどのオプションはないようでした。
$ ffmpeg -hide_banner -h muxer=matroska
Muxer matroska [Matroska]:
Common extensions: mkv.
Mime type: video/x-matroska.
Default video codec: mpeg4.
Default audio codec: ac3.
Default subtitle codec: ass.
matroska muxer AVOptions:
-reserve_index_space <int> E......... Reserve a given amount of space (in bytes) at the beginning of the file for the index (cues). (from 0 to INT_MAX) (default 0)
-cluster_size_limit <int> E......... Store at most the provided amount of bytes in a cluster. (from -1 to INT_MAX) (default -1)
-cluster_time_limit <int64> E......... Store at most the provided number of milliseconds in a cluster. (from -1 to I64_MAX) (default -1)
-dash <boolean> E......... Create a WebM file conforming to WebM DASH specification (default false)
-dash_track_number <int> E......... Track number for the DASH stream (from 1 to INT_MAX) (default 1)
-live <boolean> E......... Write files assuming it is a live stream. (default false)
-allow_raw_vfw <boolean> E......... allow RAW VFW mode (default false)
-write_crc32 <boolean> E......... write a CRC32 element inside every Level 1 element (default true)
-default_mode <int> E......... Controls how a track's FlagDefault is inferred (from 0 to 2) (default infer)
infer 0 E......... For each track type, mark the first track of disposition default as default; if none exists, mark the first track as default.
infer_no_subs 1 E......... For each track type, mark the first track of disposition default as default; for audio and video: if none exists, mark the first track as default.
passthrough 2 E......... Use the disposition flag as-is
次にffmpegのソースコードを確認すると、Clusterを区切る判断をしているところは以下で
if (mkv->is_dash && codec_type == AVMEDIA_TYPE_VIDEO) {
// WebM DASH specification states that the first block of
// every Cluster has to be a key frame. So for DASH video,
// we only create a Cluster on seeing key frames.
start_new_cluster = keyframe;
} else if (mkv->is_dash && codec_type == AVMEDIA_TYPE_AUDIO &&
cluster_time > mkv->cluster_time_limit) {
// For DASH audio, we create a Cluster based on cluster_time_limit.
start_new_cluster = 1;
} else if (!mkv->is_dash &&
(cluster_size > mkv->cluster_size_limit ||
cluster_time > mkv->cluster_time_limit ||
(codec_type == AVMEDIA_TYPE_VIDEO && keyframe &&
cluster_size > 4 * 1024))) {
start_new_cluster = 1;
} else
start_new_cluster = 0;
if (start_new_cluster) {
ret = mkv_end_cluster(s);
if (ret < 0)
return ret;
}
ここの cluster_size_limit
cluster_time_limit
を決めるロジックは以下
// start a new cluster every 5 MB or 5 sec, or 32k / 1 sec for streaming or
// after 4k and on a keyframe
if (IS_SEEKABLE(pb, mkv)) {
if (mkv->cluster_time_limit < 0)
mkv->cluster_time_limit = 5000;
if (mkv->cluster_size_limit < 0)
mkv->cluster_size_limit = 5 * 1024 * 1024;
} else {
if (mkv->cluster_time_limit < 0)
mkv->cluster_time_limit = 1000;
if (mkv->cluster_size_limit < 0)
mkv->cluster_size_limit = 32 * 1024;
}
そして IS_SEEKABLE
が以下
#define IS_SEEKABLE(pb, mkv) (((pb)->seekable & AVIO_SEEKABLE_NORMAL) && \
!(mkv)->is_live)
ここの (pb)->seekable
はどうやら出力がseek可能かどうかで、例えば普通のファイルであれば seek 出来るので true, 標準出力の場合出来ないので false になるようでした。
つまり、私の使っていたffmpegコマンドの場合
-
IS_SEEKABLE
がfalseになり -
mkv->cluster_time_limit
が 1000 -
mkv->cluster_size_limit
が 32 * 1024
という設定になっていたようで、Clusterが区切られる以下の部分で
} else if (!mkv->is_dash &&
(cluster_size > mkv->cluster_size_limit ||
cluster_time > mkv->cluster_time_limit ||
(codec_type == AVMEDIA_TYPE_VIDEO && keyframe &&
cluster_size > 4 * 1024))) {
start_new_cluster = 1;
以下の場合にClusterが区切られていました
- 動画のサイズが
mkv->cluster_time_limit
(== 32 * 1024 byte) を超えるか - 動画の長さが
mkv->cluster_time_limit
(== 1000 ms) を超えるか - そのフレームが keyframe かつ、サイズが 4 * 1024 byte を超える
このコマンドを試していた時のbpsは約 178 kbps (kilo bits / second) ≒ 22 * 1024 bytes / second なので、サイズより時間が先に条件に引っ掛かり、これが原因で約1秒ずつClusterが区切られていた、つまりHLSのsegmentが1秒になっていました。
さらに時間が1秒を超えていなくてもkeyframeの時だけサイズが 4 * 1024 を超えているとClusterが区切られるので、ここに引っかかった時に0.2秒のsegmentができているようでした。
原因が分かったので、次にどう解決するかを考えます。
オプションで cluster_size_limit
, cluster_time_limit
を十分に大きくすることもで今の問題は解決できますが、上記の分岐には最初に !mkv->is_dash
となっていて、別の分岐が以下のようになっています。
if (mkv->is_dash && codec_type == AVMEDIA_TYPE_VIDEO) {
// WebM DASH specification states that the first block of
// every Cluster has to be a key frame. So for DASH video,
// we only create a Cluster on seeing key frames.
start_new_cluster = keyframe;
今回初めて知ったのですが、 ここのコメントにあるWebM DASH とは WebMプロジェクトが定めたMatroska/WebMフォーマットの動画で最適にMPEG-DASHするための仕様のようで、これが今回の要件にマッチしており、 is_dash
がtrueであればkeyframeごとにClusterが区切られるようになっていました。
この is_dash
はコマンドラインオプションの -dash 1
でtrueにすることができるので、これを試してみたところ以下のとおりClusterが約2秒ごとになりました。
$ ffmpeg -t 10 -i /dev/video1 -c:v h264_omx -f matroska -r 30 -g 60 -dash 1 pipe:1 > dash.mkv
$ mkvinfo -a dash.mkv | grep 'Cluster timestamp:'
| + Cluster timestamp: 00:00:00.000000000
| + Cluster timestamp: 00:00:02.267000000
| + Cluster timestamp: 00:00:04.500000000
| + Cluster timestamp: 00:00:06.567000000
| + Cluster timestamp: 00:00:08.833000000
実際にこれでKVSに入力し、HLSで視聴したところローディングが頻繁に発生することがなくなり、playlistを見ても以下のとおりsegmentが2秒ほどで安定するようになりました。
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXT-X-MAP:URI="getMP4InitFragment.mp4?SessionToken=***&TrackNumber=1&SequenceNumber=0"
#EXTINF:2.033,
getMP4MediaFragment.mp4?FragmentNumber=***&SessionToken=***&TrackNumber=1
#EXT-X-DISCONTINUITY
#EXTINF:2.44,
getMP4MediaFragment.mp4?FragmentNumber=***&SessionToken=***&TrackNumber=1
#EXT-X-DISCONTINUITY
#EXTINF:2.372,
getMP4MediaFragment.mp4?FragmentNumber=***&SessionToken=***&TrackNumber=1
#EXT-X-DISCONTINUITY
#EXTINF:2.17,
getMP4MediaFragment.mp4?FragmentNumber=***&SessionToken=***&TrackNumber=1
#EXT-X-DISCONTINUITY
#EXTINF:2.203,
getMP4MediaFragment.mp4?FragmentNumber=***&SessionToken=***&TrackNumber=1
#EXT-X-DISCONTINUITY
#EXTINF:2.271,
getMP4MediaFragment.mp4?FragmentNumber=***&SessionToken=***&TrackNumber=1
#EXT-X-DISCONTINUITY
#EXTINF:2.203,
getMP4MediaFragment.mp4?FragmentNumber=***&SessionToken=***&TrackNumber=1
#EXT-X-DISCONTINUITY
#EXTINF:2.135,
getMP4MediaFragment.mp4?FragmentNumber=***&SessionToken=***&TrackNumber=1
#EXT-X-DISCONTINUITY
#EXTINF:2.271,
getMP4MediaFragment.mp4?FragmentNumber=***&SessionToken=***&TrackNumber=1
#EXT-X-DISCONTINUITY
#EXTINF:2.407,
getMP4MediaFragment.mp4?FragmentNumber=***&SessionToken=***&TrackNumber=1
結論としてはコマンドラインオプションに -dash 1
をつけると今回の問題は解決できる、ということになります。
ウミトロンでは一緒に働く仲間を募集しております。持続可能な水産養殖を地球に実装するというミッションの元で、私たちと一緒に水産養殖xテクノロジーに取り組みませんか?
Discussion