From 273ab09f053e68904e971959a7a8ee39dd6df987 Mon Sep 17 00:00:00 2001 From: KongQun Yang Date: Wed, 10 Oct 2018 15:30:28 -0700 Subject: [PATCH] Support HLS characteristics Add hls_characteristics stream descriptor, which is a colon or semi-colon separated list of strings. It is optional. Fixes #430. Change-Id: Ifcf79316e68768ff065891933de565cd0ff32ec4 --- .../source/options/hls_stream_descriptors.rst | 6 ++++ packager/app/packager_main.cc | 5 ++- packager/app/stream_descriptor.cc | 12 +++++-- packager/app/test/packager_test.py | 19 +++++++--- .../testdata/hls-segmented-webvtt/output.m3u8 | 2 +- packager/hls/base/master_playlist.cc | 6 ++++ packager/hls/base/master_playlist_unittest.cc | 36 +++++++++++++++++++ packager/hls/base/media_playlist.cc | 8 +++++ packager/hls/base/media_playlist.h | 12 ++++++- packager/hls/base/media_playlist_unittest.cc | 15 ++++++++ .../media/event/hls_notify_muxer_listener.cc | 6 ++++ .../media/event/hls_notify_muxer_listener.h | 6 ++++ .../hls_notify_muxer_listener_unittest.cc | 15 ++++++-- .../media/event/muxer_listener_factory.cc | 19 +++++----- packager/media/event/muxer_listener_factory.h | 2 ++ packager/mpd/base/media_info.proto | 3 ++ packager/packager.cc | 1 + packager/packager.h | 4 +++ 18 files changed, 156 insertions(+), 21 deletions(-) diff --git a/docs/source/options/hls_stream_descriptors.rst b/docs/source/options/hls_stream_descriptors.rst index 72e9dd8045..6590613044 100644 --- a/docs/source/options/hls_stream_descriptors.rst +++ b/docs/source/options/hls_stream_descriptors.rst @@ -24,3 +24,9 @@ HLS specific stream descriptor fields '.m3u8', and is relative to hls_master_playlist_output (see below). Should only be set for video streams. If unspecified, no I-Frames only playlist is created. + +:hls_characteristics (charcs): + + Optional colon or semi-colon separated list of values for the + CHARACTERISTICS attribute for EXT-X-MEDIA. See CHARACTERISTICS attribute in + http://bit.ly/2OOUkdB for details. diff --git a/packager/app/packager_main.cc b/packager/app/packager_main.cc index e76f6ce67f..45f44aa758 100644 --- a/packager/app/packager_main.cc +++ b/packager/app/packager_main.cc @@ -101,7 +101,10 @@ const char kUsage[] = " - iframe_playlist_name: The optional HLS I-Frames only playlist file\n" " to create. Usually ends with '.m3u8', and is relative to\n" " hls_master_playlist_output. Should only be set for video streams. If\n" - " unspecified, no I-Frames only playlist is created.\n"; + " unspecified, no I-Frames only playlist is created.\n" + " - hls_characteristics (charcs): Optional colon/semicolon separated\n" + " list of values for the CHARACTERISTICS attribute for EXT-X-MEDIA.\n" + " See CHARACTERISTICS attribute in http://bit.ly/2OOUkdB for details.\n"; // Labels for parameters in RawKey key info. const char kDrmLabelLabel[] = "label"; diff --git a/packager/app/stream_descriptor.cc b/packager/app/stream_descriptor.cc index 30bdc953d9..b02b3ae3f5 100644 --- a/packager/app/stream_descriptor.cc +++ b/packager/app/stream_descriptor.cc @@ -30,6 +30,7 @@ enum FieldType { kTrickPlayFactorField, kSkipEncryptionField, kDrmStreamLabelField, + kHlsCharacteristicsField, }; struct FieldNameToTypeMapping { @@ -63,6 +64,9 @@ const FieldNameToTypeMapping kFieldNameTypeMappings[] = { {"skip_encryption", kSkipEncryptionField}, {"drm_stream_label", kDrmStreamLabelField}, {"drm_label", kDrmStreamLabelField}, + {"hls_characteristics", kHlsCharacteristicsField}, + {"characteristics", kHlsCharacteristicsField}, + {"charcs", kHlsCharacteristicsField}, }; FieldType GetFieldType(const std::string& field_name) { @@ -164,10 +168,14 @@ base::Optional ParseStreamDescriptor( descriptor.skip_encryption = skip_encryption_value > 0; break; } - case kDrmStreamLabelField: { + case kDrmStreamLabelField: descriptor.drm_label = iter->second; break; - } + case kHlsCharacteristicsField: + descriptor.hls_characteristics = + base::SplitString(iter->second, ";:", base::TRIM_WHITESPACE, + base::SPLIT_WANT_NONEMPTY); + break; default: LOG(ERROR) << "Unknown field in stream descriptor (\"" << iter->first << "\")."; diff --git a/packager/app/test/packager_test.py b/packager/app/test/packager_test.py index 78280bba1f..32607fc08b 100755 --- a/packager/app/test/packager_test.py +++ b/packager/app/test/packager_test.py @@ -271,6 +271,7 @@ class PackagerAppTest(unittest.TestCase): segmented=False, using_time_specifier=False, hls=False, + hls_characteristics=None, trick_play_factor=None, drm_label=None, skip_encryption=None, @@ -290,23 +291,24 @@ class PackagerAppTest(unittest.TestCase): language: The language override for the input stream. output_file_prefix: The output file prefix. Default to empty if not specified. - output_format: Specify the format for the output. + output_format: The format for the output. segmented: Should the output use a segmented formatted. This will affect the output extensions and manifests. using_time_specifier: Use $Time$ in segment name instead of using $Number$. This flag is only relevant if segmented is True. hls: Should the output be for an HLS manifest. + hls_characteristics: CHARACTERISTICS attribute for the HLS stream. trick_play_factor: Signals the stream is to be used for a trick play stream and which key frames to use. A trick play factor of 0 is the same as not specifying a trick play factor. - drm_label: Sets the drm label for the stream. + drm_label: The drm label for the stream. skip_encryption: If set to true, the stream will not be encrypted. bandwidth: The expected bandwidth value that should be listed in the manifest. split_content_on_ad_cues: If set to true, the output file will be split into multiple files, with a total of NumAdCues + 1 files. - test_file: Specify the input file to use. If the input file is not - specify, a default file will be used. + test_file: The input file to use. If the input file is not specified, a + default file will be used. Returns: @@ -348,6 +350,9 @@ class PackagerAppTest(unittest.TestCase): stream.Append('iframe_playlist_name', output_file_name_base + '-iframe.m3u8') + if hls_characteristics: + stream.Append('hls_characteristics', hls_characteristics) + requires_init_segment = segmented and base_ext not in [ 'aac', 'ac3', 'ec3', 'ts', 'vtt' ] @@ -1503,7 +1508,11 @@ class PackagerFunctionalTest(PackagerAppTest): streams = self._GetStreams( ['audio', 'video'], output_format='ts', segmented=True) streams += self._GetStreams( - ['text'], test_files=['bear-english.vtt'], segmented=True) + ['text'], + test_files=['bear-english.vtt'], + segmented=True, + hls_characteristics='public.accessibility.transcribes-spoken-dialog;' + 'private.accessibility.widevine-special') flags = self._GetFlags(output_hls=True) diff --git a/packager/app/test/testdata/hls-segmented-webvtt/output.m3u8 b/packager/app/test/testdata/hls-segmented-webvtt/output.m3u8 index c3d1692173..ed0e9b67d9 100644 --- a/packager/app/test/testdata/hls-segmented-webvtt/output.m3u8 +++ b/packager/app/test/testdata/hls-segmented-webvtt/output.m3u8 @@ -3,7 +3,7 @@ #EXT-X-MEDIA:TYPE=AUDIO,URI="stream_1.m3u8",GROUP-ID="default-audio-group",NAME="stream_1",AUTOSELECT=YES,CHANNELS="2" -#EXT-X-MEDIA:TYPE=SUBTITLES,URI="stream_0.m3u8",GROUP-ID="default-text-group",NAME="stream_0",AUTOSELECT=YES +#EXT-X-MEDIA:TYPE=SUBTITLES,URI="stream_0.m3u8",GROUP-ID="default-text-group",NAME="stream_0",AUTOSELECT=YES,CHARACTERISTICS="public.accessibility.transcribes-spoken-dialog,private.accessibility.widevine-special" #EXT-X-STREAM-INF:BANDWIDTH=1217518,AVERAGE-BANDWIDTH=1117319,CODECS="avc1.64001e,mp4a.40.2",RESOLUTION=640x360,AUDIO="default-audio-group",SUBTITLES="default-text-group" stream_2.m3u8 diff --git a/packager/hls/base/master_playlist.cc b/packager/hls/base/master_playlist.cc index b00349d2b1..1819d0ea18 100644 --- a/packager/hls/base/master_playlist.cc +++ b/packager/hls/base/master_playlist.cc @@ -287,6 +287,12 @@ void BuildMediaTag(const MediaPlaylist& playlist, tag.AddString("AUTOSELECT", "YES"); } + const std::vector& characteristics = playlist.characteristics(); + if (!characteristics.empty()) { + tag.AddQuotedString("CHARACTERISTICS", + base::JoinString(characteristics, ",")); + } + const MediaPlaylist::MediaPlaylistStreamType kAudio = MediaPlaylist::MediaPlaylistStreamType::kAudio; if (playlist.stream_type() == kAudio) { diff --git a/packager/hls/base/master_playlist_unittest.cc b/packager/hls/base/master_playlist_unittest.cc index 8e52f17d38..0e1bb7daff 100644 --- a/packager/hls/base/master_playlist_unittest.cc +++ b/packager/hls/base/master_playlist_unittest.cc @@ -399,6 +399,42 @@ TEST_F(MasterPlaylistTest, WriteMasterPlaylistVideosAndTexts) { ASSERT_EQ(expected, actual); } +TEST_F(MasterPlaylistTest, WriteMasterPlaylistVideoAndTextWithCharacteritics) { + // Video, sd.m3u8. + std::unique_ptr video = + CreateVideoPlaylist("sd.m3u8", "sdvideocodec", 300000, 200000); + + // Text, eng.m3u8. + std::unique_ptr text = + CreateTextPlaylist("eng.m3u8", "english", "textgroup", "textcodec", "en"); + text->SetCharacteristicsForTesting(std::vector{ + "public.accessibility.transcribes-spoken-dialog", "public.easy-to-read"}); + + const char kBaseUrl[] = "http://playlists.org/"; + EXPECT_TRUE(master_playlist_.WriteMasterPlaylist(kBaseUrl, test_output_dir_, + {video.get(), text.get()})); + + std::string actual; + ASSERT_TRUE(File::ReadFileToString(master_playlist_path_.c_str(), &actual)); + + const std::string expected = + "#EXTM3U\n" + "## Generated with https://github.com/google/shaka-packager version " + "test\n" + "\n" + "#EXT-X-MEDIA:TYPE=SUBTITLES,URI=\"http://playlists.org/eng.m3u8\"," + "GROUP-ID=\"textgroup\",LANGUAGE=\"en\",NAME=\"english\",DEFAULT=YES," + "AUTOSELECT=YES,CHARACTERISTICS=\"" + "public.accessibility.transcribes-spoken-dialog,public.easy-to-read\"\n" + "\n" + "#EXT-X-STREAM-INF:BANDWIDTH=300000,AVERAGE-BANDWIDTH=200000," + "CODECS=\"sdvideocodec,textcodec\",RESOLUTION=800x600," + "SUBTITLES=\"textgroup\"\n" + "http://playlists.org/sd.m3u8\n"; + + ASSERT_EQ(expected, actual); +} + TEST_F(MasterPlaylistTest, WriteMasterPlaylistVideoAndTextGroups) { // Video, sd.m3u8. std::unique_ptr video = diff --git a/packager/hls/base/media_playlist.cc b/packager/hls/base/media_playlist.cc index b1652eeef8..15b9375542 100644 --- a/packager/hls/base/media_playlist.cc +++ b/packager/hls/base/media_playlist.cc @@ -348,6 +348,11 @@ void MediaPlaylist::SetLanguageForTesting(const std::string& language) { language_ = language; } +void MediaPlaylist::SetCharacteristicsForTesting( + const std::vector& characteristics) { + characteristics_ = characteristics; +} + bool MediaPlaylist::SetMediaInfo(const MediaInfo& media_info) { const uint32_t time_scale = GetTimeScale(media_info); if (time_scale == 0) { @@ -370,6 +375,9 @@ bool MediaPlaylist::SetMediaInfo(const MediaInfo& media_info) { media_info_ = media_info; language_ = GetLanguage(media_info); use_byte_range_ = !media_info_.has_segment_template_url(); + characteristics_ = + std::vector(media_info_.hls_characteristics().begin(), + media_info_.hls_characteristics().end()); return true; } diff --git a/packager/hls/base/media_playlist.h b/packager/hls/base/media_playlist.h index d363f5f7cf..61306d2fb0 100644 --- a/packager/hls/base/media_playlist.h +++ b/packager/hls/base/media_playlist.h @@ -10,6 +10,7 @@ #include #include #include +#include #include "packager/base/macros.h" #include "packager/hls/public/hls_params.h" @@ -87,6 +88,10 @@ class MediaPlaylist { /// For testing only. void SetLanguageForTesting(const std::string& language); + /// For testing only. + void SetCharacteristicsForTesting( + const std::vector& characteristics); + /// This must succeed before calling any other public methods. /// @param media_info is the info of the segments that are going to be added /// to this playlist. @@ -182,7 +187,11 @@ class MediaPlaylist { /// @return the language of the media, as an ISO language tag in its shortest /// form. May be an empty string for video. - std::string language() const { return language_; } + const std::string& language() const { return language_; } + + const std::vector& characteristics() const { + return characteristics_; + } private: // Add a SegmentInfoEntry (#EXTINF). @@ -213,6 +222,7 @@ class MediaPlaylist { bool use_byte_range_ = false; std::string codec_; std::string language_; + std::vector characteristics_; int media_sequence_number_ = 0; bool inserted_discontinuity_tag_ = false; int discontinuity_sequence_number_ = 0; diff --git a/packager/hls/base/media_playlist_unittest.cc b/packager/hls/base/media_playlist_unittest.cc index 64eb546753..74100ec6cd 100644 --- a/packager/hls/base/media_playlist_unittest.cc +++ b/packager/hls/base/media_playlist_unittest.cc @@ -18,6 +18,7 @@ namespace shaka { namespace hls { using ::testing::_; +using ::testing::ElementsAreArray; using ::testing::ReturnArg; namespace { @@ -483,6 +484,20 @@ TEST_F(MediaPlaylistMultiSegmentTest, GetNumChannels) { EXPECT_EQ(8, media_playlist_->GetNumChannels()); } +TEST_F(MediaPlaylistMultiSegmentTest, Characteristics) { + MediaInfo media_info; + media_info.set_reference_time_scale(kTimeScale); + + static const char* kCharacteristics[] = {"some.characteristic", + "another.characteristic"}; + + media_info.add_hls_characteristics(kCharacteristics[0]); + media_info.add_hls_characteristics(kCharacteristics[1]); + ASSERT_TRUE(media_playlist_->SetMediaInfo(media_info)); + EXPECT_THAT(media_playlist_->characteristics(), + ElementsAreArray(kCharacteristics)); +} + TEST_F(MediaPlaylistMultiSegmentTest, InitSegment) { valid_video_media_info_.set_reference_time_scale(90000); valid_video_media_info_.set_init_segment_url("init_segment.mp4"); diff --git a/packager/media/event/hls_notify_muxer_listener.cc b/packager/media/event/hls_notify_muxer_listener.cc index 67a49dec04..ea7da62d2d 100644 --- a/packager/media/event/hls_notify_muxer_listener.cc +++ b/packager/media/event/hls_notify_muxer_listener.cc @@ -21,11 +21,13 @@ HlsNotifyMuxerListener::HlsNotifyMuxerListener( bool iframes_only, const std::string& ext_x_media_name, const std::string& ext_x_media_group_id, + const std::vector& characteristics, hls::HlsNotifier* hls_notifier) : playlist_name_(playlist_name), iframes_only_(iframes_only), ext_x_media_name_(ext_x_media_name), ext_x_media_group_id_(ext_x_media_group_id), + characteristics_(characteristics), hls_notifier_(hls_notifier) { DCHECK(hls_notifier); } @@ -90,6 +92,10 @@ void HlsNotifyMuxerListener::OnMediaStart(const MuxerOptions& muxer_options, LOG(ERROR) << "Failed to generate MediaInfo from input."; return; } + if (!characteristics_.empty()) { + for (const std::string& characteristic : characteristics_) + media_info->add_hls_characteristics(characteristic); + } if (protection_scheme_ != FOURCC_NULL) { internal::SetContentProtectionFields(protection_scheme_, next_key_id_, next_key_system_infos_, diff --git a/packager/media/event/hls_notify_muxer_listener.h b/packager/media/event/hls_notify_muxer_listener.h index d377db4b84..ed0cb8a418 100644 --- a/packager/media/event/hls_notify_muxer_listener.h +++ b/packager/media/event/hls_notify_muxer_listener.h @@ -9,6 +9,7 @@ #include #include +#include #include "packager/base/optional.h" #include "packager/media/event/event_info.h" @@ -35,11 +36,15 @@ class HlsNotifyMuxerListener : public MuxerListener { /// @param ext_x_media_group_id is the group ID for this playlist. This is the /// value of GROUP-ID attribute for EXT-X-MEDIA. This may be empty for /// video. + /// @param characteristics is the characteristics for this playlist. This is + /// the value of CHARACTERISTICS attribute for EXT-X-MEDIA. This may be + /// empty. /// @param hls_notifier used by this listener. Ownership does not transfer. HlsNotifyMuxerListener(const std::string& playlist_name, bool iframes_only, const std::string& ext_x_media_name, const std::string& ext_x_media_group_id, + const std::vector& characteristics, hls::HlsNotifier* hls_notifier); ~HlsNotifyMuxerListener() override; @@ -77,6 +82,7 @@ class HlsNotifyMuxerListener : public MuxerListener { const bool iframes_only_; const std::string ext_x_media_name_; const std::string ext_x_media_group_id_; + const std::vector characteristics_; hls::HlsNotifier* const hls_notifier_; base::Optional stream_id_; diff --git a/packager/media/event/hls_notify_muxer_listener_unittest.cc b/packager/media/event/hls_notify_muxer_listener_unittest.cc index 6cabc88794..da1636d44f 100644 --- a/packager/media/event/hls_notify_muxer_listener_unittest.cc +++ b/packager/media/event/hls_notify_muxer_listener_unittest.cc @@ -18,7 +18,9 @@ namespace media { using ::testing::_; using ::testing::Bool; +using ::testing::ElementsAre; using ::testing::InSequence; +using ::testing::Property; using ::testing::Return; using ::testing::StrEq; using ::testing::TestWithParam; @@ -92,6 +94,8 @@ const bool kIFramesOnlyPlaylist = true; const char kDefaultPlaylistName[] = "default_playlist.m3u8"; const char kDefaultName[] = "DEFAULTNAME"; const char kDefaultGroupId[] = "DEFAULTGROUPID"; +const char kCharactersticA[] = "public.accessibility.transcribes-spoken-dialog"; +const char kCharactersticB[] = "public.easy-to-read"; MATCHER_P(HasEncryptionScheme, expected_scheme, "") { *result_listener << "it has_protected_content: " @@ -113,6 +117,7 @@ class HlsNotifyMuxerListenerTest : public ::testing::Test { !kIFramesOnlyPlaylist, kDefaultName, kDefaultGroupId, + std::vector{kCharactersticA, kCharactersticB}, &mock_notifier_) {} MuxerListener::MediaRanges GetMediaRanges( @@ -152,9 +157,12 @@ TEST_F(HlsNotifyMuxerListenerTest, OnMediaStart) { std::shared_ptr video_stream_info = CreateVideoStreamInfo(video_params); - EXPECT_CALL(mock_notifier_, - NotifyNewStream(_, StrEq(kDefaultPlaylistName), - StrEq("DEFAULTNAME"), StrEq("DEFAULTGROUPID"), _)) + EXPECT_CALL( + mock_notifier_, + NotifyNewStream(Property(&MediaInfo::hls_characteristics, + ElementsAre(kCharactersticA, kCharactersticB)), + StrEq(kDefaultPlaylistName), StrEq("DEFAULTNAME"), + StrEq("DEFAULTGROUPID"), _)) .WillOnce(Return(true)); MuxerOptions muxer_options; @@ -436,6 +444,7 @@ class HlsNotifyMuxerListenerKeyFrameTest : public TestWithParam { GetParam(), kDefaultName, kDefaultGroupId, + std::vector(), // no characteristics. &mock_notifier_) {} MockHlsNotifier mock_notifier_; diff --git a/packager/media/event/muxer_listener_factory.cc b/packager/media/event/muxer_listener_factory.cc index 99604c4f8e..704bfa7bae 100644 --- a/packager/media/event/muxer_listener_factory.cc +++ b/packager/media/event/muxer_listener_factory.cc @@ -44,26 +44,29 @@ std::list> CreateHlsListenersInternal( DCHECK(notifier); DCHECK_GE(stream_index, 0); - std::string group_id = stream.hls_group_id; std::string name = stream.hls_name; - std::string hls_playlist_name = stream.hls_playlist_name; - std::string hls_iframe_playlist_name = stream.hls_iframe_playlist_name; + std::string playlist_name = stream.hls_playlist_name; + + const std::string& group_id = stream.hls_group_id; + const std::string& iframe_playlist_name = stream.hls_iframe_playlist_name; + const std::vector& characteristics = stream.hls_characteristics; if (name.empty()) { name = base::StringPrintf("stream_%d", stream_index); } - if (hls_playlist_name.empty()) { - hls_playlist_name = base::StringPrintf("stream_%d.m3u8", stream_index); + if (playlist_name.empty()) { + playlist_name = base::StringPrintf("stream_%d.m3u8", stream_index); } const bool kIFramesOnly = true; std::list> listeners; listeners.emplace_back(new HlsNotifyMuxerListener( - hls_playlist_name, !kIFramesOnly, name, group_id, notifier)); - if (!hls_iframe_playlist_name.empty()) { + playlist_name, !kIFramesOnly, name, group_id, characteristics, notifier)); + if (!iframe_playlist_name.empty()) { listeners.emplace_back(new HlsNotifyMuxerListener( - hls_iframe_playlist_name, kIFramesOnly, name, group_id, notifier)); + iframe_playlist_name, kIFramesOnly, name, group_id, + std::vector(), notifier)); } return listeners; } diff --git a/packager/media/event/muxer_listener_factory.h b/packager/media/event/muxer_listener_factory.h index b1bfc006d4..8e8f3f6941 100644 --- a/packager/media/event/muxer_listener_factory.h +++ b/packager/media/event/muxer_listener_factory.h @@ -9,6 +9,7 @@ #include #include +#include namespace shaka { class MpdNotifier; @@ -44,6 +45,7 @@ class MuxerListenerFactory { std::string hls_name; std::string hls_playlist_name; std::string hls_iframe_playlist_name; + std::vector hls_characteristics; }; /// Create a new muxer listener. diff --git a/packager/mpd/base/media_info.proto b/packager/mpd/base/media_info.proto index 8c9c02d16c..7432c112da 100644 --- a/packager/mpd/base/media_info.proto +++ b/packager/mpd/base/media_info.proto @@ -161,4 +161,7 @@ message MediaInfo { optional string media_file_url = 17; optional string init_segment_url = 18; optional string segment_template_url = 19; + + // HLS only. Defines CHARACTERISTICS attribute of the stream. + repeated string hls_characteristics = 20; } diff --git a/packager/packager.cc b/packager/packager.cc index 5a303ac8d5..335919997c 100644 --- a/packager/packager.cc +++ b/packager/packager.cc @@ -91,6 +91,7 @@ MuxerListenerFactory::StreamData ToMuxerListenerData( data.hls_name = stream.hls_name; data.hls_playlist_name = stream.hls_playlist_name; data.hls_iframe_playlist_name = stream.hls_iframe_playlist_name; + data.hls_characteristics = stream.hls_characteristics; return data; }; diff --git a/packager/packager.h b/packager/packager.h index 1fd621ae82..a70d2b5f40 100644 --- a/packager/packager.h +++ b/packager/packager.h @@ -106,6 +106,7 @@ struct StreamDescriptor { /// Optional value which contains a user-specified language tag. If specified, /// this value overrides any language metadata in the input stream. std::string language; + /// Required for audio when outputting HLS. It defines the name of the output /// stream, which is not necessarily the same as output. This is used as the /// `NAME` attribute for EXT-X-MEDIA. @@ -119,6 +120,9 @@ struct StreamDescriptor { /// Optional for HLS output. It defines the name of the I-Frames only playlist /// for the stream. For Video only. Usually ends with `.m3u8`. std::string hls_iframe_playlist_name; + /// Optional for HLS output. It defines the CHARACTERISTICS attribute of the + /// stream. + std::vector hls_characteristics; }; class SHAKA_EXPORT Packager {