Shaka Packager SDK
Loading...
Searching...
No Matches
webvtt_parser.cc
1// Copyright 2017 Google LLC. All rights reserved.
2//
3// Use of this source code is governed by a BSD-style
4// license that can be found in the LICENSE file or at
5// https://developers.google.com/open-source/licenses/bsd
6
7#include <packager/media/formats/webvtt/webvtt_parser.h>
8
9#include <cstddef>
10#include <cstdint>
11#include <memory>
12#include <string>
13#include <utility>
14#include <vector>
15
16#include <absl/log/check.h>
17#include <absl/log/log.h>
18#include <absl/strings/ascii.h>
19#include <absl/strings/match.h>
20#include <absl/strings/numbers.h>
21
22#include <packager/kv_pairs/kv_pairs.h>
23#include <packager/media/base/media_parser.h>
24#include <packager/media/base/stream_info.h>
25#include <packager/media/base/text_sample.h>
26#include <packager/media/base/text_stream_info.h>
27#include <packager/media/formats/webvtt/webvtt_utils.h>
28#include <packager/utils/string_trim_split.h>
29
30namespace shaka {
31namespace media {
32namespace {
33
34const uint64_t kStreamIndex = 0;
35
36std::string BlockToString(const std::string* block, size_t size) {
37 std::string out = " --- BLOCK START ---\n";
38
39 for (size_t i = 0; i < size; i++) {
40 out.append(" ");
41 out.append(block[i]);
42 out.append("\n");
43 }
44
45 out.append(" --- BLOCK END ---");
46
47 return out;
48}
49
50// Comments are just blocks that are preceded by a blank line, start with the
51// word "NOTE" (followed by a space or newline), and end at the first blank
52// line.
53// SOURCE: https://www.w3.org/TR/webvtt1
54bool IsLikelyNote(const std::string& line) {
55 return line == "NOTE" || absl::StartsWith(line, "NOTE ") ||
56 absl::StartsWith(line, "NOTE\t");
57}
58
59// As cue time is the only part of a WEBVTT file that is allowed to have
60// "-->" appear, then if the given line contains it, we can safely assume
61// that the line is likely to be a cue time.
62bool IsLikelyCueTiming(const std::string& line) {
63 return line.find("-->") != std::string::npos;
64}
65
66// A WebVTT cue identifier is any sequence of one or more characters not
67// containing the substring "-->" (U+002D HYPHEN-MINUS, U+002D HYPHEN-MINUS,
68// U+003E GREATER-THAN SIGN), nor containing any U+000A LINE FEED (LF)
69// characters or U+000D CARRIAGE RETURN (CR) characters.
70// SOURCE: https://www.w3.org/TR/webvtt1/#webvtt-cue-identifier
71bool MaybeCueId(const std::string& line) {
72 return line.find("-->") == std::string::npos;
73}
74
75// Check to see if the block is likely a style block. Style blocks are
76// identified as any block that starts with a line that only contains
77// "STYLE".
78// SOURCE: https://w3c.github.io/webvtt/#styling
79bool IsLikelyStyle(const std::string& line) {
80 return absl::StripTrailingAsciiWhitespace(line) == "STYLE";
81}
82
83// Check to see if the block is likely a region block. Region blocks are
84// identified as any block that starts with a line that only contains
85// "REGION".
86// SOURCE: https://w3c.github.io/webvtt/#webvtt-region
87bool IsLikelyRegion(const std::string& line) {
88 return absl::StripTrailingAsciiWhitespace(line) == "REGION";
89}
90
91bool ParsePercent(const std::string& str, float* value) {
92 // https://www.w3.org/TR/webvtt1/#webvtt-percentage
93 // E.g. "4%" or "1.5%"
94 if (str[str.size() - 1] != '%') {
95 return false;
96 }
97
98 double temp;
99 if (!absl::SimpleAtod(str.substr(0, str.size() - 1), &temp) || temp > 100) {
100 return false;
101 }
102 *value = temp;
103 return true;
104}
105
106bool ParseDoublePercent(const std::string& str, float* a, float* b) {
107 std::vector<std::string> percents = SplitAndTrimSkipEmpty(str, ',');
108
109 if (percents.size() != 2) {
110 return false;
111 }
112 float temp_a, temp_b;
113 if (!ParsePercent(percents[0], &temp_a) ||
114 !ParsePercent(percents[1], &temp_b)) {
115 return false;
116 }
117 *a = temp_a;
118 *b = temp_b;
119 return true;
120}
121
122void ParseSettings(const std::string& id,
123 const std::string& value,
124 TextSettings* settings) {
125 // https://www.w3.org/TR/webvtt1/#ref-for-parse-the-webvtt-cue-settings-1
126 if (id == "region") {
127 settings->region = value;
128 } else if (id == "vertical") {
129 if (value == "rl") {
130 settings->writing_direction = WritingDirection::kVerticalGrowingLeft;
131 } else if (value == "lr") {
132 settings->writing_direction = WritingDirection::kVerticalGrowingRight;
133 } else {
134 LOG(WARNING) << "Invalid WebVTT vertical setting: " << value;
135 }
136 } else if (id == "line") {
137 const auto pos = value.find(',');
138 const std::string line = value.substr(0, pos);
139 const std::string align =
140 pos != std::string::npos ? value.substr(pos + 1) : "";
141 if (pos != std::string::npos) {
142 LOG(WARNING) << "WebVTT line alignment isn't supported";
143 }
144
145 if (!line.empty() && line[line.size() - 1] == '%') {
146 float temp;
147 if (!ParsePercent(line, &temp)) {
148 LOG(WARNING) << "Invalid WebVTT line: " << value;
149 return;
150 }
151 settings->line.emplace(temp, TextUnitType::kPercent);
152 } else {
153 double temp;
154 if (!absl::SimpleAtod(line, &temp)) {
155 LOG(WARNING) << "Invalid WebVTT line: " << value;
156 return;
157 }
158 settings->line.emplace(temp, TextUnitType::kLines);
159 }
160 } else if (id == "position") {
161 const auto pos = value.find(',');
162 const std::string position = value.substr(0, pos);
163 const std::string align =
164 pos != std::string::npos ? value.substr(pos + 1) : "";
165 if (pos != std::string::npos) {
166 LOG(WARNING) << "WebVTT position alignment isn't supported";
167 }
168
169 float temp;
170 if (ParsePercent(position, &temp)) {
171 settings->position.emplace(temp, TextUnitType::kPercent);
172 } else {
173 LOG(WARNING) << "Invalid WebVTT position: " << value;
174 }
175 } else if (id == "size") {
176 float temp;
177 if (ParsePercent(value, &temp)) {
178 settings->width.emplace(temp, TextUnitType::kPercent);
179 } else {
180 LOG(WARNING) << "Invalid WebVTT size: " << value;
181 }
182 } else if (id == "align") {
183 if (value == "start") {
184 settings->text_alignment = TextAlignment::kStart;
185 } else if (value == "center" || value == "middle") {
186 settings->text_alignment = TextAlignment::kCenter;
187 } else if (value == "end") {
188 settings->text_alignment = TextAlignment::kEnd;
189 } else if (value == "left") {
190 settings->text_alignment = TextAlignment::kLeft;
191 } else if (value == "right") {
192 settings->text_alignment = TextAlignment::kRight;
193 } else {
194 LOG(WARNING) << "Invalid WebVTT align: " << value;
195 }
196 } else {
197 LOG(WARNING) << "Unknown WebVTT setting: " << id;
198 }
199}
200
201} // namespace
202
203WebVttParser::WebVttParser() {}
204
205void WebVttParser::Init(const InitCB& init_cb,
206 const NewMediaSampleCB& new_media_sample_cb,
207 const NewTextSampleCB& new_text_sample_cb,
208 KeySource* decryption_key_source) {
209 DCHECK(init_cb_ == nullptr);
210 DCHECK(init_cb != nullptr);
211 DCHECK(new_text_sample_cb != nullptr);
212 DCHECK(!decryption_key_source) << "Encrypted WebVTT not supported";
213
214 init_cb_ = init_cb;
215 new_text_sample_cb_ = new_text_sample_cb;
216}
217
219 reader_.Flush();
220 return Parse();
221}
222
223bool WebVttParser::Parse(const uint8_t* buf, int size) {
224 reader_.PushData(buf, size);
225 return Parse();
226}
227
228bool WebVttParser::Parse() {
229 if (!initialized_) {
230 std::vector<std::string> block;
231 if (!reader_.Next(&block)) {
232 return true;
233 }
234
235 // Check the header. It is possible for a 0xFEFF BOM to come before the
236 // header text.
237 if (block.size() != 1) {
238 LOG(WARNING) << "Failed to read WEBVTT header - "
239 << "block size should be 1 but was " << block.size() << ".";
240 }
241 if (block[0] != "WEBVTT" && block[0] != "\xEF\xBB\xBFWEBVTT") {
242 LOG(WARNING) << "Failed to read WEBVTT header - should be WEBVTT but was "
243 << block[0];
244 }
245 initialized_ = true;
246 }
247
248 std::vector<std::string> block;
249 while (reader_.Next(&block)) {
250 if (!ParseBlock(block))
251 return false;
252 }
253 return true;
254}
255
256bool WebVttParser::ParseBlock(const std::vector<std::string>& block) {
257 // NOTE
258 if (IsLikelyNote(block[0])) {
259 // We can safely ignore the whole block.
260 return true;
261 }
262
263 // STYLE
264 if (IsLikelyStyle(block[0])) {
265 if (saw_cue_) {
266 LOG(WARNING)
267 << "Found style block after seeing cue. Ignoring style block";
268 } else {
269 for (size_t i = 1; i < block.size(); i++) {
270 if (!css_styles_.empty())
271 css_styles_ += "\n";
272 css_styles_ += block[i];
273 }
274 }
275 return true;
276 }
277
278 // REGION
279 if (IsLikelyRegion(block[0])) {
280 if (saw_cue_) {
281 LOG(WARNING)
282 << "Found region block after seeing cue. Ignoring region block";
283 return true;
284 } else {
285 return ParseRegion(block);
286 }
287 }
288
289 // CUE with ID
290 if (block.size() >= 2 && MaybeCueId(block[0]) &&
291 IsLikelyCueTiming(block[1]) && ParseCueWithId(block)) {
292 saw_cue_ = true;
293 return true;
294 }
295
296 // CUE with no ID
297 if (IsLikelyCueTiming(block[0]) && ParseCueWithNoId(block)) {
298 saw_cue_ = true;
299 return true;
300 }
301
302 LOG(ERROR) << "Failed to determine block classification:\n"
303 << BlockToString(block.data(), block.size());
304 return false;
305}
306
307bool WebVttParser::ParseRegion(const std::vector<std::string>& block) {
308 TextRegion region;
309 std::string region_id;
310 // Fill in defaults. Some may already be this, but set them anyway.
311 // See https://www.w3.org/TR/webvtt1/#regions
312 region.width.value = 100;
313 region.width.type = TextUnitType::kPercent;
314 region.height.value = 3;
315 region.height.type = TextUnitType::kLines;
316 region.window_anchor_x.value = 0;
317 region.window_anchor_x.type = TextUnitType::kPercent;
318 region.window_anchor_y.value = 100;
319 region.window_anchor_y.type = TextUnitType::kPercent;
320 region.region_anchor_x.value = 0;
321 region.region_anchor_x.type = TextUnitType::kPercent;
322 region.region_anchor_y.value = 100;
323 region.region_anchor_y.type = TextUnitType::kPercent;
324
325 bool first = true;
326 for (const auto& line : block) {
327 // First line is "REGION", skip.
328 if (first) {
329 first = false;
330 continue;
331 }
332
333 std::vector<KVPair> kv_pairs = SplitStringIntoKeyValuePairs(line, ':', ' ');
334
335 for (const auto& pair : kv_pairs) {
336 const std::string& value = pair.second;
337 if (pair.first == "id") {
338 if (value.find("-->") != std::string::npos) {
339 LOG(ERROR) << "Invalid WebVTT REGION ID: " << value;
340 return false;
341 }
342 if (regions_.find(value) != regions_.end()) {
343 LOG(ERROR) << "Duplicate WebVTT REGION: " << value;
344 return false;
345 }
346 region_id = value;
347 } else if (pair.first == "width") {
348 if (!ParsePercent(value, &region.width.value)) {
349 LOG(ERROR) << "Invalid WebVTT REGION width: " << value;
350 return false;
351 }
352 } else if (pair.first == "lines") {
353 unsigned int temp;
354 if (!absl::SimpleAtoi(value, &temp)) {
355 LOG(ERROR) << "Invalid WebVTT REGION lines: " << value;
356 return false;
357 }
358 region.height.value = temp;
359 } else if (pair.first == "regionanchor") {
360 if (!ParseDoublePercent(value, &region.region_anchor_x.value,
361 &region.region_anchor_y.value)) {
362 LOG(ERROR) << "Invalid WebVTT REGION regionanchor: " << value;
363 return false;
364 }
365 } else if (pair.first == "viewportanchor") {
366 if (!ParseDoublePercent(value, &region.window_anchor_x.value,
367 &region.window_anchor_y.value)) {
368 LOG(ERROR) << "Invalid WebVTT REGION windowanchor: " << value;
369 return false;
370 }
371 } else if (pair.first == "scroll") {
372 if (value != "up") {
373 LOG(ERROR) << "Invalid WebVTT REGION scroll: " << value;
374 return false;
375 }
376 region.scroll = true;
377 } else {
378 LOG(ERROR) << "Unknown WebVTT REGION setting: " << pair.first;
379 return false;
380 }
381 }
382 }
383 if (region_id.empty()) {
384 LOG(ERROR) << "WebVTT REGION id is required";
385 return false;
386 }
387 regions_.insert(std::make_pair(region_id, std::move(region)));
388 return true;
389}
390
391bool WebVttParser::ParseCueWithNoId(const std::vector<std::string>& block) {
392 return ParseCue("", block.data(), block.size());
393}
394
395bool WebVttParser::ParseCueWithId(const std::vector<std::string>& block) {
396 return ParseCue(block[0], block.data() + 1, block.size() - 1);
397}
398
399bool WebVttParser::ParseCue(const std::string& id,
400 const std::string* block,
401 size_t block_size) {
402 std::vector<std::string> time_and_style =
403 SplitAndTrimSkipEmpty(block[0], ' ');
404
405 int64_t start_time = 0;
406 int64_t end_time = 0;
407
408 const bool parsed_time =
409 time_and_style.size() >= 3 && time_and_style[1] == "-->" &&
410 WebVttTimestampToMs(time_and_style[0], &start_time) &&
411 WebVttTimestampToMs(time_and_style[2], &end_time);
412
413 if (!parsed_time) {
414 LOG(ERROR) << "Could not parse start time, -->, and end time from "
415 << block[0];
416 return false;
417 }
418
419 if (!stream_info_dispatched_)
420 DispatchTextStreamInfo();
421
422 // According to the WebVTT spec end time must be greater than the start time
423 // of the cue. Since we are seeing content with invalid times in the field, we
424 // are going to drop the cue instead of failing to package.
425 //
426 // For more context see:
427 // - https://www.w3.org/TR/webvtt1/#webvtt-cue-timings
428 // - https://github.com/shaka-project/shaka-packager/issues/335
429 // - https://github.com/shaka-project/shaka-packager/issues/425
430 //
431 // Print a warning so that those packaging content can know that their
432 // content is not spec compliant.
433 if (end_time <= start_time) {
434 LOG(WARNING) << "WebVTT input is not spec compliant. Start time ("
435 << start_time << ") should be less than end time (" << end_time
436 << "). Skipping webvtt cue:"
437 << BlockToString(block, block_size);
438 return true;
439 }
440
441 TextSettings settings;
442 for (size_t i = 3; i < time_and_style.size(); i++) {
443 const auto pos = time_and_style[i].find(':');
444 if (pos == std::string::npos) {
445 continue;
446 }
447
448 const std::string key = time_and_style[i].substr(0, pos);
449 const std::string value = time_and_style[i].substr(pos + 1);
450 ParseSettings(key, value, &settings);
451 }
452
453 // The rest of the block is the payload.
454 // TODO: Parse tags to support <b>, <i>, etc.
455 TextFragment body;
456 TextFragmentStyle no_styles;
457 for (size_t i = 1; i < block_size; i++) {
458 if (i > 1 && i != block_size) {
459 body.sub_fragments.emplace_back(no_styles, /* newline= */ true);
460 }
461 body.sub_fragments.emplace_back(no_styles, block[i]);
462 }
463
464 const auto sample =
465 std::make_shared<TextSample>(id, start_time, end_time, settings, body);
466 return new_text_sample_cb_(kStreamIndex, sample);
467}
468
469void WebVttParser::DispatchTextStreamInfo() {
470 stream_info_dispatched_ = true;
471
472 const int kTrackId = 0;
473 // The resolution of timings are in milliseconds.
474 const int kTimescale = 1000;
475 // The duration passed here is not very important. Also the whole file
476 // must be read before determining the real duration which doesn't
477 // work nicely with the current demuxer.
478 const int kDuration = 0;
479 const char kWebVttCodecString[] = "wvtt";
480 const int64_t kNoWidth = 0;
481 const int64_t kNoHeight = 0;
482 // The language of the stream will be overwritten by the Demuxer later.
483 const char kNoLanguage[] = "";
484
485 const auto stream = std::make_shared<TextStreamInfo>(
486 kTrackId, kTimescale, kDuration, kCodecWebVtt, kWebVttCodecString, "",
487 kNoWidth, kNoHeight, kNoLanguage);
488 stream->set_css_styles(css_styles_);
489 for (const auto& pair : regions_)
490 stream->AddRegion(pair.first, pair.second);
491
492 std::vector<std::shared_ptr<StreamInfo>> streams{stream};
493 init_cb_(streams);
494}
495
496} // namespace media
497} // namespace shaka
void PushData(const uint8_t *data, size_t data_size)
Pushes data onto the end of the buffer.
bool Next(std::vector< std::string > *out)
KeySource is responsible for encryption key acquisition.
Definition key_source.h:56
std::function< bool(uint32_t track_id, std::shared_ptr< MediaSample > media_sample)> NewMediaSampleCB
std::function< bool(uint32_t track_id, std::shared_ptr< TextSample > text_sample)> NewTextSampleCB
std::function< void(const std::vector< std::shared_ptr< StreamInfo > > &stream_info)> InitCB
void Init(const InitCB &init_cb, const NewMediaSampleCB &new_media_sample_cb, const NewTextSampleCB &new_text_sample_cb, KeySource *decryption_key_source) override
bool Parse(const uint8_t *buf, int size) override
All the methods that are virtual are virtual for mocking.