Add Subtitle Support to HLS Playlists

The master playlist and media playlist did not have implementations
for handling text streams. This change adds support for both.

Bug: #205

Change-Id: I1329b8cc2585f15b89959071db9dd16d35847cba
This commit is contained in:
Aaron Vaage 2018-01-31 11:14:15 -08:00
parent 82735be58d
commit e2dae2d960
5 changed files with 346 additions and 14 deletions

View File

@ -22,10 +22,13 @@ namespace shaka {
namespace hls { namespace hls {
namespace { namespace {
const char* kDefaultAudioGroupId = "default-audio-group"; const char* kDefaultAudioGroupId = "default-audio-group";
const char* kDefaultSubtitleGroupId = "default-text-group";
const char* kUnexpectedGroupId = "unexpected-group";
struct Variant { struct Variant {
std::string audio_codec; std::string audio_codec;
const std::string* audio_group_id = nullptr; const std::string* audio_group_id = nullptr;
const std::string* text_group_id = nullptr;
uint64_t audio_bitrate = 0; uint64_t audio_bitrate = 0;
}; };
@ -66,10 +69,72 @@ std::list<Variant> AudioGroupsToVariants(
} }
const char* GetGroupId(const MediaPlaylist& playlist) { const char* GetGroupId(const MediaPlaylist& playlist) {
// TODO(vaage): Add support to get a subtitle group id when text support
// is added.
const std::string& group_id = playlist.group_id(); const std::string& group_id = playlist.group_id();
return group_id.empty() ? kDefaultAudioGroupId : group_id.c_str();
if (!group_id.empty()) {
return group_id.c_str();
}
switch (playlist.stream_type()) {
case MediaPlaylist::MediaPlaylistStreamType::kAudio:
return kDefaultAudioGroupId;
case MediaPlaylist::MediaPlaylistStreamType::kSubtitle:
return kDefaultSubtitleGroupId;
default:
return kUnexpectedGroupId;
}
}
std::list<Variant> SubtitleGroupsToVariants(
const std::map<std::string, std::list<const MediaPlaylist*>>& groups) {
std::list<Variant> variants;
for (const auto& group : groups) {
Variant variant;
variant.text_group_id = &group.first;
variants.push_back(variant);
}
// Make sure we return at least one variant so create a null variant if there
// are no variants.
if (variants.empty()) {
variants.emplace_back();
}
return variants;
}
std::list<Variant> BuildVariants(
const std::map<std::string, std::list<const MediaPlaylist*>>& audio_groups,
const std::map<std::string, std::list<const MediaPlaylist*>>&
subtitle_groups) {
std::list<Variant> audio_variants = AudioGroupsToVariants(audio_groups);
std::list<Variant> subtitle_variants =
SubtitleGroupsToVariants(subtitle_groups);
DCHECK_GE(audio_variants.size(), 1u);
DCHECK_GE(subtitle_variants.size(), 1u);
std::list<Variant> merged;
for (const auto& audio_variant : audio_variants) {
for (const auto& subtitle_variant : subtitle_variants) {
Variant variant;
variant.audio_codec = audio_variant.audio_codec;
variant.audio_group_id = audio_variant.audio_group_id;
variant.text_group_id = subtitle_variant.text_group_id;
variant.audio_bitrate = audio_variant.audio_bitrate;
merged.push_back(variant);
}
}
DCHECK_GE(merged.size(), 1u);
return merged;
} }
void BuildAudioTag(const std::string& base_url, void BuildAudioTag(const std::string& base_url,
@ -105,6 +170,7 @@ void BuildVideoTag(const MediaPlaylist& playlist,
uint64_t max_audio_bitrate, uint64_t max_audio_bitrate,
const std::string& audio_codec, const std::string& audio_codec,
const std::string* audio_group_id, const std::string* audio_group_id,
const std::string* text_group_id,
const std::string& base_url, const std::string& base_url,
std::string* out) { std::string* out) {
DCHECK(out); DCHECK(out);
@ -130,9 +196,31 @@ void BuildVideoTag(const MediaPlaylist& playlist,
tag.AddQuotedString("AUDIO", *audio_group_id); tag.AddQuotedString("AUDIO", *audio_group_id);
} }
if (text_group_id) {
tag.AddQuotedString("SUBTITLES", *text_group_id);
}
base::StringAppendF(out, "\n%s%s\n", base_url.c_str(), base::StringAppendF(out, "\n%s%s\n", base_url.c_str(),
playlist.file_name().c_str()); playlist.file_name().c_str());
} }
void BuildSubtitleTag(const MediaPlaylist& playlist,
const std::string& base_url,
const std::string& group_id,
std::string* out) {
Tag tag("#EXT-X-MEDIA", out);
tag.AddString("TYPE", "SUBTITLES");
tag.AddQuotedString("URI", base_url + playlist.file_name());
tag.AddQuotedString("GROUP-ID", group_id);
const std::string& language = playlist.GetLanguage();
if (!language.empty()) {
tag.AddQuotedString("LANGUAGE", language);
}
tag.AddQuotedString("NAME", playlist.name());
out->append("\n");
}
} // namespace } // namespace
MasterPlaylist::MasterPlaylist(const std::string& file_name, MasterPlaylist::MasterPlaylist(const std::string& file_name,
@ -152,6 +240,11 @@ void MasterPlaylist::AddMediaPlaylist(MediaPlaylist* media_playlist) {
video_playlists_.push_back(media_playlist); video_playlists_.push_back(media_playlist);
break; break;
} }
case MediaPlaylist::MediaPlaylistStreamType::kSubtitle: {
std::string group_id = GetGroupId(*media_playlist);
subtitle_playlist_groups_[group_id].push_back(media_playlist);
break;
}
default: { default: {
NOTIMPLEMENTED() << static_cast<int>(media_playlist->stream_type()) NOTIMPLEMENTED() << static_cast<int>(media_playlist->stream_type())
<< " not handled."; << " not handled.";
@ -168,6 +261,7 @@ bool MasterPlaylist::WriteMasterPlaylist(const std::string& base_url,
// TODO(rkuroiwa): Handle audio only. // TODO(rkuroiwa): Handle audio only.
std::string audio_output; std::string audio_output;
std::string video_output; std::string video_output;
std::string subtitle_output;
// Write out all the audio tags. // Write out all the audio tags.
for (const auto& group : audio_playlist_groups_) { for (const auto& group : audio_playlist_groups_) {
@ -203,13 +297,24 @@ bool MasterPlaylist::WriteMasterPlaylist(const std::string& base_url,
} }
} }
std::list<Variant> variants = AudioGroupsToVariants(audio_playlist_groups_); // Write out all the text tags.
for (const auto& group : subtitle_playlist_groups_) {
const auto& group_id = group.first;
const auto& playlists = group.second;
for (const auto& playlist : playlists) {
BuildSubtitleTag(*playlist, base_url, group_id, &subtitle_output);
}
}
std::list<Variant> variants =
BuildVariants(audio_playlist_groups_, subtitle_playlist_groups_);
// Write all the video tags out. // Write all the video tags out.
for (const auto& playlist : video_playlists_) { for (const auto& playlist : video_playlists_) {
for (const auto& variant : variants) { for (const auto& variant : variants) {
BuildVideoTag(*playlist, variant.audio_bitrate, variant.audio_codec, BuildVideoTag(*playlist, variant.audio_bitrate, variant.audio_codec,
variant.audio_group_id, base_url, &video_output); variant.audio_group_id, variant.text_group_id, base_url,
&video_output);
} }
} }
@ -222,8 +327,9 @@ bool MasterPlaylist::WriteMasterPlaylist(const std::string& base_url,
} }
std::string content = ""; std::string content = "";
base::StringAppendF(&content, "#EXTM3U\n%s%s%s", version_line.c_str(), base::StringAppendF(&content, "#EXTM3U\n%s%s%s%s", version_line.c_str(),
audio_output.c_str(), video_output.c_str()); audio_output.c_str(), subtitle_output.c_str(),
video_output.c_str());
// Skip if the playlist is already written. // Skip if the playlist is already written.
if (content == written_playlist_) if (content == written_playlist_)

View File

@ -52,8 +52,13 @@ class MasterPlaylist {
const std::string default_language_; const std::string default_language_;
std::list<MediaPlaylist*> all_playlists_; std::list<MediaPlaylist*> all_playlists_;
std::list<const MediaPlaylist*> video_playlists_; std::list<const MediaPlaylist*> video_playlists_;
// The key is the audio group name.
// The ID is the group name, and the value is the list of all media playlists
// in that group. Keep audio and subtitle separate as they are processed
// separately.
std::map<std::string, std::list<const MediaPlaylist*>> audio_playlist_groups_; std::map<std::string, std::list<const MediaPlaylist*>> audio_playlist_groups_;
std::map<std::string, std::list<const MediaPlaylist*>>
subtitle_playlist_groups_;
DISALLOW_COPY_AND_ASSIGN(MasterPlaylist); DISALLOW_COPY_AND_ASSIGN(MasterPlaylist);
}; };

View File

@ -84,6 +84,20 @@ std::unique_ptr<MockMediaPlaylist> CreateAudioPlaylist(
return playlist; return playlist;
} }
std::unique_ptr<MockMediaPlaylist> MakeText(const std::string& filename,
const std::string& name,
const std::string& group,
const std::string& language) {
std::unique_ptr<MockMediaPlaylist> playlist(
new MockMediaPlaylist(kVodPlaylist, filename, name, group));
EXPECT_CALL(*playlist, GetLanguage()).WillRepeatedly(Return(language));
playlist->SetStreamTypeForTesting(
MediaPlaylist::MediaPlaylistStreamType::kSubtitle);
return playlist;
}
} // namespace } // namespace
class MasterPlaylistTest : public ::testing::Test { class MasterPlaylistTest : public ::testing::Test {
@ -279,5 +293,214 @@ TEST_F(MasterPlaylistTest, WriteMasterPlaylistSameAudioGroupSameLanguage) {
ASSERT_EQ(expected, actual); ASSERT_EQ(expected, actual);
} }
TEST_F(MasterPlaylistTest, WriteMasterPlaylistVideosAndTexts) {
// Video, sd.m3u8.
std::unique_ptr<MockMediaPlaylist> video1 =
CreateVideoPlaylist("sd.m3u8", "sdvideocodec", 300000);
master_playlist_.AddMediaPlaylist(video1.get());
// Video, hd.m3u8.
std::unique_ptr<MockMediaPlaylist> video2 =
CreateVideoPlaylist("hd.m3u8", "sdvideocodec", 600000);
master_playlist_.AddMediaPlaylist(video2.get());
// Text, eng.m3u8.
std::unique_ptr<MockMediaPlaylist> text_eng =
MakeText("eng.m3u8", "english", "textgroup", "en");
master_playlist_.AddMediaPlaylist(text_eng.get());
// Text, fr.m3u8.
std::unique_ptr<MockMediaPlaylist> text_fr =
MakeText("fr.m3u8", "french", "textgroup", "fr");
master_playlist_.AddMediaPlaylist(text_fr.get());
const char kBaseUrl[] = "http://playlists.org/";
EXPECT_TRUE(master_playlist_.WriteMasterPlaylist(kBaseUrl, test_output_dir_));
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"
"#EXT-X-MEDIA:TYPE=SUBTITLES,URI=\"http://playlists.org/eng.m3u8\","
"GROUP-ID=\"textgroup\",LANGUAGE=\"en\",NAME=\"english\"\n"
"#EXT-X-MEDIA:TYPE=SUBTITLES,URI=\"http://playlists.org/fr.m3u8\","
"GROUP-ID=\"textgroup\",LANGUAGE=\"fr\",NAME=\"french\"\n"
"#EXT-X-STREAM-INF:BANDWIDTH=300000,CODECS=\"sdvideocodec\","
"RESOLUTION=800x600,SUBTITLES=\"textgroup\"\n"
"http://playlists.org/sd.m3u8\n"
"#EXT-X-STREAM-INF:BANDWIDTH=600000,CODECS=\"sdvideocodec\","
"RESOLUTION=800x600,SUBTITLES=\"textgroup\"\n"
"http://playlists.org/hd.m3u8\n";
ASSERT_EQ(expected, actual);
}
TEST_F(MasterPlaylistTest, WriteMasterPlaylistVideoAndTextGroups) {
// Video, sd.m3u8.
std::unique_ptr<MockMediaPlaylist> video =
CreateVideoPlaylist("sd.m3u8", "sdvideocodec", 300000);
master_playlist_.AddMediaPlaylist(video.get());
// Text, eng.m3u8.
std::unique_ptr<MockMediaPlaylist> text_eng =
MakeText("eng.m3u8", "english", "en-text-group", "en");
master_playlist_.AddMediaPlaylist(text_eng.get());
// Text, fr.m3u8.
std::unique_ptr<MockMediaPlaylist> text_fr =
MakeText("fr.m3u8", "french", "fr-text-group", "fr");
master_playlist_.AddMediaPlaylist(text_fr.get());
const char kBaseUrl[] = "http://playlists.org/";
EXPECT_TRUE(master_playlist_.WriteMasterPlaylist(kBaseUrl, test_output_dir_));
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"
"#EXT-X-MEDIA:TYPE=SUBTITLES,URI=\"http://playlists.org/eng.m3u8\","
"GROUP-ID=\"en-text-group\",LANGUAGE=\"en\",NAME=\"english\"\n"
"#EXT-X-MEDIA:TYPE=SUBTITLES,URI=\"http://playlists.org/fr.m3u8\","
"GROUP-ID=\"fr-text-group\",LANGUAGE=\"fr\",NAME=\"french\"\n"
"#EXT-X-STREAM-INF:BANDWIDTH=300000,CODECS=\"sdvideocodec\","
"RESOLUTION=800x600,SUBTITLES=\"en-text-group\"\n"
"http://playlists.org/sd.m3u8\n"
"#EXT-X-STREAM-INF:BANDWIDTH=300000,CODECS=\"sdvideocodec\","
"RESOLUTION=800x600,SUBTITLES=\"fr-text-group\"\n"
"http://playlists.org/sd.m3u8\n";
ASSERT_EQ(expected, actual);
}
TEST_F(MasterPlaylistTest, WriteMasterPlaylistVideoAndAudioAndText) {
// Video, sd.m3u8.
std::unique_ptr<MockMediaPlaylist> video =
CreateVideoPlaylist("sd.m3u8", "sdvideocodec", 300000);
master_playlist_.AddMediaPlaylist(video.get());
// Audio, english.m3u8.
std::unique_ptr<MockMediaPlaylist> audio = CreateAudioPlaylist(
"eng.m3u8", "english", "audiogroup", "audiocodec", "en", 2, 50000);
master_playlist_.AddMediaPlaylist(audio.get());
// Text, english.m3u8.
std::unique_ptr<MockMediaPlaylist> text =
MakeText("eng.m3u8", "english", "textgroup", "en");
master_playlist_.AddMediaPlaylist(text.get());
const char kBaseUrl[] = "http://playlists.org/";
EXPECT_TRUE(master_playlist_.WriteMasterPlaylist(kBaseUrl, test_output_dir_));
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"
"#EXT-X-MEDIA:TYPE=AUDIO,URI=\"http://playlists.org/eng.m3u8\","
"GROUP-ID=\"audiogroup\",LANGUAGE=\"en\",NAME=\"english\","
"DEFAULT=YES,AUTOSELECT=YES,CHANNELS=\"2\"\n"
"#EXT-X-MEDIA:TYPE=SUBTITLES,URI=\"http://playlists.org/eng.m3u8\","
"GROUP-ID=\"textgroup\",LANGUAGE=\"en\",NAME=\"english\"\n"
"#EXT-X-STREAM-INF:BANDWIDTH=350000,CODECS=\"sdvideocodec,audiocodec\","
"RESOLUTION=800x600,AUDIO=\"audiogroup\",SUBTITLES=\"textgroup\"\n"
"http://playlists.org/sd.m3u8\n";
ASSERT_EQ(expected, actual);
}
TEST_F(MasterPlaylistTest, WriteMasterPlaylistVidesAudiosTextsDifferentGroups) {
const uint64_t kAudioChannels = 2;
const uint64_t kAudioBitRate = 50000;
const uint64_t kVideoBitRate = 300000;
std::unique_ptr<MockMediaPlaylist> media_playlists[] = {
// AUDIO
CreateAudioPlaylist("audio-1.m3u8", "audio 1", "audio-group-1",
"audiocodec", "en", kAudioChannels, kAudioBitRate),
CreateAudioPlaylist("audio-2.m3u8", "audio 2", "audio-group-2",
"audiocodec", "en", kAudioChannels, kAudioBitRate),
// SUBTITLES
MakeText("text-1.m3u8", "text 1", "text-group-1", "en"),
MakeText("text-2.m3u8", "text 2", "text-group-2", "en"),
// VIDEO
CreateVideoPlaylist("video-1.m3u8", "sdvideocodec", kVideoBitRate),
CreateVideoPlaylist("video-2.m3u8", "sdvideocodec", kVideoBitRate),
};
// Add all the media playlists to the master playlist.
for (const auto& media_playlist : media_playlists) {
master_playlist_.AddMediaPlaylist(media_playlist.get());
}
const char kBaseUrl[] = "http://playlists.org/";
EXPECT_TRUE(master_playlist_.WriteMasterPlaylist(kBaseUrl, test_output_dir_));
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"
"#EXT-X-MEDIA:TYPE=AUDIO,URI=\"http://playlists.org/audio-1.m3u8\","
"GROUP-ID=\"audio-group-1\",LANGUAGE=\"en\",NAME=\"audio 1\","
"DEFAULT=YES,AUTOSELECT=YES,CHANNELS=\"2\"\n"
"#EXT-X-MEDIA:TYPE=AUDIO,URI=\"http://playlists.org/audio-2.m3u8\","
"GROUP-ID=\"audio-group-2\",LANGUAGE=\"en\",NAME=\"audio 2\","
"DEFAULT=YES,AUTOSELECT=YES,CHANNELS=\"2\"\n"
"#EXT-X-MEDIA:TYPE=SUBTITLES,URI=\"http://playlists.org/text-1.m3u8\","
"GROUP-ID=\"text-group-1\",LANGUAGE=\"en\",NAME=\"text 1\"\n"
"#EXT-X-MEDIA:TYPE=SUBTITLES,URI=\"http://playlists.org/text-2.m3u8\","
"GROUP-ID=\"text-group-2\",LANGUAGE=\"en\",NAME=\"text 2\"\n"
"#EXT-X-STREAM-INF:BANDWIDTH=350000,CODECS=\"sdvideocodec,audiocodec\","
"RESOLUTION=800x600,AUDIO=\"audio-group-1\",SUBTITLES=\"text-group-1\"\n"
"http://playlists.org/video-1.m3u8\n"
"#EXT-X-STREAM-INF:BANDWIDTH=350000,CODECS=\"sdvideocodec,audiocodec\","
"RESOLUTION=800x600,AUDIO=\"audio-group-1\",SUBTITLES=\"text-group-2\"\n"
"http://playlists.org/video-1.m3u8\n"
"#EXT-X-STREAM-INF:BANDWIDTH=350000,CODECS=\"sdvideocodec,audiocodec\","
"RESOLUTION=800x600,AUDIO=\"audio-group-2\",SUBTITLES=\"text-group-1\"\n"
"http://playlists.org/video-1.m3u8\n"
"#EXT-X-STREAM-INF:BANDWIDTH=350000,CODECS=\"sdvideocodec,audiocodec\","
"RESOLUTION=800x600,AUDIO=\"audio-group-2\",SUBTITLES=\"text-group-2\"\n"
"http://playlists.org/video-1.m3u8\n"
"#EXT-X-STREAM-INF:BANDWIDTH=350000,CODECS=\"sdvideocodec,audiocodec\","
"RESOLUTION=800x600,AUDIO=\"audio-group-1\",SUBTITLES=\"text-group-1\"\n"
"http://playlists.org/video-2.m3u8\n"
"#EXT-X-STREAM-INF:BANDWIDTH=350000,CODECS=\"sdvideocodec,audiocodec\","
"RESOLUTION=800x600,AUDIO=\"audio-group-1\",SUBTITLES=\"text-group-2\"\n"
"http://playlists.org/video-2.m3u8\n"
"#EXT-X-STREAM-INF:BANDWIDTH=350000,CODECS=\"sdvideocodec,audiocodec\","
"RESOLUTION=800x600,AUDIO=\"audio-group-2\",SUBTITLES=\"text-group-1\"\n"
"http://playlists.org/video-2.m3u8\n"
"#EXT-X-STREAM-INF:BANDWIDTH=350000,CODECS=\"sdvideocodec,audiocodec\","
"RESOLUTION=800x600,AUDIO=\"audio-group-2\",SUBTITLES=\"text-group-2\"\n"
"http://playlists.org/video-2.m3u8\n";
ASSERT_EQ(expected, actual);
}
} // namespace hls } // namespace hls
} // namespace shaka } // namespace shaka

View File

@ -221,7 +221,6 @@ std::string EncryptionInfoEntry::ToString() {
std::string tag_string; std::string tag_string;
Tag tag("#EXT-X-KEY", &tag_string); Tag tag("#EXT-X-KEY", &tag_string);
std::string method_attribute;
if (method_ == MediaPlaylist::EncryptionMethod::kSampleAes) { if (method_ == MediaPlaylist::EncryptionMethod::kSampleAes) {
tag.AddString("METHOD", "SAMPLE-AES"); tag.AddString("METHOD", "SAMPLE-AES");
} else if (method_ == MediaPlaylist::EncryptionMethod::kAes128) { } else if (method_ == MediaPlaylist::EncryptionMethod::kAes128) {
@ -342,8 +341,8 @@ bool MediaPlaylist::SetMediaInfo(const MediaInfo& media_info) {
stream_type_ = MediaPlaylistStreamType::kAudio; stream_type_ = MediaPlaylistStreamType::kAudio;
codec_ = media_info.audio_info().codec(); codec_ = media_info.audio_info().codec();
} else { } else {
NOTIMPLEMENTED(); stream_type_ = MediaPlaylistStreamType::kSubtitle;
return false; codec_ = media_info.text_info().format();
} }
time_scale_ = time_scale; time_scale_ = time_scale;

View File

@ -99,13 +99,12 @@ TEST_F(MediaPlaylistMultiSegmentTest, NoTimeScale) {
EXPECT_FALSE(media_playlist_.SetMediaInfo(media_info)); EXPECT_FALSE(media_playlist_.SetMediaInfo(media_info));
} }
// The current implementation only handles video and audio. TEST_F(MediaPlaylistMultiSegmentTest, SetMediaInfoText) {
TEST_F(MediaPlaylistMultiSegmentTest, NoAudioOrVideo) {
MediaInfo media_info; MediaInfo media_info;
media_info.set_reference_time_scale(kTimeScale); media_info.set_reference_time_scale(kTimeScale);
MediaInfo::TextInfo* text_info = media_info.mutable_text_info(); MediaInfo::TextInfo* text_info = media_info.mutable_text_info();
text_info->set_format("vtt"); text_info->set_format("vtt");
EXPECT_FALSE(media_playlist_.SetMediaInfo(media_info)); EXPECT_TRUE(media_playlist_.SetMediaInfo(media_info));
} }
TEST_F(MediaPlaylistMultiSegmentTest, SetMediaInfo) { TEST_F(MediaPlaylistMultiSegmentTest, SetMediaInfo) {