🪆

Kinesis Video Streamsにffmpegで生成した動画を入力すると発生する問題とその対応

2023/12/12に公開

この記事は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テクノロジーに取り組みませんか?

https://umitron.com/ja/career.html

https://open.talentio.com/r/1/c/umitron/homes/3603

Discussion