+static
+void
+verify_text_timing (
+ vector<shared_ptr<Reel>> reels,
+ int edit_rate,
+ vector<VerificationNote>& notes,
+ std::function<bool (shared_ptr<Reel>)> check,
+ std::function<string (shared_ptr<Reel>)> xml,
+ std::function<int64_t (shared_ptr<Reel>)> duration
+ )
+{
+ /* end of last subtitle (in editable units) */
+ optional<int64_t> last_out;
+ auto too_short = false;
+ auto too_close = false;
+ auto too_early = false;
+ auto reel_overlap = false;
+ /* current reel start time (in editable units) */
+ int64_t reel_offset = 0;
+
+ std::function<void (cxml::ConstNodePtr, optional<int>, optional<Time>, int, bool)> parse;
+ parse = [&parse, &last_out, &too_short, &too_close, &too_early, &reel_offset](cxml::ConstNodePtr node, optional<int> tcr, optional<Time> start_time, int er, bool first_reel) {
+ if (node->name() == "Subtitle") {
+ Time in (node->string_attribute("TimeIn"), tcr);
+ if (start_time) {
+ in -= *start_time;
+ }
+ Time out (node->string_attribute("TimeOut"), tcr);
+ if (start_time) {
+ out -= *start_time;
+ }
+ if (first_reel && tcr && in < Time(0, 0, 4, 0, *tcr)) {
+ too_early = true;
+ }
+ auto length = out - in;
+ if (length.as_editable_units_ceil(er) < 15) {
+ too_short = true;
+ }
+ if (last_out) {
+ /* XXX: this feels dubious - is it really what Bv2.1 means? */
+ auto distance = reel_offset + in.as_editable_units_ceil(er) - *last_out;
+ if (distance >= 0 && distance < 2) {
+ too_close = true;
+ }
+ }
+ last_out = reel_offset + out.as_editable_units_floor(er);
+ } else {
+ for (auto i: node->node_children()) {
+ parse(i, tcr, start_time, er, first_reel);
+ }
+ }
+ };
+
+ for (auto i = 0U; i < reels.size(); ++i) {
+ if (!check(reels[i])) {
+ continue;
+ }
+
+ /* We need to look at <Subtitle> instances in the XML being checked, so we can't use the subtitles
+ * read in by libdcp's parser.
+ */
+
+ shared_ptr<cxml::Document> doc;
+ optional<int> tcr;
+ optional<Time> start_time;
+ try {
+ doc = make_shared<cxml::Document>("SubtitleReel");
+ doc->read_string (xml(reels[i]));
+ tcr = doc->number_child<int>("TimeCodeRate");
+ auto start_time_string = doc->optional_string_child("StartTime");
+ if (start_time_string) {
+ start_time = Time(*start_time_string, tcr);
+ }
+ } catch (...) {
+ doc = make_shared<cxml::Document>("DCSubtitle");
+ doc->read_string (xml(reels[i]));
+ }
+ parse (doc, tcr, start_time, edit_rate, i == 0);
+ auto end = reel_offset + duration(reels[i]);
+ if (last_out && *last_out > end) {
+ reel_overlap = true;
+ }
+ reel_offset = end;
+ }
+
+ if (last_out && *last_out > reel_offset) {
+ reel_overlap = true;
+ }
+
+ if (too_early) {
+ notes.push_back({
+ VerificationNote::Type::WARNING, VerificationNote::Code::INVALID_SUBTITLE_FIRST_TEXT_TIME
+ });
+ }
+
+ if (too_short) {
+ notes.push_back ({
+ VerificationNote::Type::WARNING, VerificationNote::Code::INVALID_SUBTITLE_DURATION
+ });
+ }
+
+ if (too_close) {
+ notes.push_back ({
+ VerificationNote::Type::WARNING, VerificationNote::Code::INVALID_SUBTITLE_SPACING
+ });
+ }
+
+ if (reel_overlap) {
+ notes.push_back ({
+ VerificationNote::Type::ERROR, VerificationNote::Code::SUBTITLE_OVERLAPS_REEL_BOUNDARY
+ });
+ }
+}
+
+
+struct LinesCharactersResult
+{
+ bool warning_length_exceeded = false;
+ bool error_length_exceeded = false;
+ bool line_count_exceeded = false;
+};
+
+
+static
+void
+verify_text_lines_and_characters (
+ shared_ptr<SubtitleAsset> asset,
+ int warning_length,
+ int error_length,
+ LinesCharactersResult* result
+ )
+{
+ class Event
+ {
+ public:
+ Event (Time time_, float position_, int characters_)
+ : time (time_)
+ , position (position_)
+ , characters (characters_)
+ {}
+
+ Event (Time time_, shared_ptr<Event> start_)
+ : time (time_)
+ , start (start_)
+ {}
+
+ Time time;
+ int position; //< position from 0 at top of screen to 100 at bottom
+ int characters;
+ shared_ptr<Event> start;
+ };
+
+ vector<shared_ptr<Event>> events;
+
+ auto position = [](shared_ptr<const SubtitleString> sub) {
+ switch (sub->v_align()) {
+ case VAlign::TOP:
+ return lrintf(sub->v_position() * 100);
+ case VAlign::CENTER:
+ return lrintf((0.5f + sub->v_position()) * 100);
+ case VAlign::BOTTOM:
+ return lrintf((1.0f - sub->v_position()) * 100);
+ }
+
+ return 0L;
+ };
+
+ for (auto j: asset->subtitles()) {
+ auto text = dynamic_pointer_cast<const SubtitleString>(j);
+ if (text) {
+ auto in = make_shared<Event>(text->in(), position(text), text->text().length());
+ events.push_back(in);
+ events.push_back(make_shared<Event>(text->out(), in));
+ }
+ }
+
+ std::sort(events.begin(), events.end(), [](shared_ptr<Event> const& a, shared_ptr<Event>const& b) {
+ return a->time < b->time;
+ });
+
+ map<int, int> current;
+ for (auto i: events) {
+ if (current.size() > 3) {
+ result->line_count_exceeded = true;
+ }
+ for (auto j: current) {
+ if (j.second >= warning_length) {
+ result->warning_length_exceeded = true;
+ }
+ if (j.second >= error_length) {
+ result->error_length_exceeded = true;
+ }
+ }
+
+ if (i->start) {
+ /* end of a subtitle */
+ DCP_ASSERT (current.find(i->start->position) != current.end());
+ if (current[i->start->position] == i->start->characters) {
+ current.erase(i->start->position);
+ } else {
+ current[i->start->position] -= i->start->characters;
+ }
+ } else {
+ /* start of a subtitle */
+ if (current.find(i->position) == current.end()) {
+ current[i->position] = i->characters;
+ } else {
+ current[i->position] += i->characters;
+ }
+ }
+ }
+}
+
+
+static
+void
+verify_text_timing (vector<shared_ptr<Reel>> reels, vector<VerificationNote>& notes)
+{
+ if (reels.empty()) {
+ return;
+ }
+
+ if (reels[0]->main_subtitle()) {
+ verify_text_timing (reels, reels[0]->main_subtitle()->edit_rate().numerator, notes,
+ [](shared_ptr<Reel> reel) {
+ return static_cast<bool>(reel->main_subtitle());
+ },
+ [](shared_ptr<Reel> reel) {
+ return reel->main_subtitle()->asset()->raw_xml();
+ },
+ [](shared_ptr<Reel> reel) {
+ return reel->main_subtitle()->actual_duration();
+ }
+ );
+ }
+
+ for (auto i = 0U; i < reels[0]->closed_captions().size(); ++i) {
+ verify_text_timing (reels, reels[0]->closed_captions()[i]->edit_rate().numerator, notes,
+ [i](shared_ptr<Reel> reel) {
+ return i < reel->closed_captions().size();
+ },
+ [i](shared_ptr<Reel> reel) {
+ return reel->closed_captions()[i]->asset()->raw_xml();
+ },
+ [i](shared_ptr<Reel> reel) {
+ return reel->closed_captions()[i]->actual_duration();
+ }
+ );
+ }
+}
+
+
+void
+verify_extension_metadata (shared_ptr<CPL> cpl, vector<VerificationNote>& notes)
+{
+ DCP_ASSERT (cpl->file());
+ cxml::Document doc ("CompositionPlaylist");
+ doc.read_file (cpl->file().get());
+
+ auto missing = false;
+ string malformed;
+
+ if (auto reel_list = doc.node_child("ReelList")) {
+ auto reels = reel_list->node_children("Reel");
+ if (!reels.empty()) {
+ if (auto asset_list = reels[0]->optional_node_child("AssetList")) {
+ if (auto metadata = asset_list->optional_node_child("CompositionMetadataAsset")) {
+ if (auto extension_list = metadata->optional_node_child("ExtensionMetadataList")) {
+ missing = true;
+ for (auto extension: extension_list->node_children("ExtensionMetadata")) {
+ if (extension->optional_string_attribute("scope").get_value_or("") != "http://isdcf.com/ns/cplmd/app") {
+ continue;
+ }
+ missing = false;
+ if (auto name = extension->optional_node_child("Name")) {
+ if (name->content() != "Application") {
+ malformed = "<Name> should be 'Application'";
+ }
+ }
+ if (auto property_list = extension->optional_node_child("PropertyList")) {
+ if (auto property = property_list->optional_node_child("Property")) {
+ if (auto name = property->optional_node_child("Name")) {
+ if (name->content() != "DCP Constraints Profile") {
+ malformed = "<Name> property should be 'DCP Constraints Profile'";
+ }
+ }
+ if (auto value = property->optional_node_child("Value")) {
+ if (value->content() != "SMPTE-RDD-52:2020-Bv2.1") {
+ malformed = "<Value> property should be 'SMPTE-RDD-52:2020-Bv2.1'";
+ }
+ }
+ }
+ }
+ }
+ } else {
+ missing = true;
+ }
+ }
+ }
+ }
+ }
+
+ if (missing) {
+ notes.push_back ({VerificationNote::Type::BV21_ERROR, VerificationNote::Code::MISSING_EXTENSION_METADATA, cpl->id(), cpl->file().get()});
+ } else if (!malformed.empty()) {
+ notes.push_back ({VerificationNote::Type::BV21_ERROR, VerificationNote::Code::INVALID_EXTENSION_METADATA, malformed, cpl->file().get()});
+ }
+}
+
+
+bool
+pkl_has_encrypted_assets (shared_ptr<DCP> dcp, shared_ptr<PKL> pkl)
+{
+ vector<string> encrypted;
+ for (auto i: dcp->cpls()) {
+ for (auto j: i->reel_file_assets()) {
+ if (j->asset_ref().resolved()) {
+ /* It's a bit surprising / broken but Interop subtitle assets are represented
+ * in reels by ReelSubtitleAsset which inherits ReelEncryptableAsset, so it's possible for
+ * ReelEncryptableAssets to have assets which are not MXFs.
+ */
+ if (auto asset = dynamic_pointer_cast<MXF>(j->asset_ref().asset())) {
+ if (asset->encrypted()) {
+ encrypted.push_back(j->asset_ref().id());
+ }
+ }
+ }
+ }
+ }
+
+ for (auto i: pkl->asset_list()) {
+ if (find(encrypted.begin(), encrypted.end(), i->id()) != encrypted.end()) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+
+vector<VerificationNote>